Something went wrong. Try again.
atproto Thingiverse but good
Something went wrong. Try again.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066206720682069207020712072207320742075207620772078207920802081208220832084208520862087208820892090209120922093209420952096209720982099210021012102210321042105210621072108210921102111211221132114211521162117211821192120212121222123212421252126212721282129213021312132213321342135213621372138213921402141214221432144214521462147214821492150215121522153215421552156215721582159216021612162216321642165216621672168216921702171217221732174217521762177217821792180218121822183218421852186218721882189219021912192219321942195219621972198219922002201220222032204220522062207220822092210221122122213221422152216221722182219222022212222222322242225222622272228222922302231223222332234223522362237223822392240224122422243224422452246224722482249225022512252225322542255225622572258225922602261226222632264226522662267226822692270227122722273227422752276227722782279228022812282228322842285228622872288228922902291229222932294229522962297229822992300230123022303230423052306230723082309231023112312231323142315231623172318231923202321232223232324232523262327232823292330233123322333233423352336233723382339234023412342234323442345234623472348234923502351235223532354235523562357235823592360236123622363236423652366236723682369237023712372237323742375237623772378237923802381238223832384238523862387238823892390239123922393239423952396239723982399240024012402240324042405240624072408240924102411241224132414241524162417241824192420242124222423242424252426242724282429243024312432243324342435243624372438243924402441244224432444244524462447244824492450245124522453245424552456245724582459246024612462246324642465246624672468246924702471247224732474247524762477247824792480248124822483248424852486248724882489249024912492249324942495249624972498249925002501250225032504250525062507250825092510251125122513251425152516251725182519252025212522252325242525252625272528252925302531253225332534253525362537253825392540254125422543254425452546254725482549255025512552255325542555255625572558255925602561256225632564256525662567256825692570257125722573257425752576257725782579258025812582258325842585258625872588258925902591259225932594259525962597259825992600260126022603260426052606260726082609261026112612261326142615261626172618261926202621262226232624262526262627262826292630263126322633263426352636263726382639264026412642264326442645264626472648264926502651265226532654265526562657265826592660266126622663266426652666266726682669267026712672267326742675267626772678267926802681268226832684268526862687268826892690269126922693269426952696269726982699270027012702270327042705270627072708270927102711271227132714271527162717271827192720272127222723272427252726272727282729273027312732273327342735273627372738273927402741274227432744274527462747274827492750275127522753275427552756275727582759276027612762276327642765276627672768276927702771277227732774277527762777277827792780278127822783278427852786278727882789279027912792279327942795279627972798279928002801280228032804280528062807280828092810281128122813281428152816281728182819282028212822282328242825282628272828282928302831283228332834283528362837283828392840284128422843284428452846284728482849285028512852285328542855285628572858285928602861286228632864286528662867286828692870287128722873287428752876287728782879288028812882288328842885288628872888288928902891289228932894289528962897289828992900290129022903290429052906290729082909291029112912291329142915291629172918291929202921292229232924292529262927292829292930293129322933293429352936293729382939294029412942294329442945294629472948294929502951295229532954295529562957295829592960296129622963296429652966296729682969297029712972297329742975297629772978297929802981298229832984298529862987298829892990299129922993299429952996299729982999300030013002300330043005300630073008300930103011301230133014301530163017301830193020302130223023302430253026302730283029303030313032303330343035303630373038303930403041304230433044304530463047304830493050305130523053305430553056305730583059306030613062306330643065306630673068306930703071307230733074307530763077307830793080308130823083308430853086308730883089309030913092309330943095309630973098309931003101310231033104310531063107310831093110311131123113311431153116311731183119312031213122312331243125312631273128312931303131313231333134313531363137313831393140314131423143314431453146314731483149315031513152315331543155315631573158315931603161316231633164316531663167316831693170317131723173317431753176317731783179318031813182318331843185318631873188318931903191319231933194319531963197319831993200320132023203320432053206320732083209321032113212321332143215321632173218321932203221322232233224322532263227322832293230323132323233323432353236323732383239324032413242324332443245324632473248324932503251325232533254325532563257325832593260326132623263326432653266326732683269327032713272327332743275327632773278327932803281328232833284328532863287328832893290329132923293329432953296329732983299330033013302330333043305330633073308330933103311331233133314331533163317331833193320332133223323332433253326332733283329333033313332333333343335333633373338333933403341334233433344334533463347334833493350335133523353335433553356335733583359336033613362336333643365336633673368336933703371337233733374337533763377337833793380338133823383338433853386338733883389339033913392339333943395339633973398339934003401340234033404340534063407340834093410341134123413341434153416341734183419342034213422342334243425342634273428342934303431343234333434343534363437343834393440344134423443344434453446344734483449345034513452345334543455345634573458345934603461346234633464346534663467346834693470347134723473347434753476347734783479348034813482348334843485348634873488348934903491349234933494349534963497349834993500350135023503350435053506350735083509351035113512351335143515351635173518351935203521352235233524352535263527352835293530353135323533353435353536353735383539354035413542354335443545354635473548354935503551355235533554355535563557355835593560356135623563356435653566356735683569357035713572357335743575357635773578357935803581358235833584358535863587358835893590359135923593359435953596359735983599360036013602360336043605360636073608360936103611361236133614361536163617361836193620362136223623362436253626362736283629363036313632363336343635363636373638363936403641364236433644364536463647364836493650365136523653365436553656365736583659366036613662366336643665366636673668366936703671367236733674367536763677367836793680368136823683368436853686368736883689369036913692369336943695369636973698369937003701370237033704370537063707370837093710371137123713371437153716371737183719372037213722372337243725372637273728372937303731373237333734373537363737373837393740374137423743374437453746374737483749375037513752375337543755375637573758375937603761376237633764376537663767376837693770377137723773377437753776377737783779378037813782378337843785378637873788378937903791379237933794379537963797379837993800380138023803380438053806380738083809381038113812381338143815381638173818381938203821382238233824382538263827382838293830383138323833383438353836383738383839384038413842384338443845384638473848384938503851385238533854385538563857385838593860386138623863386438653866386738683869387038713872387338743875387638773878387938803881388238833884388538863887388838893890389138923893389438953896389738983899390039013902390339043905390639073908390939103911391239133914391539163917391839193920392139223923392439253926392739283929393039313932393339343935393639373938393939403941394239433944394539463947394839493950395139523953395439553956395739583959396039613962396339643965396639673968396939703971397239733974397539763977397839793980398139823983398439853986398739883989399039913992399339943995399639973998399940004001400240034004400540064007400840094010401140124013401440154016401740184019402040214022402340244025402640274028402940304031403240334034403540364037403840394040404140424043404440454046404740484049405040514052405340544055405640574058405940604061406240634064406540664067406840694070407140724073407440754076407740784079408040814082408340844085408640874088408940904091409240934094409540964097409840994100410141024103410441054106410741084109411041114112411341144115411641174118411941204121412241234124412541264127412841294130413141324133413441354136413741384139414041414142414341444145414641474148414941504151415241534154415541564157415841594160416141624163416441654166416741684169417041714172417341744175417641774178417941804181418241834184418541864187418841894190419141924193419441954196419741984199420042014202420342044205420642074208420942104211421242134214421542164217421842194220422142224223422442254226422742284229423042314232423342344235423642374238423942404241424242434244424542464247424842494250425142524253425442554256425742584259426042614262426342644265426642674268426942704271427242734274427542764277427842794280428142824283428442854286428742884289429042914292429342944295429642974298429943004301430243034304430543064307430843094310431143124313431443154316431743184319432043214322432343244325432643274328432943304331433243334334433543364337433843394340434143424343434443454346434743484349435043514352435343544355435643574358435943604361436243634364436543664367436843694370437143724373437443754376437743784379438043814382438343844385438643874388438943904391439243934394439543964397439843994400440144024403440444054406440744084409441044114412441344144415441644174418441944204421442244234424442544264427442844294430443144324433443444354436443744384439444044414442444344444445444644474448444944504451445244534454445544564457445844594460//! 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=<id>`).#[allow(clippy::useless_format)]#[component]pub(crate) fn Publish(draft: String) -> Element { let session = use_context::<Signal<SessionIdentity>>(); 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<DraftThingInput>, draft_id: Signal<Option<String>>, phase: Signal<PublishPhase>, save: Signal<SaveState>, upload: Signal<UploadStatus>, image_upload: Signal<UploadStatus>, drafts: Signal<Vec<DraftSummary>>, /// Authenticated actor DID, used to build CDN preview URLs for uploaded images. did: Option<String>, /// 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<HashMap<SmolStr, SmolStr>>, /// Advanced (multi-model) disclosure open-state, shared with the auto-expand /// at the add-model site and `resume_draft`. Controlled-<details> `ontoggle` /// keeps it honest. advanced_open: Signal<bool>, /// 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<bool>, /// Live text of the "Works with" paste field, so blur can resolve it. works_with_text: Signal<String>, /// Whether a file drag is over the hero dropzone (drives the drag-over class). drag_over: Signal<bool>, /// 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<std::collections::HashMap<String, String>>, /// License fields where the user explicitly opened the custom-license input. custom_license_targets: Signal<HashSet<String>>, /// 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<UploadStatus>, /// 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<bool>, /// The mounted name input element, focused once after the first upload. name_element: Signal<Option<std::rc::Rc<MountedData>>>, /// 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<HashMap<String, String>>, /// 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<Option<u64>>, /// 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<Option<u64>>, /// Monotonic token source for asynchronous operations in this flow instance. operation_counter: Signal<u64>,}
#[component]fn PublishFlow(resume: String) -> Element { let client = use_context::<PolymodelClient>(); let session = use_context::<Signal<SessionIdentity>>(); 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::<String>::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::<DraftSummary>::new); let image_data_uris = use_signal(HashMap::<SmolStr, SmolStr>::new);
// Disclosure open-state for the Advanced (multi-model) section, opened at the // add-model site. Controlled-<details> `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::<String>::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<Option<std::rc::Rc<MountedData>>> = 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=<id>` 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=<id>` 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<DraftSummary> = 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<String> = 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<Bbox>) -> 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<String> = 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<String> = 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<String> = text.split_whitespace().map(str::to_string).collect(); let mut added = false; let mut unresolved: Vec<String> = 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<FileData>) { 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<u64>, ) -> 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()); // Resolve the numeric-index target to a stable identity BEFORE the // first await. If the model/part is already gone or lacks identity, // skip the upload entirely — the composition mutation below uses the // resolved identity, never the stale index. let stable_target = { let composition = self.composition; let guard = composition.read(); resolve_image_target(&guard, target) }; if stable_target.is_none() { self.set_image_upload_if_current(image_token, UploadStatus::Idle); return; } let stable_target = stable_target.unwrap(); 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(); // Re-find by stable identity; if the model/part was removed // or reordered away mid-flight, skip the attach cleanly. let list = image_list_stable_mut(&mut guard, &stable_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 `<details>` 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<String>, open: Option<Signal<bool>>, 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 `<details>` 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<bool> { use dioxus::web::WebEventExt; use wasm_bindgen::JsCast; evt.try_as_web_event() .and_then(|e| e.target()) .and_then(|t| t.dyn_into::<web_sys::HtmlDetailsElement>().ok()) .map(|el| el.open())}
#[cfg(not(target_arch = "wasm32"))]fn read_details_open(_evt: &ToggleEvent) -> Option<bool> { None}
fn image_list_mut( thing: &mut DraftThingInput, target: FieldTarget, is_cover: bool,) -> Option<&mut Vec<Image>> { 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)) } }}
/// A stable, index-free identity for an image-attach target, resolved from a/// `FieldTarget` *before* the first await so that model/part reordering or/// removal mid-flight cannot redirect the attach to the wrong slot.////// - `Thing` has no numeric index and is unaffected./// - `Model` resolves the model's `model_id` (ensured/normalized via the/// existing identity machinery; legacy models carry ids after J6 hydration)./// - `Part` additionally captures a stable part discriminator. We prefer/// `resource_id` (the PDS-level resource identity set during staging) over/// `upload_id` (which carries the same value in the current staging flow but/// is semantically the upload handle, not the resource identity) and `name`/// (user-editable, not guaranteed unique). `resource_id` is the most stable/// discriminator available on `DraftPartInput`.enum StableImageTarget { Thing, Model { model_id: String, }, Part { model_id: String, part_discriminator: String, },}
/// Extract the most stable part discriminator available, or `None` if the part/// has none of resource_id / upload_id / name.fn part_discriminator(part: &DraftPartInput) -> Option<String> { part.resource_id .clone() .or_else(|| part.upload_id.clone()) .or_else(|| part.name.clone())}
/// Resolve a `FieldTarget` to a `StableImageTarget` against the current/// composition *before* any await. Returns `None` if the index-based target/// is already stale (model/part not found at the given index, or the model/// lacks a `model_id`, or the part lacks a discriminator).fn resolve_image_target(thing: &DraftThingInput, target: FieldTarget) -> Option<StableImageTarget> { match target { FieldTarget::Thing => Some(StableImageTarget::Thing), FieldTarget::Model(mi) => { let model = thing.models.get(mi)?; let model_id = model.model_id.clone()?; Some(StableImageTarget::Model { model_id }) } FieldTarget::Part(mi, pi) => { let model = thing.models.get(mi)?; let model_id = model.model_id.clone()?; let part = model.parts.get(pi)?; let part_discriminator = part_discriminator(part)?; Some(StableImageTarget::Part { model_id, part_discriminator, }) } }}
/// Re-find the image list by stable identity *after* the upload await, under/// the composition write guard. Returns `None` if the target was removed or/// reordered away — the caller skips the attach cleanly in that case.fn image_list_stable_mut<'a>( thing: &'a mut DraftThingInput, target: &'a StableImageTarget, is_cover: bool,) -> Option<&'a mut Vec<Image>> { match target { StableImageTarget::Thing => { if is_cover { Some(thing.cover.get_or_insert_with(Vec::new)) } else { Some(thing.previews.get_or_insert_with(Vec::new)) } } StableImageTarget::Model { model_id } => { let model = thing .models .iter_mut() .find(|m| m.model_id.as_deref() == Some(model_id.as_str()))?; if is_cover { Some(model.cover.get_or_insert_with(Vec::new)) } else { Some(model.previews.get_or_insert_with(Vec::new)) } } StableImageTarget::Part { model_id, part_discriminator, } => { let model = thing .models .iter_mut() .find(|m| m.model_id.as_deref() == Some(model_id.as_str()))?; let part = model.parts.iter_mut().find(|p| { p.resource_id.as_deref() == Some(part_discriminator.as_str()) || p.upload_id.as_deref() == Some(part_discriminator.as_str()) || p.name.as_deref() == Some(part_discriminator.as_str()) })?; 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>), 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<String> { 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::<std::collections::HashSet<_>>(); 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::<String>(); 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<String>, file: &polymodel_api::space_polymodel::library::File,) -> Option<DraftStagedResource> { 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<String> { if resources.is_empty() || resources.len() > MAX_PREVIEW_RESOURCES { return None; } let mut aggregate = 0_u64; let mut entries = resources.iter().collect::<Vec<_>>(); entries.sort_by(|left, right| left.resource_id.cmp(&right.resource_id)); let mut hasher = <sha2::Sha256 as sha2::Digest>::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<Vec<AcquiredDraftResource>, 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<Vec<AcquiredDraftResource>, String> { Err("preview acquisition is browser-only".into())}
fn remove_image(list: &mut Option<Vec<Image>>, 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<F, E, C>(completion: F, commit: C) -> Result<bool, E>where F: std::future::Future<Output = Result<Image, E>>, 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<Vec<u8>, 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<u8>) -> Result<Vec<u8>, 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<Vec<u8>, 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<u8>) -> Result<Vec<u8>, String> { Err("preview rendering is browser-only".into())}
async fn upload_generated_preview( client: PolymodelClient, filename: String, png_bytes: Vec<u8>, mut image_data_uris: Signal<HashMap<SmolStr, SmolStr>>,) -> Result<Image, crate::client::RawError> { 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<Vec<u8>, String> { request_preview_png(LoadSource::Bundle(bundle), width, height).await }
#[allow(dead_code)] pub(super) async fn render_preview_png( filename: &str, bytes: Vec<u8>, width: u32, height: u32, ) -> Result<Vec<u8>, 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<u8>, mime: &str) -> Result<String, String> { 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<String, String> { 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<dyn FnMut(web_sys::MessageEvent)>); 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<dyn FnMut(web_sys::ErrorEvent)>); 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<wasm_bindgen::JsValue> { js_sys::Reflect::get(value, &name.into()).ok() }
fn js_string_field(value: &wasm_bindgen::JsValue, name: &str) -> Option<String> { js_value_field(value, name).and_then(|v| v.as_string()) }
async fn request_preview_png( source: LoadSource, width: u32, height: u32, ) -> Result<Vec<u8>, 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::<js_sys::Uint8Array>() 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<dyn FnMut(web_sys::MessageEvent)>);
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<dyn FnMut(web_sys::ErrorEvent)>);
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::<Vec<_>>(); 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::<HashSet<_>>().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()); }
/// Test 1: In-flight image upload to FieldTarget::Model targeting model-b /// (index 1); remove model-a (index 0) mid flight (index shift); completion /// resolves → assert image lands on model-b (by identity), NOT on whatever /// sits at old index 1, and no provenance/user-media damage. #[tokio::test] async fn image_upload_attach_follows_model_identity_after_vector_compaction() { let img = test_image("uploaded preview"); // Two models; we target model-b at index 1. let composition = std::sync::Arc::new(std::sync::Mutex::new(DraftThingInput { models: vec![ DraftModelInput { model_id: Some("model-a".into()), ..Default::default() }, DraftModelInput { model_id: Some("model-b".into()), ..Default::default() }, ], ..Default::default() })); // — BEFORE the await: resolve the target to stable identity — let stable_target = { let guard = composition.lock().unwrap(); resolve_image_target(&guard, FieldTarget::Model(1)) }; // The target must resolve to model-b's identity. assert!( matches!(&stable_target, Some(StableImageTarget::Model { model_id }) if model_id == "model-b"), "stable target must resolve to model-b before the await" ); let stable_target = stable_target.unwrap();
// — SIMULATE the await gap (controllable future, like the J7 test) — let (release_tx, release_rx) = tokio::sync::oneshot::channel(); let completion = async move { release_rx.await.unwrap(); Ok::<Image, ()>(img) }; tokio::pin!(completion); assert!(futures::poll!(&mut completion).is_pending());
// — MID-FLIGHT: remove model-a (index 0), compacting the Vec so that // model-b now sits at index 0. With the old numeric-index approach, // index 1 would now point at a non-existent slot (out of bounds, drop). — composition.lock().unwrap().models.remove(0);
// Release the upload future. release_tx.send(()).unwrap(); let image = completion.await.unwrap();
// — POST-AWAIT: re-find by stable identity and attach — { let mut guard = composition.lock().unwrap(); let list = image_list_stable_mut(&mut guard, &stable_target, false); assert!( list.is_some(), "must re-find model-b by identity after compaction" ); if let Some(list) = list { list.push(image); } }
// — ASSERTIONS: image landed on model-b, not on a wrong slot — let guard = composition.lock().unwrap(); assert_eq!(guard.models.len(), 1, "only model-b should remain"); assert_eq!( guard.models[0].model_id.as_deref(), Some("model-b"), "surviving model must be model-b" ); assert_eq!( guard.models[0].previews.as_ref().unwrap().len(), 1, "model-b must have the uploaded image" ); assert_eq!( guard.models[0].previews.as_ref().unwrap()[0].alt, "uploaded preview" ); // No stray images anywhere else. assert!(guard.previews.is_none()); assert!(guard.cover.is_none()); assert!(guard.models[0].cover.is_none()); }
/// Test 2: Target model removed mid flight → completion resolves → assert no /// attach anywhere + truthful status behavior (attach skipped cleanly). #[tokio::test] async fn image_upload_attach_skips_when_target_model_removed_mid_flight() { let img = test_image("uploaded preview"); let composition = std::sync::Arc::new(std::sync::Mutex::new(DraftThingInput { models: vec![ DraftModelInput { model_id: Some("model-a".into()), ..Default::default() }, DraftModelInput { model_id: Some("model-b".into()), ..Default::default() }, ], ..Default::default() })); // Target model-b at index 1. let stable_target = { let guard = composition.lock().unwrap(); resolve_image_target(&guard, FieldTarget::Model(1)) }; assert!( matches!(&stable_target, Some(StableImageTarget::Model { model_id }) if model_id == "model-b"), ); let stable_target = stable_target.unwrap();
// Simulate the await gap. let (release_tx, release_rx) = tokio::sync::oneshot::channel(); let completion = async move { release_rx.await.unwrap(); Ok::<Image, ()>(img) }; tokio::pin!(completion); assert!(futures::poll!(&mut completion).is_pending());
// — MID-FLIGHT: remove model-b entirely (the target itself) — composition.lock().unwrap().models.remove(1);
release_tx.send(()).unwrap(); let image = completion.await.unwrap();
// — POST-AWAIT: re-find by stable identity — model-b is gone, so the // attach must be skipped cleanly (returns None). — let attached; { let mut guard = composition.lock().unwrap(); let list = image_list_stable_mut(&mut guard, &stable_target, false); attached = list.is_some(); if let Some(list) = list { list.push(image); } }
// Truthful status: the attach was skipped, no image written anywhere. assert!(!attached, "must not attach when target model is gone"); let guard = composition.lock().unwrap(); assert_eq!(guard.models.len(), 1, "only model-a should remain"); assert_eq!(guard.models[0].model_id.as_deref(), Some("model-a")); assert!( guard.models[0].previews.is_none(), "model-a must not receive the image" ); assert!(guard.previews.is_none()); assert!(guard.cover.is_none()); }
/// Test 3: FieldTarget::Part equivalent — remove/reorder the containing /// model or its parts mid flight → attach follows the stable part /// discriminator or skips cleanly. #[tokio::test] async fn image_upload_attach_follows_part_discriminator_after_model_compaction() { let img = test_image("uploaded part preview"); // Two models; we target the part in model-b at index 1. let composition = std::sync::Arc::new(std::sync::Mutex::new(DraftThingInput { models: vec![ DraftModelInput { model_id: Some("model-a".into()), parts: vec![DraftPartInput { resource_id: Some("resource-a".into()), ..Default::default() }], ..Default::default() }, DraftModelInput { model_id: Some("model-b".into()), parts: vec![DraftPartInput { resource_id: Some("resource-b".into()), ..Default::default() }], ..Default::default() }, ], ..Default::default() })); // Target model-b's part at (1, 0). let stable_target = { let guard = composition.lock().unwrap(); resolve_image_target(&guard, FieldTarget::Part(1, 0)) }; assert!( matches!( &stable_target, Some(StableImageTarget::Part { model_id, part_discriminator }) if model_id == "model-b" && part_discriminator == "resource-b" ), "must resolve to model-b / resource-b" ); let stable_target = stable_target.unwrap();
// Simulate the await gap. let (release_tx, release_rx) = tokio::sync::oneshot::channel(); let completion = async move { release_rx.await.unwrap(); Ok::<Image, ()>(img) }; tokio::pin!(completion); assert!(futures::poll!(&mut completion).is_pending());
// — MID-FLIGHT: remove model-a (index 0), compacting the Vec so model-b // now sits at index 0 and its part at (0, 0). The old numeric index // (1, 0) would now point at model-a's part or be out of bounds. — composition.lock().unwrap().models.remove(0);
release_tx.send(()).unwrap(); let image = completion.await.unwrap();
// — POST-AWAIT: re-find by stable identity — { let mut guard = composition.lock().unwrap(); let list = image_list_stable_mut(&mut guard, &stable_target, false); assert!( list.is_some(), "must re-find model-b's part by identity after compaction" ); if let Some(list) = list { list.push(image); } }
// — ASSERTIONS — image landed on model-b's part, not model-a's — let guard = composition.lock().unwrap(); assert_eq!(guard.models.len(), 1, "only model-b should remain"); assert_eq!(guard.models[0].model_id.as_deref(), Some("model-b")); assert_eq!(guard.models[0].parts.len(), 1); assert_eq!( guard.models[0].parts[0].resource_id.as_deref(), Some("resource-b") ); assert_eq!( guard.models[0].parts[0].previews.as_ref().unwrap().len(), 1, "model-b's part must have the uploaded image" ); assert_eq!( guard.models[0].parts[0].previews.as_ref().unwrap()[0].alt, "uploaded part preview" ); // No stray images. assert!(guard.models[0].previews.is_none()); assert!(guard.models[0].cover.is_none()); assert!(guard.previews.is_none()); assert!(guard.cover.is_none()); }
/// Test 3b: Part target where the containing model is removed mid-flight /// → attach must skip cleanly (no attach anywhere). #[tokio::test] async fn image_upload_part_attach_skips_when_model_removed_mid_flight() { let img = test_image("uploaded part preview"); let composition = std::sync::Arc::new(std::sync::Mutex::new(DraftThingInput { models: vec![ DraftModelInput { model_id: Some("model-a".into()), parts: vec![DraftPartInput { resource_id: Some("resource-a".into()), ..Default::default() }], ..Default::default() }, DraftModelInput { model_id: Some("model-b".into()), parts: vec![DraftPartInput { resource_id: Some("resource-b".into()), ..Default::default() }], ..Default::default() }, ], ..Default::default() })); let stable_target = { let guard = composition.lock().unwrap(); resolve_image_target(&guard, FieldTarget::Part(1, 0)) }; assert!(stable_target.is_some()); let stable_target = stable_target.unwrap();
// Simulate the await gap. let (release_tx, release_rx) = tokio::sync::oneshot::channel(); let completion = async move { release_rx.await.unwrap(); Ok::<Image, ()>(img) }; tokio::pin!(completion); assert!(futures::poll!(&mut completion).is_pending());
// Remove model-b entirely (the containing model of the target part). composition.lock().unwrap().models.remove(1);
release_tx.send(()).unwrap(); let image = completion.await.unwrap();
// Re-find: model-b is gone, so skip cleanly. let attached; { let mut guard = composition.lock().unwrap(); let list = image_list_stable_mut(&mut guard, &stable_target, false); attached = list.is_some(); if let Some(list) = list { list.push(image); } } assert!(!attached, "must not attach when containing model is gone"); let guard = composition.lock().unwrap(); assert_eq!(guard.models.len(), 1); assert_eq!(guard.models[0].model_id.as_deref(), Some("model-a")); assert!( guard.models[0].parts[0].previews.is_none(), "model-a's part must not receive the image" ); assert!(guard.previews.is_none()); assert!(guard.cover.is_none()); }}