diff --git a/Cargo.toml b/Cargo.toml index b6fcfcc..64465a6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -63,7 +63,6 @@ server = [ # aspectRatio) and opaque draft id generation. "dep:imagesize", "dep:ulid", - "dep:polymodel-ldraw-core", "dep:cid", ] [dependencies] @@ -79,7 +78,7 @@ jacquard-lexicon = { workspace = true } # Generated lexicon bindings (typed request/response/view types for XRPC routes). polymodel-api = { path = "crates/polymodel-api" } polymodel-renderer-protocol = { path = "crates/polymodel-renderer-protocol" } -polymodel-ldraw-core = { path = "crates/polymodel-ldraw-core", optional = true } +polymodel-ldraw-core = { path = "crates/polymodel-ldraw-core" } serde = { workspace = true } serde_json = "1.0" thiserror = { workspace = true } diff --git a/src/publish.rs b/src/publish.rs index bfc2028..8987e25 100644 --- a/src/publish.rs +++ b/src/publish.rs @@ -19,8 +19,8 @@ use crate::Route; use crate::client::PolymodelClient; use crate::session::SessionIdentity; use draft::{ - Bbox, DraftModelInput, DraftPartInput, DraftSummary, DraftThingInput, - ensure_default_model_for_upload, parse_thing_token, + Bbox, DraftBundlePlan, DraftModelInput, DraftPartInput, DraftStagedResource, DraftSummary, + DraftThingInput, ensure_default_model_for_upload, parse_thing_token, plan_draft_ldraw_bundle, }; use jacquard_common::deps::smol_str::SmolStr; use polymodel_api::com_atproto::repo::strong_ref::StrongRef; @@ -178,6 +178,9 @@ struct Flow { save_in_flight: Signal, /// The mounted name input element, focused once after the first upload. name_element: Signal>>, + /// Monotonic draft-local preview generation. A completion may install only + /// when it still names the current staged resource set. + preview_generation: Signal, } #[component] @@ -221,6 +224,7 @@ fn PublishFlow(resume: String) -> Element { let save_in_flight = use_signal(|| false); // The mounted name input element, focused once after the first upload. let name_element: Signal>> = use_signal(|| None); + let preview_generation = use_signal(|| 0_u64); let flow = Flow { client, @@ -242,6 +246,7 @@ fn PublishFlow(resume: String) -> Element { works_with_status, save_in_flight, name_element, + preview_generation, }; // Resume a draft once on mount when `?draft=` is present; always load @@ -806,7 +811,15 @@ impl Flow { class: "button button-ghost wizard-remove", onclick: move |_| { let mut c = premove_w.composition; - c.write().models[0].parts.remove(pi); + let removed = c.write().models[0].parts.remove(pi); + if let Some(name) = removed.name.as_deref() { + c.write().models[0].staged_resources.retain(|resource| resource.name != name); + } + let mut generation = premove_w.preview_generation; + let next_generation = generation.read().saturating_add(1); + generation.set(next_generation); + let flow = premove_w.clone(); + spawn(async move { flow.recompute_model_preview(0).await; }); premove_w.clone().mark_dirty_and_save(); }, "Remove" @@ -1004,7 +1017,15 @@ impl Flow { class: "button button-ghost wizard-remove", onclick: move |_| { let mut c = premove_w.composition; - c.write().models[mi].parts.remove(pi); + let removed = c.write().models[mi].parts.remove(pi); + if let Some(name) = removed.name.as_deref() { + c.write().models[mi].staged_resources.retain(|resource| resource.name != name); + } + let mut generation = premove_w.preview_generation; + let next_generation = generation.read().saturating_add(1); + generation.set(next_generation); + let flow = premove_w.clone(); + spawn(async move { flow.recompute_model_preview(mi).await; }); premove_w.clone().mark_dirty_and_save(); }, "Remove" @@ -2010,46 +2031,34 @@ impl Flow { .await { Ok(staged) => { - let mut part = DraftPartInput { + let part = DraftPartInput { name: Some(filename.clone()), upload_id: Some(staged.upload_id.to_string()), file: Some(staged.file), format: format.clone(), ..Default::default() }; - let preview = if supports_generated_preview(&filename) { - generate_preview( - self.client.clone(), - filename.clone(), - bytes, - self.upload, - self.image_data_uris, - ) - .await - } else { - None - }; let was_first = { let g = self.composition.read(); g.models.first().map(|m| m.parts.is_empty()).unwrap_or(true) }; let mut composition = self.composition; let mut g = composition.write(); - if let Some(image) = preview { - let parts_before_push = - g.models.first().map_or(0, |m| m.parts.len()); - attach_generated_preview( - &mut g, - 0, - parts_before_push, - &mut part, - image, - ); - } if let Some(model) = g.models.get_mut(0) { + install_staged_resource( + model, + DraftStagedResource { + name: filename.clone(), + mime_type: mime.clone(), + bytes: bytes.clone(), + digest: None, + byte_length: bytes.len() as u64, + }, + ); model.parts.push(part); } drop(g); + self.clone().recompute_model_preview(0).await; // Focus the name field once, on the first staged // file, so the user lands on the next likely edit. if was_first && let Some(element) = self.name_element.read().clone() { @@ -2108,43 +2117,31 @@ impl Flow { }; match draft_client::stage_file(&self.client, &filename, &mime, bytes.clone()).await { Ok(staged) => { - let mut part = DraftPartInput { + let part = DraftPartInput { name: Some(filename.clone()), upload_id: Some(staged.upload_id.to_string()), file: Some(staged.file), format: format.clone(), ..Default::default() }; - let preview = if supports_generated_preview(&filename) { - generate_preview( - self.client.clone(), - filename.clone(), - bytes, - self.upload, - self.image_data_uris, - ) - .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()); - attach_generated_preview( - &mut guard, - model_index, - parts_before_push, - &mut part, - image, - ); - } if let Some(model) = guard.models.get_mut(model_index) { + install_staged_resource( + model, + DraftStagedResource { + name: filename.clone(), + mime_type: mime.clone(), + bytes: bytes.clone(), + digest: None, + byte_length: bytes.len() as u64, + }, + ); model.parts.push(part); } } + self.clone().recompute_model_preview(model_index).await; upload.set(UploadStatus::Idle); let flow = self.clone(); flow.save_draft().await; @@ -2155,6 +2152,78 @@ impl Flow { } } + /// Recompute the generated preview from the complete staged resource set. + /// Missing companions are transient: the next staged-set mutation retries. + async fn recompute_model_preview(self, model_index: usize) { + let generation = { + let mut generation = self.preview_generation; + let next = generation.read().saturating_add(1); + generation.set(next); + next + }; + let (primary_name, staged) = { + let composition = self.composition.read(); + let Some(model) = composition.models.get(model_index) else { + return; + }; + let primary = model.parts.iter().find_map(|part| { + part.name + .as_deref() + .filter(|name| supports_generated_preview(name)) + .map(str::to_owned) + }); + (primary, model.staged_resources.clone()) + }; + let Some(primary_name) = primary_name else { + return; + }; + let plan = if format_registry::format_for_extension(&primary_name) + == Some(format_registry::FormatId::Ldraw) + { + plan_draft_ldraw_bundle(&primary_name, &staged) + } else { + DraftBundlePlan::Unsupported + }; + let DraftBundlePlan::Complete(bundle) = plan else { + if let DraftBundlePlan::Incomplete(error) = plan { + tracing::debug!(%error, %primary_name, "generated preview pending staged companions"); + } + return; + }; + let image = generate_preview_bundle( + self.client.clone(), + primary_name.clone(), + bundle, + self.upload, + self.image_data_uris, + ) + .await; + if *self.preview_generation.read() != generation { + tracing::debug!(%primary_name, "discarding stale generated preview"); + return; + } + if let Some(image) = image { + let mut composition = self.composition; + let mut guard = composition.write(); + let Some(model) = guard.models.get_mut(model_index) else { + return; + }; + let Some(part_index) = model + .parts + .iter() + .position(|part| part.name.as_deref() == Some(primary_name.as_str())) + else { + return; + }; + let parts_before_push = part_index; + let mut part = model.parts.remove(part_index); + attach_generated_preview(&mut guard, model_index, parts_before_push, &mut part, image); + if let Some(model) = guard.models.get_mut(model_index) { + model.parts.insert(part_index, part); + } + } + } + /// Upload an image and attach it to the target's cover/previews, then save /// once after success. The image-upload status is its own signal so it does /// not race the file-upload status. @@ -2399,6 +2468,13 @@ 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 install_staged_resource(model: &mut DraftModelInput, resource: DraftStagedResource) { + model + .staged_resources + .retain(|existing| existing.name != resource.name); + model.staged_resources.push(resource); +} + fn attach_preview(part: &mut DraftPartInput, image: Image) { part.previews = Some(vec![image]); } @@ -2445,28 +2521,28 @@ fn maybe_seed_cover( /// 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( +async fn generate_preview_bundle( client: PolymodelClient, filename: String, - bytes: Vec, + bundle: polymodel_renderer_protocol::ModelLoadBundle, mut upload: Signal, mut image_data_uris: Signal>, ) -> Option { - let png_bytes = match renderer_preview::render_preview_png(&filename, bytes, 1024, 1024).await { + let png_bytes = match renderer_preview::render_preview_bundle_png(bundle, 1024, 1024).await { Ok(bytes) => bytes, Err(err) => { - tracing::warn!(%err, "preview render failed for {filename}"); + tracing::debug!(%err, "preview render pending or failed"); 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); + image_data_uris + .write() + .insert(cid.into(), data_uri_from_bytes(&png_bytes, "image/png")); Some(image) } Err(err) => { @@ -2477,6 +2553,17 @@ async fn generate_preview( } } +#[cfg(not(all(target_family = "wasm", target_os = "unknown")))] +async fn generate_preview_bundle( + _client: PolymodelClient, + _filename: String, + _bundle: polymodel_renderer_protocol::ModelLoadBundle, + _upload: Signal, + _image_data_uris: Signal>, +) -> Option { + None +} + #[cfg(not(all(target_family = "wasm", target_os = "unknown")))] async fn generate_preview( _client: PolymodelClient, @@ -2503,6 +2590,15 @@ mod renderer_preview { use wasm_bindgen::closure::Closure; use wasm_bindgen::prelude::*; + pub(super) async fn render_preview_bundle_png( + bundle: polymodel_renderer_protocol::ModelLoadBundle, + width: u32, + height: u32, + ) -> Result, String> { + request_preview_png(LoadSource::Bundle(bundle), width, height).await + } + + #[allow(dead_code)] pub(super) async fn render_preview_png( filename: &str, bytes: Vec, @@ -2535,10 +2631,7 @@ mod renderer_preview { } if format_registry::format_for_extension(filename) == Some(FormatId::Ldraw) { - // Compound bundles are materialized by the server-owned G path only - // after projection. Draft staging has no resource URI or companion - // manifest, so never dispatch a partial browser-local bundle. - return Err("LDraw preview requires a complete projected model bundle".to_owned()); + return Err("LDraw preview requires a complete staged bundle".to_owned()); } let format = MeshFormat::from_extension(filename) diff --git a/src/publish/draft.rs b/src/publish/draft.rs index e587da6..682061d 100644 --- a/src/publish/draft.rs +++ b/src/publish/draft.rs @@ -27,7 +27,9 @@ use polymodel_api::com_atproto::repo::strong_ref::StrongRef; use crate::thing_detail::thing_uri_from_route; use polymodel_api::space_polymodel::library::publish_thing::{ModelInput, PartInput, ThingInput}; use polymodel_api::space_polymodel::library::{File, Image}; +use polymodel_renderer_protocol::{MeshFormat, ModelLoadBundle, ModelResource, PROTOCOL_VERSION}; use serde::{Deserialize, Serialize}; +use sha2::Digest; // Re-export leaf types the publish UI edits directly. pub use polymodel_api::space_polymodel::library::Bbox; @@ -99,6 +101,123 @@ pub struct DraftThingInput { pub models: Vec, } +/// Browser-local staged geometry retained with a draft so compound previews can +/// be recomputed after later companion uploads. The bytes are never assembled +/// into a publish record; `file` remains the server-owned publish manifest. +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct DraftStagedResource { + pub name: String, + pub mime_type: String, + pub bytes: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub digest: Option>, + pub byte_length: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum DraftBundlePlan { + Complete(ModelLoadBundle), + Incomplete(String), + Unsupported, +} + +/// Plan a draft-local LDraw bundle using the same path/root normalization that +/// the projected resolver uses. No incomplete plan is eligible for dispatch. +pub fn plan_draft_ldraw_bundle( + primary_name: &str, + staged: &[DraftStagedResource], +) -> DraftBundlePlan { + let Some(primary) = staged.iter().find(|resource| resource.name == primary_name) else { + return DraftBundlePlan::Incomplete("primary staged resource is missing".into()); + }; + let primary_path = if primary_name.contains('/') { + primary_name.to_owned() + } else { + format!("models/{primary_name}") + }; + let primary_path = match polymodel_ldraw_core::NormalizedPath::new(&primary_path) { + Ok(path) => path, + Err(error) => return DraftBundlePlan::Incomplete(error.to_string()), + }; + let mut resources = Vec::new(); + let mut by_path = std::collections::HashMap::new(); + for resource in staged { + let raw_path = if resource.name == primary_name { + primary_path.as_str().to_owned() + } else if resource.name.contains('/') { + resource.name.clone() + } else { + format!("p/{}", resource.name) + }; + let path = match polymodel_ldraw_core::NormalizedPath::new(&raw_path) { + Ok(path) => path, + Err(error) => return DraftBundlePlan::Incomplete(error.to_string()), + }; + let root = match path.as_str().split('/').next().unwrap_or_default() { + "models" => "models", + "parts" => "parts", + "p" => "p", + _ => "models", + }; + let key = path.as_str().to_owned(); + if by_path.insert(key.clone(), ()).is_some() { + return DraftBundlePlan::Incomplete(format!("duplicate staged path: {key}")); + } + let digest = resource + .digest + .clone() + .unwrap_or_else(|| sha2::Sha256::digest(&resource.bytes).to_vec()); + let model_resource = ModelResource { + key: key.clone(), + path: key, + root: root.into(), + byte_length: resource.byte_length, + sha256: digest, + mime_type: resource.mime_type.clone(), + target_cids: Vec::new(), + bytes: resource.bytes.clone(), + }; + if let Err(error) = model_resource.validate() { + return DraftBundlePlan::Incomplete(format!("staged resource rejected: {error:?}")); + } + resources.push(model_resource); + } + let references = match polymodel_ldraw_core::extract_references(&primary.bytes) { + Ok(references) => references, + Err(error) => return DraftBundlePlan::Incomplete(error.to_string()), + }; + for reference in references { + if reference.target_virtual_file { + continue; + } + let current = primary_path.clone(); + let resolved = match polymodel_ldraw_core::resolve_reference_path(¤t, &reference) { + Ok(resolved) => resolved, + Err(error) => return DraftBundlePlan::Incomplete(error.to_string()), + }; + if !resources + .iter() + .any(|resource| resource.path == resolved.path.as_str()) + { + return DraftBundlePlan::Incomplete(format!( + "staged companion is missing: {}", + resolved.path + )); + } + } + let bundle = ModelLoadBundle { + version: PROTOCOL_VERSION, + format: MeshFormat::CompoundLdraw, + primary: primary_path.as_str().to_owned(), + resources, + }; + match bundle.validate() { + Ok(()) => DraftBundlePlan::Complete(bundle), + Err(error) => DraftBundlePlan::Incomplete(format!("model bundle rejected: {error:?}")), + } +} + /// A model within a draft. `parts` are ordered; an empty draft model is allowed /// (it simply fails publish validation until it has a name and at least one part). #[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq)] @@ -125,6 +244,10 @@ pub struct DraftModelInput { pub intended_to_work_with: Option>, #[serde(default)] pub parts: Vec, + /// All geometry staged for this model, including files that are companions + /// rather than publishable parts. This is the draft-local preview set. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub staged_resources: Vec, } /// A part within a draft model. A part is publishable once it has a name and a @@ -534,6 +657,65 @@ pub fn parse_thing_token(token: &str) -> Result { mod tests { use super::*; + fn resource(name: &str, bytes: &[u8]) -> DraftStagedResource { + DraftStagedResource { + name: name.into(), + mime_type: "application/x-ldraw".into(), + bytes: bytes.to_vec(), + digest: None, + byte_length: bytes.len() as u64, + } + } + + const MAIN: &[u8] = b"1 16 0 0 0 1 0 0 0 1 0 0 0 0 child.dat\n"; + const CHILD: &[u8] = b"0 Child\n3 16 0 0 0 1 0 0 0 1 0 0\n"; + + #[test] + fn ldraw_preview_plan_is_incomplete_until_companion_arrives() { + let main = resource("main.ldr", MAIN); + assert!(matches!( + plan_draft_ldraw_bundle("main.ldr", std::slice::from_ref(&main)), + DraftBundlePlan::Incomplete(_) + )); + let companion = resource("child.dat", CHILD); + let plan = plan_draft_ldraw_bundle("main.ldr", &[main, companion]).clone(); + let DraftBundlePlan::Complete(bundle) = plan else { + panic!("bundle should become complete") + }; + assert_eq!(bundle.primary, "models/main.ldr"); + assert_eq!(bundle.resources.len(), 2); + } + + #[test] + fn ldraw_preview_plan_is_order_independent() { + let main = resource("main.ldr", MAIN); + let companion = resource("child.dat", CHILD); + let first = plan_draft_ldraw_bundle("main.ldr", &[main.clone(), companion.clone()]); + let second = plan_draft_ldraw_bundle("main.ldr", &[companion, main]); + assert!(matches!(first, DraftBundlePlan::Complete(_))); + assert!(matches!(second, DraftBundlePlan::Complete(_))); + } + + #[test] + fn ldraw_preview_plan_rejects_replacement_with_bad_digest_and_isolates_sets() { + let main = resource("main.ldr", MAIN); + let companion = resource("child.dat", CHILD); + assert!(matches!( + plan_draft_ldraw_bundle("main.ldr", &[main.clone(), companion.clone()]), + DraftBundlePlan::Complete(_) + )); + let mut replacement = resource("child.dat", b"changed"); + replacement.digest = Some(vec![0; 32]); + assert!(matches!( + plan_draft_ldraw_bundle("main.ldr", &[main, replacement]), + DraftBundlePlan::Incomplete(_) + )); + assert!(matches!( + plan_draft_ldraw_bundle("other.ldr", &[companion]), + DraftBundlePlan::Incomplete(_) + )); + } + fn part_with_upload(name: &str) -> DraftPartInput { DraftPartInput { name: Some(name.to_string()),