add eyedropper tool

This commit is contained in:
Skyler Lehmkuhl 2025-11-19 10:01:42 -05:00
parent a0875b1bc0
commit 2bb9aecf31
5 changed files with 180 additions and 6 deletions

View File

@ -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();

View File

@ -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<NodePath, PaneInstance>, // Pane instances per path
menu_system: Option<MenuSystem>, // Native menu system for event checking
pending_view_action: Option<MenuAction>, // 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<NodePath, PaneInstance>,
path: &NodePath,
pending_view_action: &mut Option<MenuAction>,
@ -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<NodePath, PaneInstance>,
path: &NodePath,
pending_view_action: &mut Option<MenuAction>,
@ -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,

View File

@ -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<crate::menu::MenuAction>,
/// Tracks the priority of the best fallback pane for view actions
/// Lower number = higher priority. None = no fallback pane seen yet

View File

@ -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<egui::Pos2>,
// 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<Arc<Mutex<std::collections::HashMap<u64, (egui::Color32, super::ColorMode)>>>> = 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(

View File

@ -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;
}
});
}