From 916a034403034817a28f67983a8432d0b4fd6f20 Mon Sep 17 00:00:00 2001 From: Orual Date: Sat, 27 Jun 2026 11:13:36 -0400 Subject: [PATCH] PM-59: auto-generate preview images from the renderer --- Cargo.toml | 3 +- src/main.rs | 3 +- src/preview_capture.rs | 213 ++++++++++++++++++++++++++++++ src/publish.rs | 292 ++++++++++++++++++++++++++++++++++++++++- src/viewer.rs | 108 +++++++++------ 5 files changed, 572 insertions(+), 47 deletions(-) create mode 100644 src/preview_capture.rs diff --git a/Cargo.toml b/Cargo.toml index 5077e44..1fe68db 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -49,7 +49,6 @@ server = [ "dep:axum-extra", "dep:keyring", - "dep:base64", "dep:sha2", "dep:zip", # PM-43: server-side decode of uploaded image dimensions (for #image @@ -116,7 +115,7 @@ rand = "0.9" axum-extra = { version = "0.10", optional = true, features = ["cookie"] } keyring = { version = "3", optional = true } -base64 = { version = "0.22", optional = true } +base64 = { version = "0.22" } sha2 = { version = "0.10", optional = true } zip = { version = "2", default-features = false, optional = true } # PM-43: header-only image dimension probe (no full decode) for #image diff --git a/src/main.rs b/src/main.rs index 2b67ac3..2ab05b2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -12,6 +12,7 @@ mod examples; mod fonts; mod foundation; mod mesh; +mod preview_capture; mod profile; mod publish; mod search; @@ -66,7 +67,7 @@ fn main() { let _ = tracing_subscriber::fmt() .with_env_filter( tracing_subscriber::EnvFilter::try_from_default_env() - .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info,hydrant=warn")), + .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("debug,hydrant=warn")), ) .try_init(); // The closure runs inside the server's Tokio runtime at startup, so the diff --git a/src/preview_capture.rs b/src/preview_capture.rs new file mode 100644 index 0000000..eb7f432 --- /dev/null +++ b/src/preview_capture.rs @@ -0,0 +1,213 @@ +//! Offscreen WebGL2 capture for publish-wizard preview images. +//! +//! `PreviewCapture` owns a single offscreen `` + `three_d::Context` + +//! standard light rig, lazily created once per wizard session. `render_png` +//! renders one frame and reads it back as PNG bytes via `toDataURL` + `atob`. +//! +//! The wasm-only implementation is gated behind +//! `all(target_family = "wasm", target_os = "unknown")`; a no-op stub on other +//! targets keeps the type resolvable for `Wizard`'s signal field. + +use crate::mesh::contract::ModelMesh; + +/// Errors that can occur constructing or rendering with a [`PreviewCapture`]. +#[derive(Debug, thiserror::Error)] +#[allow(dead_code)] +pub enum PreviewCaptureError { + #[error("preview capture is not supported on this target")] + Unsupported, + #[error("browser window is unavailable")] + WindowUnavailable, + #[error("document is unavailable")] + DocumentUnavailable, + #[error("could not create an offscreen canvas element")] + CanvasCreation, + #[error("WebGL2 context is unavailable")] + WebGL2Unavailable, + #[error("could not acquire a WebGL2 context: {0}")] + Context(String), + #[error("PNG readback failed: {0}")] + Readback(String), +} + +// --------------------------------------------------------------------------- +// wasm target — real implementation +// --------------------------------------------------------------------------- + +#[cfg(all(target_family = "wasm", target_os = "unknown"))] +mod wasm { + use std::sync::Arc; + + use three_d::context::Context as GlowContext; + use wasm_bindgen::JsCast; + + use super::PreviewCaptureError; + use crate::mesh::contract::ModelMesh; + use crate::viewer::{build_scene_with_viewport, standard_lights}; + + pub(super) struct PreviewCaptureInner { + canvas: web_sys::HtmlCanvasElement, + context: three_d::Context, + ambient_light: three_d::AmbientLight, + key_light: three_d::DirectionalLight, + fill_light: three_d::DirectionalLight, + } + + impl PreviewCaptureInner { + pub(super) fn new() -> Result { + let window = web_sys::window().ok_or(PreviewCaptureError::WindowUnavailable)?; + let document = window + .document() + .ok_or(PreviewCaptureError::DocumentUnavailable)?; + let canvas = document + .create_element("canvas") + .map_err(|_| PreviewCaptureError::CanvasCreation)? + .dyn_into::() + .map_err(|_| PreviewCaptureError::CanvasCreation)?; + + // `preserveDrawingBuffer: true` is REQUIRED for `toDataURL` to read + // back a non-blank frame. The viewer's plain `get_context("webgl2")` + // has no preserve flag and would yield a blank readback. + let opts = js_sys::Object::new(); + js_sys::Reflect::set(&opts, &"preserveDrawingBuffer".into(), &true.into()) + .map_err(|e| PreviewCaptureError::Context(format!("{e:?}")))?; + let gl = canvas + .get_context_with_context_options("webgl2", &opts) + .map_err(|_| PreviewCaptureError::WebGL2Unavailable)? + .ok_or(PreviewCaptureError::WebGL2Unavailable)? + .dyn_into::() + .map_err(|_| PreviewCaptureError::WebGL2Unavailable)?; + + let glow_context = GlowContext::from_webgl2_context(gl); + #[allow( + clippy::arc_with_non_send_sync, + reason = "three-d requires Arc for browser WebGL context construction" + )] + let context = three_d::Context::from_gl_context(Arc::new(glow_context)) + .map_err(|e| PreviewCaptureError::Context(format!("{e:?}")))?; + + let (ambient_light, key_light, fill_light) = standard_lights(&context); + + Ok(Self { + canvas, + context, + ambient_light, + key_light, + fill_light, + }) + } + + pub(super) fn render_png( + &self, + model: &ModelMesh, + width: u32, + height: u32, + ) -> Result, PreviewCaptureError> { + self.canvas.set_width(width); + self.canvas.set_height(height); + + let (mesh, camera, _control) = build_scene_with_viewport( + &self.context, + model, + three_d::Viewport::new_at_origo(width, height), + ); + + let screen = three_d::RenderTarget::screen(&self.context, width, height); + let _ = screen + .clear(three_d::ClearState::color_and_depth( + 0.035, 0.043, 0.067, 1.0, 1.0, + )) + .render( + &camera, + [&mesh], + &[&self.ambient_light, &self.key_light, &self.fill_light], + ); + + // Primary readback: toDataURL → base64 → atob → bytes. + // `atob` returns a Latin-1 binary string (code units 0x00–0xFF); each + // Rust `char` is exactly one byte. Iterating `.bytes()` would + // UTF-8-expand bytes ≥0x80 and silently corrupt the PNG. + let data_url = self + .canvas + .to_data_url_with_type("image/png") + .map_err(|e| PreviewCaptureError::Readback(format!("toDataURL failed: {e:?}")))?; + let b64 = data_url.split_once(',').map(|(_, b)| b).ok_or_else(|| { + PreviewCaptureError::Readback("missing data URL delimiter".into()) + })?; + let window = web_sys::window().ok_or(PreviewCaptureError::WindowUnavailable)?; + let binary_string = window + .atob(b64) + .map_err(|e| PreviewCaptureError::Readback(format!("atob failed: {e:?}")))?; + Ok(binary_string.chars().map(|c| c as u8).collect()) + } + } +} + +// --------------------------------------------------------------------------- +// non-wasm target — stub +// --------------------------------------------------------------------------- + +#[cfg(not(all(target_family = "wasm", target_os = "unknown")))] +#[allow(dead_code)] +pub(super) struct PreviewCaptureInner; + +#[cfg(not(all(target_family = "wasm", target_os = "unknown")))] +#[allow(dead_code)] +impl PreviewCaptureInner { + pub(super) fn new() -> Result { + Err(PreviewCaptureError::Unsupported) + } + + pub(super) fn render_png( + &self, + _model: &ModelMesh, + _width: u32, + _height: u32, + ) -> Result, PreviewCaptureError> { + Err(PreviewCaptureError::Unsupported) + } +} + +// --------------------------------------------------------------------------- +// Public cfg-agnostic facade +// --------------------------------------------------------------------------- + +#[cfg(all(target_family = "wasm", target_os = "unknown"))] +use wasm::PreviewCaptureInner; + +/// An offscreen WebGL2 capture surface for rendering preview images. +/// +/// On wasm this owns a real `` + `three_d::Context`; on every other +/// target it is a no-op stub whose `new()` always returns `Err(Unsupported)`. +/// The wasm inner is wrapped in `Arc` so cloning is cheap without requiring +/// the GL resources themselves to be `Clone`. +#[derive(Clone)] +pub struct PreviewCapture { + #[allow(dead_code)] + inner: std::sync::Arc, +} + +#[allow(dead_code)] // stub methods are unused on non-wasm targets +impl PreviewCapture { + /// Create a capture surface. On non-wasm targets this always fails with + /// [`PreviewCaptureError::Unsupported`]. + #[allow( + clippy::arc_with_non_send_sync, + reason = "three-d requires Arc for browser WebGL context construction" + )] + pub fn new() -> Result { + Ok(Self { + inner: std::sync::Arc::new(PreviewCaptureInner::new()?), + }) + } + + /// Render `model` to a PNG byte vector at the given dimensions. + pub fn render_png( + &self, + model: &ModelMesh, + width: u32, + height: u32, + ) -> Result, PreviewCaptureError> { + self.inner.render_png(model, width, height) + } +} diff --git a/src/publish.rs b/src/publish.rs index b99703b..cf8b98e 100644 --- a/src/publish.rs +++ b/src/publish.rs @@ -1,10 +1,14 @@ use dioxus::html::FileData; +use std::collections::HashMap; + use dioxus::prelude::*; use crate::Route; use crate::client::PolymodelClient; +use crate::preview_capture::PreviewCapture; use crate::session::SessionIdentity; use draft::{DraftModelInput, DraftPartInput, DraftSummary, DraftThingInput}; +use jacquard_common::deps::smol_str::SmolStr; use polymodel_api::space_polymodel::library::Image; use state::{PublishPhase, WizardStep}; @@ -32,6 +36,14 @@ fn image_cdn_url(did: &str, image: &Image) -> String { format!("https://cdn.bsky.app/img/feed_thumbnail/plain/{did}/{cid}@jpeg") } +/// Build a `data:` URI from raw bytes for immediate display before CDN +/// replication completes. Uses base64 encoding. +fn data_uri_from_bytes(bytes: &[u8], mime: &str) -> SmolStr { + use base64::Engine; + let encoded = base64::engine::general_purpose::STANDARD.encode(bytes); + format!("data:{mime};base64,{encoded}").into() +} + /// Transient state of a single in-flight raw upload (file or image). #[derive(Clone, PartialEq)] enum UploadStatus { @@ -118,6 +130,13 @@ struct Wizard { drafts: Signal>, /// Authenticated actor DID, used to build CDN preview URLs for uploaded images. did: Option, + /// Lazily-created offscreen WebGL2 capture surface for auto-rendered + /// preview images. `None` on non-wasm targets (stub no-ops). + preview_capture: Signal>, + /// Transient data-URI overrides for freshly-uploaded images, keyed by + /// blobref CID. CDN URLs for just-uploaded blobs aren't available until + /// replication completes; this shows the image immediately. + image_data_uris: Signal>, } #[component] @@ -141,6 +160,8 @@ fn PublishWizard(resume: String) -> Element { let upload = use_signal(|| UploadStatus::Idle); let image_upload = use_signal(|| UploadStatus::Idle); let drafts = use_signal(Vec::::new); + let preview_capture = use_signal(|| None); + let image_data_uris = use_signal(HashMap::::new); let wizard = Wizard { client, @@ -153,6 +174,8 @@ fn PublishWizard(resume: String) -> Element { image_upload, drafts, did, + preview_capture, + image_data_uris, }; // Resume a draft once on mount when `?draft=` is present; always load @@ -664,19 +687,47 @@ impl Wizard { .content_type() .filter(|s| !s.is_empty()) .unwrap_or_else(|| mime_from_filename(&filename).to_string()); - match draft_client::stage_file(&self.client, &filename, &mime, bytes).await { + // Clone before staging: `stage_file` takes `bytes` by value, but we + // need the original bytes for preview rendering. For large STL this + // briefly holds two copies — acceptable for v1. + match draft_client::stage_file(&self.client, &filename, &mime, bytes.clone()).await { Ok(staged) => { let format = format_from_filename(&filename); - let part = DraftPartInput { + let mut part = DraftPartInput { name: Some(filename.clone()), upload_id: Some(staged.upload_id.to_string()), file: Some(staged.file), - format, + format: format.clone(), ..Default::default() }; + // Best-effort preview generation (wasm-only; no-op off-wasm). + // v1 supports STL only — skip preview for other formats. + let preview = if format.as_deref() == Some("stl") { + let client = self.client.clone(); + let upload_sig = self.upload; + let preview_capture_sig = self.preview_capture; + let data_uris_sig = self.image_data_uris; + generate_preview( + client, + filename.clone(), + bytes, + upload_sig, + preview_capture_sig, + data_uris_sig, + ) + .await + } else { + None + }; { let mut composition = self.composition; let mut guard = composition.write(); + if let Some(image) = preview { + let parts_before_push = + guard.models.get(model_index).map_or(0, |m| m.parts.len()); + maybe_seed_cover(&mut guard, model_index, parts_before_push, &image); + attach_preview(&mut part, image); + } if let Some(model) = guard.models.get_mut(model_index) { model.parts.push(part); } @@ -761,7 +812,10 @@ impl Wizard { { let alt = image.alt.to_string(); let ratio = format!("{}×{}", image.aspect_ratio.width, image.aspect_ratio.height); - let preview_url = self.did.as_ref().map(|did| image_cdn_url(did, image)); + let cid = image.image.blob().r#ref.as_str().to_string(); + let preview_url = self.image_data_uris.read().get(cid.as_str()) + .map(|uri| uri.to_string()) + .or_else(|| self.did.as_ref().map(|did| image_cdn_url(did, image))); let alt_w = self.clone(); let remove_w = self.clone(); rsx! { @@ -835,9 +889,13 @@ impl Wizard { .content_type() .filter(|s| !s.is_empty()) .unwrap_or_else(|| mime_from_filename(&filename).to_string()); - match draft_client::upload_image(&self.client, &filename, &mime, bytes).await { + match draft_client::upload_image(&self.client, &filename, &mime, bytes.clone()).await { Ok(image) => { { + let cid = image.image.blob().r#ref.as_str().to_string(); + let data_uri = data_uri_from_bytes(&bytes, &mime); + let mut data_uris = self.image_data_uris; + data_uris.write().insert(cid.into(), data_uri); let mut composition = self.composition; let mut guard = composition.write(); let list = if is_cover { @@ -1160,9 +1218,105 @@ fn human_size(bytes: i64) -> String { } } +/// Attach a preview image to a draft part. v1 sets (overwrites) rather than +/// appends: each file stage creates a fresh `DraftPartInput`, so this is the +/// single auto-preview for that part. +fn attach_preview(part: &mut DraftPartInput, image: Image) { + part.previews = Some(vec![image]); +} + +/// Seed the thing-level cover from the first file of the first model when no +/// cover exists. Only fires for `model_index == 0` when the model has zero +/// parts *before* pushing the new one (the first file overall) and cover is +/// empty. Non-empty cover is never overwritten. +fn maybe_seed_cover( + composition: &mut DraftThingInput, + model_index: usize, + parts_before_push: usize, + image: &Image, +) { + if model_index == 0 + && parts_before_push == 0 + && composition.cover.as_ref().is_none_or(Vec::is_empty) + { + composition.cover = Some(vec![image.clone()]); + } +} + +/// Render a PNG preview from staged file bytes and upload it. Wasm-only: on +/// non-wasm targets this is a no-op stub returning `None`. +#[cfg(all(target_family = "wasm", target_os = "unknown"))] +async fn generate_preview( + client: PolymodelClient, + filename: String, + bytes: Vec, + mut upload: Signal, + preview_capture: Signal>, + mut image_data_uris: Signal>, +) -> Option { + // v1 supports STL only; other formats are skipped cleanly. + let model = crate::mesh::stl::load_stl(&bytes).ok()?; + + // Lazily get-or-create the capture surface. + let capture = { + let mut pc = preview_capture; + if pc.read().is_none() { + match PreviewCapture::new() { + Ok(c) => pc.set(Some(c)), + Err(err) => { + tracing::warn!(%err, "preview capture unavailable"); + return None; + } + } + } + pc.read().as_ref().unwrap().clone() + }; + + let png_bytes = match capture.render_png(&model, 1024, 1024) { + Ok(bytes) => bytes, + Err(err) => { + tracing::warn!(%err, "preview render failed for {filename}"); + return None; + } + }; + + let alt = format!("Rendered preview of {filename}"); + upload.set(UploadStatus::Uploading(format!("preview of {filename}"))); + match draft_client::upload_image(&client, &alt, "image/png", png_bytes.clone()).await { + Ok(image) => { + let cid = image.image.blob().r#ref.as_str().to_string(); + let data_uri = data_uri_from_bytes(&png_bytes, "image/png"); + image_data_uris.write().insert(cid.into(), data_uri); + Some(image) + } + Err(err) => { + tracing::warn!(%err, "preview upload failed for {filename}"); + upload.set(UploadStatus::Idle); + None + } + } +} + +#[cfg(not(all(target_family = "wasm", target_os = "unknown")))] +async fn generate_preview( + _client: PolymodelClient, + _filename: String, + _bytes: Vec, + _upload: Signal, + _preview_capture: Signal>, + _image_data_uris: Signal>, +) -> Option { + None +} + #[cfg(test)] mod tests { use super::*; + use std::str::FromStr; + + fn alt_str(image: &Image) -> &str { + >::as_ref(&image.alt) + } #[test] fn mime_and_format_from_extension() { @@ -1179,4 +1333,132 @@ mod tests { assert_eq!(human_size(2048), "2.0 KB"); assert_eq!(human_size(5 * 1024 * 1024), "5.0 MB"); } + + fn test_image(alt: &str) -> Image { + use jacquard_common::types::blob::{Blob, BlobRef, MimeType}; + use jacquard_common::types::cid::CidLink; + use polymodel_api::space_polymodel::library::AspectRatio; + Image { + alt: alt.into(), + aspect_ratio: AspectRatio { + height: 1024, + width: 1024, + extra_data: None, + }, + image: BlobRef::Blob(Blob { + r#ref: CidLink::from_str( + "bafyreigqdh7sjw7w6d7bk6f4x4n4n4n4n4n4n4n4n4n4n4n4n4n4n4n4n4n4", + ) + .unwrap(), + mime_type: MimeType::new("image/png".into()), + size: 42, + }), + extra_data: None, + } + } + + #[test] + fn attach_preview_sets_previews_when_empty() { + let mut part = DraftPartInput { + name: Some("Bracket".into()), + upload_id: Some("upload-1".into()), + ..Default::default() + }; + assert!(part.previews.is_none()); + attach_preview(&mut part, test_image("Rendered preview of Bracket.stl")); + assert_eq!(part.previews.as_ref().unwrap().len(), 1); + assert_eq!( + alt_str(&part.previews.as_ref().unwrap()[0]), + "Rendered preview of Bracket.stl" + ); + } + + #[test] + fn attach_preview_overwrites_existing_previews() { + let mut part = DraftPartInput { + name: Some("Bracket".into()), + previews: Some(vec![test_image("old user-uploaded preview")]), + ..Default::default() + }; + assert_eq!(part.previews.as_ref().unwrap().len(), 1); + attach_preview(&mut part, test_image("Rendered preview of Bracket.stl")); + assert_eq!(part.previews.as_ref().unwrap().len(), 1); + assert_eq!( + alt_str(&part.previews.as_ref().unwrap()[0]), + "Rendered preview of Bracket.stl" + ); + } + + #[test] + fn maybe_seed_cover_seeds_first_file_first_model() { + let mut composition = DraftThingInput::default(); + let image = test_image("Rendered preview of Tray.stl"); + maybe_seed_cover(&mut composition, 0, 0, &image); + assert_eq!(composition.cover.as_ref().unwrap().len(), 1); + assert_eq!( + alt_str(&composition.cover.as_ref().unwrap()[0]), + "Rendered preview of Tray.stl" + ); + } + + #[test] + fn maybe_seed_cover_does_not_seed_for_nonzero_model() { + let mut composition = DraftThingInput::default(); + let image = test_image("Rendered preview of Lid.stl"); + maybe_seed_cover(&mut composition, 1, 0, &image); + assert!(composition.cover.is_none()); + } + + #[test] + fn maybe_seed_cover_does_not_seed_when_parts_already_exist() { + let mut composition = DraftThingInput::default(); + let image = test_image("Rendered preview of second.stl"); + maybe_seed_cover(&mut composition, 0, 1, &image); + assert!(composition.cover.is_none()); + } + + #[test] + fn maybe_seed_cover_does_not_overwrite_nonempty_cover() { + let mut composition = DraftThingInput { + cover: Some(vec![test_image("user cover")]), + ..Default::default() + }; + let image = test_image("Rendered preview of Tray.stl"); + maybe_seed_cover(&mut composition, 0, 0, &image); + assert_eq!(composition.cover.as_ref().unwrap().len(), 1); + assert_eq!( + alt_str(&composition.cover.as_ref().unwrap()[0]), + "user cover" + ); + } + + #[test] + fn maybe_seed_cover_re_stage_after_delete_does_not_seed_over_user_cover() { + // Edge: model-0 parts deleted back to zero, but a user manually set a + // cover. The cover must NOT be overwritten by a new auto-preview. + let mut composition = DraftThingInput { + cover: Some(vec![test_image("user cover")]), + ..Default::default() + }; + let image = test_image("Rendered preview of re-staged.stl"); + maybe_seed_cover(&mut composition, 0, 0, &image); + assert_eq!( + alt_str(&composition.cover.as_ref().unwrap()[0]), + "user cover" + ); + } + + #[test] + fn maybe_seed_cover_re_stage_after_delete_seeds_when_both_empty() { + // Edge: model-0 parts deleted back to zero AND cover is empty. A new + // first file should re-seed the cover. + let mut composition = DraftThingInput::default(); + let image = test_image("Rendered preview of fresh.stl"); + maybe_seed_cover(&mut composition, 0, 0, &image); + assert_eq!(composition.cover.as_ref().unwrap().len(), 1); + assert_eq!( + alt_str(&composition.cover.as_ref().unwrap()[0]), + "Rendered preview of fresh.stl" + ); + } } diff --git a/src/viewer.rs b/src/viewer.rs index 49f2e8f..8826ca9 100644 --- a/src/viewer.rs +++ b/src/viewer.rs @@ -1022,6 +1022,73 @@ struct ThreeDRenderer { first_render_logged: bool, } +/// Viewport-parameterized scene construction shared by the viewer and offscreen +/// preview capture. The `viewport` dims MUST equal the backing-store dims of the +/// canvas the caller intends to render into (it does NOT call `canvas_viewport` +/// or apply `devicePixelRatio` — the caller owns canvas sizing). +#[cfg(all(target_family = "wasm", target_os = "unknown"))] +pub(crate) fn build_scene_with_viewport( + context: &three_d::Context, + model: &ModelMesh, + viewport: three_d::Viewport, +) -> ( + three_d::Gm, + three_d::Camera, + three_d::OrbitControl, +) { + let fit = model.camera_fit(); + let mesh = three_d::Gm::new( + three_d::Mesh::new(context, &model.trimesh), + three_d::PhysicalMaterial { + albedo: three_d::Srgba::new_opaque(116, 180, 255), + roughness: 0.72, + metallic: 0.0, + ..Default::default() + }, + ); + let radius = fit.radius.max(1.0); + let target = three_d::vec3(fit.center[0], fit.center[1], fit.center[2]); + let position = target + three_d::vec3(radius * 1.8, -radius * 2.4, radius * 1.6); + let camera = three_d::Camera::new_perspective( + viewport, + position, + target, + three_d::vec3(0.0, 0.0, 1.0), + three_d::degrees(45.0), + (radius / 100.0).max(0.1), + radius * 20.0, + ); + let control = three_d::OrbitControl::new(target, radius * 0.05, radius * 12.0); + (mesh, camera, control) +} + +/// Standard three-light rig shared by the viewer and preview capture so both +/// produce identical lighting. +#[cfg(all(target_family = "wasm", target_os = "unknown"))] +pub(crate) fn standard_lights( + context: &three_d::Context, +) -> ( + three_d::AmbientLight, + three_d::DirectionalLight, + three_d::DirectionalLight, +) { + let ambient_light = + three_d::AmbientLight::new(context, 0.45, three_d::Srgba::new_opaque(255, 255, 255)); + let key_light = three_d::DirectionalLight::new( + context, + 2.6, + three_d::Srgba::new_opaque(255, 255, 255), + three_d::vec3(-0.45, -0.55, -0.70), + ); + let fill_light = three_d::DirectionalLight::new( + context, + 0.9, + three_d::Srgba::new_opaque(170, 205, 255), + three_d::vec3(0.55, 0.35, -0.35), + ); + (ambient_light, key_light, fill_light) +} + #[cfg(all(target_family = "wasm", target_os = "unknown"))] impl ThreeDRenderer { /// Build the GPU mesh + camera framing for `model` against an existing @@ -1035,31 +1102,7 @@ impl ThreeDRenderer { three_d::Camera, three_d::OrbitControl, ) { - let fit = model.camera_fit(); - let mesh = three_d::Gm::new( - three_d::Mesh::new(context, &model.trimesh), - three_d::PhysicalMaterial { - albedo: three_d::Srgba::new_opaque(116, 180, 255), - roughness: 0.72, - metallic: 0.0, - ..Default::default() - }, - ); - let radius = fit.radius.max(1.0); - let target = three_d::vec3(fit.center[0], fit.center[1], fit.center[2]); - let position = target + three_d::vec3(radius * 1.8, -radius * 2.4, radius * 1.6); - let viewport = canvas_viewport(canvas); - let camera = three_d::Camera::new_perspective( - viewport, - position, - target, - three_d::vec3(0.0, 0.0, 1.0), - three_d::degrees(45.0), - (radius / 100.0).max(0.1), - radius * 20.0, - ); - let control = three_d::OrbitControl::new(target, radius * 0.05, radius * 12.0); - (mesh, camera, control) + build_scene_with_viewport(context, model, canvas_viewport(canvas)) } fn resize_and_render(&mut self) { @@ -1169,20 +1212,7 @@ impl ViewerRenderer for ThreeDRenderer { tracing::info!(backend = "WebGL2", "three-d WebGL2 context acquired"); let (mesh, camera, control) = Self::build_scene(&context, &canvas, model); - let ambient_light = - three_d::AmbientLight::new(&context, 0.45, three_d::Srgba::new_opaque(255, 255, 255)); - let key_light = three_d::DirectionalLight::new( - &context, - 2.6, - three_d::Srgba::new_opaque(255, 255, 255), - three_d::vec3(-0.45, -0.55, -0.70), - ); - let fill_light = three_d::DirectionalLight::new( - &context, - 0.9, - three_d::Srgba::new_opaque(170, 205, 255), - three_d::vec3(0.55, 0.35, -0.35), - ); + let (ambient_light, key_light, fill_light) = standard_lights(&context); let frame_started_ms = window .performance() .map(|performance| performance.now()) -- 2.51.2