From 1fcad0355d91092fc0cd5a80f5c5efa0b5689392 Mon Sep 17 00:00:00 2001 From: Skyler Lehmkuhl Date: Tue, 23 Dec 2025 09:36:54 -0500 Subject: [PATCH] handle preview rendering during shape editing --- .../lightningbeam-core/src/renderer.rs | 27 +++- .../tests/rendering_integration_test.rs | 39 +++-- .../src/export/video_exporter.rs | 2 + .../lightningbeam-editor/src/panes/stage.rs | 143 ++++++++++++++++-- 4 files changed, 180 insertions(+), 31 deletions(-) diff --git a/lightningbeam-ui/lightningbeam-core/src/renderer.rs b/lightningbeam-ui/lightningbeam-core/src/renderer.rs index 17015d5..5b7c7c1 100644 --- a/lightningbeam-ui/lightningbeam-core/src/renderer.rs +++ b/lightningbeam-ui/lightningbeam-core/src/renderer.rs @@ -178,6 +178,7 @@ pub fn render_document_for_compositing( base_transform: Affine, image_cache: &mut ImageCache, video_manager: &std::sync::Arc>, + skip_instance_id: Option, ) -> CompositeRenderResult { let time = document.current_time; @@ -211,6 +212,7 @@ pub fn render_document_for_compositing( base_transform, image_cache, video_manager, + skip_instance_id, ); rendered_layers.push(rendered); } @@ -235,6 +237,7 @@ pub fn render_layer_isolated( base_transform: Affine, image_cache: &mut ImageCache, video_manager: &std::sync::Arc>, + skip_instance_id: Option, ) -> RenderedLayer { let layer_id = layer.id(); let opacity = layer.opacity() as f32; @@ -256,6 +259,7 @@ pub fn render_layer_isolated( 1.0, // Full opacity - layer opacity handled in compositing image_cache, video_manager, + skip_instance_id, ); rendered.has_content = !vector_layer.shape_instances.is_empty() || !vector_layer.clip_instances.is_empty(); @@ -302,6 +306,7 @@ fn render_vector_layer_to_scene( parent_opacity: f64, image_cache: &mut ImageCache, video_manager: &std::sync::Arc>, + skip_instance_id: Option, ) { // Render using the existing function but to this isolated scene render_vector_layer( @@ -313,6 +318,7 @@ fn render_vector_layer_to_scene( parent_opacity, image_cache, video_manager, + skip_instance_id, ); } @@ -349,7 +355,7 @@ pub fn render_document( image_cache: &mut ImageCache, video_manager: &std::sync::Arc>, ) { - render_document_with_transform(document, scene, Affine::IDENTITY, image_cache, video_manager); + render_document_with_transform(document, scene, Affine::IDENTITY, image_cache, video_manager, None); } /// Render a document to a Vello scene with a base transform @@ -360,13 +366,14 @@ pub fn render_document_with_transform( base_transform: Affine, image_cache: &mut ImageCache, video_manager: &std::sync::Arc>, + skip_instance_id: Option, ) { // 1. Draw background render_background(document, scene, base_transform); // 2. Recursively render the root graphics object at current time let time = document.current_time; - render_graphics_object(document, time, scene, base_transform, image_cache, video_manager); + render_graphics_object(document, time, scene, base_transform, image_cache, video_manager, skip_instance_id); } /// Draw the document background @@ -393,6 +400,7 @@ fn render_graphics_object( base_transform: Affine, image_cache: &mut ImageCache, video_manager: &std::sync::Arc>, + skip_instance_id: Option, ) { // Check if any layers are soloed let any_soloed = document.visible_layers().any(|layer| layer.soloed()); @@ -405,11 +413,11 @@ fn render_graphics_object( if any_soloed { // Only render soloed layers when solo is active if layer.soloed() { - render_layer(document, time, layer, scene, base_transform, 1.0, image_cache, video_manager); + render_layer(document, time, layer, scene, base_transform, 1.0, image_cache, video_manager, skip_instance_id); } } else { // Render all visible layers when no solo is active - render_layer(document, time, layer, scene, base_transform, 1.0, image_cache, video_manager); + render_layer(document, time, layer, scene, base_transform, 1.0, image_cache, video_manager, skip_instance_id); } } } @@ -424,10 +432,11 @@ fn render_layer( parent_opacity: f64, image_cache: &mut ImageCache, video_manager: &std::sync::Arc>, + skip_instance_id: Option, ) { match layer { AnyLayer::Vector(vector_layer) => { - render_vector_layer(document, time, vector_layer, scene, base_transform, parent_opacity, image_cache, video_manager) + render_vector_layer(document, time, vector_layer, scene, base_transform, parent_opacity, image_cache, video_manager, skip_instance_id) } AnyLayer::Audio(_) => { // Audio layers don't render visually @@ -580,7 +589,7 @@ fn render_clip_instance( if !layer_node.data.visible() { continue; } - render_layer(document, clip_time, &layer_node.data, scene, instance_transform, clip_opacity, image_cache, video_manager); + render_layer(document, clip_time, &layer_node.data, scene, instance_transform, clip_opacity, image_cache, video_manager, None); } } @@ -761,6 +770,7 @@ fn render_vector_layer( parent_opacity: f64, image_cache: &mut ImageCache, video_manager: &std::sync::Arc>, + skip_instance_id: Option, ) { // Cascade opacity: parent_opacity × layer.opacity let layer_opacity = parent_opacity * layer.layer.opacity; @@ -772,6 +782,11 @@ fn render_vector_layer( // Render each shape instance in the layer for shape_instance in &layer.shape_instances { + // Skip this instance if it's being edited + if Some(shape_instance.id) == skip_instance_id { + continue; + } + // Get the shape for this instance let Some(shape) = layer.get_shape(&shape_instance.shape_id) else { continue; diff --git a/lightningbeam-ui/lightningbeam-core/tests/rendering_integration_test.rs b/lightningbeam-ui/lightningbeam-core/tests/rendering_integration_test.rs index 60cba2a..60c3f0d 100644 --- a/lightningbeam-ui/lightningbeam-core/tests/rendering_integration_test.rs +++ b/lightningbeam-ui/lightningbeam-core/tests/rendering_integration_test.rs @@ -54,9 +54,10 @@ fn test_render_empty_document() { let document = Document::new("Empty"); let mut scene = Scene::new(); let mut image_cache = ImageCache::new(); + let video_manager = std::sync::Arc::new(std::sync::Mutex::new(lightningbeam_core::video::VideoManager::new())); // Should not panic - render_document(&document, &mut scene, &mut image_cache); + render_document(&document, &mut scene, &mut image_cache, &video_manager); } #[test] @@ -64,9 +65,10 @@ fn test_render_document_with_shapes() { let (document, _ids) = setup_rendering_document(); let mut scene = Scene::new(); let mut image_cache = ImageCache::new(); + let video_manager = std::sync::Arc::new(std::sync::Mutex::new(lightningbeam_core::video::VideoManager::new())); // Should render all 3 layers without error - render_document(&document, &mut scene, &mut image_cache); + render_document(&document, &mut scene, &mut image_cache, &video_manager); } #[test] @@ -74,10 +76,11 @@ fn test_render_with_transform() { let (document, _ids) = setup_rendering_document(); let mut scene = Scene::new(); let mut image_cache = ImageCache::new(); + let video_manager = std::sync::Arc::new(std::sync::Mutex::new(lightningbeam_core::video::VideoManager::new())); // Render with zoom and pan let transform = Affine::translate((100.0, 50.0)) * Affine::scale(2.0); - render_document_with_transform(&document, &mut scene, transform, &mut image_cache); + render_document_with_transform(&document, &mut scene, transform, &mut image_cache, &video_manager, None); } #[test] @@ -102,7 +105,8 @@ fn test_render_solo_single_layer() { // Render should work let mut scene = Scene::new(); let mut image_cache = ImageCache::new(); - render_document(&document, &mut scene, &mut image_cache); + let video_manager = std::sync::Arc::new(std::sync::Mutex::new(lightningbeam_core::video::VideoManager::new())); + render_document(&document, &mut scene, &mut image_cache, &video_manager); } #[test] @@ -126,7 +130,8 @@ fn test_render_solo_multiple_layers() { let mut scene = Scene::new(); let mut image_cache = ImageCache::new(); - render_document(&document, &mut scene, &mut image_cache); + let video_manager = std::sync::Arc::new(std::sync::Mutex::new(lightningbeam_core::video::VideoManager::new())); + render_document(&document, &mut scene, &mut image_cache, &video_manager); } #[test] @@ -143,7 +148,8 @@ fn test_render_hidden_layer_not_rendered() { let mut scene = Scene::new(); let mut image_cache = ImageCache::new(); - render_document(&document, &mut scene, &mut image_cache); + let video_manager = std::sync::Arc::new(std::sync::Mutex::new(lightningbeam_core::video::VideoManager::new())); + render_document(&document, &mut scene, &mut image_cache, &video_manager); } #[test] @@ -168,7 +174,8 @@ fn test_render_with_layer_opacity() { let mut scene = Scene::new(); let mut image_cache = ImageCache::new(); - render_document(&document, &mut scene, &mut image_cache); + let video_manager = std::sync::Arc::new(std::sync::Mutex::new(lightningbeam_core::video::VideoManager::new())); + render_document(&document, &mut scene, &mut image_cache, &video_manager); } #[test] @@ -206,7 +213,8 @@ fn test_render_with_clip_instances() { let mut scene = Scene::new(); let mut image_cache = ImageCache::new(); - render_document(&document, &mut scene, &mut image_cache); + let video_manager = std::sync::Arc::new(std::sync::Mutex::new(lightningbeam_core::video::VideoManager::new())); + render_document(&document, &mut scene, &mut image_cache, &video_manager); } #[test] @@ -232,7 +240,8 @@ fn test_render_clip_instance_outside_time_range() { // Clip shouldn't render (it hasn't started yet) let mut scene = Scene::new(); let mut image_cache = ImageCache::new(); - render_document(&document, &mut scene, &mut image_cache); + let video_manager = std::sync::Arc::new(std::sync::Mutex::new(lightningbeam_core::video::VideoManager::new())); + render_document(&document, &mut scene, &mut image_cache, &video_manager); } #[test] @@ -252,7 +261,8 @@ fn test_render_all_layers_hidden() { // Should still render (just background) let mut scene = Scene::new(); let mut image_cache = ImageCache::new(); - render_document(&document, &mut scene, &mut image_cache); + let video_manager = std::sync::Arc::new(std::sync::Mutex::new(lightningbeam_core::video::VideoManager::new())); + render_document(&document, &mut scene, &mut image_cache, &video_manager); } #[test] @@ -280,7 +290,8 @@ fn test_render_solo_hidden_layer_interaction() { let mut scene = Scene::new(); let mut image_cache = ImageCache::new(); - render_document(&document, &mut scene, &mut image_cache); + let video_manager = std::sync::Arc::new(std::sync::Mutex::new(lightningbeam_core::video::VideoManager::new())); + render_document(&document, &mut scene, &mut image_cache, &video_manager); } #[test] @@ -290,18 +301,20 @@ fn test_render_background_color() { let mut scene = Scene::new(); let mut image_cache = ImageCache::new(); - render_document(&document, &mut scene, &mut image_cache); + let video_manager = std::sync::Arc::new(std::sync::Mutex::new(lightningbeam_core::video::VideoManager::new())); + render_document(&document, &mut scene, &mut image_cache, &video_manager); } #[test] fn test_render_at_different_times() { let (mut document, _ids) = setup_rendering_document(); let mut image_cache = ImageCache::new(); + let video_manager = std::sync::Arc::new(std::sync::Mutex::new(lightningbeam_core::video::VideoManager::new())); // Render at different times for time in [0.0, 0.5, 1.0, 2.5, 5.0, 10.0] { document.set_time(time); let mut scene = Scene::new(); - render_document(&document, &mut scene, &mut image_cache); + render_document(&document, &mut scene, &mut image_cache, &video_manager); } } diff --git a/lightningbeam-ui/lightningbeam-editor/src/export/video_exporter.rs b/lightningbeam-ui/lightningbeam-editor/src/export/video_exporter.rs index 4228e43..d8263fc 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/export/video_exporter.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/export/video_exporter.rs @@ -746,6 +746,7 @@ pub fn render_frame_to_rgba_hdr( base_transform, image_cache, video_manager, + None, // No skipping during export ); // Buffer specs for layer rendering @@ -1131,6 +1132,7 @@ pub fn render_frame_to_gpu_rgba( base_transform, image_cache, video_manager, + None, // No skipping during export ); // Buffer specs for layer rendering diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs index 5b5ed60..b69e774 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs @@ -358,6 +358,7 @@ struct VelloCallback { eyedropper_request: Option<(egui::Pos2, super::ColorMode)>, // Pending eyedropper sample playback_time: f64, // Current playback time for animation evaluation video_manager: std::sync::Arc>, + shape_editing_cache: Option, // Cache for vector editing preview } impl VelloCallback { @@ -378,8 +379,9 @@ impl VelloCallback { eyedropper_request: Option<(egui::Pos2, super::ColorMode)>, playback_time: f64, video_manager: std::sync::Arc>, + shape_editing_cache: Option, ) -> Self { - Self { rect, pan_offset, zoom, instance_id, document, tool_state, active_layer_id, drag_delta, selection, fill_color, stroke_color, stroke_width, selected_tool, eyedropper_request, playback_time, video_manager } + Self { rect, pan_offset, zoom, instance_id, document, tool_state, active_layer_id, drag_delta, selection, fill_color, stroke_color, stroke_width, selected_tool, eyedropper_request, playback_time, video_manager, shape_editing_cache } } } @@ -440,11 +442,16 @@ impl egui_wgpu::CallbackTrait for VelloCallback { instance_resources.ensure_hdr_texture(device, &shared, width, height); let mut image_cache = shared.image_cache.lock().unwrap(); + + // Skip rendering the shape instance being edited (for vector editing preview) + let skip_instance_id = self.shape_editing_cache.as_ref().map(|cache| cache.instance_id); + let composite_result = lightningbeam_core::renderer::render_document_for_compositing( &self.document, camera_transform, &mut image_cache, &shared.video_manager, + skip_instance_id, ); drop(image_cache); @@ -679,12 +686,17 @@ impl egui_wgpu::CallbackTrait for VelloCallback { // Legacy single-scene rendering let mut scene = vello::Scene::new(); let mut image_cache = shared.image_cache.lock().unwrap(); + + // Skip rendering the shape instance being edited (for vector editing preview) + let skip_instance_id = self.shape_editing_cache.as_ref().map(|cache| cache.instance_id); + lightningbeam_core::renderer::render_document_with_transform( &self.document, &mut scene, camera_transform, &mut image_cache, &shared.video_manager, + skip_instance_id, ); drop(image_cache); scene @@ -1199,6 +1211,59 @@ impl egui_wgpu::CallbackTrait for VelloCallback { } } + // 8. Draw vector editing preview + if let Some(cache) = &self.shape_editing_cache { + use lightningbeam_core::bezpath_editing::rebuild_bezpath; + + // Rebuild the path from the modified editable curves + let preview_path = rebuild_bezpath(&cache.editable_data); + + // Get the layer first, then the shape from the layer + if let Some(layer) = (*self.document).root.get_child(&cache.layer_id) { + if let lightningbeam_core::layer::AnyLayer::Vector(vector_layer) = layer { + if let Some(shape) = vector_layer.get_shape(&cache.shape_id) { + let transform = camera_transform * cache.local_to_world; + + // Render fill with FULL OPACITY (same as original) + if let Some(fill_color) = &shape.fill_color { + scene.fill( + shape.fill_rule.into(), + transform, + fill_color.to_peniko(), + None, + &preview_path, + ); + } + + // Render stroke with FULL OPACITY (same as original) + if let Some(stroke_color) = &shape.stroke_color { + if let Some(stroke_style) = &shape.stroke_style { + scene.stroke( + &stroke_style.to_stroke(), + transform, + stroke_color.to_peniko(), + None, + &preview_path, + ); + } + } + + // If shape has neither fill nor stroke, render with default stroke + if shape.fill_color.is_none() && shape.stroke_color.is_none() { + let default_stroke = vello::kurbo::Stroke::new(2.0); + scene.stroke( + &default_stroke, + transform, + vello::peniko::Color::from_rgba8(100, 150, 255, 255), + None, + &preview_path, + ); + } + } + } + } + } + // 6. Draw transform tool handles (when Transform tool is active) use lightningbeam_core::tool::Tool; let should_draw_transform_handles = matches!(self.selected_tool, Tool::Transform) && !self.selection.is_empty(); @@ -1683,15 +1748,24 @@ impl egui_wgpu::CallbackTrait for VelloCallback { // The overlay scene was built above with all the UI elements if let Some(hdr_view) = &instance_resources.hdr_texture_view { let mut buffer_pool = shared.buffer_pool.lock().unwrap(); - let overlay_spec = lightningbeam_core::gpu::BufferSpec::new( + let overlay_srgb_spec = lightningbeam_core::gpu::BufferSpec::new( width, height, lightningbeam_core::gpu::BufferFormat::Rgba8Srgb, ); - let overlay_handle = buffer_pool.acquire(device, overlay_spec); + let overlay_hdr_spec = lightningbeam_core::gpu::BufferSpec::new( + width, + height, + lightningbeam_core::gpu::BufferFormat::Rgba16Float, + ); + let overlay_srgb_handle = buffer_pool.acquire(device, overlay_srgb_spec); + let overlay_hdr_handle = buffer_pool.acquire(device, overlay_hdr_spec); - if let Some(overlay_view) = buffer_pool.get_view(overlay_handle) { - // Render overlay scene to temp buffer + if let (Some(overlay_srgb_view), Some(overlay_hdr_view)) = ( + buffer_pool.get_view(overlay_srgb_handle), + buffer_pool.get_view(overlay_hdr_handle), + ) { + // Render overlay scene to sRGB buffer let overlay_params = vello::RenderParams { base_color: vello::peniko::Color::TRANSPARENT, width, @@ -1700,11 +1774,18 @@ impl egui_wgpu::CallbackTrait for VelloCallback { }; if let Ok(mut renderer) = shared.renderer.lock() { - renderer.render_to_texture(device, queue, &scene, overlay_view, &overlay_params).ok(); + renderer.render_to_texture(device, queue, &scene, overlay_srgb_view, &overlay_params).ok(); } - // Composite overlay onto HDR texture (sRGB→linear conversion happens in compositor) - let overlay_layer = lightningbeam_core::gpu::CompositorLayer::normal(overlay_handle, 1.0); + // Convert sRGB to linear HDR (same as main document layers) + let mut convert_encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { + label: Some("overlay_srgb_to_linear_encoder"), + }); + shared.srgb_to_linear.convert(device, &mut convert_encoder, overlay_srgb_view, overlay_hdr_view); + queue.submit(Some(convert_encoder.finish())); + + // Composite overlay onto HDR texture + let overlay_layer = lightningbeam_core::gpu::CompositorLayer::normal(overlay_hdr_handle, 1.0); let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("overlay_composite_encoder"), }); @@ -1720,7 +1801,8 @@ impl egui_wgpu::CallbackTrait for VelloCallback { queue.submit(Some(encoder.finish())); } - buffer_pool.release(overlay_handle); + buffer_pool.release(overlay_srgb_handle); + buffer_pool.release(overlay_hdr_handle); drop(buffer_pool); } @@ -1916,7 +1998,10 @@ pub struct StagePane { } /// Cached data for editing a shape +#[derive(Clone)] struct ShapeEditingCache { + /// The layer ID containing the shape being edited + layer_id: uuid::Uuid, /// The shape ID being edited shape_id: uuid::Uuid, /// The shape instance ID being edited @@ -2428,6 +2513,7 @@ impl StagePane { // Store editing cache self.shape_editing_cache = Some(ShapeEditingCache { + layer_id: active_layer_id, shape_id: shape_instance.shape_id, instance_id: shape_instance_id, editable_data: editable_data.clone(), @@ -2505,6 +2591,7 @@ impl StagePane { // Store editing cache self.shape_editing_cache = Some(ShapeEditingCache { + layer_id: active_layer_id, shape_id: shape_instance.shape_id, instance_id: shape_instance_id, editable_data, @@ -2865,6 +2952,7 @@ impl StagePane { // Store editing cache self.shape_editing_cache = Some(ShapeEditingCache { + layer_id: active_layer_id, shape_id: shape_instance.shape_id, instance_id: shape_instance_id, editable_data, @@ -5680,7 +5768,17 @@ impl StagePane { }; let local_to_world = instance.to_affine(); - let editable = extract_editable_curves(shape.path()); + + // Use modified curves from cache if this instance is being edited + let editable = if let Some(cache) = &self.shape_editing_cache { + if cache.instance_id == instance.id { + cache.editable_data.clone() + } else { + extract_editable_curves(shape.path()) + } + } else { + extract_editable_curves(shape.path()) + }; // Determine active element from tool state (being dragged) let (active_vertex, active_control_point) = match &*shared.tool_state { @@ -5779,7 +5877,17 @@ impl StagePane { if let Some(instance) = layer.shape_instances.iter().find(|i| i.id == shape_instance_id) { if let Some(shape) = layer.get_shape(&instance.shape_id) { let local_to_world = instance.to_affine(); - let editable = extract_editable_curves(shape.path()); + + // Use modified curves from cache if this instance is being edited + let editable = if let Some(cache) = &self.shape_editing_cache { + if cache.instance_id == instance.id { + cache.editable_data.clone() + } else { + extract_editable_curves(shape.path()) + } + } else { + extract_editable_curves(shape.path()) + }; if vertex_index < editable.vertices.len() { let vertex = &editable.vertices[vertex_index]; @@ -5808,7 +5916,17 @@ impl StagePane { if let Some(instance) = layer.shape_instances.iter().find(|i| i.id == shape_instance_id) { if let Some(shape) = layer.get_shape(&instance.shape_id) { let local_to_world = instance.to_affine(); - let editable = extract_editable_curves(shape.path()); + + // Use modified curves from cache if this instance is being edited + let editable = if let Some(cache) = &self.shape_editing_cache { + if cache.instance_id == instance.id { + cache.editable_data.clone() + } else { + extract_editable_curves(shape.path()) + } + } else { + extract_editable_curves(shape.path()) + }; if curve_index < editable.curves.len() { let curve = &editable.curves[curve_index]; @@ -6226,6 +6344,7 @@ impl PaneRenderer for StagePane { self.pending_eyedropper_sample, *shared.playback_time, shared.video_manager.clone(), + self.shape_editing_cache.clone(), ); let cb = egui_wgpu::Callback::new_paint_callback(