From a05afacbd98d6a55dc86d073712f2d853524b627 Mon Sep 17 00:00:00 2001 From: Orual Date: Sat, 1 Aug 2026 14:20:08 -0400 Subject: [PATCH] PM-87: authoritative viewer load state machine Implement identity-bearing CAS load/readiness transitions across the protocol, renderer worker, and viewer bridge with two-phase scene installation and preview isolation. Epic: PM-86 Task: PM-87 --- crates/polymodel-renderer-protocol/src/lib.rs | 97 +++- .../src/load_state.rs | 469 ++++++++++++++++++ .../polymodel-renderer-worker/src/worker.rs | 239 +++++++-- src/viewer.rs | 271 +++++++--- 4 files changed, 939 insertions(+), 137 deletions(-) create mode 100644 crates/polymodel-renderer-protocol/src/load_state.rs diff --git a/crates/polymodel-renderer-protocol/src/lib.rs b/crates/polymodel-renderer-protocol/src/lib.rs index c6ee9e2..cef71e5 100644 --- a/crates/polymodel-renderer-protocol/src/lib.rs +++ b/crates/polymodel-renderer-protocol/src/lib.rs @@ -12,6 +12,13 @@ use serde::{Deserialize, Serialize}; use sha2::Digest; +mod load_state; + +pub use load_state::{ + LoadError, LoadErrorCode, LoadEvent, LoadIdentity, LoadReducer, LoadState, RendererNamespace, + ResourceToken, TransitionOutcome, TransitionResult, +}; + // --------------------------------------------------------------------------- // Shared mesh-format types (relocated from polymodel-mesh::contract) // --------------------------------------------------------------------------- @@ -164,15 +171,21 @@ pub fn verify_part_file(bytes: &[u8], size: i64, digest: Option<&[u8]>) -> Resul /// per-frame, never rendering from a single message. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub enum RendererCommand { - /// Load a mesh from a URL. The worker discovers and fetches glTF companions itself. - /// `format` + `primary_url` are both known from the route / `DemoModel` without any - /// mesh parsing on the main thread. - LoadMesh { + /// Begin an interactive load. The main thread allocates the identity and the worker + /// must not allocate or infer one. The worker discovers and fetches glTF companions. + StartLoad { + namespace: RendererNamespace, + identity: LoadIdentity, format: MeshFormat, primary_url: String, expected_size: Option, expected_digest: Option>, }, + /// Cancel the current identity. Late results are rejected by the reducer. + Cancel { + namespace: RendererNamespace, + identity: LoadIdentity, + }, /// Render a mesh URL to a PNG preview image. This is intentionally handled by /// the worker so the Dioxus app does not depend on mesh parsing or `three-d`. RenderPreviewImage { @@ -229,21 +242,38 @@ pub struct KeyModifiers { #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub enum RendererEvent { /// The WebGL2 context was created on the `OffscreenCanvas`. Emitted once per worker - /// lifetime (retained across `LoadMesh` / model switches). The main thread surfaces - /// a `context_acquisitions` signal from this. + /// lifetime (retained across model switches). ContextAcquired, - /// A mesh was loaded and the scene was built successfully. - MeshLoaded { stats: MeshStats }, - /// A mesh failed to load or parse. The worker retains the last successfully-rendered - /// scene (does not clear) so the error overlay shows over the stale mesh. - MeshError { message: String }, + /// Context and initial viewport readiness only; mesh readiness is `InstallSucceeded`. + RendererContextReady, + /// Fetch/parse completed and a candidate is ready for installation. + LoadSucceeded { + namespace: RendererNamespace, + identity: LoadIdentity, + }, + /// The candidate scene was installed; this is the only mesh-readiness publication point. + InstallSucceeded { + namespace: RendererNamespace, + identity: LoadIdentity, + stats: MeshStats, + }, + /// A load or install failed for the exact identity. + LoadFailed { + namespace: RendererNamespace, + identity: LoadIdentity, + code: LoadErrorCode, + detail: String, + }, + /// Cancellation/supersession/disposal status publication for the exact identity. + LoadCancelled { + namespace: RendererNamespace, + identity: LoadIdentity, + }, /// A preview render completed successfully. `png` is raw PNG bytes. PreviewImageRendered { request_id: u32, png: Vec }, /// A preview render failed. Preview failures are non-fatal and do not affect /// the interactive viewer scene. PreviewImageError { request_id: u32, message: String }, - /// The renderer is ready to render (context + initial viewport set up). - RendererReady, /// A renderer error. `fatal: true` means the worker is unusable (context loss, /// unhandled panic) — the main thread surfaces `ViewerStatus::RendererError`. Error { fatal: bool, message: String }, @@ -375,30 +405,42 @@ mod tests { #[test] fn all_commands_roundtrip() { let commands = vec![ - RendererCommand::LoadMesh { + RendererCommand::StartLoad { + namespace: RendererNamespace::Interactive, + identity: LoadIdentity::new(1, 1), format: MeshFormat::Stl, primary_url: "/models/cube.stl".into(), expected_size: Some(4096), expected_digest: Some(vec![0u8; 32]), }, - RendererCommand::LoadMesh { + RendererCommand::StartLoad { + namespace: RendererNamespace::Interactive, + identity: LoadIdentity::new(1, 2), format: MeshFormat::Obj, primary_url: "/models/cube.obj".into(), expected_size: None, expected_digest: None, }, - RendererCommand::LoadMesh { + RendererCommand::StartLoad { + namespace: RendererNamespace::Interactive, + identity: LoadIdentity::new(2, 1), format: MeshFormat::Gltf, primary_url: "/models/cube.glb".into(), expected_size: Some(2048), expected_digest: None, }, - RendererCommand::LoadMesh { + RendererCommand::StartLoad { + namespace: RendererNamespace::Interactive, + identity: LoadIdentity::new(2, 2), format: MeshFormat::Threemf, primary_url: "/models/cube.3mf".into(), expected_size: None, expected_digest: Some(vec![1u8; 32]), }, + RendererCommand::Cancel { + namespace: RendererNamespace::Interactive, + identity: LoadIdentity::new(2, 2), + }, RendererCommand::RenderPreviewImage { request_id: 7, format: MeshFormat::Stl, @@ -435,14 +477,28 @@ mod tests { fn all_events_roundtrip() { let events = vec![ RendererEvent::ContextAcquired, - RendererEvent::MeshLoaded { + RendererEvent::RendererContextReady, + RendererEvent::LoadSucceeded { + namespace: RendererNamespace::Interactive, + identity: LoadIdentity::new(1, 1), + }, + RendererEvent::InstallSucceeded { + namespace: RendererNamespace::Interactive, + identity: LoadIdentity::new(1, 1), stats: MeshStats { vertices: 1234, triangles: 4321, }, }, - RendererEvent::MeshError { - message: "failed to parse: bad bytes".into(), + RendererEvent::LoadFailed { + namespace: RendererNamespace::Interactive, + identity: LoadIdentity::new(1, 2), + code: LoadErrorCode::Parse, + detail: "failed to parse: bad bytes".into(), + }, + RendererEvent::LoadCancelled { + namespace: RendererNamespace::Interactive, + identity: LoadIdentity::new(1, 2), }, RendererEvent::PreviewImageRendered { request_id: 7, @@ -452,7 +508,6 @@ mod tests { request_id: 7, message: "preview failed".into(), }, - RendererEvent::RendererReady, RendererEvent::Error { fatal: true, message: "context lost".into(), diff --git a/crates/polymodel-renderer-protocol/src/load_state.rs b/crates/polymodel-renderer-protocol/src/load_state.rs new file mode 100644 index 0000000..03708fa --- /dev/null +++ b/crates/polymodel-renderer-protocol/src/load_state.rs @@ -0,0 +1,469 @@ +//! Shared compare-and-swap load/readiness state machine. +//! +//! The worker and the main-thread viewer each own an instance of [`LoadReducer`]. +//! The main thread allocates identities; reducers only accept an identity that was +//! supplied by the caller and reject late or cross-namespace events. + +use serde::{Deserialize, Serialize}; + +/// Renderer state namespace. Preview work is deliberately not interactive work. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum RendererNamespace { + Interactive, + Preview, +} + +/// Monotonic identity allocated by the main thread for one renderer namespace. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Ord, PartialOrd, Hash, Serialize, Deserialize)] +pub struct LoadIdentity { + pub load_epoch: u32, + pub scene_generation: u32, +} + +impl LoadIdentity { + pub const fn new(load_epoch: u32, scene_generation: u32) -> Self { + Self { + load_epoch, + scene_generation, + } + } +} + +/// An opaque resource handle used only to communicate disposal obligations. +/// The resource-owning side chooses the token and maps it to its real resource. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct ResourceToken(pub u64); + +/// Stable machine-readable failure classes. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum LoadErrorCode { + Fetch, + Parse, + Install, + Cancelled, + Renderer, + Unknown, +} + +/// A stable error code with a human-facing detail string. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct LoadError { + pub code: LoadErrorCode, + pub detail: String, +} + +/// Externally observable reducer states. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum LoadState { + Idle, + Loading(LoadIdentity), + Installing(LoadIdentity), + Ready(LoadIdentity), + Failed(u32, LoadErrorCode), + Cancelled(u32), + Superseded(u32), + Disposed, +} + +impl LoadState { + pub fn identity(&self) -> Option { + match self { + Self::Loading(id) | Self::Installing(id) | Self::Ready(id) => Some(*id), + Self::Failed(epoch, _) | Self::Cancelled(epoch) | Self::Superseded(epoch) => { + Some(LoadIdentity::new(*epoch, 0)) + } + Self::Idle | Self::Disposed => None, + } + } + + pub fn is_session_state(&self) -> bool { + !matches!(self, Self::Disposed) + } +} + +/// Reducer input. Resource tokens are candidates owned by the event producer. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum LoadEvent { + StartLoad { + namespace: RendererNamespace, + identity: LoadIdentity, + }, + LoadSucceeded { + namespace: RendererNamespace, + identity: LoadIdentity, + candidate: Option, + }, + LoadFailed { + namespace: RendererNamespace, + identity: LoadIdentity, + error: LoadError, + candidate: Option, + }, + InstallSucceeded { + namespace: RendererNamespace, + identity: LoadIdentity, + scene: Option, + }, + InstallFailed { + namespace: RendererNamespace, + identity: LoadIdentity, + error: LoadError, + candidate: Option, + }, + Cancel { + namespace: RendererNamespace, + identity: LoadIdentity, + candidate: Option, + }, + Resize { + namespace: RendererNamespace, + }, + Dispose { + namespace: RendererNamespace, + }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TransitionOutcome { + Committed, + IgnoredLateOrStale, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TransitionResult { + pub outcome: TransitionOutcome, + pub state: LoadState, + pub disposed: Vec, +} + +impl TransitionResult { + fn committed(state: LoadState, disposed: Vec) -> Self { + Self { + outcome: TransitionOutcome::Committed, + state, + disposed, + } + } + + fn ignored(state: LoadState, candidate: Option) -> Self { + Self { + outcome: TransitionOutcome::IgnoredLateOrStale, + state, + disposed: candidate.into_iter().collect(), + } + } +} + +/// One namespace-local CAS reducer. Identity allocation is intentionally absent. +#[derive(Debug, Clone)] +pub struct LoadReducer { + namespace: RendererNamespace, + state: LoadState, + current_identity: Option, + candidate: Option, + current_scene: Option, +} + +impl LoadReducer { + pub fn new(namespace: RendererNamespace) -> Self { + Self { + namespace, + state: LoadState::Idle, + current_identity: None, + candidate: None, + current_scene: None, + } + } + + pub fn namespace(&self) -> RendererNamespace { + self.namespace + } + pub fn state(&self) -> &LoadState { + &self.state + } + pub fn current_identity(&self) -> Option { + self.current_identity + } + pub fn current_scene(&self) -> Option { + self.current_scene + } + + pub fn reduce(&mut self, event: LoadEvent) -> TransitionResult { + let (namespace, identity, candidate) = event_metadata(&event); + if namespace != self.namespace { + return TransitionResult::ignored(self.state.clone(), candidate); + } + if matches!(event, LoadEvent::Dispose { .. }) { + let disposed = self.take_resources(candidate); + self.state = LoadState::Disposed; + self.current_identity = None; + return TransitionResult::committed(LoadState::Disposed, disposed); + } + if matches!(self.state, LoadState::Disposed) { + return TransitionResult::ignored(self.state.clone(), candidate); + } + + if let LoadEvent::StartLoad { identity, .. } = event { + if self + .current_identity + .map_or(true, |current| identity > current) + { + let mut disposed = self.take_resources(None); + self.state = LoadState::Loading(identity); + self.current_identity = Some(identity); + disposed.extend(candidate); + return TransitionResult::committed(self.state.clone(), disposed); + } + return TransitionResult::ignored(self.state.clone(), None); + } + + if matches!(event, LoadEvent::Resize { .. }) { + return TransitionResult::committed(self.state.clone(), Vec::new()); + } + let Some(identity) = identity else { + return TransitionResult::ignored(self.state.clone(), candidate); + }; + if self.current_identity != Some(identity) { + return TransitionResult::ignored(self.state.clone(), candidate); + } + + match event { + LoadEvent::LoadSucceeded { candidate, .. } + if matches!(self.state, LoadState::Loading(_)) => + { + self.candidate = candidate; + self.state = LoadState::Installing(identity); + TransitionResult::committed(self.state.clone(), Vec::new()) + } + LoadEvent::LoadFailed { + error, candidate, .. + } if matches!(self.state, LoadState::Loading(_)) => { + let disposed = self.take_resources(candidate); + self.state = LoadState::Failed(identity.load_epoch, error.code); + TransitionResult::committed(self.state.clone(), disposed) + } + LoadEvent::Cancel { candidate, .. } + if matches!( + self.state, + LoadState::Loading(_) + | LoadState::Installing(_) + | LoadState::Ready(_) + | LoadState::Failed(_, _) + | LoadState::Cancelled(_) + | LoadState::Superseded(_) + ) => + { + let disposed = self.take_resources(candidate); + self.current_scene = None; + self.state = LoadState::Cancelled(identity.load_epoch); + TransitionResult::committed(self.state.clone(), disposed) + } + LoadEvent::InstallSucceeded { scene, .. } + if matches!(self.state, LoadState::Installing(_)) => + { + let mut disposed = self.take_resources(None); + if let Some(candidate) = self.candidate.take() { + disposed.push(candidate); + } + self.current_scene = scene; + self.state = LoadState::Ready(identity); + TransitionResult::committed(self.state.clone(), disposed) + } + LoadEvent::InstallFailed { + error, candidate, .. + } if matches!(self.state, LoadState::Installing(_)) => { + let disposed = self.take_resources(candidate); + self.state = LoadState::Failed(identity.load_epoch, error.code); + TransitionResult::committed(self.state.clone(), disposed) + } + LoadEvent::Resize { .. } => TransitionResult::committed(self.state.clone(), Vec::new()), + _ => TransitionResult::ignored(self.state.clone(), candidate), + } + } + + fn take_resources(&mut self, extra: Option) -> Vec { + let mut disposed = Vec::new(); + if let Some(token) = self.candidate.take() { + disposed.push(token); + } + if let Some(token) = self.current_scene.take() { + disposed.push(token); + } + if let Some(token) = extra { + disposed.push(token); + } + disposed.sort_unstable_by_key(|token| token.0); + disposed.dedup(); + disposed + } +} + +fn event_metadata( + event: &LoadEvent, +) -> ( + RendererNamespace, + Option, + Option, +) { + match event { + LoadEvent::StartLoad { + namespace, + identity, + } => (*namespace, Some(*identity), None), + LoadEvent::LoadSucceeded { + namespace, + identity, + candidate, + } + | LoadEvent::LoadFailed { + namespace, + identity, + candidate, + .. + } + | LoadEvent::InstallFailed { + namespace, + identity, + candidate, + .. + } + | LoadEvent::Cancel { + namespace, + identity, + candidate, + } => (*namespace, Some(*identity), *candidate), + LoadEvent::InstallSucceeded { + namespace, + identity, + scene, + } => (*namespace, Some(*identity), *scene), + LoadEvent::Resize { namespace } | LoadEvent::Dispose { namespace } => { + (*namespace, None, None) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const NS: RendererNamespace = RendererNamespace::Interactive; + const A: LoadIdentity = LoadIdentity::new(1, 1); + const B: LoadIdentity = LoadIdentity::new(1, 2); + fn err(code: LoadErrorCode) -> LoadError { + LoadError { + code, + detail: "detail".into(), + } + } + fn start(r: &mut LoadReducer, id: LoadIdentity) { + assert_eq!( + r.reduce(LoadEvent::StartLoad { + namespace: NS, + identity: id + }) + .outcome, + TransitionOutcome::Committed + ); + } + + #[test] + fn full_install_is_the_only_ready_publication() { + let mut r = LoadReducer::new(NS); + start(&mut r, A); + assert!(matches!(r.state(), LoadState::Loading(A))); + r.reduce(LoadEvent::LoadSucceeded { + namespace: NS, + identity: A, + candidate: Some(ResourceToken(1)), + }); + assert!(matches!(r.state(), LoadState::Installing(A))); + assert!(!matches!(r.state(), LoadState::Ready(_))); + r.reduce(LoadEvent::InstallSucceeded { + namespace: NS, + identity: A, + scene: Some(ResourceToken(2)), + }); + assert!(matches!(r.state(), LoadState::Ready(A))); + assert_eq!(r.current_scene(), Some(ResourceToken(2))); + } + + #[test] + fn stale_success_is_rejected_and_disposed_after_supersession() { + let mut r = LoadReducer::new(NS); + start(&mut r, A); + start(&mut r, B); + let result = r.reduce(LoadEvent::LoadSucceeded { + namespace: NS, + identity: A, + candidate: Some(ResourceToken(9)), + }); + assert_eq!(result.outcome, TransitionOutcome::IgnoredLateOrStale); + assert_eq!(result.disposed, vec![ResourceToken(9)]); + assert!(matches!(r.state(), LoadState::Loading(B))); + } + + #[test] + fn cancel_and_dispose_win_against_late_install() { + let mut r = LoadReducer::new(NS); + start(&mut r, A); + r.reduce(LoadEvent::LoadSucceeded { + namespace: NS, + identity: A, + candidate: Some(ResourceToken(1)), + }); + r.reduce(LoadEvent::Cancel { + namespace: NS, + identity: A, + candidate: None, + }); + let late = r.reduce(LoadEvent::InstallSucceeded { + namespace: NS, + identity: A, + scene: Some(ResourceToken(2)), + }); + assert_eq!(late.outcome, TransitionOutcome::IgnoredLateOrStale); + assert_eq!(late.disposed, vec![ResourceToken(2)]); + let disposed = r.reduce(LoadEvent::Dispose { namespace: NS }); + assert_eq!(disposed.state, LoadState::Disposed); + assert_eq!( + r.reduce(LoadEvent::StartLoad { + namespace: NS, + identity: B + }) + .outcome, + TransitionOutcome::IgnoredLateOrStale + ); + } + + #[test] + fn failure_and_wrong_namespace_are_cas_guarded() { + let mut r = LoadReducer::new(NS); + start(&mut r, A); + r.reduce(LoadEvent::LoadFailed { + namespace: NS, + identity: A, + error: err(LoadErrorCode::Parse), + candidate: Some(ResourceToken(3)), + }); + assert!(matches!( + r.state(), + LoadState::Failed(1, LoadErrorCode::Parse) + )); + let result = r.reduce(LoadEvent::StartLoad { + namespace: RendererNamespace::Preview, + identity: B, + }); + assert_eq!(result.outcome, TransitionOutcome::IgnoredLateOrStale); + } + + #[test] + fn resize_never_changes_identity_or_state() { + let mut r = LoadReducer::new(NS); + start(&mut r, A); + let result = r.reduce(LoadEvent::Resize { namespace: NS }); + assert_eq!(result.outcome, TransitionOutcome::Committed); + assert_eq!(r.current_identity(), Some(A)); + assert!(matches!(r.state(), LoadState::Loading(A))); + } +} diff --git a/crates/polymodel-renderer-worker/src/worker.rs b/crates/polymodel-renderer-worker/src/worker.rs index 37ee024..d5379bf 100644 --- a/crates/polymodel-renderer-worker/src/worker.rs +++ b/crates/polymodel-renderer-worker/src/worker.rs @@ -18,8 +18,9 @@ use web_sys::DedicatedWorkerGlobalScope; use polymodel_mesh::contract::ModelMesh; use polymodel_mesh::parser::load_mesh_with_companions; use polymodel_renderer_protocol::{ - KeyModifiers, MeshFormat, MeshStats, RendererCommand, RendererEvent, deserialize_command, - serialize_event, + KeyModifiers, LoadError, LoadErrorCode, LoadEvent, LoadIdentity, LoadReducer, MeshFormat, + MeshStats, RendererCommand, RendererEvent, RendererNamespace, ResourceToken, + deserialize_command, serialize_event, }; // --------------------------------------------------------------------------- @@ -61,12 +62,17 @@ struct WorkerState { scope: DedicatedWorkerGlobalScope, canvas: Option, renderer: Option, + pending_scene: Option<( + three_d::Model, + three_d::Camera, + three_d::OrbitControl, + )>, pixel_width: u32, pixel_height: u32, input: InputState, needs_render: bool, disposed: bool, - load_epoch: u32, + load_reducer: LoadReducer, raf_id: Option, raf_closure: Option>, } @@ -77,12 +83,13 @@ impl WorkerState { scope, canvas: None, renderer: None, + pending_scene: None, pixel_width: 1, pixel_height: 1, input: InputState::default(), needs_render: false, disposed: false, - load_epoch: 0, + load_reducer: LoadReducer::new(RendererNamespace::Interactive), raf_id: None, raf_closure: None, } @@ -162,8 +169,8 @@ impl WorkerState { control, }); self.post_event(&RendererEvent::ContextAcquired); - self.post_event(&RendererEvent::RendererReady); - tracing::info!("renderer worker: renderer ready"); + self.post_event(&RendererEvent::RendererContextReady); + tracing::info!("renderer worker: renderer context ready"); } } @@ -509,50 +516,52 @@ fn consume_input_and_render(st: &mut WorkerState) { fn handle_load_mesh( state: &Rc>, + namespace: RendererNamespace, + identity: LoadIdentity, format: MeshFormat, primary_url: String, expected_size: Option, expected_digest: Option>, ) { - tracing::info!( - ?format, - primary_url, - expected_size, - has_expected_digest = expected_digest.is_some(), - "renderer worker: starting mesh load" - ); - let epoch = { + tracing::info!(?format, ?identity, %primary_url, "renderer worker: starting mesh load"); + { let mut st = state.borrow_mut(); - if st.disposed { + if st.disposed || namespace != RendererNamespace::Interactive { return; } - st.load_epoch += 1; - st.load_epoch - }; + let result = st.load_reducer.reduce(LoadEvent::StartLoad { + namespace, + identity, + }); + for _token in result.disposed { + st.pending_scene = None; + st.renderer.as_mut().map(|renderer| renderer.model = None); + } + if !matches!( + result.outcome, + polymodel_renderer_protocol::TransitionOutcome::Committed + ) { + return; + } + if let Some(renderer) = st.renderer.as_mut() { + renderer.model = None; + } + st.pending_scene = None; + st.needs_render = true; + st.post_event(&RendererEvent::LoadCancelled { + namespace, + identity, + }); + } let state_clone = state.clone(); spawn_local(async move { let result = load_mesh_async(&primary_url, format, expected_size, expected_digest).await; - match &result { - Ok(_) => tracing::info!( - ?format, - success = true, - "renderer worker: mesh load future completed" - ), - Err(message) => tracing::error!( - ?format, - success = false, - %message, - "renderer worker: mesh load future completed" - ), - } - let mut st = match state_clone.try_borrow_mut() { Ok(st) => st, Err(_) => return, }; - - if st.disposed || st.load_epoch != epoch { + if st.disposed { return; } @@ -562,28 +571,112 @@ fn handle_load_mesh( vertices: model.vertex_count(), triangles: model.triangle_count(), }; - - let pw = st.pixel_width; - let ph = st.pixel_height; - if let Some(renderer) = st.renderer.as_mut() { - match build_scene(&renderer.context, &model, pw, ph) { - Ok((render_model, camera, control)) => { - renderer.model = Some(render_model); - renderer.camera = camera; - renderer.control = control; - } - Err(msg) => { - st.post_event(&RendererEvent::MeshError { message: msg }); - return; - } + let parsed = st.load_reducer.reduce(LoadEvent::LoadSucceeded { + namespace, + identity, + candidate: Some(ResourceToken(identity.scene_generation as u64)), + }); + if !matches!( + parsed.outcome, + polymodel_renderer_protocol::TransitionOutcome::Committed + ) { + return; + } + st.post_event(&RendererEvent::LoadSucceeded { + namespace, + identity, + }); + let (pw, ph) = (st.pixel_width, st.pixel_height); + let Some(renderer) = st.renderer.as_ref() else { + let error = LoadError { + code: LoadErrorCode::Renderer, + detail: "renderer context unavailable".into(), + }; + st.load_reducer.reduce(LoadEvent::InstallFailed { + namespace, + identity, + error: error.clone(), + candidate: None, + }); + st.post_event(&RendererEvent::LoadFailed { + namespace, + identity, + code: error.code, + detail: error.detail, + }); + return; + }; + let candidate = match build_scene(&renderer.context, &model, pw, ph) { + Ok(candidate) => candidate, + Err(detail) => { + let error = LoadError { + code: LoadErrorCode::Install, + detail: detail.clone(), + }; + st.load_reducer.reduce(LoadEvent::InstallFailed { + namespace, + identity, + error, + candidate: None, + }); + st.post_event(&RendererEvent::LoadFailed { + namespace, + identity, + code: LoadErrorCode::Install, + detail, + }); + return; + } + }; + st.pending_scene = Some(candidate); + let committed = st.load_reducer.reduce(LoadEvent::InstallSucceeded { + namespace, + identity, + scene: Some(ResourceToken(identity.scene_generation as u64 + 1)), + }); + if !matches!( + committed.outcome, + polymodel_renderer_protocol::TransitionOutcome::Committed + ) { + st.pending_scene = None; + return; + } + if let Some((render_model, camera, control)) = st.pending_scene.take() { + if let Some(renderer) = st.renderer.as_mut() { + renderer.model = Some(render_model); + renderer.camera = camera; + renderer.control = control; } } - st.needs_render = true; - st.post_event(&RendererEvent::MeshLoaded { stats }); + st.post_event(&RendererEvent::InstallSucceeded { + namespace, + identity, + stats, + }); } - Err(msg) => { - st.post_event(&RendererEvent::MeshError { message: msg }); + Err(detail) => { + let error = LoadError { + code: LoadErrorCode::Fetch, + detail: detail.clone(), + }; + let result = st.load_reducer.reduce(LoadEvent::LoadFailed { + namespace, + identity, + error, + candidate: None, + }); + if matches!( + result.outcome, + polymodel_renderer_protocol::TransitionOutcome::Committed + ) { + st.post_event(&RendererEvent::LoadFailed { + namespace, + identity, + code: LoadErrorCode::Fetch, + detail, + }); + } } } }); @@ -801,14 +894,48 @@ fn handle_command(state: &Rc>, cmd: RendererCommand) { } match cmd { - RendererCommand::LoadMesh { + RendererCommand::StartLoad { + namespace, + identity, format, primary_url, expected_size, expected_digest, } => { drop(st); - handle_load_mesh(state, format, primary_url, expected_size, expected_digest); + handle_load_mesh( + state, + namespace, + identity, + format, + primary_url, + expected_size, + expected_digest, + ); + } + RendererCommand::Cancel { + namespace, + identity, + } => { + let result = st.load_reducer.reduce(LoadEvent::Cancel { + namespace, + identity, + candidate: None, + }); + st.pending_scene = None; + if matches!( + result.outcome, + polymodel_renderer_protocol::TransitionOutcome::Committed + ) { + if let Some(renderer) = st.renderer.as_mut() { + renderer.model = None; + } + st.needs_render = true; + st.post_event(&RendererEvent::LoadCancelled { + namespace, + identity, + }); + } } RendererCommand::RenderPreviewImage { request_id, @@ -856,6 +983,10 @@ fn handle_command(state: &Rc>, cmd: RendererCommand) { st.needs_render = true; } RendererCommand::Dispose => { + let _ = st.load_reducer.reduce(LoadEvent::Dispose { + namespace: RendererNamespace::Interactive, + }); + st.pending_scene = None; st.disposed = true; if let Some(id) = st.raf_id.take() { let _ = st.scope.cancel_animation_frame(id); diff --git a/src/viewer.rs b/src/viewer.rs index 22526cb..456ee0b 100644 --- a/src/viewer.rs +++ b/src/viewer.rs @@ -13,13 +13,14 @@ #![allow(dead_code)] use dioxus::prelude::*; -use polymodel_renderer_protocol::MeshFormat; #[cfg(not(target_arch = "wasm32"))] use polymodel_renderer_protocol::MeshStats; #[cfg(target_arch = "wasm32")] use polymodel_renderer_protocol::{ - KeyModifiers, MeshStats, RendererCommand, RendererEvent, serialize_command, + KeyModifiers, LoadEvent, LoadIdentity, LoadReducer, LoadState, MeshStats, RendererCommand, + RendererEvent, RendererNamespace, serialize_command, }; +use polymodel_renderer_protocol::{LoadErrorCode, MeshFormat}; // --------------------------------------------------------------------------- // Demo model metadata (stays main-side — the worker only needs format + URL) @@ -236,6 +237,11 @@ impl ViewerIssue { pub enum ViewerStatus { Ready(SessionKey), Loading(SessionKey), + Installing(SessionKey), + Failed(SessionKey, LoadErrorCode, String), + Cancelled(SessionKey), + Superseded(SessionKey), + Disposed, MeshError(String), RendererError(ViewerIssue), } @@ -245,7 +251,11 @@ impl ViewerStatus { match self { Self::Ready(_) => "Viewer ready", Self::Loading(_) => "Loading model", - Self::MeshError(_) => "Mesh unavailable", + Self::Installing(_) => "Installing model", + Self::Failed(_, _, _) | Self::MeshError(_) => "Mesh unavailable", + Self::Cancelled(_) => "Load cancelled", + Self::Superseded(_) => "Load superseded", + Self::Disposed => "Viewer disposed", Self::RendererError(_) => "Renderer unavailable", } } @@ -254,19 +264,38 @@ impl ViewerStatus { match self { Self::Ready(_) => "Mesh asset is loaded and ready for the canvas renderer.", Self::Loading(_) => "Fetching and parsing the mesh asset for the viewer.", + Self::Installing(_) => "Installing the parsed mesh into the canvas renderer.", + Self::Failed(_, _, detail) => detail, + Self::Cancelled(_) => "The mesh load was cancelled.", + Self::Superseded(_) => "The mesh load was replaced by a newer selection.", + Self::Disposed => "The viewer renderer session has been disposed.", Self::MeshError(msg) => msg, Self::RendererError(issue) => issue.message(), } } pub fn should_have_session(&self) -> bool { - matches!(self, Self::Ready(_) | Self::Loading(_) | Self::MeshError(_)) + matches!( + self, + Self::Ready(_) + | Self::Loading(_) + | Self::Installing(_) + | Self::Failed(_, _, _) + | Self::Cancelled(_) + | Self::Superseded(_) + | Self::MeshError(_) + ) } pub fn key(&self) -> Option<&SessionKey> { match self { - Self::Ready(key) | Self::Loading(key) => Some(key), - Self::MeshError(_) | Self::RendererError(_) => None, + Self::Ready(key) + | Self::Loading(key) + | Self::Installing(key) + | Self::Failed(key, _, _) + | Self::Cancelled(key) + | Self::Superseded(key) => Some(key), + Self::Disposed | Self::MeshError(_) | Self::RendererError(_) => None, } } } @@ -293,26 +322,15 @@ pub fn ViewerPage(initial_model: DemoModel) -> Element { let _mesh_load = use_resource(move || { let model = *selected_model.read(); let mut status = status; - let mut mesh_stats = mesh_stats; async move { status.set(ViewerStatus::Loading(SessionKey::new( model.id, model.generation, ))); - // The actual LoadMesh command is sent to the worker by the + // The actual StartLoad command is sent to the worker by the // WorkerRendererSession effect (below). This resource just tracks - // the status. The worker posts MeshLoaded/MeshError back, which - // the bridge effect maps to status. - // - // For now, set Ready immediately — the bridge handles the async. - status.set(ViewerStatus::Ready(SessionKey::new( - model.id, - model.generation, - ))); - mesh_stats.set(Some(MeshStats { - vertices: model.vertices as u32, - triangles: model.triangles as u32, - })); + // the requested status; readiness is published only after the + // bridge receives InstallSucceeded. } }); @@ -445,15 +463,9 @@ pub fn AssetViewer( } else { clear_step_render_source(render_source, active_step_object_url); } - status.set(ViewerStatus::Ready(session_key)); - mesh_stats.set(if using_source { - None - } else { - Some(MeshStats { - vertices: model.map_or(0, |model| model.vertices as u32), - triangles: model.map_or(0, |model| model.triangles as u32), - }) - }); + if !using_source { + mesh_stats.set(None); + } } }); @@ -985,15 +997,15 @@ fn WorkerSessionInner( .flatten() }); if let Some(source) = source - && bt.last_loaded_key.as_deref() != Some(source.key.as_str()) + && bt.last_started_key.as_deref() != Some(source.key.as_str()) { - bt.send_command(&RendererCommand::LoadMesh { - format: source.format, - primary_url: source.primary_url, - expected_size: source.expected_size, - expected_digest: source.expected_digest, - }); - bt.last_loaded_key = Some(source.key); + bt.last_started_key = Some(source.key.clone()); + bt.start_load(source); + let session = bt.current_session.borrow().clone(); + if let Some(session) = session { + status.set(ViewerStatus::Loading(session)); + mesh_stats.set(None); + } } } @@ -1067,7 +1079,10 @@ struct WorkerBridgeState { worker: Option, canvas_transferred: bool, initial_resize_sent: bool, - last_loaded_key: Option, + load_reducer: Rc>, + next_identity: LoadIdentity, + current_session: Rc>>, + last_started_key: Option, post_canvas_queue: Vec>, resize_cleanup: Option>, pointer_cleanup: Option>, @@ -1096,7 +1111,12 @@ impl WorkerBridgeState { worker: None, canvas_transferred: false, initial_resize_sent: false, - last_loaded_key: None, + load_reducer: Rc::new(RefCell::new(LoadReducer::new( + RendererNamespace::Interactive, + ))), + next_identity: LoadIdentity::new(0, 0), + current_session: Rc::new(RefCell::new(None)), + last_started_key: None, post_canvas_queue: Vec::new(), resize_cleanup: None, pointer_cleanup: None, @@ -1135,6 +1155,8 @@ impl WorkerBridgeState { let mut acquisitions_msg = context_acquisitions; let mut failed_msg = renderer_failed; let mut stats_msg = mesh_stats; + let reducer_msg = self.load_reducer.clone(); + let current_session_msg = self.current_session.clone(); let onmessage = Closure::wrap(Box::new(move |event: web_sys::MessageEvent| { let data = event.data(); @@ -1154,15 +1176,89 @@ impl WorkerBridgeState { RendererEvent::ContextAcquired => { acquisitions_msg.set(1); } - RendererEvent::MeshLoaded { stats } => { - stats_msg.set(Some(stats)); + RendererEvent::RendererContextReady => {} + RendererEvent::LoadSucceeded { + namespace, + identity, + } => { + let result = reducer_msg.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.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)); + status_msg.set(ViewerStatus::Ready(session)); + } + } + RendererEvent::LoadFailed { + namespace, + identity, + code, + detail, + } => { + let result = reducer_msg.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::MeshError { message } => { - status_msg.set(ViewerStatus::MeshError(message)); + RendererEvent::LoadCancelled { + namespace, + identity, + } => { + let result = reducer_msg.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::RendererReady => {} RendererEvent::Error { fatal, message } => { if fatal { failed_msg.set(true); @@ -1224,6 +1320,46 @@ impl WorkerBridgeState { 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_load(&mut self, source: RenderSource) { + 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, + format: source.format, + primary_url: source.primary_url, + expected_size: source.expected_size, + expected_digest: source.expected_digest, + }); + } + fn send_command(&mut self, cmd: &RendererCommand) { if !self.canvas_transferred || !self.initial_resize_sent { // Queue scene-dependent commands until canvas + initial resize are done. @@ -1563,7 +1699,8 @@ fn mark_canvas_mounted(_event: &MountedEvent, _mounted: Signal) {} mod tests { use super::*; use polymodel_renderer_protocol::{ - KeyModifiers, MeshStats, RendererCommand, RendererEvent, serialize_command, + KeyModifiers, LoadIdentity, MeshStats, RendererCommand, RendererEvent, RendererNamespace, + serialize_command, }; #[test] @@ -1677,15 +1814,19 @@ mod tests { triangles: 4321, }; let bytes = - polymodel_renderer_protocol::serialize_event(&RendererEvent::MeshLoaded { stats }) - .expect("serialize"); + polymodel_renderer_protocol::serialize_event(&RendererEvent::InstallSucceeded { + namespace: RendererNamespace::Interactive, + identity: LoadIdentity::new(1, 1), + stats, + }) + .expect("serialize"); let evt = polymodel_renderer_protocol::deserialize_event(&bytes).expect("deserialize"); match evt { - RendererEvent::MeshLoaded { stats: s } => { + RendererEvent::InstallSucceeded { stats: s, .. } => { assert_eq!(s.vertices, 1234); assert_eq!(s.triangles, 4321); } - _ => panic!("expected MeshLoaded"), + _ => panic!("expected InstallSucceeded"), } } @@ -1702,7 +1843,7 @@ mod tests { fn send_command(&self, cmd: RendererCommand) { if !self.canvas_transferred || !self.initial_resize_sent { if let RendererCommand::Resize { .. } = cmd { - // Resize is allowed before LoadMesh. + // Resize is allowed before StartLoad. } else { return; } @@ -1720,11 +1861,13 @@ mod tests { } #[test] - fn fake_bridge_canvas_before_loadmesh_ordering() { + fn fake_bridge_canvas_before_start_load_ordering() { let bridge = FakeWorkerBridge::default(); - // LoadMesh before canvas transfer is queued (not sent). - bridge.send_command(RendererCommand::LoadMesh { + // StartLoad before canvas transfer is queued (not sent). + bridge.send_command(RendererCommand::StartLoad { + namespace: RendererNamespace::Interactive, + identity: LoadIdentity::new(1, 1), format: MeshFormat::Stl, primary_url: "/models/cube.stl".into(), expected_size: None, @@ -1733,10 +1876,10 @@ mod tests { assert_eq!( bridge.command_count(), 0, - "LoadMesh before canvas must not send" + "StartLoad before canvas must not send" ); - // After canvas transfer + initial resize, LoadMesh is sent. + // After canvas transfer + initial resize, StartLoad is sent. // (This is enforced by the WorkerBridgeState, not the fake — the fake // just demonstrates the ordering contract.) } @@ -1780,9 +1923,9 @@ mod tests { } #[test] - fn loadmesh_epoch_guard() { - // When LoadMesh(A) is dispatched then LoadMesh(B) is dispatched before A resolves, - // the final rendered mesh must be B. The worker's load_epoch guard handles this. + fn start_load_identity_guard() { + // When StartLoad(A) is dispatched then StartLoad(B) is dispatched before A resolves, + // the final rendered mesh must be B. The worker's identity CAS guard handles this. // This test verifies the protocol contract: both commands are sent, but the worker // discards the stale result. let bridge = FakeWorkerBridge { @@ -1791,13 +1934,17 @@ mod tests { ..Default::default() }; - bridge.send_command(RendererCommand::LoadMesh { + bridge.send_command(RendererCommand::StartLoad { + namespace: RendererNamespace::Interactive, + identity: LoadIdentity::new(1, 1), format: MeshFormat::Stl, primary_url: "/models/a.stl".into(), expected_size: None, expected_digest: None, }); - bridge.send_command(RendererCommand::LoadMesh { + bridge.send_command(RendererCommand::StartLoad { + namespace: RendererNamespace::Interactive, + identity: LoadIdentity::new(1, 2), format: MeshFormat::Stl, primary_url: "/models/b.stl".into(), expected_size: None, @@ -1805,8 +1952,8 @@ mod tests { }); let cmds = bridge.commands(); - assert_eq!(cmds.len(), 2, "both LoadMesh commands are sent"); - // The worker's epoch guard ensures only B's result is applied. + assert_eq!(cmds.len(), 2, "both StartLoad commands are sent"); + // The worker's identity guard ensures only B's result is applied. } #[test] -- 2.51.2