diff --git a/lightningbeam-ui/lightningbeam-core/src/actions/paint_bucket.rs b/lightningbeam-ui/lightningbeam-core/src/actions/paint_bucket.rs index 51b8160..d71ae55 100644 --- a/lightningbeam-ui/lightningbeam-core/src/actions/paint_bucket.rs +++ b/lightningbeam-ui/lightningbeam-core/src/actions/paint_bucket.rs @@ -83,6 +83,15 @@ impl Action for PaintBucketAction { continue; } + // Check if the path is closed - winding number only makes sense for closed paths + use vello::kurbo::PathEl; + let is_closed = shape.path().elements().iter().any(|el| matches!(el, PathEl::ClosePath)); + + if !is_closed { + // Skip non-closed paths - can't use winding number test + continue; + } + // Apply the object's transform to get the transformed path let transform_affine = object.transform.to_affine(); diff --git a/lightningbeam-ui/lightningbeam-editor/src/main.rs b/lightningbeam-ui/lightningbeam-editor/src/main.rs index d857e40..cd2201e 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/main.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/main.rs @@ -253,6 +253,7 @@ struct EditorApp { selected_tool: Tool, // Currently selected drawing tool fill_color: egui::Color32, // Fill color for drawing stroke_color: egui::Color32, // Stroke color for drawing + active_color_mode: panes::ColorMode, // Which color (fill/stroke) was last interacted with pane_instances: HashMap, // Pane instances per path menu_system: Option, // Native menu system for event checking pending_view_action: Option, // Pending view action (zoom, recenter) to be handled by hovered pane @@ -311,6 +312,7 @@ impl EditorApp { selected_tool: Tool::Select, // Default tool fill_color: egui::Color32::from_rgb(100, 100, 255), // Default blue fill stroke_color: egui::Color32::from_rgb(0, 0, 0), // Default black stroke + active_color_mode: panes::ColorMode::default(), // Default to fill color pane_instances: HashMap::new(), // Initialize empty, panes created on-demand menu_system, pending_view_action: None, @@ -637,6 +639,7 @@ impl eframe::App for EditorApp { &mut self.selected_tool, &mut self.fill_color, &mut self.stroke_color, + &mut self.active_color_mode, &mut self.pane_instances, &Vec::new(), // Root path &mut self.pending_view_action, @@ -727,6 +730,7 @@ fn render_layout_node( selected_tool: &mut Tool, fill_color: &mut egui::Color32, stroke_color: &mut egui::Color32, + active_color_mode: &mut panes::ColorMode, pane_instances: &mut HashMap, path: &NodePath, pending_view_action: &mut Option, @@ -744,7 +748,7 @@ fn render_layout_node( ) { match node { LayoutNode::Pane { name } => { - render_pane(ui, name, rect, selected_pane, layout_action, split_preview_mode, icon_cache, tool_icon_cache, selected_tool, fill_color, stroke_color, pane_instances, path, pending_view_action, fallback_pane_priority, pending_handlers, theme, action_executor, selection, active_layer_id, tool_state, pending_actions, draw_simplify_mode, rdp_tolerance, schneider_max_error); + render_pane(ui, name, rect, selected_pane, layout_action, split_preview_mode, icon_cache, tool_icon_cache, selected_tool, fill_color, stroke_color, active_color_mode, pane_instances, path, pending_view_action, fallback_pane_priority, pending_handlers, theme, action_executor, selection, active_layer_id, tool_state, pending_actions, draw_simplify_mode, rdp_tolerance, schneider_max_error); } LayoutNode::HorizontalGrid { percent, children } => { // Handle dragging @@ -782,6 +786,7 @@ fn render_layout_node( selected_tool, fill_color, stroke_color, + active_color_mode, pane_instances, &left_path, pending_view_action, @@ -814,6 +819,7 @@ fn render_layout_node( selected_tool, fill_color, stroke_color, + active_color_mode, pane_instances, &right_path, pending_view_action, @@ -938,6 +944,7 @@ fn render_layout_node( selected_tool, fill_color, stroke_color, + active_color_mode, pane_instances, &top_path, pending_view_action, @@ -970,6 +977,7 @@ fn render_layout_node( selected_tool, fill_color, stroke_color, + active_color_mode, pane_instances, &bottom_path, pending_view_action, @@ -1074,6 +1082,7 @@ fn render_pane( selected_tool: &mut Tool, fill_color: &mut egui::Color32, stroke_color: &mut egui::Color32, + active_color_mode: &mut panes::ColorMode, pane_instances: &mut HashMap, path: &NodePath, pending_view_action: &mut Option, @@ -1258,6 +1267,7 @@ fn render_pane( selected_tool, fill_color, stroke_color, + active_color_mode, pending_view_action, fallback_pane_priority, theme, @@ -1301,6 +1311,7 @@ fn render_pane( selected_tool, fill_color, stroke_color, + active_color_mode, pending_view_action, fallback_pane_priority, theme, diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/mod.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/mod.rs index 96ac1d3..e8f7cd2 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/mod.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/mod.rs @@ -27,6 +27,19 @@ pub mod piano_roll; pub mod node_editor; pub mod preset_browser; +/// Which color mode is active for the eyedropper tool +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ColorMode { + Fill, + Stroke, +} + +impl Default for ColorMode { + fn default() -> Self { + ColorMode::Fill + } +} + /// Shared state that all panes can access pub struct SharedPaneState<'a> { pub tool_icon_cache: &'a mut crate::ToolIconCache, @@ -34,6 +47,8 @@ pub struct SharedPaneState<'a> { pub selected_tool: &'a mut Tool, pub fill_color: &'a mut egui::Color32, pub stroke_color: &'a mut egui::Color32, + /// Tracks which color (fill or stroke) was last interacted with, for eyedropper tool + pub active_color_mode: &'a mut ColorMode, pub pending_view_action: &'a mut Option, /// Tracks the priority of the best fallback pane for view actions /// Lower number = higher priority. None = no fallback pane seen yet diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs index 7ef6116..970cb2a 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs @@ -4,7 +4,7 @@ use eframe::egui; use super::{NodePath, PaneRenderer, SharedPaneState}; -use std::sync::{Arc, Mutex}; +use std::sync::{Arc, Mutex, OnceLock}; use vello::kurbo::Shape; /// Shared Vello resources (created once, reused by all Stage panes) @@ -163,7 +163,7 @@ impl InstanceVelloResources { sample_count: 1, dimension: wgpu::TextureDimension::D2, format: wgpu::TextureFormat::Rgba8Unorm, - usage: wgpu::TextureUsages::STORAGE_BINDING | wgpu::TextureUsages::TEXTURE_BINDING, + usage: wgpu::TextureUsages::STORAGE_BINDING | wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_SRC, view_formats: &[], }); @@ -205,6 +205,7 @@ struct VelloCallback { fill_color: egui::Color32, // Current fill color for previews stroke_color: egui::Color32, // Current stroke color for previews selected_tool: lightningbeam_core::tool::Tool, // Current tool for rendering mode-specific UI + eyedropper_request: Option<(egui::Pos2, super::ColorMode)>, // Pending eyedropper sample } impl VelloCallback { @@ -221,8 +222,9 @@ impl VelloCallback { fill_color: egui::Color32, stroke_color: egui::Color32, selected_tool: lightningbeam_core::tool::Tool, + eyedropper_request: Option<(egui::Pos2, super::ColorMode)>, ) -> Self { - Self { rect, pan_offset, zoom, instance_id, document, tool_state, active_layer_id, drag_delta, selection, fill_color, stroke_color, selected_tool } + Self { rect, pan_offset, zoom, instance_id, document, tool_state, active_layer_id, drag_delta, selection, fill_color, stroke_color, selected_tool, eyedropper_request } } } @@ -921,6 +923,93 @@ impl egui_wgpu::CallbackTrait for VelloCallback { } } + // Handle eyedropper pixel sampling if requested + if let Some((screen_pos, color_mode)) = self.eyedropper_request { + if let Some(texture) = &instance_resources.texture { + // Convert screen position to texture coordinates + let tex_x = ((screen_pos.x - self.rect.min.x).max(0.0).min(self.rect.width())) as u32; + let tex_y = ((screen_pos.y - self.rect.min.y).max(0.0).min(self.rect.height())) as u32; + + // Clamp to texture bounds + if tex_x < width && tex_y < height { + // Create a staging buffer to read back the pixel + let bytes_per_pixel = 4; // RGBA8 + // Align bytes_per_row to 256 (wgpu::COPY_BYTES_PER_ROW_ALIGNMENT) + let bytes_per_row_alignment = 256u32; + let bytes_per_row = bytes_per_row_alignment; // Single pixel, use minimum alignment + let buffer_size = bytes_per_row as u64; + + let staging_buffer = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("eyedropper_staging_buffer"), + size: buffer_size, + usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + + // Create a command encoder for the copy operation + let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { + label: Some("eyedropper_copy_encoder"), + }); + + // Copy the pixel from texture to staging buffer + encoder.copy_texture_to_buffer( + wgpu::ImageCopyTexture { + texture, + mip_level: 0, + origin: wgpu::Origin3d { x: tex_x, y: tex_y, z: 0 }, + aspect: wgpu::TextureAspect::All, + }, + wgpu::ImageCopyBuffer { + buffer: &staging_buffer, + layout: wgpu::ImageDataLayout { + offset: 0, + bytes_per_row: Some(bytes_per_row), + rows_per_image: Some(1), + }, + }, + wgpu::Extent3d { width: 1, height: 1, depth_or_array_layers: 1 }, + ); + + // Submit the copy command + queue.submit(Some(encoder.finish())); + + // Map the buffer and read the pixel (synchronous for simplicity) + let buffer_slice = staging_buffer.slice(..); + let (sender, receiver) = std::sync::mpsc::channel(); + buffer_slice.map_async(wgpu::MapMode::Read, move |result| { + sender.send(result).ok(); + }); + + // Poll the device to complete the mapping + device.poll(wgpu::Maintain::Wait); + + // Read the pixel data + if receiver.recv().is_ok() { + let data = buffer_slice.get_mapped_range(); + if data.len() >= 4 { + let r = data[0]; + let g = data[1]; + let b = data[2]; + let a = data[3]; + + let sampled_color = egui::Color32::from_rgba_unmultiplied(r, g, b, a); + + // Store the result in the global eyedropper results + if let Ok(mut results) = EYEDROPPER_RESULTS + .get_or_init(|| Arc::new(Mutex::new(std::collections::HashMap::new()))) + .lock() { + results.insert(self.instance_id, (sampled_color, color_mode)); + } + } + } + + // Unmap the buffer + let _ = buffer_slice; + staging_buffer.unmap(); + } + } + } + Vec::new() } @@ -970,11 +1059,16 @@ pub struct StagePane { last_pan_pos: Option, // Unique ID for this stage instance (for Vello resources) instance_id: u64, + // Eyedropper state + pending_eyedropper_sample: Option<(egui::Pos2, super::ColorMode)>, } // Global counter for generating unique instance IDs static INSTANCE_COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); +// Global storage for eyedropper results (instance_id -> (color, color_mode)) +static EYEDROPPER_RESULTS: OnceLock>>> = OnceLock::new(); + impl StagePane { pub fn new() -> Self { let instance_id = INSTANCE_COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed); @@ -984,6 +1078,7 @@ impl StagePane { is_panning: false, last_pan_pos: None, instance_id, + pending_eyedropper_sample: None, } } @@ -1616,6 +1711,19 @@ impl StagePane { } } + fn handle_eyedropper_tool( + &mut self, + _ui: &mut egui::Ui, + response: &egui::Response, + screen_pos: egui::Pos2, + shared: &mut SharedPaneState, + ) { + // On click, store the screen position and color mode for sampling + if response.clicked() { + self.pending_eyedropper_sample = Some((screen_pos, *shared.active_color_mode)); + } + } + /// Create a rectangle path from lines (easier for curve editing later) fn create_rectangle_path(width: f64, height: f64) -> vello::kurbo::BezPath { use vello::kurbo::{BezPath, Point}; @@ -2880,6 +2988,9 @@ impl StagePane { Tool::Polygon => { self.handle_polygon_tool(ui, &response, world_pos, shift_held, ctrl_held, shared); } + Tool::Eyedropper => { + self.handle_eyedropper_tool(ui, &response, mouse_pos, shared); + } _ => { // Other tools not implemented yet } @@ -2951,6 +3062,25 @@ impl PaneRenderer for StagePane { _path: &NodePath, shared: &mut SharedPaneState, ) { + // Check for completed eyedropper samples from GPU readback and apply them + if let Ok(mut results) = EYEDROPPER_RESULTS + .get_or_init(|| Arc::new(Mutex::new(std::collections::HashMap::new()))) + .lock() { + if let Some((color, color_mode)) = results.remove(&self.instance_id) { + // Apply the sampled color to the appropriate mode + match color_mode { + super::ColorMode::Fill => { + *shared.fill_color = color; + } + super::ColorMode::Stroke => { + *shared.stroke_color = color; + } + } + // Clear the pending request since we've processed it + self.pending_eyedropper_sample = None; + } + } + // Handle input for pan/zoom and tool controls self.handle_input(ui, rect, shared); @@ -3040,6 +3170,7 @@ impl PaneRenderer for StagePane { *shared.fill_color, *shared.stroke_color, *shared.selected_tool, + self.pending_eyedropper_sample, ); let cb = egui_wgpu::Callback::new_paint_callback( diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/toolbar.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/toolbar.rs index c426795..e618f07 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/toolbar.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/toolbar.rs @@ -156,7 +156,11 @@ impl PaneRenderer for ToolbarPane { // Show fill color picker popup egui::popup::popup_below_widget(ui, fill_button_id, &fill_response, egui::popup::PopupCloseBehavior::CloseOnClickOutside, |ui: &mut egui::Ui| { - egui::color_picker::color_picker_color32(ui, shared.fill_color, egui::color_picker::Alpha::OnlyBlend); + let changed = egui::color_picker::color_picker_color32(ui, shared.fill_color, egui::color_picker::Alpha::OnlyBlend); + // Track that the user interacted with the fill color + if changed { + *shared.active_color_mode = super::ColorMode::Fill; + } }); y += color_button_size + button_spacing; @@ -188,7 +192,11 @@ impl PaneRenderer for ToolbarPane { // Show stroke color picker popup egui::popup::popup_below_widget(ui, stroke_button_id, &stroke_response, egui::popup::PopupCloseBehavior::CloseOnClickOutside, |ui: &mut egui::Ui| { - egui::color_picker::color_picker_color32(ui, shared.stroke_color, egui::color_picker::Alpha::OnlyBlend); + let changed = egui::color_picker::color_picker_color32(ui, shared.stroke_color, egui::color_picker::Alpha::OnlyBlend); + // Track that the user interacted with the stroke color + if changed { + *shared.active_color_mode = super::ColorMode::Stroke; + } }); }