//! Single-page, upload-first publish flow. //! //! Dropping geometry files is the hero action: it auto-creates the //! Thing→Model→Part structure (one default model, each file a part) and //! auto-fills the thing name from the first filename. All optional metadata — //! thing-level fields, per-model/per-part fields, and thing-level //! `intended_to_work_with` — lives behind progressive disclosure that //! auto-expands for multi-model use, ending in a compact "what's public" //! confirm + Publish. The server-side draft/publish backend is unchanged. //! //! Validation stays in [`DraftThingInput::assemble`](draft::DraftThingInput); //! every exposed field is already carried on the draft types and mapped there. use dioxus::html::{FileData, HasFileData}; use dioxus::prelude::*; use std::collections::{HashMap, HashSet}; use crate::Route; use crate::client::PolymodelClient; use crate::session::SessionIdentity; use draft::{ AcquiredDraftResource, Bbox, DraftBundlePlan, DraftModelInput, DraftPartInput, DraftStagedResource, DraftSummary, DraftThingInput, GeneratedPreview, MAX_PREVIEW_AGGREGATE_BYTES, MAX_PREVIEW_RESOURCE_BYTES, MAX_PREVIEW_RESOURCES, 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; use polymodel_api::space_polymodel::library::Image; use polymodel_renderer_protocol::format_registry; use sha2::Digest; use state::PublishPhase; pub(crate) mod draft; pub(crate) mod draft_client; pub(crate) mod state; /// Common open licenses offered in the flow. The first is the effective /// default when none is chosen. const LICENSE_OPTIONS: &[&str] = &[ "CC-BY-4.0", "CC-BY-SA-4.0", "CC0-1.0", "CC-BY-NC-4.0", "CC-BY-NC-SA-4.0", "MIT", "All rights reserved", ]; const DEFAULT_LICENSE: &str = "CC-BY-4.0"; const CUSTOM_LICENSE_VALUE: &str = "__custom__"; const GEOMETRY_PICKER_ACCEPT: &str = ".stl,.obj,.gltf,.glb,.3mf,.step,.stp,.ldr,.mpd,.dat,model/gltf+json,model/gltf-binary,application/x-ldraw,model/ldraw,model/*,application/octet-stream"; /// Bluesky CDN thumbnail URL for an uploaded image blob (mirrors the appview's /// `blob_cdn_url`) so the flow can preview an image right after upload. fn image_cdn_url(did: &str, image: &Image) -> String { let cid = image.image.blob().r#ref.as_str(); 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 { Idle, Uploading(String), Error(String), } #[derive(Clone, Copy, PartialEq, Eq)] enum PreviewOutcome { Complete, Error, Stale, } /// Whether the server-side draft is in sync with the in-memory composition. #[derive(Clone, Copy, PartialEq)] enum SaveState { /// Initial state: nothing saved yet, so the status reads as empty rather than /// falsely claiming everything is saved. New, Saved, Saving, Dirty, Error, } /// Publish route. Anonymous visitors see the sign-in callout (returning here); /// signed-in users get the full flow. `draft` is an optional draft id to /// resume (`/publish?draft=`). #[allow(clippy::useless_format)] #[component] pub(crate) fn Publish(draft: String) -> Element { let session = use_context::>(); let identity = session.read().clone(); // Session still resolving on first load: show a neutral loading state rather // than flashing the signed-out callout at users who are actually signed in. if matches!(identity, SessionIdentity::Unknown) { return rsx! { main { class: "publish-page", section { class: "publish-intro blueprint-panel", aria_label: "Publish", h1 { "Publish" } div { class: "skeleton-line skeleton-wide" } div { class: "skeleton-line" } } } }; } if matches!(identity, SessionIdentity::Anonymous) { return rsx! { main { class: "publish-page", section { class: "publish-signed-out blueprint-panel", aria_label: "Sign in", h1 { "Share your models" } form { class: "session-control", action: "/oauth/start", method: "get", input { r#type: "hidden", name: "return_to", value: "/publish" }, input { r#type: "text", name: "identifier", placeholder: "alice.bsky.social", aria_label: "ATProto handle", } button { class: "button button-primary", r#type: "submit", "Sign in" } } p { class: "product-lede", "Sign in to share your creations using your Bluesky or other Atmosphere account." } } } }; } rsx! { PublishFlow { resume: draft } } } #[derive(Clone)] struct Flow { client: PolymodelClient, composition: Signal, draft_id: Signal>, phase: Signal, save: Signal, upload: Signal, image_upload: Signal, drafts: Signal>, /// Authenticated actor DID, used to build CDN preview URLs for uploaded images. did: Option, /// 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>, /// Advanced (multi-model) disclosure open-state, shared with the auto-expand /// at the add-model site and `resume_draft`. Controlled-
`ontoggle` /// keeps it honest. advanced_open: Signal, /// One-way flag: once Advanced has auto-expanded (or a multi-model draft was /// resumed expanded), it is never auto-opened again — so a user who collapses /// Advanced and later removes/re-adds a model stays collapsed. advanced_auto_expanded: Signal, /// Live text of the "Works with" paste field, so blur can resolve it. works_with_text: Signal, /// Whether a file drag is over the hero dropzone (drives the drag-over class). drag_over: Signal, /// Resolved display names for "Works with" chips, keyed by thing URI string. /// The persisted `intended_to_work_with` holds only `StrongRef`s (no name), so /// the human-readable label lives here alongside the refs. compat_names: Signal>, /// License fields where the user explicitly opened the custom-license input. custom_license_targets: Signal>, /// Status of the "Works with" resolve flow (kept separate from file-upload /// status so the two do not overwrite each other's feedback). works_with_status: Signal, /// Guards against concurrent saves: while a save is in flight, further /// `mark_dirty_and_save` calls just mark Dirty; the completing save re-runs /// if still Dirty. Prevents duplicate-draft creation on rapid edits. save_in_flight: Signal, /// The mounted name input element, focused once after the first upload. name_element: Signal>>, /// Current preview operation token per stable model. Independent models do /// not cancel each other; stale completions must match their own run token. preview_generations: Signal>, /// Current owner of the shared file-upload status. A stale operation may /// retire or replace the status only while its token remains current. upload_operation: Signal>, /// Current owner of the image-upload status. Separate from /// `upload_operation` so the two signals never couple; a stale image /// completion must not overwrite a newer image upload's status. image_upload_operation: Signal>, /// Monotonic token source for asynchronous operations in this flow instance. operation_counter: Signal, } #[component] fn PublishFlow(resume: String) -> Element { let client = use_context::(); let session = use_context::>(); let did = match &*session.read() { SessionIdentity::Authenticated(identity) => Some(identity.did.as_str().to_string()), _ => None, }; // Seed a new draft with the effective default license so the control reflects // real state from the start (a control only fires change when its value changes). let composition = use_signal(|| DraftThingInput { license: Some(DEFAULT_LICENSE.to_string()), ..DraftThingInput::default() }); let draft_id = use_signal(|| Option::::None); let phase = use_signal(|| PublishPhase::Editing); let save = use_signal(|| SaveState::New); let upload = use_signal(|| UploadStatus::Idle); let image_upload = use_signal(|| UploadStatus::Idle); let drafts = use_signal(Vec::::new); let image_data_uris = use_signal(HashMap::::new); // Disclosure open-state for the Advanced (multi-model) section, opened at the // add-model site. Controlled-
`ontoggle` keeps it honest about // manual collapses. let advanced_open = use_signal(|| false); // One-way flag: once Advanced has auto-expanded, it is never auto-opened again. let advanced_auto_expanded = use_signal(|| false); // Live text of the "Works with" paste field, so blur can resolve it. let works_with_text = use_signal(String::new); // Whether a file drag is over the hero dropzone (drives the drag-over class). let drag_over = use_signal(|| false); // Resolved display names for "Works with" chips (uri → name). let compat_names = use_signal(std::collections::HashMap::new); let custom_license_targets = use_signal(HashSet::::new); // "Works with" resolve status, separate from file-upload status. let works_with_status = use_signal(|| UploadStatus::Idle); // Guards against concurrent saves (see Flow::save_draft). 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_generations = use_signal(HashMap::new); let upload_operation = use_signal(|| None); let image_upload_operation = use_signal(|| None); let operation_counter = use_signal(|| 0_u64); let flow = Flow { client, composition, draft_id, phase, save, upload, image_upload, drafts, did, image_data_uris, advanced_open, advanced_auto_expanded, works_with_text, drag_over, compat_names, custom_license_targets, works_with_status, save_in_flight, name_element, preview_generations, upload_operation, image_upload_operation, operation_counter, }; // Resume a draft once on mount when `?draft=` is present; always load // the actor's existing drafts so they can be picked up as entry points. { let flow = flow.clone(); let resume = resume.clone(); use_hook(move || { if !resume.trim().is_empty() { let mut flow = flow.clone(); let resume = resume.clone(); spawn(async move { match draft_client::get_draft(&flow.client, resume.trim()).await { Ok(mut loaded) => { // A resumed multi-model draft should land with its // Advanced section expanded. let multi = loaded.models.len() > 1; normalize_loaded_draft(&mut loaded); flow.preview_generations.write().clear(); flow.upload_operation.set(None); flow.image_upload_operation.set(None); { let mut composition = flow.composition; composition.set(loaded); } let mut advanced_open = advanced_open; let mut auto_expanded = advanced_auto_expanded; advanced_open.set(multi); auto_expanded.set(multi); let mut draft_id = flow.draft_id; draft_id.set(Some(resume.trim().to_string())); } Err(error) => { tracing::warn!(%error, "failed to resume draft"); } } }); } let flow = flow.clone(); spawn(async move { flow.reload_drafts().await }); }); } // Mirror the active draft id into the URL so a full reload (e.g. the dev // server's hot-reload websocket dropping and reconnecting) resumes the same // draft via `?draft=` instead of dropping you on a blank `/publish`. // replace() avoids stacking a history entry on every autosave. { let draft_id = flow.draft_id; use_effect(move || { if let Some(id) = draft_id.read().clone() { navigator().replace(Route::Publish { draft: id }); } }); } // (Auto-expand and focus-on-first-upload are handled at the mutation site, // not via effects — observing signals to fire one-shot side effects re-runs // on every change and needs fragile prev-state bookkeeping.) rsx! { main { class: "publish-page", { flow.render_header() } if let PublishPhase::Published { repo, rkey } = flow.phase.read().clone() { { flow.render_published(repo, rkey) } } else { { flow.render_resume() } { flow.render_editor() } { flow.render_confirm() } } } } } /// The level a shared field renderer targets: the thing, a model, or a part. #[derive(Clone, Copy)] enum FieldTarget { Thing, Model(usize), Part(usize, usize), } /// Which bbox axis/unit a single dimension input edits. #[derive(Clone, Copy)] enum BboxField { X, Y, Z, Unit, } impl Flow { /// Create (first save) or update the server-side draft. Serializes saves: if /// a save is already in flight, callers should mark Dirty and let the /// in-flight save re-run — `mark_dirty_and_save` enforces that, and this /// method re-spawns when it finishes with Dirty still set, so rapid edits /// coalesce instead of racing into duplicate drafts. async fn save_draft(self) { { let mut guard = self.save_in_flight; if *guard.read() { // Another save is mid-flight; mark dirty and let it pick this up. let mut save = self.save; save.set(SaveState::Dirty); return; } guard.set(true); } let mut save = self.save; save.set(SaveState::Saving); let payload = self.composition.read().clone(); let existing = self.draft_id.read().clone(); if existing.is_none() && is_initial_composition(&payload) { save.set(SaveState::New); let mut guard = self.save_in_flight; guard.set(false); return; } let result = match existing { Some(id) => draft_client::put_draft(&self.client, &id, &payload) .await .map(|_| id), None => draft_client::create_draft(&self.client, &payload).await, }; match result { Ok(id) => { let mut draft_id = self.draft_id; draft_id.set(Some(id)); // Only mark Saved if no edit landed while the request was in // flight — otherwise leave Dirty so the re-run below persists it. if !matches!(*save.read(), SaveState::Dirty) { save.set(SaveState::Saved); } } Err(error) => { tracing::warn!(%error, "failed to save draft"); save.set(SaveState::Error); } } // Release the guard; if edits landed during the save, re-run once. let mut guard = self.save_in_flight; guard.set(false); if matches!(*self.save.read(), SaveState::Dirty) { let flow = self.clone(); spawn(async move { flow.save_draft().await }); } } /// Mark the composition dirty and persist it. Used after every structural or /// committed-text mutation now that the step-change save trigger is gone. /// Coalesces with any in-flight save (see `save_draft`). fn mark_dirty_and_save(&self) { let mut save = self.save; save.set(SaveState::Dirty); if *self.save_in_flight.read() { return; } let flow = self.clone(); spawn(async move { flow.save_draft().await }); } fn mark_dirty(&self) { let mut save = self.save; save.set(SaveState::Dirty); } fn set_license_value(&self, target: FieldTarget, value: String) { let mut composition = self.composition; let mut guard = composition.write(); let value = if value.trim().is_empty() { None } else { Some(value) }; match target { FieldTarget::Thing => guard.license = value, FieldTarget::Model(mi) => { if let Some(model) = guard.models.get_mut(mi) { model.license = value; } } FieldTarget::Part(_, _) => {} } } /// Refresh the actor's draft summaries for the resume list. async fn reload_drafts(self) { let mut drafts = self.drafts; match draft_client::list_drafts(&self.client).await { Ok(list) => drafts.set(list), Err(error) => tracing::warn!(%error, "failed to list drafts"), } } /// Load an existing draft into the editor in place (without a route change). async fn resume_draft(mut self, id: String) { match draft_client::get_draft(&self.client, &id).await { Ok(mut loaded) => { let multi = loaded.models.len() > 1; normalize_loaded_draft(&mut loaded); self.preview_generations.write().clear(); self.upload_operation.set(None); self.image_upload_operation.set(None); { let mut composition = self.composition; composition.set(loaded); } { let mut draft_id = self.draft_id; draft_id.set(Some(id)); } let mut advanced_open = self.advanced_open; let mut auto_expanded = self.advanced_auto_expanded; advanced_open.set(multi); auto_expanded.set(multi); let mut save = self.save; save.set(SaveState::Saved); } Err(error) => tracing::warn!(%error, "failed to resume draft"), } } /// Delete a draft from the resume list; clears the editor if it was active. async fn discard_draft(mut self, id: String) { match draft_client::delete_draft(&self.client, &id).await { Ok(_) => { if self.draft_id.read().as_deref() == Some(id.as_str()) { self.preview_generations.write().clear(); self.upload_operation.set(None); self.image_upload_operation.set(None); let mut composition = self.composition; let mut draft_id = self.draft_id; composition.set(DraftThingInput::default()); draft_id.set(None); } self.clone().reload_drafts().await; } Err(error) => tracing::warn!(%error, "failed to delete draft"), } } fn render_resume(&self) -> Element { let drafts = self.drafts.read().clone(); let active = self.draft_id.read().clone(); let others: Vec = drafts .into_iter() .filter(|d| active.as_deref() != Some(d.draft_id.as_str())) .collect(); if others.is_empty() { return rsx! {}; } rsx! { section { class: "wizard-resume blueprint-panel", aria_label: "Resume a draft", h2 { class: "wizard-subhead", "Resume a draft" } ul { class: "wizard-resume-list", for summary in others { { let id = summary.draft_id.clone(); let name = summary .name .clone() .filter(|n| !n.trim().is_empty()) .unwrap_or_else(|| "Untitled draft".to_string()); let model_count = summary.model_count; let resume_f = self.clone(); let resume_id = id.clone(); let discard_f = self.clone(); let discard_id = id.clone(); rsx! { li { key: "{id}", class: "wizard-resume-item", div { class: "wizard-resume-meta", span { class: "wizard-resume-name", "{name}" } span { class: "card-meta", "{model_count} model(s)" } } div { class: "button-row", button { class: "button button-secondary", onclick: move |_| { let flow = resume_f.clone(); let id = resume_id.clone(); spawn(async move { flow.resume_draft(id).await }); }, "Resume" } button { class: "button button-ghost", onclick: move |_| { let flow = discard_f.clone(); let id = discard_id.clone(); spawn(async move { flow.discard_draft(id).await }); }, "Delete" } } } } } } } } } } fn render_header(&self) -> Element { let phase = self.phase.read().clone(); let save = *self.save.read(); let (pill_class, pill_label) = match &phase { PublishPhase::Published { .. } => ("status-pill", "Published"), PublishPhase::Publishing => ("status-pill", "Publishing…"), PublishPhase::Failed(_) => ("status-pill status-error", "Publish failed"), PublishPhase::Editing => ("status-pill status-muted", "Draft"), }; let save_label = match save { SaveState::New => "", SaveState::Saved => "Saved", SaveState::Saving => "Saving…", SaveState::Dirty => "Unsaved changes", SaveState::Error => "Save failed", }; let save_f = self.clone(); rsx! { header { class: "publish-head", div { class: "publish-head-row", h1 { "Publish a thing" } span { class: "{pill_class}", "{pill_label}" } button { class: "button button-secondary publish-save", disabled: matches!(save, SaveState::Saving), onclick: move |_| { let flow = save_f.clone(); spawn(async move { flow.save_draft().await }); }, "Save draft" } } p { class: "publish-save-state", aria_live: "polite", "{save_label}" } if let PublishPhase::Failed(message) = &phase { p { class: "wizard-error", role: "alert", "{message}" } } } } } /// Upload/resolve status (progress or error), shown wherever file or /// "Works with" staging can fail — both the hero (empty state) and the files /// list (post-upload), so failures are never hidden by the hero swap. fn render_upload_status(&self) -> Element { let status = self.upload.read().clone(); match status { UploadStatus::Uploading(label) => rsx! { div { class: "wizard-upload-status", role: "status", span { class: "wizard-progress-bar", "aria-hidden": "true" } span { "Uploading {label}…" } } }, UploadStatus::Error(message) => rsx! { p { class: "wizard-error", role: "alert", "{message}" } }, UploadStatus::Idle => rsx! {}, } } /// "Works with" resolve status, shown inside that disclosure only — kept /// separate from the file-upload status so the two flows don't clobber each /// other's feedback. fn render_works_with_status(&self) -> Element { let status = self.works_with_status.read().clone(); match status { UploadStatus::Error(message) => rsx! { p { class: "wizard-error", role: "alert", "{message}" } }, _ => rsx! {}, } } /// Image-upload status (cover/previews), distinct from geometry-file status. fn render_image_upload_status(&self) -> Element { let status = self.image_upload.read().clone(); match status { UploadStatus::Uploading(label) => rsx! { div { class: "wizard-upload-status", role: "status", span { class: "wizard-progress-bar", "aria-hidden": "true" } span { "Uploading {label}…" } } }, UploadStatus::Error(message) => rsx! { p { class: "wizard-error", role: "alert", "{message}" } }, UploadStatus::Idle => rsx! {}, } } /// The hero upload zone — the empty-state dropzone, shown only before any /// file is staged. A large, keyboard-reachable label wraps a `multiple` /// file picker, and accepts dragged geometry files. fn render_hero(&self) -> Element { let drag_over = *self.drag_over.read(); let hero_w = self.clone(); let over_w = self.clone(); let leave_w = self.clone(); let drop_w = self.clone(); let drag_class = if drag_over { "is-drag-over" } else { "" }; rsx! { section { class: "publish-hero blueprint-panel", aria_label: "Upload model files", label { class: "publish-dropzone {drag_class}", ondragover: move |evt| { evt.prevent_default(); let mut drag_over = over_w.drag_over; drag_over.set(true); }, ondragleave: move |_| { let mut drag_over = leave_w.drag_over; drag_over.set(false); }, ondrop: move |evt| { evt.prevent_default(); let mut drag_over = drop_w.drag_over; drag_over.set(false); let files = evt.files(); if !files.is_empty() { let flow = drop_w.clone(); spawn(async move { flow.handle_files_upload(files).await }); } }, input { r#type: "file", multiple: true, accept: GEOMETRY_PICKER_ACCEPT, class: "wizard-file-input", aria_label: "Upload model files", onchange: move |evt| { let files = evt.files(); if !files.is_empty() { let flow = hero_w.clone(); spawn(async move { flow.handle_files_upload(files).await }); } }, } div { class: "publish-dropzone-body", p { class: "publish-dropzone-title", "Drop geometry files to begin" } p { class: "publish-dropzone-hint card-meta", "STL · OBJ · glTF · GLB · 3MF · STEP — or click to choose" } } } { self.render_upload_status() } } } } /// Inline editor: the thing name + uploaded-files list (default model's /// parts), then progressive-disclosure "more" sections and the Advanced /// multi-model disclosure with per-model/per-part metadata. fn render_editor(&self) -> Element { let composition = self.composition.read(); let has_parts = composition.models.iter().any(|m| !m.parts.is_empty()); drop(composition); rsx! { section { class: "wizard-body blueprint-panel", aria_label: "Publish", { self.render_inline_name() } { self.render_thing_sections() } if has_parts { { self.render_files_list() } } else { { self.render_hero() } } { self.render_advanced() } } } } /// The editable thing name, shown once the first file is staged. Focuses /// itself on first mount so the user lands on the most likely next edit. fn render_inline_name(&self) -> Element { let composition = self.composition.read(); let name = composition.name.clone().unwrap_or_default(); drop(composition); let name_w = self.clone(); let name_element = self.name_element; rsx! { label { class: "field publish-name-field", span { class: "field-label", "Name" } input { class: "field-input", r#type: "text", value: "{name}", placeholder: "e.g. Modular desk organizer", onmounted: move |element| { let mut name_element = name_element; name_element.set(Some(element.data())); }, oninput: move |evt| { let mut c = name_w.composition; c.write().name = Some(evt.value()); }, onblur: move |_| name_w.clone().mark_dirty_and_save(), } } } } /// The default model's parts as an editable files list (rename + remove + /// size/format meta), plus "Add more files" routed through the same batch /// handler as the hero. fn render_files_list(&self) -> Element { let composition = self.composition.read(); let parts = composition .models .first() .map(|m| m.parts.clone()) .unwrap_or_default(); drop(composition); let add_w = self.clone(); let over_w = self.clone(); let leave_w = self.clone(); let drop_w = self.clone(); let drag_over = *self.drag_over.read(); let drag_class = if drag_over { "is-drag-over" } else { "" }; rsx! { div { class: "publish-files {drag_class}", ondragover: move |evt| { evt.prevent_default(); let mut drag_over = over_w.drag_over; drag_over.set(true); }, ondragleave: move |_| { let mut drag_over = leave_w.drag_over; drag_over.set(false); }, ondrop: move |evt| { evt.prevent_default(); let mut drag_over = drop_w.drag_over; drag_over.set(false); let files = evt.files(); if !files.is_empty() { let flow = drop_w.clone(); spawn(async move { flow.handle_files_upload(files).await }); } }, h2 { "Files" } { self.render_upload_status() } ul { class: "wizard-part-list", for (pi, part) in parts.iter().enumerate() { { let part_name = part.name.clone().unwrap_or_default(); let size = part.file.as_ref().map(|f| human_size(f.size)).unwrap_or_default(); let format = part.format.clone().unwrap_or_default(); let pname_w = self.clone(); let mut premove_w = self.clone(); rsx! { li { key: "part-0-{pi}", class: "wizard-part", label { class: "field wizard-part-name", span { class: "field-label sr-only", "Part name" } input { class: "field-input", r#type: "text", value: "{part_name}", oninput: move |evt| { let mut c = pname_w.composition; c.write().models[0].parts[pi].name = Some(evt.value()); }, onblur: move |_| pname_w.clone().mark_dirty_and_save(), } } span { class: "wizard-part-meta card-meta", if !format.is_empty() { "{format} · " } "{size}" } button { class: "button button-ghost wizard-remove", onclick: move |_| { let mut c = premove_w.composition; let mut guard = c.write(); let Some(model_id) = guard.models.first().and_then(|model| model.model_id.clone()) else { return; }; let removed_primary = { let model = &mut guard.models[0]; let removed = model.parts.remove(pi); let removed_primary = removed.resource_id.as_deref() == model.primary_resource_id.as_deref(); if removed_primary { model.primary_resource_id = None; } prune_orphan_resources(model); removed_primary }; if removed_primary { clear_generated_preview(&mut guard, &model_id, None); premove_w.preview_generations.write().remove(&model_id); } drop(guard); premove_w.clone().mark_dirty_and_save(); }, "Remove" } } } } } } label { class: "button button-secondary wizard-file-button", "Add more files" input { class: "wizard-file-input", r#type: "file", multiple: true, accept: GEOMETRY_PICKER_ACCEPT, onchange: move |evt| { let files = evt.files(); if !files.is_empty() { let flow = add_w.clone(); spawn(async move { flow.handle_files_upload(files).await }); } }, } } } } } /// Thing-level "more" sections behind progressive disclosure: Summary, /// Tags, Images, Instructions, License, and Works with. fn render_thing_sections(&self) -> Element { rsx! { div { class: "publish-sections", { self.render_summary_section(FieldTarget::Thing, false) } { self.render_tags_section(FieldTarget::Thing, false) } { self.render_license_section(FieldTarget::Thing, false) } { self.render_cover_inline() } { self.render_instructions_section(FieldTarget::Thing) } { self.render_images_section(FieldTarget::Thing) } { self.render_works_with_section() } } } } /// Advanced multi-model disclosure: add/remove models, per-model metadata, /// and per-part metadata via the shared field renderers. fn render_advanced(&self) -> Element { let composition = self.composition.read(); let models = composition.models.clone(); drop(composition); let add_w = self.clone(); rsx! { Disclosure { title: "Advanced", hint: "Multiple models and per-part metadata", open: Some(self.advanced_open), body: rsx! { for (mi, model) in models.iter().enumerate() { { self.render_model_block(mi, model) } } button { class: "button button-secondary wizard-add-model", onclick: move |_| { let was_count = { let g = add_w.composition.read(); g.models.len() }; let next_name = format!("Model {}", was_count + 1); let mut c = add_w.composition; c.write().models.push(DraftModelInput { name: Some(next_name), ..Default::default() }); // First time reaching a second model: auto-open // Advanced once. The one-way flag means a later // remove→re-add after a manual collapse stays put. if was_count == 1 && !*add_w.advanced_auto_expanded.read() { let mut advanced_open = add_w.advanced_open; advanced_open.set(true); let mut auto_expanded = add_w.advanced_auto_expanded; auto_expanded.set(true); } add_w.clone().mark_dirty_and_save(); }, "Add a model" } } } } } fn render_model_block(&self, mi: usize, model: &DraftModelInput) -> Element { let model_name = model.name.clone().unwrap_or_default(); let parts = model.parts.clone(); let name_w = self.clone(); let mut remove_w = self.clone(); let addfile_w = self.clone(); rsx! { section { key: "model-{mi}", class: "wizard-model", div { class: "wizard-model-head", label { class: "field wizard-model-name", span { class: "field-label", "Model {mi + 1} name" } input { class: "field-input", r#type: "text", value: "{model_name}", placeholder: "e.g. Base plate", oninput: move |evt| { let mut c = name_w.composition; c.write().models[mi].name = Some(evt.value()); }, onblur: move |_| name_w.clone().mark_dirty_and_save(), } } button { class: "button button-ghost wizard-remove", onclick: move |_| { let mut c = remove_w.composition; let mut guard = c.write(); let Some(model_id) = guard.models.get(mi).and_then(|model| model.model_id.clone()) else { return; }; clear_generated_preview(&mut guard, &model_id, None); guard.models.remove(mi); remove_w.preview_generations.write().remove(&model_id); drop(guard); remove_w.clone().mark_dirty_and_save(); }, "Remove model" } } if parts.is_empty() { p { class: "wizard-empty", "No parts yet. Upload a file below." } } ul { class: "wizard-part-list", for (pi, part) in parts.iter().enumerate() { { self.render_part_row(mi, pi, part) } } } label { class: "button button-secondary wizard-file-button", "Add a file to this model" input { class: "wizard-file-input", r#type: "file", accept: GEOMETRY_PICKER_ACCEPT, onchange: move |evt| { if let Some(file) = evt.files().into_iter().next() { let flow = addfile_w.clone(); spawn(async move { flow.stage_file_into(mi, file).await }); } }, } } { self.render_summary_section(FieldTarget::Model(mi), true) } { self.render_tags_section(FieldTarget::Model(mi), true) } { self.render_instructions_section(FieldTarget::Model(mi)) } { self.render_license_section(FieldTarget::Model(mi), true) } { self.render_images_section(FieldTarget::Model(mi)) } } } } fn render_part_row(&self, mi: usize, pi: usize, part: &DraftPartInput) -> Element { let part_name = part.name.clone().unwrap_or_default(); let size = part .file .as_ref() .map(|f| human_size(f.size)) .unwrap_or_default(); let format = part.format.clone().unwrap_or_default(); let notes = part.notes.clone().unwrap_or_default(); let units = part.units.clone().unwrap_or_default(); let print_settings = part.print_settings.clone().unwrap_or_default().join("\n"); let bbox = part.dimensions.clone(); let pname_w = self.clone(); let mut premove_w = self.clone(); let notes_w = self.clone(); let units_w = self.clone(); let prints_w = self.clone(); rsx! { li { key: "part-{mi}-{pi}", class: "wizard-part wizard-part-advanced", div { class: "wizard-part-main", label { class: "field wizard-part-name", span { class: "field-label sr-only", "Part name" } input { class: "field-input", r#type: "text", value: "{part_name}", oninput: move |evt| { let mut c = pname_w.composition; c.write().models[mi].parts[pi].name = Some(evt.value()); }, onblur: move |_| pname_w.clone().mark_dirty_and_save(), } } span { class: "wizard-part-meta card-meta", if !format.is_empty() { "{format} · " } "{size}" } button { class: "button button-ghost wizard-remove", onclick: move |_| { let mut c = premove_w.composition; let mut guard = c.write(); let Some(model_id) = guard.models.get(mi).and_then(|model| model.model_id.clone()) else { return; }; let removed_primary = { let model = &mut guard.models[mi]; let removed = model.parts.remove(pi); let removed_primary = removed.resource_id.as_deref() == model.primary_resource_id.as_deref(); if removed_primary { model.primary_resource_id = None; } prune_orphan_resources(model); removed_primary }; if removed_primary { clear_generated_preview(&mut guard, &model_id, None); premove_w.preview_generations.write().remove(&model_id); } drop(guard); let flow = premove_w.clone(); spawn(async move { flow.recompute_model_preview(model_id, None).await; }); premove_w.clone().mark_dirty_and_save(); }, "Remove" } } div { class: "wizard-part-meta-grid", label { class: "field", span { class: "field-label", "Notes" } input { class: "field-input", r#type: "text", value: "{notes}", placeholder: "e.g. Print supports off", oninput: move |evt| { let mut c = notes_w.composition; let v = evt.value(); c.write().models[mi].parts[pi].notes = if v.trim().is_empty() { None } else { Some(v) }; }, onblur: move |_| notes_w.clone().mark_dirty_and_save(), } } label { class: "field", span { class: "field-label", "Units" } input { class: "field-input", r#type: "text", value: "{units}", placeholder: "mm", oninput: move |evt| { let mut c = units_w.composition; let v = evt.value(); c.write().models[mi].parts[pi].units = if v.trim().is_empty() { None } else { Some(v) }; }, onblur: move |_| units_w.clone().mark_dirty_and_save(), } } label { class: "field publish-field-wide", span { class: "field-label", "Print settings" } textarea { class: "field-input field-textarea", value: "{print_settings}", placeholder: "One setting per line", oninput: move |evt| { let mut c = prints_w.composition; let lines: Vec = evt.value().split('\n').map(str::to_string).collect(); c.write().models[mi].parts[pi].print_settings = if lines.iter().all(|l| l.trim().is_empty()) { None } else { Some(lines) }; }, onblur: move |_| prints_w.clone().mark_dirty_and_save(), } } fieldset { class: "field publish-bbox", legend { class: "field-label", "Dimensions" } { self.render_bbox(mi, pi, &bbox) } } } { self.render_images_section(FieldTarget::Part(mi, pi)) } } } } /// Dimensions (bbox) inputs for a part. Each axis/unit is written live into /// the part's `dimensions` (creating an empty bbox on first edit); all-or-none /// is enforced at `assemble()` via the lexicon validator, so partial values /// simply block publish until complete — no hand-rolled float checks here. /// Existing `dimensions.unit` is preserved and defaults to `"mm"`. fn render_bbox(&self, mi: usize, pi: usize, bbox: &Option) -> Element { let (bx, by, bz, bunit) = match bbox { Some(b) => ( b.x.as_str().to_string(), b.y.as_str().to_string(), b.z.as_str().to_string(), b.unit .as_ref() .map(|u| u.as_str().to_string()) .unwrap_or_else(|| "mm".to_string()), ), None => ( String::new(), String::new(), String::new(), "mm".to_string(), ), }; let x_in = self.clone(); let x_blur = self.clone(); let y_in = self.clone(); let y_blur = self.clone(); let z_in = self.clone(); let z_blur = self.clone(); let u_in = self.clone(); let u_blur = self.clone(); rsx! { div { class: "publish-bbox-row", label { class: "field", span { class: "field-label sr-only", "Width (X)" } input { class: "field-input", r#type: "text", inputmode: "decimal", value: "{bx}", placeholder: "X", aria_label: "Width", oninput: move |evt| Flow::set_bbox_axis(&x_in, mi, pi, BboxField::X, evt.value()), onblur: move |_| x_blur.clone().mark_dirty_and_save(), } } label { class: "field", span { class: "field-label sr-only", "Depth (Y)" } input { class: "field-input", r#type: "text", inputmode: "decimal", value: "{by}", placeholder: "Y", aria_label: "Depth", oninput: move |evt| Flow::set_bbox_axis(&y_in, mi, pi, BboxField::Y, evt.value()), onblur: move |_| y_blur.clone().mark_dirty_and_save(), } } label { class: "field", span { class: "field-label sr-only", "Height (Z)" } input { class: "field-input", r#type: "text", inputmode: "decimal", value: "{bz}", placeholder: "Z", aria_label: "Height", oninput: move |evt| Flow::set_bbox_axis(&z_in, mi, pi, BboxField::Z, evt.value()), onblur: move |_| z_blur.clone().mark_dirty_and_save(), } } label { class: "field", span { class: "field-label sr-only", "Dimension unit" } input { class: "field-input", r#type: "text", value: "{bunit}", placeholder: "mm", aria_label: "Dimension unit", oninput: move |evt| Flow::set_bbox_axis(&u_in, mi, pi, BboxField::Unit, evt.value()), onblur: move |_| u_blur.clone().mark_dirty_and_save(), } } } } } /// Mutate a single bbox field on a part, initializing an empty bbox when /// none exists yet. A bbox that ends up fully empty is dropped so the draft /// stays clean and does not block publish with a partial/empty dimensions. fn set_bbox_axis(&self, mi: usize, pi: usize, field: BboxField, value: String) { let mut c = self.composition; let mut g = c.write(); let Some(part) = g.models.get_mut(mi).and_then(|m| m.parts.get_mut(pi)) else { return; }; let bbox = part.dimensions.get_or_insert_with(|| Bbox { unit: None, x: "".into(), y: "".into(), z: "".into(), extra_data: None, }); match field { BboxField::X => bbox.x = value.into(), BboxField::Y => bbox.y = value.into(), BboxField::Z => bbox.z = value.into(), BboxField::Unit => { bbox.unit = if value.trim().is_empty() { None } else { Some(value.into()) }; } } // Drop a dimensions with no axes set so it does not serialize or block // publish with a partial (unit-only) bbox — unit alone is not a dimension. if bbox.x.as_str().trim().is_empty() && bbox.y.as_str().trim().is_empty() && bbox.z.as_str().trim().is_empty() { part.dimensions = None; } } // ---- Shared field renderers (one implementation per field type) ---- fn render_summary_section(&self, target: FieldTarget, wrapped: bool) -> Element { let composition = self.composition.read(); let value = match target { FieldTarget::Thing => composition.summary.clone().unwrap_or_default(), FieldTarget::Model(mi) => composition .models .get(mi) .and_then(|m| m.summary.clone()) .unwrap_or_default(), FieldTarget::Part(_, _) => return rsx! {}, }; drop(composition); let w = self.clone(); let field = rsx! { label { class: "field", span { class: "field-label", "Summary" } textarea { class: "field-input field-textarea", value: "{value}", placeholder: "What is it?", oninput: move |evt| { let v = evt.value(); let mut c = w.composition; let mut g = c.write(); match target { FieldTarget::Thing => g.summary = if v.trim().is_empty() { None } else { Some(v) }, FieldTarget::Model(mi) => { if let Some(m) = g.models.get_mut(mi) { m.summary = if v.trim().is_empty() { None } else { Some(v) }; } } FieldTarget::Part(_, _) => {} } }, onblur: move |_| w.clone().mark_dirty_and_save(), } } }; if wrapped { rsx! { Disclosure { title: "Summary", hint: Some("A short description".to_string()), open: None, body: field, } } } else { field } } fn render_tags_section(&self, target: FieldTarget, wrapped: bool) -> Element { let composition = self.composition.read(); let tags = match target { FieldTarget::Thing => composition.tags.clone().unwrap_or_default(), FieldTarget::Model(mi) => composition .models .get(mi) .and_then(|m| m.tags.clone()) .unwrap_or_default(), FieldTarget::Part(_, _) => return rsx! {}, }; drop(composition); let tags = tags.join(", "); let w = self.clone(); let field = rsx! { label { class: "field", span { class: "field-label", "Tags" } input { class: "field-input", r#type: "text", value: "{tags}", placeholder: "comma, separated", oninput: move |evt| { let parsed: Vec = evt.value().split(',').map(|t| t.trim().to_string()).filter(|t| !t.is_empty()).collect(); let mut c = w.composition; let mut g = c.write(); match target { FieldTarget::Thing => g.tags = if parsed.is_empty() { None } else { Some(parsed) }, FieldTarget::Model(mi) => { if let Some(m) = g.models.get_mut(mi) { m.tags = if parsed.is_empty() { None } else { Some(parsed) }; } } FieldTarget::Part(_, _) => {} } }, onblur: move |_| w.clone().mark_dirty_and_save(), } } }; if wrapped { rsx! { Disclosure { title: "Tags", hint: Some("Comma-separated".to_string()), open: None, body: field, } } } else { field } } fn render_instructions_section(&self, target: FieldTarget) -> Element { let composition = self.composition.read(); let value = match target { FieldTarget::Thing => composition .instructions .clone() .unwrap_or_default() .join("\n"), FieldTarget::Model(mi) => composition .models .get(mi) .and_then(|m| m.instructions.clone()) .unwrap_or_default() .join("\n"), FieldTarget::Part(_, _) => return rsx! {}, }; drop(composition); let w = self.clone(); rsx! { Disclosure { title: "Instructions", hint: "One step per line", open: None, body: rsx! { label { class: "field", span { class: "field-label sr-only", "Instructions" } textarea { class: "field-input field-textarea field-textarea-tall", value: "{value}", placeholder: "One step per line", oninput: move |evt| { let value = evt.value(); let lines: Vec = value.split('\n').map(str::to_string).collect(); let mut c = w.composition; let mut g = c.write(); let opt = if value.trim().is_empty() { None } else { Some(lines) }; match target { FieldTarget::Thing => g.instructions = opt, FieldTarget::Model(mi) => { if let Some(m) = g.models.get_mut(mi) { m.instructions = opt; } } FieldTarget::Part(_, _) => {} } }, onblur: move |_| w.clone().mark_dirty_and_save(), } } } } } } fn render_license_section(&self, target: FieldTarget, wrapped: bool) -> Element { let composition = self.composition.read(); let (value, is_default_thing) = match target { FieldTarget::Thing => { let v = composition .license .clone() .unwrap_or_else(|| DEFAULT_LICENSE.to_string()); (v, true) } FieldTarget::Model(mi) => { let v = composition .models .get(mi) .and_then(|m| m.license.clone()) .unwrap_or_default(); (v, false) } FieldTarget::Part(_, _) => return rsx! {}, }; drop(composition); let license_target = license_target_key(target); let explicit_custom = self.custom_license_targets.read().contains(&license_target); let known_license = LICENSE_OPTIONS.contains(&value.as_str()); let is_custom = explicit_custom || (!value.trim().is_empty() && !known_license); let selected = if is_custom { CUSTOM_LICENSE_VALUE } else { value.as_str() }; let custom_value = if is_custom && known_license { "" } else { value.as_str() }; let select_w = self.clone(); let custom_w = self.clone(); let custom_blur_w = self.clone(); let field = rsx! { div { class: "field publish-license-field", label { class: "field-label", r#for: "license-{license_target}", "License" } div { class: "publish-license-controls", select { id: "license-{license_target}", class: "field-input publish-license-select", value: "{selected}", onchange: move |evt| { let selected = evt.value(); let mut custom_targets = select_w.custom_license_targets; if selected == CUSTOM_LICENSE_VALUE { custom_targets.write().insert(license_target.clone()); return; } custom_targets.write().remove(&license_target); select_w.set_license_value(target, selected); select_w.clone().mark_dirty_and_save(); }, if !is_default_thing { option { value: "", "Use thing license" } } for option in LICENSE_OPTIONS { option { key: "{option}", value: "{option}", "{option}" } } option { value: CUSTOM_LICENSE_VALUE, "Custom…" } } if is_custom { input { class: "field-input publish-license-custom", r#type: "text", value: "{custom_value}", placeholder: "Custom license string", aria_label: "Custom license", oninput: move |evt| { custom_w.set_license_value(target, evt.value()); custom_w.mark_dirty(); }, onblur: move |_| custom_blur_w.clone().mark_dirty_and_save(), } } } } }; if wrapped { let quiet = is_default_thing && value == DEFAULT_LICENSE; rsx! { Disclosure { title: "License", hint: if quiet { Some(DEFAULT_LICENSE.to_string()) } else { None }, open: None, body: field, } } } else { field } } /// Inline cover-image editor for the thing level (always visible, not /// collapsed) — the cover is primary product media. fn render_cover_inline(&self) -> Element { let composition = self.composition.read(); let cover = composition.cover.clone().unwrap_or_default(); drop(composition); let target = FieldTarget::Thing; let cover_w = self.clone(); rsx! { div { class: "wizard-image-group publish-cover-inline", span { class: "field-label", "Cover image" } { self.render_image_upload_status() } { self.render_image_list(&cover, target, true) } label { class: "button button-secondary wizard-file-button", "Add cover image" input { class: "wizard-file-input", r#type: "file", accept: "image/png,image/jpeg,image/webp,image/gif", onchange: move |evt| { if let Some(file) = evt.files().into_iter().next() { let flow = cover_w.clone(); spawn(async move { flow.handle_image_upload(target, true, file).await }); } }, } } } } } fn render_images_section(&self, target: FieldTarget) -> Element { let composition = self.composition.read(); // Cover is rendered inline at the thing level (render_cover_inline), so // this section only renders previews there; per-model it renders both. let cover = match target { FieldTarget::Model(mi) => composition .models .get(mi) .and_then(|m| m.cover.clone()) .unwrap_or_default(), _ => Vec::new(), }; let previews = match target { FieldTarget::Thing => composition.previews.clone().unwrap_or_default(), FieldTarget::Model(mi) => composition .models .get(mi) .and_then(|m| m.previews.clone()) .unwrap_or_default(), FieldTarget::Part(mi, pi) => composition .models .get(mi) .and_then(|m| m.parts.get(pi)) .and_then(|p| p.previews.clone()) .unwrap_or_default(), }; let has_cover_group = matches!(target, FieldTarget::Model(_)); drop(composition); let cover_w = self.clone(); let preview_w = self.clone(); let (title, hint) = if has_cover_group { ("Images".to_string(), Some("Cover and previews".to_string())) } else { ( "Preview images".to_string(), Some("Additional views".to_string()), ) }; rsx! { Disclosure { title, hint, open: None, body: rsx! { if has_cover_group { { self.render_image_upload_status() } } if has_cover_group { div { class: "wizard-image-group", h3 { class: "wizard-subhead", "Cover" } { self.render_image_list(&cover, target, true) } label { class: "button button-secondary wizard-file-button", "Add cover image" input { class: "wizard-file-input", r#type: "file", accept: "image/png,image/jpeg,image/webp,image/gif", onchange: move |evt| { if let Some(file) = evt.files().into_iter().next() { let flow = cover_w.clone(); spawn(async move { flow.handle_image_upload(target, true, file).await }); } }, } } } } div { class: "wizard-image-group", h3 { class: "wizard-subhead", "Previews" } { self.render_image_list(&previews, target, false) } label { class: "button button-secondary wizard-file-button", "Add preview image" input { class: "wizard-file-input", r#type: "file", accept: "image/png,image/jpeg,image/webp,image/gif", onchange: move |evt| { if let Some(file) = evt.files().into_iter().next() { let flow = preview_w.clone(); spawn(async move { flow.handle_image_upload(target, false, file).await }); } }, } } } } } } } fn render_image_list(&self, images: &[Image], target: FieldTarget, is_cover: bool) -> Element { if images.is_empty() { return rsx! { p { class: "wizard-empty", "No images yet." } }; } rsx! { ul { class: "wizard-image-list", for (i, image) in images.iter().enumerate() { { let alt = image.alt.to_string(); let ratio = format!("{}×{}", image.aspect_ratio.width, image.aspect_ratio.height); 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 alt_blur_w = self.clone(); let remove_w = self.clone(); rsx! { li { key: "image-{is_cover}-{i}", class: "wizard-image", div { class: "wizard-image-thumb blueprint-media", aria_hidden: "true", if let Some(url) = preview_url { img { class: "wizard-image-img", src: "{url}", alt: "" } } else { span { class: "blueprint-dimension", "{ratio}" } } } label { class: "field wizard-image-alt", span { class: "field-label", "Alt text" } input { class: "field-input", r#type: "text", value: "{alt}", placeholder: "Describe the image", oninput: move |evt| { let mut c = alt_w.composition; let mut guard = c.write(); let list = image_list_mut(&mut guard, target, is_cover); if let Some(list) = list && let Some(img) = list.get_mut(i) { img.alt = evt.value().into(); } drop(guard); alt_w.mark_dirty(); }, onblur: move |_| alt_blur_w.clone().mark_dirty_and_save(), } } button { class: "button button-ghost wizard-remove", onclick: move |_| { let mut c = remove_w.composition; let mut guard = c.write(); let list = image_list_mut(&mut guard, target, is_cover); if let Some(list) = list && i < list.len() { list.remove(i); } drop(guard); remove_w.clone().mark_dirty_and_save(); }, "Remove" } } } } } } } } /// "Works with" — paste one or more thing URLs or `at://` URIs; each resolves /// to a `StrongRef` and renders as a removable chip showing the thing's name. fn render_works_with_section(&self) -> Element { let composition = self.composition.read(); let refs = composition .intended_to_work_with .clone() .unwrap_or_default(); drop(composition); rsx! { Disclosure { title: "Works with", hint: "Compatibility links to other things", open: None, body: rsx! { { self.render_works_with_status() } if !refs.is_empty() { ul { class: "publish-compat-list", for (i, r) in refs.iter().enumerate() { { let label = chip_label(self, r); let remove_w = self.clone(); rsx! { li { key: "{i}", class: "publish-compat-chip", span { "{label}" } button { class: "button button-ghost publish-compat-remove", aria_label: "Remove {label}", onclick: move |_| { let mut c = remove_w.composition; let mut g = c.write(); if let Some(list) = g.intended_to_work_with.as_mut() { list.remove(i); if list.is_empty() { g.intended_to_work_with = None; } } drop(g); remove_w.clone().mark_dirty_and_save(); }, "×" } } } } } } } { self.render_works_with_input() } } } } } /// The "Works with" paste field, bound to `works_with_text`. fn render_works_with_input(&self) -> Element { let current = self.works_with_text.read().clone(); let text_sig = self.works_with_text; let flow = self.clone(); rsx! { label { class: "field", span { class: "field-label sr-only", "Add compatible things" } textarea { class: "field-input field-textarea", placeholder: "Paste a thing URL or at:// URI (one per line)", value: "{current}", oninput: move |evt| { let mut text_sig = text_sig; text_sig.set(evt.value()); }, onblur: move |_| { let value = text_sig.read().clone(); if value.trim().is_empty() { return; } let flow = flow.clone(); spawn(async move { flow.resolve_works_with(value).await }); }, } } } } async fn resolve_works_with(self, text: String) { { let mut status = self.works_with_status; status.set(UploadStatus::Idle); } let tokens: Vec = text.split_whitespace().map(str::to_string).collect(); let mut added = false; let mut unresolved: Vec = Vec::new(); for token in tokens { match parse_thing_token(&token) { Ok(uri) => match draft_client::resolve_strong_ref(&uri).await { Ok(resolved) => { let uri_str = resolved.strong_ref.uri.as_str().to_string(); let name = resolved.name.clone(); let already_present = { let g = self.composition.read(); g.intended_to_work_with .as_ref() .map(|list| list.iter().any(|r| r.uri.as_str() == uri_str)) .unwrap_or(false) }; if !already_present { let mut c = self.composition; let mut g = c.write(); let list = g.intended_to_work_with.get_or_insert_with(Vec::new); list.push(resolved.strong_ref); if let Some(name) = name { let mut names = self.compat_names; names.write().insert(uri_str, name); } added = true; } } Err(error) => { unresolved.push(token.clone()); let mut status = self.works_with_status; status.set(UploadStatus::Error(format!( "Could not resolve “{token}”: {error}" ))); } }, Err(error) => { unresolved.push(token.clone()); let mut status = self.works_with_status; status.set(UploadStatus::Error(format!("“{token}”: {error}"))); } } } // Keep only the tokens that did not resolve, so the user can fix them; // clear the field once everything resolved. let mut text_sig = self.works_with_text; text_sig.set(unresolved.join("\n")); if added { self.mark_dirty_and_save(); } } /// Compact "what's public" confirm + Publish at the page bottom. fn render_confirm(&self) -> Element { let composition = self.composition.read(); let assembled = composition.assemble(); let name = composition.display_name().unwrap_or("Untitled").to_string(); let license = composition .license .clone() .unwrap_or_else(|| DEFAULT_LICENSE.to_string()); let summary = composition.summary.clone().unwrap_or_default(); let tags = composition.tags.clone().unwrap_or_default(); let models = composition.models.clone(); let model_count = composition.model_count(); let cover_count = composition.cover.as_ref().map(|c| c.len()).unwrap_or(0); let preview_count = composition.previews.as_ref().map(|p| p.len()).unwrap_or(0); let instructions = composition.instructions.clone().unwrap_or_default(); let works_count = composition .intended_to_work_with .as_ref() .map(|w| w.len()) .unwrap_or(0); drop(composition); let publishing = matches!(*self.phase.read(), PublishPhase::Publishing); let issues = assembled.as_ref().err().cloned(); let name_error = issues .as_ref() .map(|e| e.has_field("name")) .unwrap_or(false); let name_row_class = if name_error { "wizard-review-row is-error" } else { "wizard-review-row" }; let can_publish = assembled.is_ok() && !publishing; let publish_w = self.clone(); rsx! { section { class: "wizard-body blueprint-panel publish-confirm", aria_label: "Review and publish", if let Some(issues) = issues { div { class: "wizard-issues error-state state-card", role: "alert", h3 { class: "wizard-subhead", "Fix these before publishing" } ul { class: "instruction-list", for issue in issues.issues { li { key: "{issue.field}", "{issue.message}" } } } } } div { class: "wizard-review", div { class: "{name_row_class}", span { class: "field-label", "Name" } span { "{name}" } } div { class: "wizard-review-row", span { class: "field-label", "License" } span { class: "status-pill", "{license}" } } if !summary.is_empty() { div { class: "wizard-review-row", span { class: "field-label", "Summary" } span { "{summary}" } } } if !tags.is_empty() { div { class: "wizard-review-row", span { class: "field-label", "Tags" } div { class: "thing-card-chips", for tag in tags { span { key: "{tag}", class: "status-pill", "{tag}" } } } } } div { class: "wizard-review-row", span { class: "field-label", "Media" } span { class: "card-meta", "{cover_count} cover · {preview_count} preview" } } div { class: "wizard-review-row", span { class: "field-label", "Models ({model_count})" } div { class: "wizard-review-models", for (mi, model) in models.iter().enumerate() { { let mname = model.name.clone().unwrap_or_else(|| format!("Model {}", mi + 1)); let part_count = model.parts.len(); rsx! { div { key: "review-model-{mi}", class: "wizard-review-model", strong { "{mname}" } span { class: "card-meta", "{part_count} part(s)" } } } } } } } if !instructions.is_empty() { div { class: "wizard-review-row", span { class: "field-label", "Instructions" } span { class: "card-meta", "{instructions.len()} step(s)" } } } if works_count > 0 { div { class: "wizard-review-row", span { class: "field-label", "Works with" } span { class: "card-meta", "{works_count} link(s)" } } } } div { class: "wizard-publish-row", button { class: "button button-primary wizard-publish", disabled: !can_publish, onclick: move |_| { let wizard = publish_w.clone(); spawn(async move { wizard.publish().await }); }, if publishing { "Publishing…" } else { "Publish" } } } } } } async fn publish(self) { // Persist the latest edits first. If that save fails, do NOT publish — // otherwise we would publish stale server-side draft state. Re-save // until the state settles (an in-flight blur-save can leave us Dirty), // so the server-side draft reflects every edit before we publish it. loop { self.clone().save_draft().await; match *self.save.read() { SaveState::Error => { let mut phase = self.phase; phase.set(PublishPhase::Failed( "Could not save your latest changes, so nothing was published. Try again." .into(), )); return; } SaveState::Saved => break, // Dirty/Saving/New: a save is in flight or coalesced our edit. // Yield to the executor so that save's future can complete and // clear the guard, then loop to persist the latest state. SaveState::Dirty | SaveState::Saving | SaveState::New => { dioxus_sdk::time::sleep(std::time::Duration::from_millis(50)).await; continue; } } } let Some(id) = self.draft_id.read().clone() else { let mut phase = self.phase; phase.set(PublishPhase::Failed( "Draft was not saved; try again.".into(), )); return; }; let mut phase = self.phase; phase.set(PublishPhase::Publishing); match draft_client::publish_draft(&self.client, &id).await { Ok(output) => { let uri = &output.thing.uri; let repo = uri.authority().to_string(); let rkey = uri.rkey().map(|r| r.to_string()).unwrap_or_default(); phase.set(PublishPhase::Published { repo, rkey }); } Err(error) => { phase.set(PublishPhase::Failed(format!("Publish failed: {error}"))); } } } fn render_published(&self, repo: String, rkey: String) -> Element { rsx! { section { class: "wizard-published state-card", aria_label: "Published", span { class: "status-pill", "Published" } h2 { "Your thing is live" } Link { class: "button button-primary", to: Route::ThingDetail { repo, rkey }, "View it" } } } } } impl Flow { fn next_operation_token(&self) -> u64 { let mut counter = self.operation_counter; let next = counter.read().saturating_add(1); counter.set(next); next } fn begin_upload(&self, label: String) -> u64 { let token = self.next_operation_token(); let mut operation = self.upload_operation; operation.set(Some(token)); let mut upload = self.upload; upload.set(UploadStatus::Uploading(label)); token } fn set_upload_if_current(&self, token: u64, status: UploadStatus) -> bool { if self.upload_operation.read().as_ref() != Some(&token) { return false; } let mut upload = self.upload; upload.set(status); true } fn begin_image_upload(&self, label: String) -> u64 { let token = self.next_operation_token(); let mut operation = self.image_upload_operation; operation.set(Some(token)); let mut upload = self.image_upload; upload.set(UploadStatus::Uploading(label)); token } fn set_image_upload_if_current(&self, token: u64, status: UploadStatus) -> bool { if self.image_upload_operation.read().as_ref() != Some(&token) { return false; } let mut upload = self.image_upload; upload.set(status); true } /// Stage every dropped/picked file through one batch: ensure the default /// model + auto name, stage each file (stop at first failure), then save once. async fn handle_files_upload(self, files: Vec) { let first_name = files.first().map(|f| f.name()).unwrap_or_default(); { let mut composition = self.composition; let mut g = composition.write(); ensure_default_model_for_upload(&mut g, &first_name); } let count = files.len(); let upload_token = self.begin_upload(if count == 1 { first_name.clone() } else { format!("{count} files") }); for file in files { let filename = file.name(); match file.read_bytes().await { Ok(bytes) => { let bytes = bytes.to_vec(); let (mime, format) = match classify_geometry_upload( file.content_type().as_deref(), &filename, &bytes, ) { Ok(value) => value, Err(error) => { self.set_upload_if_current( upload_token, UploadStatus::Error(format!( "Upload failed for {filename}: {error}" )), ); let flow = self.clone(); flow.save_draft().await; return; } }; match draft_client::stage_file(&self.client, &filename, &mime, bytes.clone()) .await { Ok(staged) => { let resource_id = staged.upload_id.to_string(); let Some(resource) = staged_resource( resource_id.clone(), filename.clone(), mime.clone(), format.clone(), &staged.file, ) else { self.set_upload_if_current( upload_token, UploadStatus::Error(format!( "Upload failed for {filename}: staged metadata is incomplete" )), ); return; }; let part = DraftPartInput { name: Some(filename.clone()), resource_id: Some(resource_id.clone()), upload_id: Some(resource_id.clone()), file: Some(staged.file), format: format.clone(), ..Default::default() }; 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(model) = g.models.get_mut(0) { install_staged_resource(model, resource); ensure_model_identity(model, &resource_id); model.parts.push(part); } let model_id = g.models.first().and_then(|model| model.model_id.clone()); drop(g); if let Some(model_id) = model_id && self .clone() .recompute_model_preview(model_id, Some(upload_token)) .await == PreviewOutcome::Error { let flow = self.clone(); flow.save_draft().await; return; } // 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() { let name_element = self.name_element; spawn(async move { let _ = element.set_focus(true).await; let _ = name_element; }); } } Err(error) => { self.set_upload_if_current( upload_token, UploadStatus::Error(format!( "Upload failed for {filename}: {error}" )), ); let flow = self.clone(); flow.save_draft().await; return; } } } Err(_) => { self.set_upload_if_current( upload_token, UploadStatus::Error(format!("Could not read {filename}.")), ); let flow = self.clone(); flow.save_draft().await; return; } } } self.set_upload_if_current(upload_token, UploadStatus::Idle); let flow = self.clone(); flow.save_draft().await; } /// Stage a single file into a specific model (the per-model "Add a file" /// button). Shares the per-file routine; saves once. async fn stage_file_into(self, model_index: usize, file: FileData) { let model_id = { let mut composition = self.composition; let mut guard = composition.write(); let Some(model) = guard.models.get_mut(model_index) else { return; }; model .model_id .get_or_insert_with(|| format!("pending-model:{}", self.next_operation_token())); model .model_id .clone() .expect("model identity was installed") }; let filename = file.name(); let upload_token = self.begin_upload(filename.clone()); let bytes = match file.read_bytes().await { Ok(bytes) => bytes.to_vec(), Err(_) => { self.set_upload_if_current( upload_token, UploadStatus::Error("Could not read the selected file.".into()), ); return; } }; let (mime, format) = match classify_geometry_upload(file.content_type().as_deref(), &filename, &bytes) { Ok(value) => value, Err(error) => { self.set_upload_if_current( upload_token, UploadStatus::Error(format!("Upload failed: {error}")), ); return; } }; match draft_client::stage_file(&self.client, &filename, &mime, bytes.clone()).await { Ok(staged) => { let resource_id = staged.upload_id.to_string(); let Some(resource) = staged_resource( resource_id.clone(), filename.clone(), mime.clone(), format.clone(), &staged.file, ) else { self.set_upload_if_current( upload_token, UploadStatus::Error("Upload failed: staged metadata is incomplete".into()), ); return; }; let part = DraftPartInput { name: Some(filename.clone()), resource_id: Some(resource_id.clone()), upload_id: Some(resource_id.clone()), file: Some(staged.file), format: format.clone(), ..Default::default() }; let installed = { let mut composition = self.composition; let mut guard = composition.write(); let Some(model) = guard .models .iter_mut() .find(|model| model.model_id.as_deref() == Some(model_id.as_str())) else { self.set_upload_if_current(upload_token, UploadStatus::Idle); return; }; install_staged_resource(model, resource); ensure_model_identity(model, &resource_id); model.parts.push(part); true }; let outcome = if installed { self.clone() .recompute_model_preview(model_id, Some(upload_token)) .await } else { PreviewOutcome::Stale }; if outcome != PreviewOutcome::Error { self.set_upload_if_current(upload_token, UploadStatus::Idle); } let flow = self.clone(); flow.save_draft().await; } Err(error) => { self.set_upload_if_current( upload_token, UploadStatus::Error(format!("Upload failed: {error}")), ); } } } async fn recompute_model_preview( mut self, model_id: String, upload_token: Option, ) -> PreviewOutcome { let draft_scope = self .draft_id .read() .clone() .unwrap_or_else(|| "unsaved".into()); let (primary_id, primary_name, staged) = { let composition = self.composition.read(); let Some(model) = composition .models .iter() .find(|model| model.model_id.as_deref() == Some(model_id.as_str())) else { return PreviewOutcome::Stale; }; let Some(primary_id) = model.primary_resource_id.clone() else { return PreviewOutcome::Complete; }; let Some(primary) = model .staged_resources .iter() .find(|resource| resource.resource_id == primary_id) else { return PreviewOutcome::Complete; }; ( primary_id, primary.source_name.clone(), model.staged_resources.clone(), ) }; let Some(composition_identity) = preview_identity(&draft_scope, &model_id, &primary_id, &staged) else { self.clear_model_preview(&model_id, None); return PreviewOutcome::Complete; }; let operation_id = { let next = self.next_operation_token(); format!("{model_id}:{next}") }; self.preview_generations .write() .insert(model_id.clone(), operation_id.clone()); let acquired = match acquire_preview_resources(&self.client, &staged).await { Ok(resources) => resources, Err(error) => { tracing::debug!(%error, %model_id, "generated preview acquisition incomplete"); self.clear_model_preview(&model_id, Some(&operation_id)); return PreviewOutcome::Complete; } }; if !self.preview_is_current(&model_id, &operation_id) { self.retire_preview_operation(&model_id, &operation_id); return PreviewOutcome::Stale; } let png = if format_registry::format_for_extension(&primary_name) == Some(format_registry::FormatId::Ldraw) { let DraftBundlePlan::Complete(bundle) = plan_draft_ldraw_bundle(&primary_id, &acquired) else { self.clear_model_preview(&model_id, Some(&operation_id)); return PreviewOutcome::Complete; }; render_preview_bundle(bundle).await } else { let Some(primary) = acquired .iter() .find(|resource| resource.metadata.resource_id == primary_id) else { self.clear_model_preview(&model_id, Some(&operation_id)); return PreviewOutcome::Complete; }; render_preview(&primary_name, primary.bytes.clone()).await }; let Ok(png_bytes) = png else { self.clear_model_preview(&model_id, Some(&operation_id)); return PreviewOutcome::Complete; }; if !self.preview_is_current(&model_id, &operation_id) { self.retire_preview_operation(&model_id, &operation_id); return PreviewOutcome::Stale; } if let Some(upload_token) = upload_token { self.set_upload_if_current( upload_token, UploadStatus::Uploading(format!("preview of {primary_name}")), ); } let completion = upload_generated_preview( self.client.clone(), primary_name.clone(), png_bytes, self.image_data_uris, ); let mut composition = self.composition; let current_preview = self.clone(); let committed = match commit_preview_after(completion, |image| { if !current_preview.preview_is_current(&model_id, &operation_id) { return false; } let mut guard = composition.write(); commit_generated_preview( &mut guard, &draft_scope, &model_id, &primary_id, &composition_identity, &operation_id, image, ) }) .await { Ok(committed) => committed, Err(error) => { let current = self.preview_is_current(&model_id, &operation_id); self.clear_model_preview(&model_id, Some(&operation_id)); if current && upload_token.is_some_and(|token| { self.set_upload_if_current( token, UploadStatus::Error(format!( "Preview upload failed for {primary_name}: {error}" )), ) }) { return PreviewOutcome::Error; } return PreviewOutcome::Stale; } }; self.retire_preview_operation(&model_id, &operation_id); if committed { PreviewOutcome::Complete } else { PreviewOutcome::Stale } } fn retire_preview_operation(&self, model_id: &str, operation_id: &str) -> bool { let mut preview_generations = self.preview_generations; let mut generations = preview_generations.write(); if generations .get(model_id) .is_some_and(|current| current == operation_id) { generations.remove(model_id); true } else { false } } fn preview_is_current(&self, model_id: &str, identity: &str) -> bool { self.preview_generations .read() .get(model_id) .is_some_and(|current| current == identity) } fn clear_model_preview(&mut self, model_id: &str, operation_id: Option<&str>) { let should_clear = match operation_id { Some(operation_id) => self.retire_preview_operation(model_id, operation_id), None => self.preview_generations.write().remove(model_id).is_some(), }; if should_clear || operation_id.is_none() { let mut composition = self.composition; clear_generated_preview(&mut composition.write(), model_id, operation_id); } } /// 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. async fn handle_image_upload(self, target: FieldTarget, is_cover: bool, file: FileData) { let filename = file.name(); let image_token = self.begin_image_upload(filename.clone()); let bytes = match file.read_bytes().await { Ok(bytes) => bytes.to_vec(), Err(_) => { self.set_image_upload_if_current( image_token, UploadStatus::Error("Could not read the selected image.".into()), ); return; } }; let mime = file .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.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 = image_list_mut(&mut guard, target, is_cover); if let Some(list) = list { list.push(image); } } self.set_image_upload_if_current(image_token, UploadStatus::Idle); self.mark_dirty_and_save(); } Err(error) => { self.set_image_upload_if_current( image_token, UploadStatus::Error(format!("Image upload failed: {error}")), ); } } } } /// A controlled `
` disclosure. When `open` is `Some(signal)` the caller /// drives open-state (the Advanced section, opened by the auto-expand effect); /// when `None` the disclosure manages its own open-state. In both cases /// `ontoggle` reads the real DOM open-state back into the owning signal so the /// program and the user share one synced source of truth without fighting on /// re-render. #[component] fn Disclosure( title: String, hint: Option, open: Option>, body: Element, ) -> Element { let local = use_signal(|| false); let on_toggle = move |evt: ToggleEvent| { if let Some(now_open) = read_details_open(&evt) { match open { Some(signal) => { let mut signal = signal; signal.set(now_open); } None => { let mut local = local; local.set(now_open); } } } }; let is_open = match open { Some(signal) => *signal.read(), None => *local.read(), }; rsx! { details { class: "publish-disclosure", open: is_open, ontoggle: on_toggle, summary { class: "publish-disclosure-summary", h3 { class: "publish-disclosure-title", "{title}" } if let Some(hint) = &hint { span { class: "publish-disclosure-hint card-meta", "{hint}" } } } div { class: "publish-disclosure-body", {body} } } } } /// Read the real open-state of a toggled `
` from its DOM target. Wasm /// only; returns `None` off-wasm (where the toggle event has no web backing). #[cfg(target_arch = "wasm32")] fn read_details_open(evt: &ToggleEvent) -> Option { use dioxus::web::WebEventExt; use wasm_bindgen::JsCast; evt.try_as_web_event() .and_then(|e| e.target()) .and_then(|t| t.dyn_into::().ok()) .map(|el| el.open()) } #[cfg(not(target_arch = "wasm32"))] fn read_details_open(_evt: &ToggleEvent) -> Option { None } fn image_list_mut( thing: &mut DraftThingInput, target: FieldTarget, is_cover: bool, ) -> Option<&mut Vec> { match target { FieldTarget::Thing => { if is_cover { Some(thing.cover.get_or_insert_with(Vec::new)) } else { Some(thing.previews.get_or_insert_with(Vec::new)) } } FieldTarget::Model(mi) => { let model = thing.models.get_mut(mi)?; if is_cover { Some(model.cover.get_or_insert_with(Vec::new)) } else { Some(model.previews.get_or_insert_with(Vec::new)) } } FieldTarget::Part(mi, pi) => { let part = thing.models.get_mut(mi)?.parts.get_mut(pi)?; Some(part.previews.get_or_insert_with(Vec::new)) } } } fn license_target_key(target: FieldTarget) -> String { match target { FieldTarget::Thing => "thing".to_string(), FieldTarget::Model(mi) => format!("model-{mi}"), FieldTarget::Part(mi, pi) => format!("part-{mi}-{pi}"), } } fn is_initial_composition(composition: &DraftThingInput) -> bool { composition.name.is_none() && matches!(composition.license.as_deref(), None | Some(DEFAULT_LICENSE)) && composition.summary.is_none() && composition.tags.is_none() && composition.instructions.is_none() && composition.cover.is_none() && composition.previews.is_none() && composition.derived_from.is_none() && composition.intended_to_work_with.is_none() && composition.models.is_empty() } /// A short human label for a resolved "works with" chip: the resolved thing's /// name when known, else the rkey (never a raw URI or CID). fn chip_label(flow: &Flow, r: &StrongRef) -> String { if let Some(name) = flow.compat_names.read().get(r.uri.as_str()) { return name.clone(); } if let Some(rkey) = r.uri.rkey() { rkey.as_str().to_string() } else { "thing".to_string() } } /// Best-effort MIME type from a filename extension (the server re-sniffs image /// dimensions independently). fn mime_from_filename(name: &str) -> &'static str { format_registry::canonical_mime_for_filename(name).unwrap_or_else(|| { match extension(name).as_deref() { Some("png") => "image/png", Some("jpg") | Some("jpeg") => "image/jpeg", Some("webp") => "image/webp", Some("gif") => "image/gif", _ => format_registry::GENERIC_MIME, } }) } /// Classify a browser-selected geometry file before authenticated staging. /// /// A browser may report `application/octet-stream` for a recognized extension; /// that neutral declaration is replaced with the registry's canonical MIME. A /// specific unsupported or conflicting declaration is rejected, while an /// extensionless neutral file is accepted only when bounded evidence selects a /// single format. fn classify_geometry_upload( declared_mime: Option<&str>, filename: &str, evidence: &[u8], ) -> Result<(String, Option), String> { let classification = format_registry::classify(declared_mime, Some(filename), evidence); let format = match classification { format_registry::Classification::Known(format) => format, format_registry::Classification::UnsupportedMime => { return Err("unsupported or conflicting geometry MIME".into()); } format_registry::Classification::Ambiguous => { return Err("ambiguous extensionless geometry content".into()); } format_registry::Classification::Unknown => return Err("unknown geometry format".into()), }; let mime = format_registry::canonical_mime(Some(filename), declared_mime) .or_else(|| format_registry::canonical_mime_for_evidence(evidence)) .ok_or_else(|| "unable to determine canonical geometry MIME".to_string())?; Ok(( mime.to_owned(), Some(format.capability().canonical_id.to_owned()), )) } fn extension(name: &str) -> Option { name.rsplit_once('.') .map(|(_, ext)| ext.to_ascii_lowercase()) } /// Human-readable byte size. fn human_size(bytes: i64) -> String { let bytes = bytes.max(0) as f64; const UNITS: [&str; 4] = ["B", "KB", "MB", "GB"]; let mut value = bytes; let mut unit = 0; while value >= 1024.0 && unit < UNITS.len() - 1 { value /= 1024.0; unit += 1; } if unit == 0 { format!("{} {}", value as i64, UNITS[unit]) } else { format!("{value:.1} {}", UNITS[unit]) } } /// 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.resource_id != resource.resource_id); model.staged_resources.push(resource); } fn prune_orphan_resources(model: &mut DraftModelInput) { let referenced = model .parts .iter() .filter_map(|part| part.resource_id.as_deref()) .collect::>(); model .staged_resources .retain(|resource| referenced.contains(resource.resource_id.as_str())); } fn ensure_model_identity(model: &mut DraftModelInput, primary_resource_id: &str) { if model.primary_resource_id.is_none() { model.primary_resource_id = Some(primary_resource_id.to_owned()); } if model.model_id.is_none() { model.model_id = Some(stable_model_id(primary_resource_id)); } } fn stable_model_id(primary_resource_id: &str) -> String { let digest = sha2::Sha256::digest(primary_resource_id.as_bytes()); let suffix = digest[..16] .iter() .map(|byte| format!("{byte:02x}")) .collect::(); format!("model-{suffix}") } fn normalize_loaded_draft(composition: &mut DraftThingInput) { let mut used_ids = std::collections::HashSet::new(); for model in &mut composition.models { if let Some(model_id) = model.model_id.as_ref() && !used_ids.insert(model_id.clone()) { model.model_id = None; } } for (index, model) in composition.models.iter_mut().enumerate() { let primary = model .primary_resource_id .as_ref() .filter(|resource_id| { model .staged_resources .iter() .any(|resource| resource.resource_id.as_str() == resource_id.as_str()) }) .cloned() .or_else(|| { model.parts.iter().find_map(|part| { let resource_id = part.resource_id.as_ref()?; model .staged_resources .iter() .any(|resource| resource.resource_id == *resource_id) .then(|| resource_id.clone()) }) }) .or_else(|| { model .staged_resources .first() .map(|resource| resource.resource_id.clone()) }); if let Some(primary) = primary { model.primary_resource_id = Some(primary.clone()); if model.model_id.is_none() { let base = stable_model_id(&primary); let mut candidate = base.clone(); let mut suffix = index; while !used_ids.insert(candidate.clone()) { candidate = format!("{base}-{suffix}"); suffix = suffix.saturating_add(1); } model.model_id = Some(candidate); } } if let Some(generated) = model.generated_preview.as_mut() && generated.operation_id.is_none() { generated.operation_id = Some(format!( "legacy:{}", stable_model_id(&generated.source_resource_id) )); } for part in &mut model.parts { if let Some(generated) = part.generated_preview.as_mut() && generated.operation_id.is_none() { generated.operation_id = Some(format!( "legacy:{}", stable_model_id(&generated.source_resource_id) )); } } } } fn staged_resource( upload_id: String, source_name: String, mime_type: String, format: Option, file: &polymodel_api::space_polymodel::library::File, ) -> Option { let digest = file.digest.as_deref()?.to_vec(); let byte_length = u64::try_from(file.size).ok()?; (digest.len() == 32).then_some(DraftStagedResource { resource_id: upload_id.clone(), upload_id, source_name, mime_type, digest, byte_length, format, }) } fn preview_identity( draft_scope: &str, model_id: &str, primary_id: &str, resources: &[DraftStagedResource], ) -> Option { if resources.is_empty() || resources.len() > MAX_PREVIEW_RESOURCES { return None; } let mut aggregate = 0_u64; let mut entries = resources.iter().collect::>(); entries.sort_by(|left, right| left.resource_id.cmp(&right.resource_id)); let mut hasher = ::new(); hasher.update(draft_scope.as_bytes()); hasher.update(model_id.as_bytes()); hasher.update(primary_id.as_bytes()); for resource in entries { if resource.digest.len() != 32 || resource.byte_length > MAX_PREVIEW_RESOURCE_BYTES { return None; } aggregate = aggregate.checked_add(resource.byte_length)?; if aggregate > MAX_PREVIEW_AGGREGATE_BYTES { return None; } hasher.update(resource.resource_id.as_bytes()); hasher.update(resource.source_name.as_bytes()); hasher.update(resource.byte_length.to_le_bytes()); hasher.update(&resource.digest); } Some( hasher .finalize() .iter() .map(|byte| format!("{byte:02x}")) .collect(), ) } #[cfg(all(target_family = "wasm", target_os = "unknown"))] async fn acquire_preview_resources( client: &PolymodelClient, resources: &[DraftStagedResource], ) -> Result, String> { if preview_identity("admission", "model", "primary", resources).is_none() { return Err("preview resource bounds exceeded".into()); } let mut acquired = Vec::with_capacity(resources.len()); for metadata in resources { let bytes = draft_client::get_staged_resource(client, &metadata.upload_id) .await .map_err(|error| error.to_string())?; if bytes.len() as u64 != metadata.byte_length || &sha2::Sha256::digest(&bytes)[..] != metadata.digest.as_slice() { return Err("reacquired staged resource failed integrity check".into()); } acquired.push(AcquiredDraftResource { metadata: metadata.clone(), bytes, }); } Ok(acquired) } #[cfg(not(all(target_family = "wasm", target_os = "unknown")))] async fn acquire_preview_resources( _client: &PolymodelClient, _resources: &[DraftStagedResource], ) -> Result, String> { Err("preview acquisition is browser-only".into()) } fn remove_image(list: &mut Option>, image: &Image) { if let Some(images) = list { if let Some(index) = images.iter().position(|candidate| candidate == image) { images.remove(index); } if images.is_empty() { *list = None; } } } fn clear_generated_preview( composition: &mut DraftThingInput, model_id: &str, operation_id: Option<&str>, ) { let Some(model_index) = composition .models .iter() .position(|model| model.model_id.as_deref() == Some(model_id)) else { return; }; let generated = composition.models[model_index] .generated_preview .as_ref() .filter(|preview| { operation_id.is_none_or(|operation| preview.operation_id.as_deref() == Some(operation)) }) .cloned(); let Some(generated) = generated else { return }; let image = generated.image; remove_image(&mut composition.previews, &image); remove_image(&mut composition.cover, &image); let model = &mut composition.models[model_index]; remove_image(&mut model.previews, &image); remove_image(&mut model.cover, &image); model.generated_preview = None; for part in &mut model.parts { if part.generated_preview.as_ref().is_some_and(|preview| { preview.operation_id == generated.operation_id && preview.source_resource_id == generated.source_resource_id }) { remove_image(&mut part.previews, &image); part.generated_preview = None; } } } async fn commit_preview_after(completion: F, commit: C) -> Result where F: std::future::Future>, C: FnOnce(Image) -> bool, { completion.await.map(commit) } fn commit_generated_preview( composition: &mut DraftThingInput, draft_scope: &str, model_id: &str, expected_primary: &str, expected_identity: &str, operation_id: &str, image: Image, ) -> bool { let Some(model) = composition .models .iter() .find(|model| model.model_id.as_deref() == Some(model_id)) else { return false; }; let Some(current_primary) = model.primary_resource_id.as_deref() else { return false; }; if current_primary != expected_primary || preview_identity( draft_scope, model_id, current_primary, &model.staged_resources, ) .as_deref() != Some(expected_identity) { return false; } attach_generated_preview(composition, model_id, expected_primary, operation_id, image); true } fn attach_generated_preview( composition: &mut DraftThingInput, model_id: &str, source_resource_id: &str, operation_id: &str, image: Image, ) { clear_generated_preview(composition, model_id, None); let Some(model_index) = composition .models .iter() .position(|model| model.model_id.as_deref() == Some(model_id)) else { return; }; let seed_cover = model_index == 0 && composition.cover.as_ref().is_none_or(Vec::is_empty); let provenance = GeneratedPreview { source_resource_id: source_resource_id.to_owned(), operation_id: Some(operation_id.to_owned()), image: image.clone(), }; composition .previews .get_or_insert_with(Vec::new) .push(image.clone()); if seed_cover { composition.cover = Some(vec![image.clone()]); } let model = &mut composition.models[model_index]; model .previews .get_or_insert_with(Vec::new) .push(image.clone()); model.generated_preview = Some(provenance.clone()); if let Some(part) = model .parts .iter_mut() .find(|part| part.resource_id.as_deref() == Some(source_resource_id)) { part.previews.get_or_insert_with(Vec::new).push(image); part.generated_preview = Some(provenance); } } #[cfg(all(target_family = "wasm", target_os = "unknown"))] async fn render_preview_bundle( bundle: polymodel_renderer_protocol::ModelLoadBundle, ) -> Result, String> { renderer_preview::render_preview_bundle_png(bundle, 1024, 1024).await } #[cfg(all(target_family = "wasm", target_os = "unknown"))] async fn render_preview(filename: &str, bytes: Vec) -> Result, String> { renderer_preview::render_preview_png(filename, bytes, 1024, 1024).await } #[cfg(not(all(target_family = "wasm", target_os = "unknown")))] async fn render_preview_bundle( _bundle: polymodel_renderer_protocol::ModelLoadBundle, ) -> Result, String> { Err("preview rendering is browser-only".into()) } #[cfg(not(all(target_family = "wasm", target_os = "unknown")))] async fn render_preview(_filename: &str, _bytes: Vec) -> Result, String> { Err("preview rendering is browser-only".into()) } async fn upload_generated_preview( client: PolymodelClient, filename: String, png_bytes: Vec, mut image_data_uris: Signal>, ) -> Result { let alt = format!("Rendered preview of {filename}"); let image = draft_client::upload_image(&client, &alt, "image/png", png_bytes.clone()).await?; let cid = image.image.blob().r#ref.as_str().to_string(); image_data_uris .write() .insert(cid.into(), data_uri_from_bytes(&png_bytes, "image/png")); Ok(image) } #[cfg(all(target_family = "wasm", target_os = "unknown"))] mod renderer_preview { use polymodel_renderer_protocol::{ Capability, CommandEnvelope, CommandPayload, EventPayload, HandshakeState, LoadIdentity, LoadOutput, LoadSource, MeshFormat, ProtocolHello, RendererCommand, RendererEvent, RendererNamespace, deserialize_event_envelope, format_registry::{self, FormatId}, required_capabilities, serialize_command_envelope, }; use std::cell::RefCell; use std::rc::Rc; use wasm_bindgen::JsCast; 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, width: u32, height: u32, ) -> Result, String> { if format_registry::format_for_extension(filename) == Some(FormatId::Step) { let step_url = blob_url_for_bytes(bytes, "model/step")?; let glb_url = match convert_step_to_glb_url(step_url.clone()).await { Ok(url) => url, Err(error) => { let _ = web_sys::Url::revoke_object_url(&step_url); return Err(error); } }; let result = request_preview_png( LoadSource::Url { format: MeshFormat::Gltf, primary_url: glb_url.clone(), expected_size: None, expected_digest: None, }, width, height, ) .await; let _ = web_sys::Url::revoke_object_url(&glb_url); let _ = web_sys::Url::revoke_object_url(&step_url); return result; } if format_registry::format_for_extension(filename) == Some(FormatId::Ldraw) { return Err("LDraw preview requires a complete staged bundle".to_owned()); } let format = MeshFormat::from_extension(filename) .ok_or_else(|| "unsupported preview format".to_string())?; let mime = format_registry::canonical_mime_for_filename(filename) .ok_or_else(|| "unsupported preview MIME".to_string())?; let blob_url = blob_url_for_bytes(bytes, mime)?; let result = request_preview_png( LoadSource::Url { format, primary_url: blob_url.clone(), expected_size: None, expected_digest: None, }, width, height, ) .await; let _ = web_sys::Url::revoke_object_url(&blob_url); result } fn blob_url_for_bytes(bytes: Vec, mime: &str) -> Result { let array = js_sys::Uint8Array::from(bytes.as_slice()); let parts = js_sys::Array::new(); parts.push(&array); let options = web_sys::BlobPropertyBag::new(); options.set_type(mime); let blob = web_sys::Blob::new_with_u8_array_sequence_and_options(&parts, &options) .map_err(|e| format!("preview blob: {e:?}"))?; web_sys::Url::create_object_url_with_blob(&blob) .map_err(|e| format!("preview blob URL: {e:?}")) } async fn convert_step_to_glb_url(step_url: String) -> Result { let opts = web_sys::WorkerOptions::new(); opts.set_type(web_sys::WorkerType::Module); let worker = web_sys::Worker::new_with_options("/step_worker_loader.js", &opts) .map_err(|e| format!("STEP preview worker: {e:?}"))?; let worker_for_message = worker.clone(); let worker_for_error = worker.clone(); let promise = js_sys::Promise::new(&mut move |resolve, reject| { let step_url = step_url.clone(); let worker_for_message = worker_for_message.clone(); let resolve_message = resolve.clone(); let reject_message = reject.clone(); let onmessage = Closure::wrap(Box::new(move |event: web_sys::MessageEvent| { let data = event.data(); let kind = js_string_field(&data, "kind").unwrap_or_default(); match kind.as_str() { "ready" => { let msg = js_sys::Object::new(); let _ = js_sys::Reflect::set(&msg, &"command".into(), &"convert-step".into()); let _ = js_sys::Reflect::set(&msg, &"stepUrl".into(), &step_url.clone().into()); if let Err(e) = worker_for_message.post_message(&msg) { let _ = reject_message.call1( &JsValue::NULL, &JsValue::from_str(&format!("STEP preview command post: {e:?}")), ); } } "converted" => { let Some(buffer) = js_value_field(&data, "buffer") else { let _ = reject_message.call1( &JsValue::NULL, &JsValue::from_str("STEP preview returned no GLB bytes"), ); return; }; let bytes = js_sys::Uint8Array::new(&buffer); let parts = js_sys::Array::new(); parts.push(&bytes.buffer()); let opts = web_sys::BlobPropertyBag::new(); opts.set_type("model/gltf-binary"); let blob = match web_sys::Blob::new_with_buffer_source_sequence_and_options( &parts, &opts, ) { Ok(blob) => blob, Err(e) => { let _ = reject_message.call1( &JsValue::NULL, &JsValue::from_str(&format!("STEP preview GLB blob: {e:?}")), ); return; } }; match web_sys::Url::create_object_url_with_blob(&blob) { Ok(url) => { let _ = resolve_message.call1(&JsValue::NULL, &JsValue::from_str(&url)); } Err(e) => { let _ = reject_message.call1( &JsValue::NULL, &JsValue::from_str(&format!("STEP preview GLB URL: {e:?}")), ); } } } "error" => { let message = js_string_field(&data, "message") .unwrap_or_else(|| "STEP preview conversion failed".into()); let _ = reject_message.call1(&JsValue::NULL, &JsValue::from_str(&message)); } _ => {} } }) as Box); let reject_error = reject.clone(); let onerror = Closure::wrap(Box::new(move |event: web_sys::ErrorEvent| { let _ = reject_error.call1(&JsValue::NULL, &JsValue::from_str(&event.message())); }) as Box); worker.set_onmessage(Some(onmessage.as_ref().unchecked_ref())); worker.set_onerror(Some(onerror.as_ref().unchecked_ref())); onmessage.forget(); onerror.forget(); }); let output = wasm_bindgen_futures::JsFuture::from(promise) .await .map_err(|e| e.as_string().unwrap_or_else(|| format!("{e:?}")))?; worker_for_error.terminate(); output .as_string() .ok_or_else(|| "STEP preview returned a non-string GLB URL".to_string()) } fn js_value_field(value: &wasm_bindgen::JsValue, name: &str) -> Option { js_sys::Reflect::get(value, &name.into()).ok() } fn js_string_field(value: &wasm_bindgen::JsValue, name: &str) -> Option { js_value_field(value, name).and_then(|v| v.as_string()) } async fn request_preview_png( source: LoadSource, width: u32, height: u32, ) -> Result, String> { let opts = web_sys::WorkerOptions::new(); opts.set_type(web_sys::WorkerType::Module); let worker = web_sys::Worker::new_with_options("/renderer_worker_loader.js", &opts) .map_err(|e| format!("preview worker: {e:?}"))?; let request_id = js_sys::Math::random().to_bits() as u32; let command = RendererCommand::StartLoad { namespace: RendererNamespace::Preview, identity: LoadIdentity::new(1, request_id.max(1)), source, output: LoadOutput::Preview { request_id, width, height, }, }; let local_hello = ProtocolHello { min_version: polymodel_renderer_protocol::PROTOCOL_VERSION, max_version: polymodel_renderer_protocol::PROTOCOL_VERSION, required: vec![Capability::CurrentFormats, Capability::Cancellation], optional: vec![Capability::Preview, Capability::CompoundLdraw], }; let handshake = Rc::new(RefCell::new(HandshakeState::Created)); let worker_for_message = worker.clone(); let worker_for_error = worker.clone(); let promise = js_sys::Promise::new(&mut move |resolve, reject| { let local_hello = local_hello.clone(); let handshake = handshake.clone(); let command = command.clone(); let worker_for_message = worker_for_message.clone(); let resolve_message = resolve.clone(); let reject_message = reject.clone(); let onmessage = Closure::wrap(Box::new(move |event: web_sys::MessageEvent| { let data = event.data(); if data.as_string().as_deref() == Some("__worker_ready") { let hello = CommandEnvelope { version: polymodel_renderer_protocol::PROTOCOL_VERSION, session_id: 0, payload: CommandPayload::Hello(local_hello.clone()), }; match serialize_command_envelope(&hello) { Ok(bytes) => { if let Err(error) = worker_for_message .post_message(&js_sys::Uint8Array::from(bytes.as_slice())) { let _ = reject_message.call1( &JsValue::NULL, &JsValue::from_str(&format!("preview hello post: {error:?}")), ); } } Err(error) => { let _ = reject_message.call1( &JsValue::NULL, &JsValue::from_str(&format!("preview hello serialize: {error}")), ); } } return; } let Ok(array) = data.dyn_into::() else { return; }; let envelope = match deserialize_event_envelope(&array.to_vec()) { Ok(envelope) => envelope, Err(error) => { let _ = reject_message.call1( &JsValue::NULL, &JsValue::from_str(&format!("preview event deserialize: {error}")), ); return; } }; let negotiated = match &envelope.payload { EventPayload::Negotiated(protocol) => Some(protocol.clone()), _ => None, }; let event = match handshake.borrow_mut().accept_event(&envelope) { Ok(Some(event)) => event, Ok(None) => { if let Some(protocol) = negotiated { if let Some(capability) = required_capabilities(&command) .into_iter() .find(|capability| !protocol.capabilities.contains(capability)) { let _ = reject_message.call1( &JsValue::NULL, &JsValue::from_str(&format!( "preview capability was not negotiated: {capability:?}" )), ); return; } let accept = CommandEnvelope { version: polymodel_renderer_protocol::PROTOCOL_VERSION, session_id: 0, payload: CommandPayload::Accept(protocol.clone()), }; let command_envelope = CommandEnvelope { version: polymodel_renderer_protocol::PROTOCOL_VERSION, session_id: protocol.session_id, payload: CommandPayload::Command(command.clone()), }; match serialize_command_envelope(&accept) { Ok(bytes) => { if let Err(error) = worker_for_message .post_message(&js_sys::Uint8Array::from(bytes.as_slice())) { let _ = reject_message.call1( &JsValue::NULL, &JsValue::from_str(&format!( "preview accept post: {error:?}" )), ); return; } } Err(error) => { let _ = reject_message.call1( &JsValue::NULL, &JsValue::from_str(&format!( "preview accept serialize: {error}" )), ); return; } } match serialize_command_envelope(&command_envelope) { Ok(bytes) => { if let Err(error) = worker_for_message .post_message(&js_sys::Uint8Array::from(bytes.as_slice())) { let _ = reject_message.call1( &JsValue::NULL, &JsValue::from_str(&format!( "preview command post: {error:?}" )), ); } } Err(error) => { let _ = reject_message.call1( &JsValue::NULL, &JsValue::from_str(&format!( "preview command serialize: {error}" )), ); } } } return; } Err(error) => { let _ = reject_message.call1( &JsValue::NULL, &JsValue::from_str(&format!("preview handshake rejected: {error:?}")), ); return; } }; match event { RendererEvent::PreviewImageRendered { request_id: event_request_id, png, } if event_request_id == request_id => { let array = js_sys::Uint8Array::from(png.as_slice()); let _ = resolve_message.call1(&JsValue::NULL, &array); } RendererEvent::PreviewImageError { request_id: event_request_id, message, } if event_request_id == request_id => { let _ = reject_message.call1(&JsValue::NULL, &JsValue::from_str(&message)); } _ => {} } }) as Box); let reject_error = reject.clone(); let onerror = Closure::wrap(Box::new(move |event: web_sys::ErrorEvent| { let _ = reject_error.call1(&JsValue::NULL, &JsValue::from_str(&event.message())); }) as Box); worker.set_onmessage(Some(onmessage.as_ref().unchecked_ref())); worker.set_onerror(Some(onerror.as_ref().unchecked_ref())); onmessage.forget(); onerror.forget(); }); let output = wasm_bindgen_futures::JsFuture::from(promise) .await .map_err(|e| e.as_string().unwrap_or_else(|| format!("{e:?}")))?; worker_for_error.terminate(); Ok(js_sys::Uint8Array::new(&output).to_vec()) } } #[cfg(test)] mod tests { use super::*; use std::str::FromStr; #[test] fn mime_and_format_from_extension() { assert_eq!(mime_from_filename("Tray.STL"), "model/stl"); assert_eq!(mime_from_filename("photo.png"), "image/png"); assert_eq!(mime_from_filename("scene.gltf"), "model/gltf+json"); assert_eq!(mime_from_filename("scene.glb"), "model/gltf-binary"); assert_eq!(mime_from_filename("model.dat"), "application/x-ldraw"); assert_eq!(mime_from_filename("weird"), "application/octet-stream"); assert_eq!( format_registry::format_for_extension("a.glb") .map(|format| format.capability().canonical_id), Some("gltf") ); assert_eq!( format_registry::format_for_extension("a.dat") .map(|format| format.capability().canonical_id), Some("ldraw") ); assert_eq!(format_registry::format_for_extension("a.txt"), None); } #[test] fn geometry_picker_accept_is_shared_and_explicit() { for expected in [ ".ldr", ".mpd", ".dat", "application/x-ldraw", "model/ldraw", "model/gltf+json", "model/gltf-binary", ] { assert!( GEOMETRY_PICKER_ACCEPT .split(',') .any(|value| value == expected) ); } let source = include_str!("publish.rs"); assert_eq!( source .lines() .filter(|line| line.contains("accept: GEOMETRY_PICKER_ACCEPT")) .count(), 4 ); } #[test] fn geometry_upload_classification_canonicalizes_neutral_and_rejects_conflicts() { assert_eq!( classify_geometry_upload(Some(format_registry::GENERIC_MIME), "part.dat", &[]).unwrap(), ("application/x-ldraw".into(), Some("ldraw".into())) ); assert_eq!( classify_geometry_upload(Some(format_registry::GENERIC_MIME), "scene.glb", &[]) .unwrap(), ("model/gltf-binary".into(), Some("gltf".into())) ); assert!(classify_geometry_upload(Some("model/gltf+json"), "scene.glb", &[]).is_err()); assert!(classify_geometry_upload(Some("text/plain"), "scene.glb", &[]).is_err()); } #[test] fn human_size_scales_units() { assert_eq!(human_size(512), "512 B"); 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 generated_preview_replaces_its_slot_and_preserves_user_images() { let user = test_image("user preview"); let first = test_image("generated one"); let second = test_image("generated two"); let mut composition = DraftThingInput { cover: Some(vec![user.clone()]), previews: Some(vec![user.clone()]), models: vec![DraftModelInput { previews: Some(vec![user.clone()]), parts: vec![DraftPartInput { resource_id: Some("resource-1".into()), previews: Some(vec![user.clone()]), ..Default::default() }], ..Default::default() }], ..Default::default() }; composition.models[0].model_id = Some("model-1".into()); attach_generated_preview(&mut composition, "model-1", "resource-1", "op-1", first); attach_generated_preview( &mut composition, "model-1", "resource-1", "op-2", second.clone(), ); for images in [ composition.previews.as_ref().unwrap(), composition.models[0].previews.as_ref().unwrap(), composition.models[0].parts[0].previews.as_ref().unwrap(), ] { assert_eq!(images.len(), 2); assert!(images.contains(&user)); assert!(images.contains(&second)); } assert_eq!(composition.cover, Some(vec![user])); } #[test] fn stale_preview_cleanup_cannot_clear_newer_operation() { let old = test_image("old generated"); let current = test_image("current generated"); let mut composition = DraftThingInput { models: vec![DraftModelInput { model_id: Some("model-1".into()), parts: vec![DraftPartInput { resource_id: Some("resource-1".into()), ..Default::default() }], ..Default::default() }], ..Default::default() }; attach_generated_preview(&mut composition, "model-1", "resource-1", "old-op", old); attach_generated_preview( &mut composition, "model-1", "resource-1", "current-op", current.clone(), ); clear_generated_preview(&mut composition, "model-1", Some("old-op")); assert_eq!( composition.models[0] .generated_preview .as_ref() .unwrap() .operation_id .as_deref(), Some("current-op") ); assert!( composition.models[0] .previews .as_ref() .unwrap() .contains(¤t) ); } #[test] fn legacy_draft_hydration_preserves_existing_primary_and_reconciles_preview_identity() { let generated = test_image("legacy generated"); let mut composition = DraftThingInput { models: vec![DraftModelInput { parts: vec![ DraftPartInput { resource_id: Some("older-resource".into()), generated_preview: Some(GeneratedPreview { source_resource_id: "older-resource".into(), operation_id: None, image: generated.clone(), }), ..Default::default() }, DraftPartInput { resource_id: Some("newer-resource".into()), ..Default::default() }, ], staged_resources: vec![ DraftStagedResource { resource_id: "older-resource".into(), upload_id: "older-upload".into(), source_name: "old.stl".into(), mime_type: "model/stl".into(), digest: vec![1; 32], byte_length: 3, format: Some("stl".into()), }, DraftStagedResource { resource_id: "newer-resource".into(), upload_id: "newer-upload".into(), source_name: "new.obj".into(), mime_type: "model/obj".into(), digest: vec![2; 32], byte_length: 4, format: Some("obj".into()), }, ], generated_preview: Some(GeneratedPreview { source_resource_id: "older-resource".into(), operation_id: None, image: generated, }), ..Default::default() }], ..Default::default() }; normalize_loaded_draft(&mut composition); let expected_model_id = stable_model_id("older-resource"); let model = &composition.models[0]; assert_eq!(model.primary_resource_id.as_deref(), Some("older-resource")); assert_eq!(model.model_id.as_deref(), Some(expected_model_id.as_str())); assert!( model .generated_preview .as_ref() .unwrap() .operation_id .is_some() ); assert!( model.parts[0] .generated_preview .as_ref() .unwrap() .operation_id .is_some() ); ensure_model_identity(&mut composition.models[0], "newer-resource"); assert_eq!( composition.models[0].primary_resource_id.as_deref(), Some("older-resource") ); } #[test] fn legacy_draft_hydration_reserves_explicit_ids_and_loops_until_unique() { let base = stable_model_id("resource-a"); let mut composition = DraftThingInput { models: vec![ DraftModelInput { primary_resource_id: Some("resource-a".into()), staged_resources: vec![DraftStagedResource { resource_id: "resource-a".into(), upload_id: "upload-a".into(), source_name: "a.stl".into(), mime_type: "model/stl".into(), digest: vec![1; 32], byte_length: 1, format: Some("stl".into()), }], ..Default::default() }, DraftModelInput { model_id: Some(base.clone()), primary_resource_id: Some("resource-b".into()), staged_resources: vec![DraftStagedResource { resource_id: "resource-b".into(), upload_id: "upload-b".into(), source_name: "b.stl".into(), mime_type: "model/stl".into(), digest: vec![2; 32], byte_length: 1, format: Some("stl".into()), }], ..Default::default() }, DraftModelInput { model_id: Some(format!("{base}-0")), primary_resource_id: Some("resource-c".into()), staged_resources: vec![DraftStagedResource { resource_id: "resource-c".into(), upload_id: "upload-c".into(), source_name: "c.stl".into(), mime_type: "model/stl".into(), digest: vec![3; 32], byte_length: 1, format: Some("stl".into()), }], ..Default::default() }, DraftModelInput { model_id: Some(base.clone()), primary_resource_id: Some("resource-d".into()), staged_resources: vec![DraftStagedResource { resource_id: "resource-d".into(), upload_id: "upload-d".into(), source_name: "d.stl".into(), mime_type: "model/stl".into(), digest: vec![4; 32], byte_length: 1, format: Some("stl".into()), }], ..Default::default() }, ], ..Default::default() }; normalize_loaded_draft(&mut composition); let ids = composition .models .iter() .map(|model| model.model_id.as_deref().unwrap()) .collect::>(); assert_eq!(ids[0], format!("{base}-1")); assert_eq!(ids[1], base); assert_eq!(ids[2], format!("{base}-0")); assert_ne!(ids[3], ids[1]); assert_eq!(ids.iter().copied().collect::>().len(), ids.len()); } #[test] fn mismatched_primary_cannot_commit_preview() { // Unique to the sync path: directly invoke commit_generated_preview // with a mismatched primary/identity (the identity targets // resource-a/model-a but the composition's remaining model has a // different primary resource). The commit must be rejected without // mutating the composition — no preview, no parts, no cover. let generated = test_image("mismatched generated"); let resource = DraftStagedResource { resource_id: "resource-a".into(), upload_id: "upload-a".into(), source_name: "a.stl".into(), mime_type: "model/stl".into(), digest: vec![7; 32], byte_length: 12, format: Some("stl".into()), }; let mut composition = DraftThingInput { models: vec![DraftModelInput { model_id: Some("model-b".into()), primary_resource_id: Some("resource-b".into()), ..Default::default() }], ..Default::default() }; let identity = preview_identity("draft", "model-a", "resource-a", &[resource]).unwrap(); assert!(!commit_generated_preview( &mut composition, "draft", "model-a", "resource-a", &identity, "model-a:1", generated, )); assert_eq!(composition.models[0].model_id.as_deref(), Some("model-b")); assert!(composition.models[0].generated_preview.is_none()); assert!(composition.models[0].previews.is_none()); assert!(composition.models[0].parts.is_empty()); assert!(composition.previews.is_none()); } #[tokio::test] async fn in_flight_removed_model_cannot_receive_preview_after_vector_compaction() { let generated = test_image("stale async generated"); let resource = DraftStagedResource { resource_id: "resource-a".into(), upload_id: "upload-a".into(), source_name: "a.stl".into(), mime_type: "model/stl".into(), digest: vec![7; 32], byte_length: 12, format: Some("stl".into()), }; let composition = std::sync::Arc::new(std::sync::Mutex::new(DraftThingInput { models: vec![ DraftModelInput { model_id: Some("model-a".into()), primary_resource_id: Some("resource-a".into()), staged_resources: vec![resource.clone()], parts: vec![DraftPartInput { resource_id: Some("resource-a".into()), ..Default::default() }], ..Default::default() }, DraftModelInput { model_id: Some("model-b".into()), primary_resource_id: Some("resource-b".into()), ..Default::default() }, ], ..Default::default() })); let identity = preview_identity("draft", "model-a", "resource-a", &[resource]).unwrap(); let (started_tx, started_rx) = tokio::sync::oneshot::channel(); let (release_tx, release_rx) = tokio::sync::oneshot::channel(); let commit_composition = composition.clone(); let completion = async move { started_tx.send(()).unwrap(); release_rx.await.unwrap(); Ok::<_, ()>(generated) }; let commit = commit_preview_after(completion, move |image| { commit_generated_preview( &mut commit_composition.lock().unwrap(), "draft", "model-a", "resource-a", &identity, "model-a:1", image, ) }); tokio::pin!(commit); assert!(futures::poll!(&mut commit).is_pending()); started_rx.await.unwrap(); composition.lock().unwrap().models.remove(0); release_tx.send(()).unwrap(); assert!(!commit.await.unwrap()); let composition = composition.lock().unwrap(); assert_eq!(composition.models[0].model_id.as_deref(), Some("model-b")); assert!(composition.models[0].generated_preview.is_none()); assert!(composition.models[0].previews.is_none()); assert!(composition.models[0].parts.is_empty()); assert!(composition.previews.is_none()); assert!(composition.cover.is_none()); } #[test] fn generated_preview_cleanup_preserves_user_cover_and_normalizes_empty_lists() { let generated = test_image("generated"); let user = test_image("user"); let mut composition = DraftThingInput { cover: Some(vec![user.clone()]), models: vec![DraftModelInput { model_id: Some("model-1".into()), cover: Some(vec![user.clone()]), parts: vec![DraftPartInput { resource_id: Some("resource-1".into()), ..Default::default() }], ..Default::default() }], ..Default::default() }; attach_generated_preview(&mut composition, "model-1", "resource-1", "op-1", generated); clear_generated_preview(&mut composition, "model-1", Some("op-1")); assert_eq!(composition.cover, Some(vec![user.clone()])); assert_eq!(composition.models[0].cover, Some(vec![user])); } #[test] fn clearing_generated_preview_removes_only_generated_cover_and_refs() { let generated = test_image("generated"); let user = test_image("user"); let mut composition = DraftThingInput { models: vec![DraftModelInput { model_id: Some("model-1".into()), parts: vec![DraftPartInput { resource_id: Some("resource-1".into()), previews: Some(vec![user.clone()]), ..Default::default() }], ..Default::default() }], ..Default::default() }; composition.models[0].model_id = Some("model-1".into()); attach_generated_preview(&mut composition, "model-1", "resource-1", "op-1", generated); clear_generated_preview(&mut composition, "model-1", Some("op-1")); assert!(composition.cover.is_none()); assert!(composition.previews.is_none()); assert_eq!(composition.models[0].parts[0].previews, Some(vec![user])); assert!(composition.models[0].generated_preview.is_none()); } }