From c056cc975fe74aefdd710e0cc26e7598155b1bdc Mon Sep 17 00:00:00 2001 From: Orual Date: Fri, 7 Aug 2026 11:42:55 -0400 Subject: [PATCH] PM-79: add compound LDraw preview Render compound LDraw plans through the worker-owned temporary preview candidate and shared PNG lifecycle without touching Interactive state. Epic: PM-86 Task: PM-79 --- crates/polymodel-renderer-protocol/src/lib.rs | 53 +++ .../polymodel-renderer-worker/src/worker.rs | 351 ++++++++++++++---- 2 files changed, 339 insertions(+), 65 deletions(-) diff --git a/crates/polymodel-renderer-protocol/src/lib.rs b/crates/polymodel-renderer-protocol/src/lib.rs index 7c8362a..96fb43f 100644 --- a/crates/polymodel-renderer-protocol/src/lib.rs +++ b/crates/polymodel-renderer-protocol/src/lib.rs @@ -443,6 +443,16 @@ pub enum RendererCommand { identity: LoadIdentity, plan: CompoundLoadPlan, }, + /// Render a compound LDraw plan to a temporary PNG preview. Verified bytes and + /// parsed scene data remain inside the worker; only the PNG crosses the bridge. + StartCompoundPreview { + request_id: u32, + namespace: RendererNamespace, + identity: LoadIdentity, + plan: CompoundLoadPlan, + width: u32, + height: u32, + }, /// Cancel the current identity. Late results are rejected by the reducer. Cancel { namespace: RendererNamespace, @@ -699,6 +709,7 @@ pub fn deserialize_event_envelope(bytes: &[u8]) -> Result Option { match command { RendererCommand::StartCompoundLoad { .. } => Some(Capability::CompoundLdraw), + RendererCommand::StartCompoundPreview { .. } => Some(Capability::CompoundLdraw), RendererCommand::RenderPreviewImage { .. } => Some(Capability::Preview), RendererCommand::Cancel { .. } => Some(Capability::Cancellation), RendererCommand::StartLoad { .. } @@ -901,6 +912,14 @@ mod tests { identity: LoadIdentity::new(3, 2), plan: compound_plan(), }, + RendererCommand::StartCompoundPreview { + request_id: 8, + namespace: RendererNamespace::Preview, + identity: LoadIdentity::new(3, 3), + plan: compound_plan(), + width: 640, + height: 480, + }, RendererCommand::Cancel { namespace: RendererNamespace::Interactive, identity: LoadIdentity::new(2, 2), @@ -937,6 +956,40 @@ mod tests { } } + #[test] + fn compound_preview_requires_compound_capability() { + let command = RendererCommand::StartCompoundPreview { + request_id: 91, + namespace: RendererNamespace::Preview, + identity: LoadIdentity::new(7, 4), + plan: compound_plan(), + width: 320, + height: 240, + }; + assert_eq!( + required_capability(&command), + Some(Capability::CompoundLdraw) + ); + let encoded = roundtrip_command(&command); + assert_eq!(encoded, command); + if let RendererCommand::StartCompoundPreview { + request_id, + namespace, + identity, + width, + height, + .. + } = encoded + { + assert_eq!(request_id, 91); + assert_eq!(namespace, RendererNamespace::Preview); + assert_eq!(identity, LoadIdentity::new(7, 4)); + assert_eq!((width, height), (320, 240)); + } else { + panic!("compound preview command changed during roundtrip"); + } + } + #[test] fn all_events_roundtrip() { let events = vec![ diff --git a/crates/polymodel-renderer-worker/src/worker.rs b/crates/polymodel-renderer-worker/src/worker.rs index f4fef73..d25eef7 100644 --- a/crates/polymodel-renderer-worker/src/worker.rs +++ b/crates/polymodel-renderer-worker/src/worker.rs @@ -86,6 +86,12 @@ struct PreviewAbortSlot { controller: AbortController, } +struct PreviewTarget { + canvas: web_sys::OffscreenCanvas, + context: three_d::Context, + reservation: Reservation, +} + struct VerifiedCompoundSource { primary: String, descriptors: Vec<(polymodel_renderer_protocol::WorkerFetchDescriptor, Vec)>, @@ -966,6 +972,47 @@ mod tests { } } +fn create_preview_target( + ledger: &ReservationLedger, + owner: ReservationOwner, + width: u32, + height: u32, +) -> Result { + let canvas_bytes = canvas_charge( + u64::from(width), + u64::from(height), + INITIAL_CANVAS_ATTACHMENTS, + INITIAL_CANVAS_SAMPLES, + ) + .map_err(|error| format!("preview canvas admission formula: {error}"))?; + let reservation = ledger + .reserve(owner, ResourceClass::CanvasBackbuffers, canvas_bytes) + .map_err(|error| format!("preview canvas admission: {error}"))?; + let canvas = web_sys::OffscreenCanvas::new(width, height) + .map_err(|error| format!("preview canvas: {error:?}"))?; + let options = js_sys::Object::new(); + js_sys::Reflect::set( + &options, + &"preserveDrawingBuffer".into(), + &JsValue::from_bool(true), + ) + .map_err(|error| format!("preview context options: {error:?}"))?; + let gl = canvas + .get_context_with_context_options("webgl2", &options) + .map_err(|error| format!("preview context: {error:?}"))? + .ok_or_else(|| "preview WebGL2 context unavailable".to_owned())? + .dyn_into::() + .map_err(|_| "preview context is not WebGL2".to_owned())?; + let glow_context = three_d::context::Context::from_webgl2_context(gl); + let context = three_d::Context::from_gl_context(Arc::new(glow_context)) + .map_err(|error| format!("preview three-d context: {error}"))?; + Ok(PreviewTarget { + canvas, + context, + reservation, + }) +} + fn standard_lights( context: &three_d::Context, ) -> ( @@ -1580,6 +1627,193 @@ fn handle_compound_load( }); } +fn handle_compound_preview( + state: &Rc>, + request_id: u32, + namespace: RendererNamespace, + identity: LoadIdentity, + plan: polymodel_renderer_protocol::CompoundLoadPlan, + width: u32, + height: u32, +) { + if namespace != RendererNamespace::Preview { + state + .borrow_mut() + .post_event(&RendererEvent::PreviewImageError { + request_id, + message: "compound preview requires the Preview namespace".to_owned(), + }); + return; + } + let abort = AbortController::new().ok(); + { + let mut st = state.borrow_mut(); + let previous = st.preview_reducer.current_identity(); + let transition = st.preview_reducer.reduce(LoadEvent::StartLoad { + namespace, + identity, + }); + if !matches!( + transition.outcome, + polymodel_renderer_protocol::TransitionOutcome::Committed + ) { + st.post_event(&RendererEvent::PreviewImageError { + request_id, + message: "compound preview identity was rejected".to_owned(), + }); + return; + } + if let Some(previous) = previous { + if let Some(slot) = st.preview_abort.take() { + slot.controller.abort(); + } + st.post_event(&RendererEvent::LoadCancelled { + namespace, + identity: previous, + }); + } + if let Some(controller) = abort.as_ref() + && let Some(previous) = st.preview_abort.replace(PreviewAbortSlot { + identity, + controller: controller.clone(), + }) + { + previous.controller.abort(); + } + } + let state_clone = state.clone(); + spawn_local(async move { + let result = render_compound_preview_request( + &state_clone, + identity, + plan, + width.max(1), + height.max(1), + abort.clone(), + ) + .await; + finish_compound_preview(&state_clone, request_id, identity, result); + }); +} + +fn finish_compound_preview( + state: &Rc>, + request_id: u32, + identity: LoadIdentity, + result: Result, String>, +) { + let Ok(mut st) = state.try_borrow_mut() else { + return; + }; + if st.disposed + || st.preview_reducer.current_identity() != Some(identity) + || st + .preview_abort + .as_ref() + .is_none_or(|slot| slot.identity != identity) + { + return; + } + st.preview_abort.take(); + if result.as_ref().is_err_and(|message| { + message.contains("cancelled") + || message.contains("superseded") + || message.contains("AbortError") + }) { + let transition = st.preview_reducer.reduce(LoadEvent::Cancel { + namespace: RendererNamespace::Preview, + identity, + candidate: None, + }); + if matches!( + transition.outcome, + polymodel_renderer_protocol::TransitionOutcome::Committed + ) { + st.post_event(&RendererEvent::LoadCancelled { + namespace: RendererNamespace::Preview, + identity, + }); + } + return; + } + match result { + Ok(png) => st.post_event(&RendererEvent::PreviewImageRendered { request_id, png }), + Err(message) => { + let _ = st.preview_reducer.reduce(LoadEvent::LoadFailed { + namespace: RendererNamespace::Preview, + identity, + error: LoadError { + code: LoadErrorCode::Install, + detail: message.clone(), + }, + candidate: None, + }); + st.post_event(&RendererEvent::PreviewImageError { + request_id, + message, + }); + } + } +} + +async fn render_compound_preview_request( + state: &Rc>, + identity: LoadIdentity, + plan: polymodel_renderer_protocol::CompoundLoadPlan, + width: u32, + height: u32, + abort: Option, +) -> Result, String> { + let source = load_compound_source( + state, + RendererNamespace::Preview, + identity, + plan, + abort.clone(), + ) + .await?; + compound_is_current(state, RendererNamespace::Preview, identity, abort.as_ref())?; + let owner = preview_owner(identity); + let ledger = state.borrow().ledger.clone(); + let (_stats, scene) = parse_compound_source(source, owner, &ledger)?; + compound_is_current(state, RendererNamespace::Preview, identity, abort.as_ref())?; + let bounds = scene + .bounds + .ok_or_else(|| "LDraw scene has no bounds".to_owned())?; + let target = create_preview_target(&ledger, owner, width, height)?; + let scene = Rc::new(scene); + let style = polymodel_renderer_worker::RenderStyle::Faces; + let (static_model, (_explicit, _conditional), _picker, _plan) = + polymodel_renderer_worker::build_scene_model_parts_for_style( + &target.context, + &scene, + [0.0, 0.0, 1.0], + style, + )?; + compound_is_current(state, RendererNamespace::Preview, identity, abort.as_ref())?; + let fit = polymodel_renderer_worker::scene_camera_fit(bounds, width, height); + let camera = three_d::Camera::new_perspective( + three_d::Viewport::new_at_origo(width, height), + fit.position, + fit.center, + three_d::vec3(0.0, 0.0, 1.0), + three_d::degrees(45.0), + fit.near, + fit.far, + ); + let (ambient, key, fill) = standard_lights(&target.context); + let render_target = three_d::RenderTarget::screen(&target.context, width, height); + let screen = render_target.clear(three_d::ClearState::color_and_depth( + 0.78, 0.80, 0.84, 1.0, 1.0, + )); + let lights: [&dyn three_d::Light; 3] = [&ambient, &key, &fill]; + screen.render(&camera, static_model.iter(), &lights); + compound_is_current(state, RendererNamespace::Preview, identity, abort.as_ref())?; + let png = offscreen_canvas_png_bytes(&target.canvas).await?; + drop(target.reservation); + Ok(png) +} + fn abort_compound_slot( state: &mut WorkerState, namespace: RendererNamespace, @@ -1734,31 +1968,6 @@ fn finish_compound_source( if st.disposed || reducer.current_identity() != Some(identity) { return; } - if namespace == RendererNamespace::Preview { - let detail = "compound preview must use the temporary RenderPreviewImage path" - .to_owned(); - let transition = st.preview_reducer.reduce(LoadEvent::LoadFailed { - namespace, - identity, - error: LoadError { - code: LoadErrorCode::Install, - detail: detail.clone(), - }, - candidate: None, - }); - if matches!( - transition.outcome, - polymodel_renderer_protocol::TransitionOutcome::Committed - ) { - st.post_event(&RendererEvent::LoadFailed { - namespace, - identity, - code: LoadErrorCode::Install, - detail, - }); - } - return; - } let parse_owner = load_owner(namespace, identity); let (stats, scene) = match parse_compound_source(source, parse_owner, &st.ledger) { Ok(result) => result, @@ -2157,43 +2366,11 @@ async fn render_preview_png( u64::from(load_epoch), u64::from(scene_generation), ); - let canvas_bytes = canvas_charge( - u64::from(width), - u64::from(height), - INITIAL_CANVAS_ATTACHMENTS, - INITIAL_CANVAS_SAMPLES, - ) - .map_err(|error| format!("preview canvas admission formula: {error}"))?; - let canvas_reservation = ledger - .reserve( - preview_owner, - ResourceClass::CanvasBackbuffers, - canvas_bytes, - ) - .map_err(|error| format!("preview canvas admission: {error}"))?; + let target = create_preview_target(ledger, preview_owner, width, height)?; let (scene_reservations, texture_reservations) = admit_scene(ledger, preview_owner, model)?; - let canvas = web_sys::OffscreenCanvas::new(width, height) - .map_err(|e| format!("preview canvas: {e:?}"))?; - let opts = js_sys::Object::new(); - js_sys::Reflect::set( - &opts, - &"preserveDrawingBuffer".into(), - &JsValue::from_bool(true), - ) - .map_err(|e| format!("preview context options: {e:?}"))?; - let gl = canvas - .get_context_with_context_options("webgl2", &opts) - .map_err(|e| format!("preview context: {e:?}"))? - .ok_or_else(|| "preview WebGL2 context unavailable".to_string())? - .dyn_into::() - .map_err(|_| "preview context is not WebGL2".to_string())?; - - let glow_context = three_d::context::Context::from_webgl2_context(gl); - let context = three_d::Context::from_gl_context(Arc::new(glow_context)) - .map_err(|e| format!("preview three-d context: {e}"))?; - let (model, camera, _control) = build_scene(&context, model, width, height)?; - let (ambient_light, key_light, fill_light) = standard_lights(&context); - let screen = three_d::RenderTarget::screen(&context, width, height); + let (model, camera, _control) = build_scene(&target.context, model, width, height)?; + let (ambient_light, key_light, fill_light) = standard_lights(&target.context); + let screen = three_d::RenderTarget::screen(&target.context, width, height); let _ = screen .clear(three_d::ClearState::color_and_depth( 0.78, 0.80, 0.84, 1.0, 1.0, @@ -2204,10 +2381,10 @@ async fn render_preview_png( &[&ambient_light, &key_light, &fill_light], ); - let png = offscreen_canvas_png_bytes(&canvas).await; + let png = offscreen_canvas_png_bytes(&target.canvas).await; drop(texture_reservations); drop(scene_reservations); - drop(canvas_reservation); + drop(target.reservation); png } @@ -2409,7 +2586,9 @@ fn handle_command(state: &Rc>, cmd: RendererCommand) { return; }; let capability_allowed = polymodel_renderer_protocol::required_capability(&cmd) - .is_none_or(|capability| capabilities.contains(&capability)); + .is_none_or(|capability| capabilities.contains(&capability)) + && (!matches!(cmd, RendererCommand::StartCompoundPreview { .. }) + || capabilities.contains(&Capability::Preview)); if !capability_allowed { tracing::warn!("renderer command rejected by negotiated capabilities"); return; @@ -2435,6 +2614,17 @@ fn handle_command(state: &Rc>, cmd: RendererCommand) { expected_digest, ); } + RendererCommand::StartCompoundPreview { + request_id, + namespace, + identity, + plan, + width, + height, + } => { + drop(st); + handle_compound_preview(state, request_id, namespace, identity, plan, width, height); + } RendererCommand::StartCompoundLoad { namespace, identity, @@ -2443,6 +2633,15 @@ fn handle_command(state: &Rc>, cmd: RendererCommand) { if st.disposed { return; } + if namespace == RendererNamespace::Preview { + st.post_event(&RendererEvent::LoadFailed { + namespace, + identity, + code: LoadErrorCode::Install, + detail: "compound Preview requires StartCompoundPreview".to_owned(), + }); + return; + } let reducer = match namespace { RendererNamespace::Interactive => &mut st.load_reducer, RendererNamespace::Preview => &mut st.preview_reducer, @@ -2539,6 +2738,20 @@ fn handle_command(state: &Rc>, cmd: RendererCommand) { handle_render_preview_image(state, request_id, format, primary_url, width, height); } RendererCommand::Resize { css_w, css_h, dpr } => { + if let Some(slot) = st.preview_abort.take() { + slot.controller.abort(); + if let Some(identity) = st.preview_reducer.current_identity() { + let _ = st.preview_reducer.reduce(LoadEvent::Cancel { + namespace: RendererNamespace::Preview, + identity, + candidate: None, + }); + st.post_event(&RendererEvent::LoadCancelled { + namespace: RendererNamespace::Preview, + identity, + }); + } + } let _ = st.load_reducer.reduce(LoadEvent::Resize { namespace: RendererNamespace::Interactive, }); @@ -2621,7 +2834,15 @@ fn handle_command(state: &Rc>, cmd: RendererCommand) { st.interactive_compound_abort .take() .map(|slot| slot.controller.abort()); - st.preview_abort.take().map(|slot| slot.controller.abort()); + if let Some(slot) = st.preview_abort.take() { + slot.controller.abort(); + if let Some(identity) = st.preview_reducer.current_identity() { + st.post_event(&RendererEvent::LoadCancelled { + namespace: RendererNamespace::Preview, + identity, + }); + } + } st.pending_scene = None; st.disposed = true; st.ledger.retire(owner(st.load_epoch, st.scene_generation)); -- 2.51.2