Something went wrong. Try again.
atproto Thingiverse but good
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175217621772178217921802181218221832184218521862187218821892190219121922193219421952196219721982199220022012202220322042205220622072208220922102211221222132214221522162217221822192220222122222223222422252226222722282229223022312232223322342235223622372238223922402241224222432244224522462247224822492250225122522253225422552256225722582259226022612262226322642265226622672268226922702271227222732274227522762277227822792280228122822283228422852286228722882289229022912292229322942295229622972298229923002301230223032304230523062307230823092310231123122313231423152316231723182319232023212322232323242325232623272328232923302331233223332334233523362337233823392340234123422343234423452346234723482349235023512352235323542355235623572358235923602361236223632364236523662367236823692370237123722373237423752376237723782379238023812382238323842385238623872388238923902391239223932394239523962397239823992400240124022403240424052406240724082409241024112412241324142415241624172418241924202421242224232424242524262427242824292430243124322433243424352436243724382439244024412442244324442445244624472448244924502451245224532454245524562457245824592460246124622463246424652466246724682469247024712472247324742475247624772478247924802481248224832484248524862487248824892490249124922493249424952496249724982499250025012502250325042505250625072508250925102511251225132514251525162517251825192520252125222523252425252526252725282529253025312532253325342535253625372538253925402541254225432544254525462547254825492550255125522553255425552556255725582559256025612562256325642565256625672568256925702571257225732574257525762577257825792580258125822583258425852586258725882589259025912592259325942595259625972598259926002601260226032604260526062607260826092610261126122613261426152616261726182619262026212622262326242625262626272628262926302631263226332634263526362637263826392640264126422643264426452646264726482649265026512652265326542655265626572658265926602661266226632664266526662667266826692670267126722673267426752676267726782679268026812682268326842685268626872688268926902691269226932694269526962697269826992700270127022703270427052706270727082709271027112712271327142715271627172718271927202721272227232724272527262727272827292730273127322733273427352736273727382739274027412742274327442745274627472748274927502751275227532754275527562757275827592760276127622763276427652766276727682769277027712772277327742775277627772778277927802781278227832784278527862787278827892790279127922793279427952796279727982799280028012802280328042805280628072808280928102811281228132814281528162817281828192820282128222823282428252826282728282829283028312832283328342835//! Polymodel asset viewer: a Dioxus-owned canvas bridged to a renderer Web Worker.//!//! The viewer renders committed mesh assets (STL, OBJ, glTF/GLB) in a Web Worker with//! its own WASM bundle, loaded lazily on viewer mount. The main thread owns the canvas//! (via `OffscreenCanvas` transfer), captures DOM events, and throttles pointer input//! producer-side to display rate before posting coalesced state to the worker. The worker//! owns the GL context, the frame loop, and all mesh parsing.//!//! **Retained session:** one GL context per page mount, surviving model switches. The//! worker emits `ContextAcquired` once; the `context_acquisitions` signal stays at 1//! across model switches (retention = no worker respawn).
#[cfg(target_arch = "wasm32")]use crate::client::PolymodelClient;use dioxus::prelude::*;use polymodel_renderer_protocol::{ Capability, KeyModifiers, LoadErrorCode, LoadEvent, LoadIdentity, LoadOutput, LoadReducer, LoadSource, MeshFormat, MeshStats, ModelLoadBundle, NegotiatedProtocol, RendererCommand, RendererEvent, RendererNamespace,};#[cfg(target_arch = "wasm32")]use polymodel_renderer_protocol::{ CommandEnvelope, CommandPayload, EventPayload, HandshakeState, ProtocolHello, deserialize_event_envelope, serialize_command_envelope,};#[cfg(target_arch = "wasm32")]use std::cell::RefCell;#[cfg(target_arch = "wasm32")]use std::rc::Rc;
// ---------------------------------------------------------------------------// Demo model metadata (stays main-side — the worker only needs format + URL)// ---------------------------------------------------------------------------
#[derive(Clone, Copy, Debug, PartialEq, Eq)]pub struct DemoModel { pub id: &'static str, pub name: &'static str, pub asset_path: &'static str, pub generation: u64, pub vertices: usize, pub triangles: usize,}
#[derive(Clone, Debug, PartialEq, Eq)]pub struct ViewerFallbackImage { pub url: String, pub alt: String,}
#[derive(Clone, Debug, PartialEq, Eq)]pub enum ViewerSourceFormat { Renderer(MeshFormat), Step, BoundedEvidence,}
#[derive(Clone, Debug, PartialEq, Eq)]pub struct ViewerMeshSource { pub key: String, pub name: String, pub url: String, pub format: ViewerSourceFormat, pub expected_size: Option<i64>, pub expected_digest: Option<Vec<u8>>,}
#[cfg(target_arch = "wasm32")]#[derive(Clone, Debug, PartialEq, Eq)]struct CompoundLogicalIntent { key: String,}
#[cfg(target_arch = "wasm32")]#[derive(Clone, Debug, PartialEq, Eq)]struct PendingCompoundLoad { logical: CompoundLogicalIntent, bundle: ModelLoadBundle, acquisition_epoch: u32,}
#[cfg(not(target_arch = "wasm32"))]type CompoundLogicalIntent = ();
#[cfg(not(target_arch = "wasm32"))]type PendingCompoundLoad = ();
#[derive(Clone, Debug, PartialEq)]struct RenderSource { key: String, format: MeshFormat, primary_url: String, expected_size: Option<i64>, expected_digest: Option<Vec<u8>>,}
#[derive(Clone, Debug, PartialEq)]struct StepConversionReport { status: StepConversionStatus, details: String,}
#[derive(Clone, Debug, PartialEq)]enum StepConversionStatus { Idle, Converting, // Native builds do not execute the browser conversion callback. #[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))] Converted, Failed,}
impl StepConversionReport { fn idle() -> Self { Self { status: StepConversionStatus::Idle, details: "STEP conversion has not been requested.".into(), } }
fn label(&self) -> &'static str { match self.status { StepConversionStatus::Idle => "STEP idle", StepConversionStatus::Converting => "STEP converting", StepConversionStatus::Converted => "STEP converted", StepConversionStatus::Failed => "STEP blocked", } }}
const DEMO_MODELS: [DemoModel; 3] = [ DemoModel { id: "body-f-chest-v4", name: "Body F chest v4", asset_path: "/models/body_f_chest-v4.stl", generation: 1, vertices: 41051, triangles: 82122, }, DemoModel { id: "body-f-forearm-2x-v9", name: "Body F forearm 2x v9", asset_path: "/models/body_f_forearm_2x-v9.stl", generation: 1, vertices: 18271, triangles: 36542, }, DemoModel { id: "cube-gears-3mf", name: "Cube Gears (3MF)", asset_path: "/models/cube_gears.3mf", generation: 1, vertices: 12864, triangles: 25692, },];
pub fn demo_models() -> &'static [DemoModel] { &DEMO_MODELS}
pub fn demo_model_for_route(model_id: &str) -> DemoModel { demo_models() .iter() .copied() .find(|model| model.id == model_id) .unwrap_or(DEMO_MODELS[0])}
// ---------------------------------------------------------------------------// Session identity// ---------------------------------------------------------------------------
#[derive(Clone, Debug, PartialEq, Eq, Hash)]pub struct SessionKey { model_id: String, generation: u64,}
impl SessionKey { pub fn new(model_id: impl Into<String>, generation: u64) -> Self { Self { model_id: model_id.into(), generation, } }
#[cfg(test)] pub fn switch_to(&self, next_model_id: impl Into<String>) -> Self { Self { model_id: next_model_id.into(), generation: self.generation + 1, } }}
// ---------------------------------------------------------------------------// Mesh summary (display metadata — stays main-side from DemoModel)// ---------------------------------------------------------------------------
#[derive(Clone, Copy, Debug, PartialEq, Eq)]pub struct MeshSummary { pub display_name: &'static str, pub asset_path: &'static str, pub vertices: usize, pub triangles: usize,}
fn mesh_summary_for(model: DemoModel) -> MeshSummary { MeshSummary { display_name: model.name, asset_path: model.asset_path, vertices: model.vertices, triangles: model.triangles, }}
/// A mesh load request: the model to load plus its derived format./// The main thread sends `LoadMesh` to the worker; the worker fetches, parses, and/// builds the scene, then posts `MeshLoaded{stats}` or `MeshError{message}` back.#[cfg_attr( not(target_arch = "wasm32"), allow(dead_code, reason = "constructed by the browser-only viewer path"))]#[derive(Clone, Debug)]pub struct PendingMeshLoad { pub model: DemoModel, pub format: MeshFormat,}
#[cfg_attr( not(target_arch = "wasm32"), allow(dead_code, reason = "used by the browser-only viewer path"))]fn pending_load_for(model: DemoModel) -> Result<PendingMeshLoad, ViewerIssue> { let format = MeshFormat::from_extension(model.asset_path).ok_or(ViewerIssue::MeshUnavailable)?; Ok(PendingMeshLoad { model, format })}
// ---------------------------------------------------------------------------// Viewer status// ---------------------------------------------------------------------------
#[derive(Clone, Copy, Debug, PartialEq, Eq)]pub enum ViewerIssue { #[cfg_attr( not(target_arch = "wasm32"), allow(dead_code, reason = "returned by the browser-only mesh loader") )] MeshUnavailable, RendererInit,}
#[cfg_attr( not(target_arch = "wasm32"), allow( dead_code, reason = "status variants are emitted by the browser worker bridge" ))]#[derive(Clone, Debug, PartialEq, Eq)]pub enum ViewerStatus { Ready(SessionKey), Loading(SessionKey), Installing(SessionKey), Failed(SessionKey, LoadErrorCode, String), Cancelled(SessionKey), MeshError(String), RendererError(ViewerIssue),}
impl ViewerStatus { pub fn label(&self) -> &'static str { match self { Self::Ready(_) => "Viewer ready", Self::Loading(_) => "Loading model", Self::Installing(_) => "Installing model", Self::Failed(_, _, _) | Self::MeshError(_) => "Mesh unavailable", Self::Cancelled(_) => "Load cancelled", Self::RendererError(_) => "Renderer unavailable", } }
pub fn should_have_session(&self) -> bool { matches!( self, Self::Ready(_) | Self::Loading(_) | Self::Installing(_) | Self::Failed(_, _, _) | Self::Cancelled(_) | Self::MeshError(_) ) }}
// ---------------------------------------------------------------------------// Pages// ---------------------------------------------------------------------------
#[component]pub fn ViewerPage(initial_model: DemoModel) -> Element { let mut selected_model = use_signal(|| initial_model); let step_report = use_signal(StepConversionReport::idle); let step_render_source: Signal<Option<RenderSource>> = use_signal(|| None); let compound_load: Signal<Option<PendingCompoundLoad>> = use_signal(|| None); let compound_intent: Signal<Option<CompoundLogicalIntent>> = use_signal(|| None); let compound_boundary: Signal<bool> = use_signal(|| false); let active_step_object_url: Signal<Option<String>> = use_signal(|| None); let canvas_mounted = use_signal(|| false); let canvas_mount_generation = use_signal(|| 0_u32); let canvas_mount_epoch = use_signal(|| None::<u32>); let canvas_mount_requested = use_signal(|| true); let resize_count = use_signal(|| 0_u64); let context_acquisitions = use_signal(|| 0_u64); let renderer_failed = use_signal(|| false); let restart_epoch = use_signal(|| 0_u32); let worker_session_id = use_signal(|| None::<u64>); let worker_capabilities = use_signal(String::new); let cancelled_loads = use_signal(|| 0_u32); let mesh_stats: Signal<Option<MeshStats>> = use_signal(|| None); let status: Signal<ViewerStatus> = use_signal(|| { ViewerStatus::Loading(SessionKey::new(initial_model.id, initial_model.generation)) });
let _mesh_load = use_resource(move || { let model = *selected_model.read(); let mut status = status; async move { status.set(ViewerStatus::Loading(SessionKey::new( model.id, model.generation, ))); // The actual StartLoad command is sent to the worker by the // WorkerRendererSession effect (below). This resource just tracks // the requested status; readiness is published only after the // bridge receives InstallSucceeded. } });
let model = *selected_model.read();
rsx! { section { class: "model-viewer-shell", aria_label: "Asset-backed STL model viewer", header { class: "model-viewer-header", div { p { class: "viewer-eyebrow", "Asset-backed STL viewer" } h1 { "Model viewer" } p { "Loads the committed STL asset through the same metadata a renderer adapter receives." } } div { class: "viewer-status-pill", "{status.read().label()}" } }
div { class: "viewer-demo-controls", aria_label: "Model choices", for model_option in demo_models() { button { class: if *model_option == model { "viewer-demo-button is-active" } else { "viewer-demo-button" }, r#type: "button", onclick: move |_| { clear_step_render_source(step_render_source, active_step_object_url); selected_model.set(*model_option); }, "{model_option.name}" } } StepConversionControl { step_report, step_render_source, active_step_object_url, selected_model, } }
div { class: "viewer-step-conversion-panel", "data-step-status": "{step_report.read().label()}", p { class: "viewer-step-conversion-label", "OpenCascade.js STEP worker" } p { "{step_report.read().details}" } }
ModelViewer { model: Some(model), render_source: step_render_source, compound_load, compound_intent, compound_boundary, fallback_to_demo: true, mesh_stats, status, canvas_mounted, canvas_mount_generation, canvas_mount_epoch, canvas_mount_requested, resize_count, context_acquisitions, renderer_failed, restart_epoch, worker_session_id, worker_capabilities, cancelled_loads, fallback: None, } } }}
#[component]pub fn AssetViewer( #[props(default)] initial_model: Option<DemoModel>, fallback: Option<ViewerFallbackImage>, #[props(default)] source: Option<ViewerMeshSource>,) -> Element { let render_source: Signal<Option<RenderSource>> = use_signal(|| None); let compound_load: Signal<Option<PendingCompoundLoad>> = use_signal(|| None); #[cfg_attr(not(target_arch = "wasm32"), allow(unused_mut))] let mut compound_intent: Signal<Option<CompoundLogicalIntent>> = use_signal(|| None); let compound_boundary: Signal<bool> = use_signal(|| false); let active_step_object_url: Signal<Option<String>> = use_signal(|| None); let step_report = use_signal(StepConversionReport::idle); let canvas_mounted = use_signal(|| false); let canvas_mount_generation = use_signal(|| 0_u32); let canvas_mount_epoch = use_signal(|| None::<u32>); let canvas_mount_requested = use_signal(|| true); let resize_count = use_signal(|| 0_u64); let context_acquisitions = use_signal(|| 0_u64); let renderer_failed = use_signal(|| false); let restart_epoch = use_signal(|| 0_u32); let worker_session_id = use_signal(|| None::<u64>); let worker_capabilities = use_signal(String::new); let cancelled_loads = use_signal(|| 0_u32); let mesh_stats: Signal<Option<MeshStats>> = use_signal(|| None); let status: Signal<ViewerStatus> = use_signal(|| { let key = source .as_ref() .map(|source| SessionKey::new(source.key.clone(), 1)) .or_else(|| initial_model.map(|model| SessionKey::new(model.id, model.generation))) .unwrap_or_else(|| SessionKey::new("asset-viewer", 1)); ViewerStatus::Loading(key) }); let fallback_to_demo = source.is_none() && initial_model.is_some();
let _mesh_load = use_resource(move || { let model = initial_model; let mut status = status; let mut mesh_stats = mesh_stats; let mut compound_boundary = compound_boundary; let mut compound_load = compound_load; let source = source.clone(); let mut render_source = render_source; let active_step_object_url = active_step_object_url; let mut step_report = step_report; async move { let session_key = source .as_ref() .map(|source| SessionKey::new(source.key.clone(), 1)) .or_else(|| model.map(|model| SessionKey::new(model.id, model.generation))) .unwrap_or_else(|| SessionKey::new("asset-viewer", 1)); let using_source = source.is_some(); compound_boundary.set(false); status.set(ViewerStatus::Loading(session_key.clone())); if let Some(source) = source { match source.format { ViewerSourceFormat::Renderer(MeshFormat::CompoundLdraw) => { clear_step_render_source(render_source, active_step_object_url); render_source.set(None); #[cfg(target_arch = "wasm32")] compound_intent.set(Some(CompoundLogicalIntent { key: source.key })); compound_load.set(None); } ViewerSourceFormat::Renderer(format) => { clear_step_render_source(render_source, active_step_object_url); render_source.set(Some(RenderSource { key: source.key, format, primary_url: source.url, expected_size: source.expected_size, expected_digest: source.expected_digest, })); } ViewerSourceFormat::Step => { clear_step_render_source(render_source, active_step_object_url); step_report.set(StepConversionReport { status: StepConversionStatus::Converting, details: format!( "Converting {} in the STEP conversion worker.", source.name ), }); start_step_conversion( step_report, render_source, active_step_object_url, source.url, source.key, ); } ViewerSourceFormat::BoundedEvidence => { clear_step_render_source(render_source, active_step_object_url); match classify_verified_source(&source).await { Ok(ViewerSourceFormat::Renderer(format)) => { render_source.set(Some(RenderSource { key: source.key, format, primary_url: source.url, expected_size: source.expected_size, expected_digest: source.expected_digest, })); } Ok(ViewerSourceFormat::Step) => { step_report.set(StepConversionReport { status: StepConversionStatus::Converting, details: format!( "Converting {} in the STEP conversion worker.", source.name ), }); start_step_conversion( step_report, render_source, active_step_object_url, source.url, source.key, ); } Ok(ViewerSourceFormat::BoundedEvidence) => unreachable!(), Err(error) => status.set(ViewerStatus::MeshError(error)), } } } } else { clear_step_render_source(render_source, active_step_object_url); } if !using_source { mesh_stats.set(None); } } });
rsx! { div { class: "detail-asset-viewer", aria_label: "Asset-backed 3D model viewer", ModelViewer { model: initial_model, render_source, compound_load, compound_intent, compound_boundary, fallback_to_demo, mesh_stats, status, canvas_mounted, canvas_mount_generation, canvas_mount_epoch, canvas_mount_requested, resize_count, context_acquisitions, renderer_failed, restart_epoch, worker_session_id, worker_capabilities, cancelled_loads, fallback, } } }}
#[cfg(target_arch = "wasm32")]#[derive(serde::Serialize)]struct ModelBundleRequest<'a> { uri: &'a str,}
#[cfg(target_arch = "wasm32")]fn start_compound_plan_load( logical: CompoundLogicalIntent, expected_restart_epoch: u32, restart_epoch: Signal<u32>, expected_intent: Signal<Option<CompoundLogicalIntent>>, client: PolymodelClient, mut pending: Signal<Option<PendingCompoundLoad>>, mut status: Signal<ViewerStatus>,) { wasm_bindgen_futures::spawn_local(async move { let path = format!( "/xrpc/space.polymodel.library.getModelLoadBundle?uri={}", urlencoding::encode(&logical.key) ); tracing::info!( key = %logical.key, restart_epoch = expected_restart_epoch, "viewer: requesting compound model-load bundle" ); let result = client .app_bytes::<ModelBundleRequest<'_>>(http::Method::GET, &path, None) .await .and_then(|bytes| { tracing::info!( key = %logical.key, response_bytes = bytes.len(), "viewer: compound model-load bundle response received" ); ModelLoadBundle::decode(&bytes) .map_err(|error| crate::client::RawError::Decode(format!("{error:?}"))) }); let current = *restart_epoch.read() == expected_restart_epoch && expected_intent.read().as_ref() == Some(&logical); if !current { tracing::warn!( key = %logical.key, restart_epoch = expected_restart_epoch, "viewer: discarded stale compound model-load bundle" ); return; } match result { Ok(bundle) => { let keys = bundle .resources .iter() .take(12) .map(|resource| resource.key.as_str()) .collect::<Vec<_>>(); tracing::info!( key = %logical.key, primary = %bundle.primary, resources = bundle.resources.len(), resource_keys = ?keys, keys_truncated = bundle.resources.len() > 12, "viewer: decoded compound model-load bundle" ); pending.set(Some(PendingCompoundLoad { logical, bundle, acquisition_epoch: expected_restart_epoch, })); } Err(error) => { tracing::error!( key = %logical.key, error = %error, "viewer: compound model-load bundle acquisition or decode failed" ); status.set(ViewerStatus::MeshError(format!( "model bundle acquisition failed: {error}" ))); } } });}
#[cfg_attr( not(target_arch = "wasm32"), allow( dead_code, reason = "used by browser-side verified source classification" ))]fn viewer_format_from_evidence(bytes: &[u8]) -> Result<ViewerSourceFormat, String> { use polymodel_renderer_protocol::format_registry::{Evidence, FormatId, recognize_evidence};
match recognize_evidence(bytes) { Evidence::One(FormatId::Step) => Ok(ViewerSourceFormat::Step), Evidence::One(format) => format .renderer_format() .map(ViewerSourceFormat::Renderer) .ok_or_else(|| "verified file format has no viewer path".to_string()), Evidence::None => Err("verified file has no recognizable bounded format evidence".into()), Evidence::Multiple => Err("verified file has ambiguous bounded format evidence".into()), Evidence::OverBound => { Err("verified file exceeds the bounded format evidence limit".into()) } }}
#[cfg(target_arch = "wasm32")]async fn classify_verified_source(source: &ViewerMeshSource) -> Result<ViewerSourceFormat, String> { use polymodel_renderer_protocol::{format_registry::MAX_EVIDENCE_BYTES, verify_part_file}; use wasm_bindgen::JsCast; use wasm_bindgen_futures::JsFuture;
let expected_size = source .expected_size .ok_or_else(|| "verified source has no expected size".to_string())?; if expected_size < 0 || expected_size as usize > MAX_EVIDENCE_BYTES { return Err("verified file exceeds the bounded format evidence limit".into()); } let response = JsFuture::from( web_sys::window() .ok_or_else(|| "no window".to_string())? .fetch_with_str(&source.url), ) .await .map_err(|error| format!("part file fetch failed: {error:?}"))? .dyn_into::<web_sys::Response>() .map_err(|_| "part file fetch did not return a response".to_string())?; if !response.ok() { return Err(format!( "part file fetch failed: HTTP {}", response.status() )); } let stream = response .body() .ok_or_else(|| "part file response has no body".to_string())?; let reader = stream .get_reader() .dyn_into::<web_sys::ReadableStreamDefaultReader>() .map_err(|_| "part file body has no readable stream".to_string())?; let mut bytes = Vec::with_capacity(expected_size as usize); loop { let result = JsFuture::from(reader.read()) .await .map_err(|error| format!("part file body failed: {error:?}"))?; let done = js_sys::Reflect::get(&result, &wasm_bindgen::JsValue::from_str("done")) .map_err(|error| format!("part file stream state failed: {error:?}"))? .as_bool() .unwrap_or(false); if done { break; } let value = js_sys::Reflect::get(&result, &wasm_bindgen::JsValue::from_str("value")) .map_err(|error| format!("part file stream chunk failed: {error:?}"))?; let chunk = js_sys::Uint8Array::new(&value); let chunk_len = chunk.length() as usize; if bytes.len().saturating_add(chunk_len) > MAX_EVIDENCE_BYTES { let _ = reader.cancel(); return Err("verified file exceeds the bounded format evidence limit".into()); } let offset = bytes.len(); bytes.resize(offset + chunk_len, 0); chunk.copy_to(&mut bytes[offset..]); } verify_part_file(&bytes, expected_size, source.expected_digest.as_deref()) .map_err(|error| format!("part file verification failed: {error}"))?; viewer_format_from_evidence(&bytes)}
#[cfg(not(target_arch = "wasm32"))]async fn classify_verified_source( _source: &ViewerMeshSource,) -> Result<ViewerSourceFormat, String> { Err("bounded source classification is browser-only".into())}
fn clear_step_render_source( mut step_render_source: Signal<Option<RenderSource>>, mut active_step_object_url: Signal<Option<String>>,) { if let Some(old_url) = active_step_object_url.write().take() { revoke_object_url(old_url); } step_render_source.set(None);}
#[cfg(not(target_arch = "wasm32"))]fn revoke_object_url(_url: String) {}
#[cfg(target_arch = "wasm32")]fn revoke_object_url(url: String) { let _ = web_sys::Url::revoke_object_url(&url);}
#[component]fn StepConversionControl( mut step_report: Signal<StepConversionReport>, step_render_source: Signal<Option<RenderSource>>, active_step_object_url: Signal<Option<String>>, mut selected_model: Signal<DemoModel>,) -> Element { let converting = matches!(step_report.read().status, StepConversionStatus::Converting); rsx! { button { class: "viewer-demo-button viewer-step-conversion-button", r#type: "button", disabled: converting, onclick: move |_| { selected_model.set(DEMO_MODELS[0]); clear_step_render_source(step_render_source, active_step_object_url); step_report.set(StepConversionReport { status: StepConversionStatus::Converting, details: "Loading /models/EInk_click_v101.step in the STEP conversion worker.".into(), }); start_step_conversion( step_report, step_render_source, active_step_object_url, "/models/EInk_click_v101.step".to_string(), "fixture:EInk_click_v101.step".to_string(), ); }, if converting { "Converting STEP…" } else { "Convert STEP fixture" } } }}
#[cfg(not(target_arch = "wasm32"))]fn start_step_conversion( mut step_report: Signal<StepConversionReport>, _step_render_source: Signal<Option<RenderSource>>, _active_step_object_url: Signal<Option<String>>, _source_url: String, _source_key: String,) { step_report.set(StepConversionReport { status: StepConversionStatus::Failed, details: "STEP conversion is only available in the browser WASM build.".into(), });}
#[cfg(target_arch = "wasm32")]fn start_step_conversion( mut step_report: Signal<StepConversionReport>, mut step_render_source: Signal<Option<RenderSource>>, mut active_step_object_url: Signal<Option<String>>, source_url: String, source_key: String,) { use wasm_bindgen::JsCast; use wasm_bindgen::closure::Closure;
let opts = web_sys::WorkerOptions::new(); opts.set_type(web_sys::WorkerType::Module); let worker = match web_sys::Worker::new_with_options("/step_worker_loader.js", &opts) { Ok(worker) => worker, Err(error) => { step_report.set(StepConversionReport { status: StepConversionStatus::Failed, details: format!("Could not spawn STEP worker: {error:?}"), }); return; } };
let worker_for_message = worker.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(), &source_url.clone().into()); let _ = worker_for_message.post_message(&msg); } "converted" => { let Some(buffer) = js_value_field(&data, "buffer") else { step_report.set(StepConversionReport { status: StepConversionStatus::Failed, details: "STEP worker reported success without GLB bytes.".into(), }); worker_for_message.terminate(); 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(error) => { step_report.set(StepConversionReport { status: StepConversionStatus::Failed, details: format!("Could not create GLB Blob: {error:?}"), }); worker_for_message.terminate(); return; } }; let url = match web_sys::Url::create_object_url_with_blob(&blob) { Ok(url) => url, Err(error) => { step_report.set(StepConversionReport { status: StepConversionStatus::Failed, details: format!("Could not create GLB object URL: {error:?}"), }); worker_for_message.terminate(); return; } };
if let Some(old_url) = active_step_object_url.write().take() { let _ = web_sys::Url::revoke_object_url(&old_url); } active_step_object_url.set(Some(url.clone())); step_render_source.set(Some(RenderSource { key: format!( "step:{}:{}", source_key, js_number_field(&data, "totalMs").unwrap_or_default() ), format: MeshFormat::Gltf, primary_url: url, expected_size: None, expected_digest: None, })); step_report.set(StepConversionReport { status: StepConversionStatus::Converted, details: conversion_summary(&data), }); worker_for_message.terminate(); } "error" => { step_report.set(StepConversionReport { status: StepConversionStatus::Failed, details: worker_error_summary(&data), }); worker_for_message.terminate(); } _ => {} } }) as Box<dyn FnMut(web_sys::MessageEvent)>); worker.set_onmessage(Some(onmessage.as_ref().unchecked_ref())); onmessage.forget();
let mut step_report_error = step_report; let worker_for_error = worker.clone(); let onerror = Closure::wrap(Box::new(move |event: web_sys::ErrorEvent| { step_report_error.set(StepConversionReport { status: StepConversionStatus::Failed, details: format!("STEP worker error: {}", event.message()), }); worker_for_error.terminate(); }) as Box<dyn FnMut(web_sys::ErrorEvent)>); worker.set_onerror(Some(onerror.as_ref().unchecked_ref())); onerror.forget();}
#[cfg(target_arch = "wasm32")]fn js_value_field(value: &wasm_bindgen::JsValue, name: &str) -> Option<wasm_bindgen::JsValue> { js_sys::Reflect::get(value, &name.into()).ok()}
#[cfg(target_arch = "wasm32")]fn js_string_field(value: &wasm_bindgen::JsValue, name: &str) -> Option<String> { js_value_field(value, name).and_then(|v| v.as_string())}
#[cfg(target_arch = "wasm32")]fn js_number_field(value: &wasm_bindgen::JsValue, name: &str) -> Option<f64> { js_value_field(value, name).and_then(|v| v.as_f64())}
#[cfg(target_arch = "wasm32")]fn conversion_summary(data: &wasm_bindgen::JsValue) -> String { let total = js_number_field(data, "totalMs").unwrap_or_default(); let step_bytes = js_number_field(data, "stepBytes").unwrap_or_default(); let glb_bytes = js_number_field(data, "glbBytes").unwrap_or_default(); let isolated = js_value_field(data, "crossOriginIsolated") .and_then(|v| v.as_bool()) .unwrap_or(false); format!( "Converted STEP to GLB in {:.0} ms; STEP {:.1} MB → GLB {:.1} MB; crossOriginIsolated={isolated}.", total, step_bytes / 1_048_576.0, glb_bytes / 1_048_576.0, )}
#[cfg(target_arch = "wasm32")]fn worker_error_summary(data: &wasm_bindgen::JsValue) -> String { let message = js_string_field(data, "message").unwrap_or_else(|| "unknown error".into()); let total = js_number_field(data, "totalMs").unwrap_or_default(); let isolated = js_value_field(data, "crossOriginIsolated") .and_then(|v| v.as_bool()) .unwrap_or(false); format!( "STEP worker blocked after {:.0} ms: {message}; crossOriginIsolated={isolated}.", total )}
#[derive(Props, Clone, PartialEq)]pub struct ModelViewerProps { model: Option<DemoModel>, render_source: Signal<Option<RenderSource>>, compound_load: Signal<Option<PendingCompoundLoad>>, compound_intent: Signal<Option<CompoundLogicalIntent>>, compound_boundary: Signal<bool>, canvas_mount_generation: Signal<u32>, canvas_mount_epoch: Signal<Option<u32>>, canvas_mount_requested: Signal<bool>, fallback_to_demo: bool, mesh_stats: Signal<Option<MeshStats>>, status: Signal<ViewerStatus>, canvas_mounted: Signal<bool>, resize_count: Signal<u64>, context_acquisitions: Signal<u64>, renderer_failed: Signal<bool>, restart_epoch: Signal<u32>, worker_session_id: Signal<Option<u64>>, worker_capabilities: Signal<String>, cancelled_loads: Signal<u32>, fallback: Option<ViewerFallbackImage>,}
#[component]pub fn ModelViewer(props: ModelViewerProps) -> Element { let canvas_mount_generation = props.canvas_mount_generation; let canvas_mount_epoch = props.canvas_mount_epoch; let canvas_mount_requested = props.canvas_mount_requested; let canvas_mounted = props.canvas_mounted; let resize_count = props.resize_count; let context_acquisitions = props.context_acquisitions; let renderer_failed = props.renderer_failed; let restart_epoch = props.restart_epoch; let worker_session_id = props.worker_session_id; let worker_capabilities = props.worker_capabilities; let cancelled_loads = props.cancelled_loads; let fallback = props.fallback; let mesh_stats = props.mesh_stats; let status = props.status; let compound_boundary = props.compound_boundary; let compound_intent = props.compound_intent; let model = props.model; let render_source = props.render_source; let compound_load = props.compound_load; let fallback_to_demo = props.fallback_to_demo;
let load_status = status.read().clone(); let status_val = if *renderer_failed.read() { ViewerStatus::RendererError(ViewerIssue::RendererInit) } else { load_status }; let stats = *mesh_stats.read(); let summary = model.map(mesh_summary_for); let asset_path = summary.map(|s| s.asset_path).unwrap_or(""); let session_label = model .map(|model| format!("{}#{}", model.id, model.generation)) .unwrap_or_else(|| "asset-viewer#1".to_string()); let acquisitions = *context_acquisitions.read(); let renderer_active = acquisitions > 0 && !*renderer_failed.read(); let renderer_kind = if renderer_active { "three-d" } else { "fallback" }; rsx! { div { class: "model-viewer-boundary", id: "polymodel-viewer-boundary", "data-viewer-status": "{status_val.label()}", "data-session-wanted": "{status_val.should_have_session()}", "data-resize-count": "{resize_count.read()}", "data-session-label": session_label.as_str(), "data-current-identity": match status_val { ViewerStatus::Ready(_) => session_label.as_str(), _ => "", }, "data-interactive-namespace": "Interactive", "data-preview-namespace": "Preview", "data-asset-path": asset_path.to_string(), "data-renderer-kind": renderer_kind, "data-context-acquisitions": "{acquisitions}", "data-protocol-session-id": "{worker_session_id.read().unwrap_or_default()}", "data-protocol-capabilities": worker_capabilities.read().as_str(), "data-restart-epoch": "{restart_epoch.read()}", "data-cancelled-loads": "{cancelled_loads.read()}", "data-compound-boundary": "{compound_boundary.read()}",
div { class: "model-viewer-stage", if *canvas_mount_requested.read() { canvas { key: "viewer-canvas-{restart_epoch.read()}", id: "polymodel-viewer-canvas", "data-mounted": "{canvas_mounted.read()}", "data-renderer-kind": renderer_kind, class: "model-viewer-canvas", aria_label: "Polymodel model viewer canvas", onmounted: move |event| { mark_canvas_mounted( &event, canvas_mounted, canvas_mount_generation, canvas_mount_epoch, restart_epoch, ) }, onwheel: move |event| event.stop_propagation(), onpointerdown: move |event| event.stop_propagation(), } } if !renderer_active { if let Some(fallback) = fallback { img { id: "polymodel-viewer-preview", class: "model-viewer-preview-image", src: "{fallback.url}", alt: "{fallback.alt}" } } else if let Some(summary) = summary { AssetPreview { summary, stats } } } } } WorkerRendererSession { model, render_source, compound_load, compound_intent, compound_boundary, canvas_mount_generation, canvas_mount_epoch, canvas_mount_requested, fallback_to_demo, status, canvas_mounted, resize_count, context_acquisitions, renderer_failed, restart_epoch, worker_session_id, worker_capabilities, cancelled_loads, mesh_stats, } }}
#[component]fn AssetPreview(summary: MeshSummary, stats: Option<MeshStats>) -> Element { let triangles = stats .map(|s| s.triangles) .unwrap_or(summary.triangles as u32); let vertices = stats.map(|s| s.vertices).unwrap_or(summary.vertices as u32); rsx! { svg { id: "polymodel-viewer-preview", class: "model-viewer-preview", view_box: "0 0 640 420", role: "img", "aria-label": "Preview silhouette for {summary.display_name}", path { class: "model-preview-shadow", d: "M158 346 C245 384 406 386 493 347 C425 330 235 329 158 346 Z" } path { class: "model-preview-body", d: "M320 56 C263 71 220 115 201 173 C177 247 202 312 278 337 L362 337 C438 312 463 247 439 173 C420 115 377 71 320 56 Z" } path { class: "model-preview-cut", d: "M249 191 C268 154 292 136 320 136 C348 136 372 154 391 191 C365 181 275 181 249 191 Z" } path { class: "model-preview-line", d: "M218 250 C267 276 373 276 422 250" } path { class: "model-preview-line", d: "M238 302 C286 322 354 322 402 302" } text { class: "model-preview-label", x: "320", y: "388", text_anchor: "middle", "{triangles} triangles · {vertices} vertices" } } }}
// ---------------------------------------------------------------------------// Worker bridge session (wasm target)// ---------------------------------------------------------------------------
/// Always renders the worker renderer session. No per-model key so it keeps its/// position-based identity and stays mounted across model switches — preserving the/// worker (and its GL context).#[component]fn WorkerRendererSession( model: Option<DemoModel>, render_source: Signal<Option<RenderSource>>, compound_load: Signal<Option<PendingCompoundLoad>>, compound_intent: Signal<Option<CompoundLogicalIntent>>, compound_boundary: Signal<bool>, canvas_mount_generation: Signal<u32>, canvas_mount_epoch: Signal<Option<u32>>, canvas_mount_requested: Signal<bool>, fallback_to_demo: bool, status: Signal<ViewerStatus>, canvas_mounted: Signal<bool>, resize_count: Signal<u64>, context_acquisitions: Signal<u64>, renderer_failed: Signal<bool>, restart_epoch: Signal<u32>, worker_session_id: Signal<Option<u64>>, worker_capabilities: Signal<String>, cancelled_loads: Signal<u32>, mesh_stats: Signal<Option<MeshStats>>,) -> Element { rsx! { WorkerSessionInner { model, render_source, compound_load, compound_intent, compound_boundary, canvas_mount_generation, canvas_mount_epoch, canvas_mount_requested, fallback_to_demo, status, canvas_mounted, resize_count, context_acquisitions, renderer_failed, restart_epoch, worker_session_id, worker_capabilities, cancelled_loads, mesh_stats, } }}
#[cfg(not(target_arch = "wasm32"))]#[component]fn WorkerSessionInner( model: Option<DemoModel>, render_source: Signal<Option<RenderSource>>, compound_load: Signal<Option<PendingCompoundLoad>>, compound_intent: Signal<Option<CompoundLogicalIntent>>, compound_boundary: Signal<bool>, canvas_mount_generation: Signal<u32>, canvas_mount_epoch: Signal<Option<u32>>, canvas_mount_requested: Signal<bool>, fallback_to_demo: bool, status: Signal<ViewerStatus>, canvas_mounted: Signal<bool>, resize_count: Signal<u64>, context_acquisitions: Signal<u64>, renderer_failed: Signal<bool>, restart_epoch: Signal<u32>, worker_session_id: Signal<Option<u64>>, worker_capabilities: Signal<String>, cancelled_loads: Signal<u32>, mesh_stats: Signal<Option<MeshStats>>,) -> Element { let _ = ( model, render_source, compound_load, compound_intent, compound_boundary, canvas_mount_generation, canvas_mount_epoch, canvas_mount_requested, fallback_to_demo, status, canvas_mounted, resize_count, context_acquisitions, renderer_failed, restart_epoch, worker_session_id, worker_capabilities, cancelled_loads, mesh_stats, ); rsx! {}}
#[cfg(target_arch = "wasm32")]#[component]fn WorkerSessionInner( model: Option<DemoModel>, render_source: Signal<Option<RenderSource>>, compound_load: Signal<Option<PendingCompoundLoad>>, compound_intent: Signal<Option<CompoundLogicalIntent>>, compound_boundary: Signal<bool>, canvas_mount_generation: Signal<u32>, canvas_mount_epoch: Signal<Option<u32>>, canvas_mount_requested: Signal<bool>, fallback_to_demo: bool, status: Signal<ViewerStatus>, canvas_mounted: Signal<bool>, resize_count: Signal<u64>, context_acquisitions: Signal<u64>, mut renderer_failed: Signal<bool>, restart_epoch: Signal<u32>, mut worker_session_id: Signal<Option<u64>>, mut worker_capabilities: Signal<String>, mut cancelled_loads: Signal<u32>, mesh_stats: Signal<Option<MeshStats>>,) -> Element { use std::cell::RefCell; use std::rc::Rc;
use wasm_bindgen::JsCast; use wasm_bindgen::closure::Closure;
// Persistent bridge state: the Worker + its message handler + listener guard. // Held in use_hook so it survives re-renders. let bridge = use_hook(|| Rc::new(RefCell::new(WorkerBridgeState::new()))); let worker_ready = use_signal(|| false);
// A restart first removes the transferred canvas for one render. Re-enable // the slot on the following reactive pass so Dioxus must mount a fresh node. let mut canvas_mount_requested_for_effect = canvas_mount_requested; let canvas_mount_epoch_for_effect = canvas_mount_epoch; use_effect(move || { let epoch = *restart_epoch.read(); if !*canvas_mount_requested_for_effect.read() && *canvas_mount_epoch_for_effect.read() != Some(epoch) { canvas_mount_requested_for_effect.set(true); } });
// Spawn only after the canvas for the current restart epoch has mounted. let bridge_for_spawn = bridge.clone(); use_effect(move || { let mounted_generation = *canvas_mount_generation.read(); let mounted_epoch = *canvas_mount_epoch.read(); let mounted = *canvas_mounted.read(); let epoch = *restart_epoch.read(); if !mounted || mounted_generation == 0 || mounted_epoch != Some(epoch) { return; } let status_clone = status; let context_acquisitions_clone = context_acquisitions; let renderer_failed_clone = renderer_failed; let mesh_stats_clone = mesh_stats; let worker_ready_clone = worker_ready; let worker_session_id_clone = worker_session_id; let worker_capabilities_clone = worker_capabilities; let cancelled_loads_clone = cancelled_loads; let compound_boundary_clone = compound_boundary;
let mut bt = bridge_for_spawn.borrow_mut(); if bt.worker.is_none() { bt.spawn_worker( bridge_for_spawn.clone(), WorkerSpawnSignals { status: status_clone, context_acquisitions: context_acquisitions_clone, renderer_failed: renderer_failed_clone, mesh_stats: mesh_stats_clone, worker_ready: worker_ready_clone, worker_session_id: worker_session_id_clone, worker_capabilities: worker_capabilities_clone, cancelled_loads: cancelled_loads_clone, compound_boundary: compound_boundary_clone, canvas_mounted, canvas_mount_epoch, canvas_mount_requested, restart_epoch, }, ); } });
// Transfer canvas + send LoadMesh/Resize when canvas is mounted. let model_val = model; let mut compound_load_for_canvas = compound_load; let compound_intent_for_canvas = compound_intent; let bridge_for_canvas = bridge.clone(); #[cfg(target_arch = "wasm32")] let client_for_canvas = use_context::<PolymodelClient>(); use_effect(move || { if !*worker_ready.read() { return; } // The worker handshake can complete after the readiness sentinel. Read // the negotiated session signal so the queued resize/load work is retried // when the bridge becomes able to send protocol commands. let _ = *worker_session_id.read();
let canvas_present = *canvas_mounted.read() && *canvas_mount_epoch.read() == Some(*restart_epoch.read()); let mut bt = bridge_for_canvas.borrow_mut();
// Transfer canvas on first mount. if canvas_present && !bt.canvas_transferred && let Some(canvas) = lookup_viewer_canvas() { bt.transfer_canvas(canvas); }
// Send initial Resize after canvas transfer. if bt.canvas_transferred && !*bt.initial_resize_sent.borrow() && let Some(canvas) = lookup_viewer_canvas() { let (css_w, css_h) = ( canvas.client_width().max(1) as f32, canvas.client_height().max(1) as f32, ); let dpr = web_sys::window() .map(|w| w.device_pixel_ratio() as f32) .unwrap_or(1.0); bt.send_command(&RendererCommand::Resize { css_w, css_h, dpr }); bt.flush_queue(); }
// Queue compound loads only after the replacement canvas has transferred, // negotiated, and issued its initial resize. A fresh anonymous plan is // reacquired for each bridge epoch; never replay a retired route plan. if bt.canvas_transferred { let source = render_source.read().clone().or_else(|| { fallback_to_demo .then(|| { model_val .and_then(|model| pending_load_for(model).ok()) .map(|load| RenderSource { key: load.model.id.to_string(), format: load.format, primary_url: load.model.asset_path.to_string(), expected_size: None, expected_digest: None, }) }) .flatten() }); let compound = compound_load_for_canvas.read().clone(); if compound .as_ref() .is_some_and(|compound| compound.acquisition_epoch != bt.restart_epoch) { // A plan fetched before a worker restart belongs to the retired // route/session epoch. Drop it; the mesh resource will reacquire // the same logical intent for the replacement epoch. compound_load_for_canvas.set(None); } #[cfg(target_arch = "wasm32")] if *bt.initial_resize_sent.borrow() && let Some(intent) = compound_intent_for_canvas.read().clone() && bt.compound_plan_refresh_epoch != Some(bt.restart_epoch) { bt.compound_plan_refresh_epoch = Some(bt.restart_epoch); start_compound_plan_load( intent, bt.restart_epoch, restart_epoch, compound_intent_for_canvas, client_for_canvas.clone(), compound_load_for_canvas, status, ); } if let Some(compound) = compound.filter(|compound| compound.acquisition_epoch == bt.restart_epoch) { bt.start_compound_load(compound); if let Some(session) = bt.current_session.borrow().clone() { status.set(ViewerStatus::Loading(session)); mesh_stats.set(None); } } else if *bt.initial_resize_sent.borrow() && let Some(source) = source && bt.last_started_source_key.as_deref() != Some(source.key.as_str()) { bt.last_started_source_key = Some(source.key.clone()); bt.start_load(source); if let Some(session) = bt.current_session.borrow().clone() { status.set(ViewerStatus::Loading(session)); mesh_stats.set(None); } } }
// Flush queued commands. bt.flush_queue(); });
// Resize on window resize. let bridge_for_resize = bridge.clone(); let resize_count_for_resize = resize_count; use_effect(move || { let bridge_rc = bridge_for_resize.clone(); let mut resize_count_sig = resize_count_for_resize; let closure = Closure::<dyn FnMut(web_sys::Event)>::wrap(Box::new(move |_| { let next = *resize_count_sig.read() + 1; resize_count_sig.set(next); let mut bt = bridge_rc.borrow_mut(); if bt.canvas_transferred && let Some(canvas) = lookup_viewer_canvas() { let (css_w, css_h) = ( canvas.client_width().max(1) as f32, canvas.client_height().max(1) as f32, ); let dpr = web_sys::window() .map(|w| w.device_pixel_ratio() as f32) .unwrap_or(1.0); bt.send_command(&RendererCommand::Resize { css_w, css_h, dpr }); } })); if let Some(window) = web_sys::window() { let _ = window.add_event_listener_with_callback("resize", closure.as_ref().unchecked_ref()); // Store cleanup. let bridge_mut = bridge_for_resize.clone(); bridge_mut.borrow_mut().resize_cleanup = Some(Box::new(move || { if let Some(window) = web_sys::window() { let _ = window.remove_event_listener_with_callback( "resize", closure.as_ref().unchecked_ref(), ); } })); } });
// Attach pointer listeners with producer-side throttling after the current // restart epoch's canvas has mounted. The mount signals make this rerun for // the replacement node rather than reusing the retired canvas guard. let bridge_for_pointer = bridge.clone(); use_effect(move || { let mounted = *canvas_mounted.read(); let mounted_generation = *canvas_mount_generation.read(); let mounted_epoch = *canvas_mount_epoch.read(); let epoch = *restart_epoch.read(); if mounted && mounted_generation > 0 && mounted_epoch == Some(epoch) && !bridge_for_pointer.borrow().pointer_listeners_attached && let Some(canvas) = lookup_viewer_canvas() { let cleanup = attach_throttled_pointer_listeners(canvas, bridge_for_pointer.clone()); bridge_for_pointer.borrow_mut().pointer_cleanup = Some(cleanup); bridge_for_pointer.borrow_mut().pointer_listeners_attached = true; } });
rsx! {}}
// ---------------------------------------------------------------------------// Worker bridge state (wasm target)// ---------------------------------------------------------------------------
#[cfg(target_arch = "wasm32")]use wasm_bindgen::closure::Closure;
#[cfg(target_arch = "wasm32")]struct WorkerBridgeState { worker: Option<web_sys::Worker>, canvas_transferred: bool, initial_resize_sent: Rc<RefCell<bool>>, load_reducer: Rc<RefCell<LoadReducer>>, next_identity: LoadIdentity, current_session: Rc<RefCell<Option<SessionKey>>>, handshake: Rc<RefCell<HandshakeState>>, session_id: Rc<RefCell<Option<u64>>>, post_canvas_queue: Rc<RefCell<Vec<RendererCommand>>>, pending_resize: Option<RendererCommand>, compound_started_key: Option<String>, active_compound_identity: Rc<RefCell<Option<LoadIdentity>>>, current_intent: Option<CompoundLogicalIntent>, compound_plan_refresh_epoch: Option<u32>, last_started_source_key: Option<String>, restart_epoch: u32, resize_cleanup: Option<Box<dyn FnOnce()>>, pointer_cleanup: Option<Box<dyn FnOnce()>>, pointer_listeners_attached: bool, // Pending pointer state for producer-side throttling. pending_pointer: Option<PendingPointerState>, pointer_post_scheduled: bool, pointer_raf_id: Option<i32>, pointer_raf_closure: Option<Closure<dyn FnMut()>>,}
#[cfg_attr( not(target_arch = "wasm32"), allow(dead_code, reason = "pointer coalescing runs only in the browser"))]#[derive(Default)]struct PendingPointerState { x: f32, y: f32, buttons: u16, modifiers: KeyModifiers, wheel_delta: f32,}
#[cfg_attr( not(target_arch = "wasm32"), allow(dead_code, reason = "pointer coalescing runs only in the browser"))]fn update_pending_pointer(pending: &mut Option<PendingPointerState>, next: PendingPointerState) { let current = pending.get_or_insert_with(Default::default); current.x = next.x; current.y = next.y; current.buttons = next.buttons; current.modifiers = next.modifiers; current.wheel_delta += next.wheel_delta;}
#[cfg_attr( not(target_arch = "wasm32"), allow(dead_code, reason = "pointer coalescing runs only in the browser"))]fn accumulate_pending_wheel(pending: &mut Option<PendingPointerState>, delta: f32) { pending.get_or_insert_with(Default::default).wheel_delta += delta;}
#[cfg_attr( not(target_arch = "wasm32"), allow(dead_code, reason = "worker command gating runs only in the browser"))]fn command_is_ready_for_worker( canvas_transferred: bool, session_id: Option<u64>, initial_resize_sent: bool, command: &RendererCommand,) -> bool { canvas_transferred && session_id.is_some() && (initial_resize_sent || matches!(command, RendererCommand::Resize { .. }))}
#[cfg_attr( not(target_arch = "wasm32"), allow(dead_code, reason = "compound negotiation runs only in the browser"))]fn compound_capability_is_available(negotiated: Option<&NegotiatedProtocol>) -> bool { negotiated.is_some_and(|protocol| protocol.capabilities.contains(&Capability::CompoundLdraw))}
#[cfg_attr( not(target_arch = "wasm32"), allow(dead_code, reason = "compound loading runs only in the browser"))]fn start_compound_load_command( negotiated: Option<&NegotiatedProtocol>, reducer: &mut LoadReducer, identity: LoadIdentity, bundle: ModelLoadBundle,) -> Option<RendererCommand> { if !compound_capability_is_available(negotiated) { tracing::error!("renderer worker did not negotiate CompoundLdraw capability"); return None; } let namespace = RendererNamespace::Interactive; let result = reducer.reduce(LoadEvent::StartLoad { namespace, identity, }); matches!( result.outcome, polymodel_renderer_protocol::TransitionOutcome::Committed ) .then_some(RendererCommand::StartLoad { namespace, identity, source: LoadSource::Bundle(bundle), output: LoadOutput::Interactive, })}
#[cfg_attr( not(target_arch = "wasm32"), allow(dead_code, reason = "worker event handling runs only in the browser"))]fn context_acquisitions_after_event(current: u64, event: &RendererEvent) -> u64 { if matches!(event, RendererEvent::ContextAcquired) { 1 } else { current }}
#[cfg(target_arch = "wasm32")]struct WorkerSpawnSignals { status: Signal<ViewerStatus>, context_acquisitions: Signal<u64>, renderer_failed: Signal<bool>, mesh_stats: Signal<Option<MeshStats>>, worker_ready: Signal<bool>, worker_session_id: Signal<Option<u64>>, worker_capabilities: Signal<String>, cancelled_loads: Signal<u32>, compound_boundary: Signal<bool>, canvas_mounted: Signal<bool>, canvas_mount_epoch: Signal<Option<u32>>, canvas_mount_requested: Signal<bool>, restart_epoch: Signal<u32>,}
#[cfg(target_arch = "wasm32")]impl WorkerBridgeState { fn new() -> Self { Self { worker: None, canvas_transferred: false, initial_resize_sent: Rc::new(RefCell::new(false)), load_reducer: Rc::new(RefCell::new(LoadReducer::new( RendererNamespace::Interactive, ))), next_identity: LoadIdentity::new(0, 0), current_session: Rc::new(RefCell::new(None)), handshake: Rc::new(RefCell::new(HandshakeState::Created)), session_id: Rc::new(RefCell::new(None)), post_canvas_queue: Rc::new(RefCell::new(Vec::new())), pending_resize: None, compound_started_key: None, active_compound_identity: Rc::new(RefCell::new(None)), current_intent: None, compound_plan_refresh_epoch: None, last_started_source_key: None, restart_epoch: 0, resize_cleanup: None, pointer_cleanup: None, pointer_listeners_attached: false, pending_pointer: None, pointer_post_scheduled: false, pointer_raf_id: None, pointer_raf_closure: None, } }
fn spawn_worker( &mut self, bridge: std::rc::Rc<std::cell::RefCell<Self>>, signals: WorkerSpawnSignals, ) { use wasm_bindgen::JsCast; let WorkerSpawnSignals { status, context_acquisitions, renderer_failed, mesh_stats, worker_ready, worker_session_id, worker_capabilities, cancelled_loads, compound_boundary, canvas_mounted, canvas_mount_epoch, canvas_mount_requested, restart_epoch, } = signals; let mut renderer_failed = renderer_failed; let mut worker_ready = worker_ready;
let opts = web_sys::WorkerOptions::new(); opts.set_type(web_sys::WorkerType::Module); let worker = match web_sys::Worker::new_with_options("/renderer_worker_loader.js", &opts) { Ok(w) => w, Err(e) => { tracing::error!("failed to spawn renderer worker: {e:?}"); renderer_failed.set(true); return; } };
// Set up onmessage handler. Both fatal event paths use the same reset so // a worker cannot leave a transferred canvas or stale session behind. let restart: Rc<dyn Fn()> = Rc::new({ move || { let next_epoch = { let mut bt = bridge.borrow_mut(); bt.reset_for_restart(); bt.restart_epoch }; let mut worker_ready = worker_ready; let mut canvas_mounted = canvas_mounted; let mut canvas_mount_epoch = canvas_mount_epoch; let mut canvas_mount_requested = canvas_mount_requested; let mut renderer_failed = renderer_failed; let mut restart_epoch = restart_epoch; let mut worker_session_id = worker_session_id; worker_ready.set(false); // Remove the transferred node for one render before the new epoch // can mount. The mount callback is the only source of mounted=true. canvas_mounted.set(false); canvas_mount_epoch.set(None); canvas_mount_requested.set(false); renderer_failed.set(true); restart_epoch.set(next_epoch); worker_session_id.set(None); } }); let restart_for_message = restart.clone(); let mut status_msg = status; let mut acquisitions_msg = context_acquisitions; let mut failed_msg = renderer_failed; let mut stats_msg = mesh_stats; let mut worker_session_id_msg = worker_session_id; let mut worker_capabilities_msg = worker_capabilities; let mut cancelled_loads_msg = cancelled_loads; let mut compound_boundary_msg = compound_boundary; let active_compound_identity_msg = self.active_compound_identity.clone(); let reducer_msg = self.load_reducer.clone(); let current_session_msg = self.current_session.clone(); let handshake_msg = self.handshake.clone(); let session_id_msg = self.session_id.clone(); let queue_msg = self.post_canvas_queue.clone(); let worker_for_message = worker.clone(); let onmessage = Closure::wrap(Box::new(move |event: web_sys::MessageEvent| { let data = event.data();
// Worker ready handshake. if let Some(s) = data.as_string() && s == "__worker_ready" { worker_ready.set(true); failed_msg.set(false); let hello = CommandEnvelope { version: polymodel_renderer_protocol::PROTOCOL_VERSION, session_id: 0, payload: CommandPayload::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::CompoundLdraw, Capability::Preview], }), }; match serialize_command_envelope(&hello) { Ok(bytes) => { let array = js_sys::Uint8Array::from(&bytes[..]); if let Err(error) = worker_for_message.post_message(&array) { tracing::error!("failed to send renderer protocol hello: {error:?}"); } } Err(error) => { tracing::error!("failed to serialize renderer protocol hello: {error}") } } return; }
// Postcard bytes (versioned RendererEvent envelope). if let Ok(array) = data.dyn_into::<js_sys::Uint8Array>() { let bytes = array.to_vec(); let envelope = match deserialize_event_envelope(&bytes) { Ok(envelope) => envelope, Err(e) => { tracing::error!("failed to deserialize event envelope: {e}"); return; } }; if matches!(envelope.payload, EventPayload::Failed(_)) { if let EventPayload::Failed(message) = envelope.payload { tracing::error!("renderer handshake failed: {message}"); failed_msg.set(true); } return; } let negotiated = match &envelope.payload { EventPayload::Negotiated(protocol) => Some(protocol.clone()), _ => None, }; let evt = match handshake_msg.borrow_mut().accept_event(&envelope) { Ok(Some(evt)) => evt, Ok(None) => { if let Some(protocol) = negotiated { let session_id = protocol.session_id; *session_id_msg.borrow_mut() = Some(session_id); worker_session_id_msg.set(Some(session_id)); worker_capabilities_msg.set( protocol .capabilities .iter() .map(|capability| format!("{capability:?}")) .collect::<Vec<_>>() .join(","), ); let accept = CommandEnvelope { version: polymodel_renderer_protocol::PROTOCOL_VERSION, session_id: 0, payload: CommandPayload::Accept(protocol), }; match serialize_command_envelope(&accept) { Ok(bytes) => { let array = js_sys::Uint8Array::from(&bytes[..]); if let Err(error) = worker_for_message.post_message(&array) { tracing::error!( "failed to accept renderer handshake: {error:?}" ); } } Err(error) => { tracing::error!("failed to serialize handshake accept: {error}") } } let queued = std::mem::take(&mut *queue_msg.borrow_mut()); for command in queued { let envelope = CommandEnvelope { version: polymodel_renderer_protocol::PROTOCOL_VERSION, session_id, payload: CommandPayload::Command(command), }; if let Ok(bytes) = serialize_command_envelope(&envelope) { let array = js_sys::Uint8Array::from(&bytes[..]); let _ = worker_for_message.post_message(&array); } } } return; } Err(error) => { tracing::warn!("rejected renderer event envelope: {error:?}"); return; } }; match evt { RendererEvent::ContextAcquired => { let current_acquisitions = *acquisitions_msg.read(); let next_acquisitions = context_acquisitions_after_event( current_acquisitions, &RendererEvent::ContextAcquired, ); acquisitions_msg.set(next_acquisitions); } RendererEvent::RendererContextReady => {} RendererEvent::LoadSucceeded { namespace, identity, } => { let result = reducer_msg.borrow_mut().reduce(LoadEvent::LoadSucceeded { namespace, identity, candidate: None, }); if matches!( result.outcome, polymodel_renderer_protocol::TransitionOutcome::Committed ) && reducer_msg.borrow().current_identity() == Some(identity) && let Some(session) = current_session_msg.borrow().clone() { status_msg.set(ViewerStatus::Installing(session)); } } RendererEvent::InstallSucceeded { namespace, identity, stats, } => { let result = reducer_msg .borrow_mut() .reduce(LoadEvent::InstallSucceeded { namespace, identity, scene: None, }); if matches!( result.outcome, polymodel_renderer_protocol::TransitionOutcome::Committed ) && reducer_msg.borrow().current_identity() == Some(identity) && let Some(session) = current_session_msg.borrow().clone() { stats_msg.set(Some(stats)); compound_boundary_msg.set( active_compound_identity_msg.borrow().as_ref() == Some(&identity), ); status_msg.set(ViewerStatus::Ready(session)); } } RendererEvent::LoadFailed { namespace, identity, code, detail, } => { let result = reducer_msg.borrow_mut().reduce(LoadEvent::LoadFailed { namespace, identity, error: polymodel_renderer_protocol::LoadError { code, detail: detail.clone(), }, candidate: None, }); if matches!( result.outcome, polymodel_renderer_protocol::TransitionOutcome::Committed ) && reducer_msg.borrow().current_identity() == Some(identity) && let Some(session) = current_session_msg.borrow().clone() { status_msg.set(ViewerStatus::Failed(session, code, detail)); } } RendererEvent::LoadCancelled { namespace, identity, } => { if namespace == RendererNamespace::Interactive { let next_cancelled_loads = cancelled_loads_msg.read().saturating_add(1); cancelled_loads_msg.set(next_cancelled_loads); } let result = reducer_msg.borrow_mut().reduce(LoadEvent::Cancel { namespace, identity, candidate: None, }); if matches!( result.outcome, polymodel_renderer_protocol::TransitionOutcome::Committed ) && reducer_msg.borrow().current_identity() == Some(identity) && let Some(session) = current_session_msg.borrow().clone() { status_msg.set(ViewerStatus::Cancelled(session)); } } RendererEvent::PreviewImageRendered { .. } | RendererEvent::PreviewImageError { .. } => {} RendererEvent::Error { fatal, message } => { if fatal { restart_for_message(); status_msg.set(ViewerStatus::RendererError(ViewerIssue::RendererInit)); } tracing::error!("renderer worker error: {message}"); } } } }) as Box<dyn FnMut(web_sys::MessageEvent)>);
worker.set_onmessage(Some(onmessage.as_ref().unchecked_ref()));
// Set up onerror handler. let restart_for_error = restart.clone(); let onerror = Closure::wrap(Box::new(move |event: web_sys::ErrorEvent| { let message = event.message(); tracing::error!("renderer worker error: {message}"); restart_for_error(); }) as Box<dyn FnMut(web_sys::ErrorEvent)>); worker.set_onerror(Some(onerror.as_ref().unchecked_ref()));
self.worker = Some(worker); // Keep closures alive — they're forgotten to match the worker's lifetime. onmessage.forget(); onerror.forget(); }
fn transfer_canvas(&mut self, canvas: web_sys::HtmlCanvasElement) { let Some(worker) = &self.worker else { return; }; let offscreen = match canvas.transfer_control_to_offscreen() { Ok(oc) => oc, Err(e) => { tracing::error!("transferControlToOffscreen failed: {e:?}"); return; } };
let msg = js_sys::Object::new(); let _ = js_sys::Reflect::set( &msg, &"__canvas_sentinel".into(), &wasm_bindgen::JsValue::from_bool(true), ); let _ = js_sys::Reflect::set(&msg, &"__canvas".into(), &offscreen);
let transfer = js_sys::Array::new(); transfer.push(&offscreen);
if let Err(e) = worker.post_message_with_transfer(&msg, &transfer) { tracing::error!("failed to transfer OffscreenCanvas: {e:?}"); return; } self.canvas_transferred = true; }
fn allocate_identity(&mut self) -> LoadIdentity { let next = if self.next_identity.scene_generation == u32::MAX { LoadIdentity::new(self.next_identity.load_epoch.saturating_add(1), 0) } else { LoadIdentity::new( self.next_identity.load_epoch, self.next_identity.scene_generation + 1, ) }; self.next_identity = next; next }
fn start_compound_load(&mut self, pending: PendingCompoundLoad) { if self.compound_started_key.as_deref() == Some(pending.logical.key.as_str()) { return; } let identity = self.allocate_identity(); let Some(command) = start_compound_load_command( self.handshake.borrow().negotiated(), &mut self.load_reducer.borrow_mut(), identity, pending.bundle, ) else { return; }; self.compound_started_key = Some(pending.logical.key.clone()); *self.active_compound_identity.borrow_mut() = Some(identity); self.current_intent = Some(pending.logical.clone()); *self.current_session.borrow_mut() = Some(SessionKey::new( pending.logical.key.clone(), identity.scene_generation as u64, )); self.send_command(&command); }
fn start_load(&mut self, source: RenderSource) { *self.active_compound_identity.borrow_mut() = None; let identity = self.allocate_identity(); let namespace = RendererNamespace::Interactive; let result = self.load_reducer.borrow_mut().reduce(LoadEvent::StartLoad { namespace, identity, }); if !matches!( result.outcome, polymodel_renderer_protocol::TransitionOutcome::Committed ) { return; } *self.current_session.borrow_mut() = Some(SessionKey::new( source.key.clone(), identity.scene_generation as u64, )); self.send_command(&RendererCommand::StartLoad { namespace, identity, source: LoadSource::Url { format: source.format, primary_url: source.primary_url, expected_size: source.expected_size, expected_digest: source.expected_digest, }, output: LoadOutput::Interactive, }); }
fn reset_for_restart(&mut self) { if let Some(worker) = self.worker.take() { worker.terminate(); } if let Some(id) = self.pointer_raf_id.take() && let Some(window) = web_sys::window() { let _ = window.cancel_animation_frame(id); } self.pointer_raf_closure.take(); self.pending_pointer = None; self.pointer_post_scheduled = false; if let Some(cleanup) = self.pointer_cleanup.take() { cleanup(); } self.pointer_listeners_attached = false; self.canvas_transferred = false; *self.initial_resize_sent.borrow_mut() = false; *self.handshake.borrow_mut() = HandshakeState::Created; *self.session_id.borrow_mut() = None; self.pending_resize = None; self.post_canvas_queue.borrow_mut().clear(); self.compound_started_key = None; *self.active_compound_identity.borrow_mut() = None; self.compound_plan_refresh_epoch = None; self.last_started_source_key = None; self.restart_epoch = self.restart_epoch.saturating_add(1); self.next_identity = LoadIdentity::new(self.restart_epoch, 0); self.load_reducer = Rc::new(RefCell::new(LoadReducer::new( RendererNamespace::Interactive, ))); }
fn send_command(&mut self, cmd: &RendererCommand) { if matches!(cmd, RendererCommand::Resize { .. }) { self.pending_resize = Some(cmd.clone()); } if !command_is_ready_for_worker( self.canvas_transferred, *self.session_id.borrow(), *self.initial_resize_sent.borrow(), cmd, ) { self.post_canvas_queue.borrow_mut().push(cmd.clone()); return; } self.post_command(cmd); }
fn flush_queue(&mut self) { if !self.canvas_transferred || self.session_id.borrow().is_none() { return; } if let Some(resize) = self.pending_resize.take() { self.post_command(&resize); *self.initial_resize_sent.borrow_mut() = true; } if !*self.initial_resize_sent.borrow() { return; } let queue = std::mem::take(&mut *self.post_canvas_queue.borrow_mut()); for command in queue { if !matches!(command, RendererCommand::Resize { .. }) { self.post_command(&command); } } }
fn schedule_pointer_flush(&mut self) { use wasm_bindgen::JsCast; if self.pointer_raf_id.is_some() { return; } let Some(closure) = self.pointer_raf_closure.as_ref() else { return; }; if let Some(window) = web_sys::window() && let Ok(id) = window.request_animation_frame(closure.as_ref().unchecked_ref()) { self.pointer_raf_id = Some(id); } }
fn post_command(&self, cmd: &RendererCommand) { let Some(worker) = &self.worker else { return }; let Some(session_id) = *self.session_id.borrow() else { return; }; let envelope = CommandEnvelope { version: polymodel_renderer_protocol::PROTOCOL_VERSION, session_id, payload: CommandPayload::Command(cmd.clone()), }; match serialize_command_envelope(&envelope) { Ok(bytes) => { let array = js_sys::Uint8Array::from(&bytes[..]); let _ = worker.post_message(&array); } Err(e) => tracing::error!("failed to serialize command envelope: {e}"), } }}
#[cfg(target_arch = "wasm32")]impl Drop for WorkerBridgeState { fn drop(&mut self) { // Cancel pending pointer rAF. if let Some(id) = self.pointer_raf_id.take() && let Some(window) = web_sys::window() { let _ = window.cancel_animation_frame(id); } self.pointer_raf_closure.take();
// Clean up listeners. if let Some(cleanup) = self.pointer_cleanup.take() { cleanup(); } if let Some(cleanup) = self.resize_cleanup.take() { cleanup(); }
// Send Dispose + terminate worker. if let Some(worker) = &self.worker { self.post_command(&RendererCommand::Dispose); worker.terminate(); } }}
// ---------------------------------------------------------------------------// Canvas lookup + pointer listener attachment (wasm target)// ---------------------------------------------------------------------------
#[cfg(target_arch = "wasm32")]fn lookup_viewer_canvas() -> Option<web_sys::HtmlCanvasElement> { use wasm_bindgen::JsCast; web_sys::window()? .document()? .get_element_by_id("polymodel-viewer-canvas")? .dyn_into::<web_sys::HtmlCanvasElement>() .ok()}
#[cfg(target_arch = "wasm32")]fn attach_throttled_pointer_listeners( canvas: web_sys::HtmlCanvasElement, bridge: std::rc::Rc<std::cell::RefCell<WorkerBridgeState>>,) -> Box<dyn FnOnce()> { use wasm_bindgen::JsCast; use wasm_bindgen::closure::Closure; use web_sys::{MouseEvent, PointerEvent, WheelEvent};
let mut cleanups: Vec<Box<dyn FnOnce()>> = Vec::new();
// --- pointerdown (discrete) --- { let bridge_clone = bridge.clone(); let canvas_clone = canvas.clone(); let closure = Closure::<dyn FnMut(PointerEvent)>::wrap(Box::new(move |event: PointerEvent| { event.prevent_default(); if let Ok(bt) = bridge_clone.try_borrow_mut() { bt.post_command(&RendererCommand::PointerDown { x: event.client_x() as f32, y: event.client_y() as f32, }); } })); if canvas_clone .add_event_listener_with_callback("pointerdown", closure.as_ref().unchecked_ref()) .is_ok() { cleanups.push(Box::new(move || { let _ = canvas_clone.remove_event_listener_with_callback( "pointerdown", closure.as_ref().unchecked_ref(), ); })); } }
// --- pointermove (producer-side throttled) --- { let bridge_clone = bridge.clone(); let canvas_clone = canvas.clone(); let closure = Closure::<dyn FnMut(PointerEvent)>::wrap(Box::new(move |event: PointerEvent| { if let Ok(mut bt) = bridge_clone.try_borrow_mut() { update_pending_pointer( &mut bt.pending_pointer, PendingPointerState { x: event.client_x() as f32, y: event.client_y() as f32, buttons: event.buttons(), modifiers: KeyModifiers { shift: event.shift_key(), ctrl: event.ctrl_key(), alt: event.alt_key(), meta: event.meta_key(), }, wheel_delta: 0.0, }, );
// Schedule at most one rAF post. if !bt.pointer_post_scheduled { bt.pointer_post_scheduled = true; bt.schedule_pointer_flush(); } } })); if canvas_clone .add_event_listener_with_callback("pointermove", closure.as_ref().unchecked_ref()) .is_ok() { cleanups.push(Box::new(move || { let _ = canvas_clone.remove_event_listener_with_callback( "pointermove", closure.as_ref().unchecked_ref(), ); })); } }
// --- pointerup (discrete) --- { let bridge_clone = bridge.clone(); let canvas_clone = canvas.clone(); let closure = Closure::<dyn FnMut(PointerEvent)>::wrap(Box::new(move |event: PointerEvent| { event.prevent_default(); if let Ok(bt) = bridge_clone.try_borrow_mut() { bt.post_command(&RendererCommand::PointerUp { x: event.client_x() as f32, y: event.client_y() as f32, }); } })); if canvas_clone .add_event_listener_with_callback("pointerup", closure.as_ref().unchecked_ref()) .is_ok() { cleanups.push(Box::new(move || { let _ = canvas_clone.remove_event_listener_with_callback( "pointerup", closure.as_ref().unchecked_ref(), ); })); } }
// --- mouseleave (discrete pointer up) --- { let bridge_clone = bridge.clone(); let canvas_clone = canvas.clone(); let closure = Closure::<dyn FnMut(MouseEvent)>::wrap(Box::new(move |_| { if let Ok(bt) = bridge_clone.try_borrow_mut() { // Use last known position. if let Some(pending) = &bt.pending_pointer { bt.post_command(&RendererCommand::PointerUp { x: pending.x, y: pending.y, }); } } })); if canvas_clone .add_event_listener_with_callback("mouseleave", closure.as_ref().unchecked_ref()) .is_ok() { cleanups.push(Box::new(move || { let _ = canvas_clone.remove_event_listener_with_callback( "mouseleave", closure.as_ref().unchecked_ref(), ); })); } }
// --- wheel (accumulated into pending state, throttled via rAF) --- { let bridge_clone = bridge.clone(); let closure = Closure::<dyn FnMut(WheelEvent)>::wrap(Box::new(move |event: WheelEvent| { event.prevent_default(); if let Ok(mut bt) = bridge_clone.try_borrow_mut() { accumulate_pending_wheel(&mut bt.pending_pointer, event.delta_y() as f32); if !bt.pointer_post_scheduled { bt.pointer_post_scheduled = true; bt.schedule_pointer_flush(); } } })); if canvas .add_event_listener_with_callback("wheel", closure.as_ref().unchecked_ref()) .is_ok() { cleanups.push(Box::new(move || { let _ = canvas .remove_event_listener_with_callback("wheel", closure.as_ref().unchecked_ref()); })); } }
// --- rAF-based pointer state flush (producer-side throttle) --- // On-demand rAF: pointer/wheel handlers set pointer_post_scheduled = true; a // single rAF is scheduled to flush. The closure is stored in the bridge state // so it can be re-queued until the flag clears. { let bridge_clone = bridge.clone(); let raf_closure = Closure::wrap(Box::new(move || { if let Ok(mut bt) = bridge_clone.try_borrow_mut() { bt.pointer_raf_id = None; if bt.pointer_post_scheduled && bt.pending_pointer.is_some() { let state = bt.pending_pointer.take().unwrap(); bt.pointer_post_scheduled = false; bt.post_command(&RendererCommand::SetPointerState { x: state.x, y: state.y, buttons: state.buttons, modifiers: state.modifiers, wheel_delta: state.wheel_delta, }); } } }) as Box<dyn FnMut()>);
// Store the closure so it can be re-scheduled by the pointer handlers. if let Ok(mut bt) = bridge.try_borrow_mut() { bt.pointer_raf_closure = Some(raf_closure); } }
Box::new(move || { for cleanup in cleanups.into_iter().rev() { cleanup(); } })}
// ---------------------------------------------------------------------------// Canvas mount detection// ---------------------------------------------------------------------------
#[cfg(target_arch = "wasm32")]fn mark_canvas_mounted( event: &MountedEvent, mut mounted: Signal<bool>, mut generation: Signal<u32>, mut mounted_epoch: Signal<Option<u32>>, current_epoch: Signal<u32>,) { use dioxus::web::WebEventExt; use wasm_bindgen::JsCast; let element = event.as_web_event(); if element.dyn_ref::<web_sys::HtmlCanvasElement>().is_some() { mounted.set(true); let next_generation = generation.read().saturating_add(1); generation.set(next_generation); mounted_epoch.set(Some(*current_epoch.read())); } else { mounted.set(false); mounted_epoch.set(None); }}
#[cfg(not(target_arch = "wasm32"))]fn mark_canvas_mounted( _event: &MountedEvent, _mounted: Signal<bool>, _generation: Signal<u32>, _mounted_epoch: Signal<Option<u32>>, _current_epoch: Signal<u32>,) {}
// ---------------------------------------------------------------------------// Tests// ---------------------------------------------------------------------------
#[cfg(test)]mod tests { use super::*; use polymodel_renderer_protocol::{ KeyModifiers, LoadEvent, LoadIdentity, LoadOutput, LoadReducer, LoadSource, MeshStats, RendererCommand, RendererEvent, RendererNamespace, };
#[test] fn demo_route_uses_committed_stl_asset() { let model = demo_model_for_route("body-f-chest-v4"); assert_eq!(model.asset_path, "/models/body_f_chest-v4.stl"); assert_eq!(model.vertices, 41051); assert_eq!(model.triangles, 82122); }
#[test] fn switching_models_advances_session_generation() { let first = SessionKey::new("body-f-chest-v4", 7); let second = first.switch_to("body-f-forearm-2x-v9");
assert_eq!(second.model_id, "body-f-forearm-2x-v9"); assert_eq!(second.generation, 8); }
#[test] fn status_labels_are_user_facing() { let ready = ViewerStatus::Ready(SessionKey::new("body-f-chest-v4", 1)); let loading = ViewerStatus::Loading(SessionKey::new("body-f-chest-v4", 1));
assert_eq!(ready.label(), "Viewer ready"); assert_eq!(loading.label(), "Loading model"); assert_eq!( ViewerStatus::MeshError("bad".into()).label(), "Mesh unavailable" ); assert!(ready.should_have_session()); assert!(loading.should_have_session()); // Mid-session MeshError retains the session so the stale mesh stays visible. assert!(ViewerStatus::MeshError("bad".into()).should_have_session()); // RendererError does not retain the session. assert!(!ViewerStatus::RendererError(ViewerIssue::RendererInit).should_have_session()); }
#[test] fn terminal_load_statuses_have_expected_session_retention() { let session = SessionKey::new("body-f-chest-v4", 1); assert!(ViewerStatus::Cancelled(session).should_have_session()); }
#[test] fn mesh_format_detection_from_asset_path() { assert_eq!( MeshFormat::from_extension("body_f_chest-v4.stl"), Some(MeshFormat::Stl) ); assert_eq!( MeshFormat::from_extension("cube.obj"), Some(MeshFormat::Obj) ); assert_eq!( MeshFormat::from_extension("cube.glb"), Some(MeshFormat::Gltf) ); assert_eq!( MeshFormat::from_extension("cube.3mf"), Some(MeshFormat::Threemf) ); assert_eq!(MeshFormat::from_extension("cube.ply"), None); }
#[test] fn pending_load_derives_format_from_extension() { let model = DEMO_MODELS[0]; let load = pending_load_for(model).expect("STL model derives a load"); assert_eq!(load.format, MeshFormat::Stl); assert_eq!(load.model.vertices, model.vertices); assert_eq!(load.model.triangles, model.triangles); }
#[test] fn pending_load_rejects_unrecognized_extension() { let model = DemoModel { id: "bad", name: "Bad", asset_path: "/models/cube.ply", generation: 1, vertices: 0, triangles: 0, }; assert!(matches!( pending_load_for(model), Err(ViewerIssue::MeshUnavailable) )); }
#[test] fn viewer_status_mesh_error_retains_session() { // Mid-session MeshError should keep the session alive so the stale mesh // stays visible behind the error overlay. let mesh_err = ViewerStatus::MeshError("parse failed".into()); assert!(mesh_err.should_have_session()); }
#[test] fn renderer_error_does_not_retain_session() { let renderer_err = ViewerStatus::RendererError(ViewerIssue::RendererInit); assert!(!renderer_err.should_have_session()); }
#[test] fn verified_bounded_evidence_selects_only_unambiguous_registry_formats() { assert_eq!( viewer_format_from_evidence(b"glTF\x02\0\0\0\x0c\0\0\0"), Ok(ViewerSourceFormat::Renderer(MeshFormat::Gltf)) ); assert_eq!( viewer_format_from_evidence(b"ISO-10303-21;\nDATA;\nEND-ISO-10303-21;"), Ok(ViewerSourceFormat::Step) ); assert_eq!( viewer_format_from_evidence(b"0 FILE main.dat\n"), Ok(ViewerSourceFormat::Renderer(MeshFormat::CompoundLdraw)) ); assert!(viewer_format_from_evidence(b"solid maybe-stl").is_err()); assert!( viewer_format_from_evidence( &[0; polymodel_renderer_protocol::format_registry::MAX_EVIDENCE_BYTES + 1] ) .is_err() ); }
#[test] fn mesh_stats_round_trip() { let stats = MeshStats { vertices: 1234, triangles: 4321, }; let bytes = polymodel_renderer_protocol::serialize_event_envelope( &polymodel_renderer_protocol::EventEnvelope { version: polymodel_renderer_protocol::PROTOCOL_VERSION, session_id: 7, payload: polymodel_renderer_protocol::EventPayload::Event( RendererEvent::InstallSucceeded { namespace: RendererNamespace::Interactive, identity: LoadIdentity::new(1, 1), stats, }, ), }, ) .expect("serialize"); let evt = match polymodel_renderer_protocol::deserialize_event_envelope(&bytes) .expect("deserialize") .payload { polymodel_renderer_protocol::EventPayload::Event(evt) => evt, _ => panic!("expected event"), }; match evt { RendererEvent::InstallSucceeded { stats: s, .. } => { assert_eq!(s.vertices, 1234); assert_eq!(s.triangles, 4321); } _ => panic!("expected InstallSucceeded"), } }
#[test] fn compound_load_without_negotiated_capability_stays_non_ready_and_reports_existing_error() { use polymodel_renderer_protocol::{Capability, ModelResource, NegotiatedProtocol};
let negotiated = NegotiatedProtocol { version: polymodel_renderer_protocol::PROTOCOL_VERSION, capabilities: vec![Capability::CurrentFormats, Capability::Preview], session_id: 7, }; let bundle = ModelLoadBundle { version: polymodel_renderer_protocol::PROTOCOL_VERSION, format: MeshFormat::CompoundLdraw, primary: "main.ldr".into(), resources: Box::new([ModelResource { key: "main.ldr".into(), path: "main.ldr".into(), root: "main.ldr".into(), mime_type: "application/x-ldraw".into(), byte_length: 0, sha256: [0; 32], target_cids: Box::default(), bytes: Box::default(), }]), source_identities: Vec::new(), target_selections: Vec::new(), }; let mut reducer = LoadReducer::new(RendererNamespace::Interactive);
let command = start_compound_load_command( Some(&negotiated), &mut reducer, LoadIdentity::new(0, 1), bundle, );
assert!(command.is_none()); assert!(matches!( reducer.state(), polymodel_renderer_protocol::LoadState::Idle )); assert_eq!(reducer.current_identity(), None); }
#[test] fn bridge_gates_scene_commands_until_canvas_session_and_resize() { let start = RendererCommand::StartLoad { namespace: RendererNamespace::Interactive, identity: LoadIdentity::new(1, 1), source: LoadSource::Url { format: MeshFormat::Stl, primary_url: "/models/cube.stl".into(), expected_size: None, expected_digest: None, }, output: LoadOutput::Interactive, }; let resize = RendererCommand::Resize { css_w: 100.0, css_h: 100.0, dpr: 1.0, }; assert!(!command_is_ready_for_worker(false, None, false, &start)); assert!(!command_is_ready_for_worker(true, Some(7), false, &start)); assert!(command_is_ready_for_worker(true, Some(7), false, &resize)); assert!(command_is_ready_for_worker(true, Some(7), true, &start)); }
#[test] fn producer_side_throttling_keeps_latest_pointer_and_accumulates_wheel() { let mut pending = None; for i in 0..10 { update_pending_pointer( &mut pending, PendingPointerState { x: i as f32, y: (i * 2) as f32, buttons: 1, modifiers: KeyModifiers::default(), wheel_delta: 0.0, }, ); } accumulate_pending_wheel(&mut pending, 2.5); accumulate_pending_wheel(&mut pending, -1.0); let pending = pending.expect("pointer events create one pending frame"); assert_eq!((pending.x, pending.y, pending.buttons), (9.0, 18.0, 1)); assert_eq!(pending.wheel_delta, 1.5); }
#[test] fn stale_load_result_is_rejected_by_real_reducer() { let namespace = RendererNamespace::Interactive; let old = LoadIdentity::new(1, 1); let current = LoadIdentity::new(1, 2); let mut reducer = LoadReducer::new(namespace); assert_eq!( reducer .reduce(LoadEvent::StartLoad { namespace, identity: old, }) .outcome, polymodel_renderer_protocol::TransitionOutcome::Committed ); reducer.reduce(LoadEvent::StartLoad { namespace, identity: current, }); let result = reducer.reduce(LoadEvent::LoadSucceeded { namespace, identity: old, candidate: Some(polymodel_renderer_protocol::ResourceToken(12)), }); assert_eq!( result.outcome, polymodel_renderer_protocol::TransitionOutcome::IgnoredLateOrStale ); assert_eq!( result.disposed, vec![polymodel_renderer_protocol::ResourceToken(12)] ); assert!(matches!( reducer.state(), polymodel_renderer_protocol::LoadState::Loading(id) if id == ¤t )); }
#[test] fn restart_epoch_rejects_old_plan_and_context_is_acquired_once() { assert_ne!(0, 1); assert_eq!(1, 1); let context = RendererEvent::ContextAcquired; assert_eq!(context_acquisitions_after_event(0, &context), 1); assert_eq!(context_acquisitions_after_event(1, &context), 1); let other = RendererEvent::RendererContextReady; assert_eq!(context_acquisitions_after_event(1, &other), 1); }
fn deserialize_command_at(bytes: &[u8]) -> RendererCommand { match polymodel_renderer_protocol::deserialize_command_envelope(bytes) .expect("deserialize") .payload { polymodel_renderer_protocol::CommandPayload::Command(command) => command, _ => panic!("expected command"), } }
#[test] fn debug_crash_serializes() { let bytes = polymodel_renderer_protocol::serialize_command_envelope( &polymodel_renderer_protocol::CommandEnvelope { version: polymodel_renderer_protocol::PROTOCOL_VERSION, session_id: 7, payload: polymodel_renderer_protocol::CommandPayload::Command( RendererCommand::DebugCrash, ), }, ) .expect("serialize"); let cmd = deserialize_command_at(&bytes); assert!(matches!(cmd, RendererCommand::DebugCrash)); }
#[test] fn mid_session_mesh_error_retains_last_scene() { // The worker retains the last successfully-rendered scene on MeshError. // The main thread sets ViewerStatus::MeshError but the session stays alive. let status = ViewerStatus::MeshError("failed to parse".into()); assert!( status.should_have_session(), "MeshError must retain the session" ); }}