diff --git a/lightningbeam-ui/lightningbeam-core/src/actions/raster_fill.rs b/lightningbeam-ui/lightningbeam-core/src/actions/raster_fill.rs index 38a4358..0973ab0 100644 --- a/lightningbeam-ui/lightningbeam-core/src/actions/raster_fill.rs +++ b/lightningbeam-ui/lightningbeam-core/src/actions/raster_fill.rs @@ -12,6 +12,7 @@ pub struct RasterFillAction { buffer_after: Vec, width: u32, height: u32, + name: String, } impl RasterFillAction { @@ -23,7 +24,12 @@ impl RasterFillAction { width: u32, height: u32, ) -> Self { - Self { layer_id, time, buffer_before, buffer_after, width, height } + Self { layer_id, time, buffer_before, buffer_after, width, height, name: "Flood fill".to_string() } + } + + pub fn with_description(mut self, name: &str) -> Self { + self.name = name.to_string(); + self } } @@ -53,6 +59,6 @@ impl Action for RasterFillAction { } fn description(&self) -> String { - "Flood fill".to_string() + self.name.clone() } } diff --git a/lightningbeam-ui/lightningbeam-core/src/lib.rs b/lightningbeam-ui/lightningbeam-core/src/lib.rs index 858bc07..fcc11fd 100644 --- a/lightningbeam-ui/lightningbeam-core/src/lib.rs +++ b/lightningbeam-ui/lightningbeam-core/src/lib.rs @@ -52,6 +52,7 @@ pub mod webcam; pub mod raster_layer; pub mod brush_settings; pub mod brush_engine; +pub mod raster_draw; #[cfg(debug_assertions)] pub mod test_mode; diff --git a/lightningbeam-ui/lightningbeam-core/src/raster_draw.rs b/lightningbeam-ui/lightningbeam-core/src/raster_draw.rs new file mode 100644 index 0000000..7602341 --- /dev/null +++ b/lightningbeam-ui/lightningbeam-core/src/raster_draw.rs @@ -0,0 +1,194 @@ +//! CPU-side raster drawing primitives for geometric shapes on raster layers. +//! +//! All coordinates are in canvas pixels (f32). The pixel buffer is RGBA u8, +//! 4 bytes per pixel, row-major, top-left origin. + +/// RGBA color as `[R, G, B, A]` bytes. +pub type Rgba = [u8; 4]; + +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- + +/// Alpha-composite `color` (RGBA) onto `pixels[idx..idx+4]` with an extra +/// `coverage` factor (0.0 = transparent, 1.0 = full color alpha). +#[inline] +fn blend_at(pixels: &mut [u8], idx: usize, color: Rgba, coverage: f32) { + let a = (color[3] as f32 / 255.0) * coverage; + if a <= 0.0 { return; } + let inv = 1.0 - a; + pixels[idx] = (color[0] as f32 * a + pixels[idx] as f32 * inv) as u8; + pixels[idx + 1] = (color[1] as f32 * a + pixels[idx + 1] as f32 * inv) as u8; + pixels[idx + 2] = (color[2] as f32 * a + pixels[idx + 2] as f32 * inv) as u8; + pixels[idx + 3] = ((a + pixels[idx + 3] as f32 / 255.0 * inv) * 255.0).min(255.0) as u8; +} + +/// Write a pixel at integer canvas coordinates, clipped to canvas bounds. +#[inline] +fn put(pixels: &mut [u8], w: u32, h: u32, x: i32, y: i32, color: Rgba, coverage: f32) { + if x < 0 || y < 0 || x >= w as i32 || y >= h as i32 { return; } + let idx = (y as u32 * w + x as u32) as usize * 4; + blend_at(pixels, idx, color, coverage); +} + +/// Draw an anti-aliased filled disk at (`cx`, `cy`) with the given `radius`. +fn draw_disk(pixels: &mut [u8], w: u32, h: u32, cx: f32, cy: f32, radius: f32, color: Rgba) { + let r = (radius + 1.0) as i32; + let ix = cx as i32; + let iy = cy as i32; + for dy in -r..=r { + for dx in -r..=r { + let px = ix + dx; + let py = iy + dy; + let dist = ((px as f32 - cx).powi(2) + (py as f32 - cy).powi(2)).sqrt(); + let cov = (radius + 0.5 - dist).clamp(0.0, 1.0); + if cov > 0.0 { + put(pixels, w, h, px, py, color, cov); + } + } + } +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/// Draw a thick line from (`x0`, `y0`) to (`x1`, `y1`) by stamping +/// anti-aliased disks of radius `thickness / 2` at every half-pixel step. +pub fn draw_line( + pixels: &mut [u8], w: u32, h: u32, + x0: f32, y0: f32, x1: f32, y1: f32, + color: Rgba, thickness: f32, +) { + let radius = (thickness / 2.0).max(0.5); + let dx = x1 - x0; + let dy = y1 - y0; + let len = (dx * dx + dy * dy).sqrt(); + if len < 0.5 { + draw_disk(pixels, w, h, x0, y0, radius, color); + return; + } + let steps = ((len * 2.0).ceil() as i32).max(1); + for i in 0..=steps { + let t = i as f32 / steps as f32; + draw_disk(pixels, w, h, x0 + dx * t, y0 + dy * t, radius, color); + } +} + +/// Draw a rectangle with corners (`x0`, `y0`) and (`x1`, `y1`). +/// +/// `stroke` draws the four edges; `fill` fills the interior. Either may be +/// `None` to skip that part. +pub fn draw_rect( + pixels: &mut [u8], w: u32, h: u32, + x0: f32, y0: f32, x1: f32, y1: f32, + stroke: Option, fill: Option, thickness: f32, +) { + let (lx, rx) = if x0 <= x1 { (x0, x1) } else { (x1, x0) }; + let (ty, by) = if y0 <= y1 { (y0, y1) } else { (y1, y0) }; + + if let Some(fc) = fill { + let px0 = lx.ceil() as i32; + let py0 = ty.ceil() as i32; + let px1 = rx.floor() as i32; + let py1 = by.floor() as i32; + for py in py0..=py1 { + for px in px0..=px1 { + put(pixels, w, h, px, py, fc, 1.0); + } + } + } + + if let Some(sc) = stroke { + draw_line(pixels, w, h, lx, ty, rx, ty, sc, thickness); // top + draw_line(pixels, w, h, rx, ty, rx, by, sc, thickness); // right + draw_line(pixels, w, h, rx, by, lx, by, sc, thickness); // bottom + draw_line(pixels, w, h, lx, by, lx, ty, sc, thickness); // left + } +} + +/// Draw an ellipse centred at (`cx`, `cy`) with semi-axes `rx` and `ry`. +/// +/// `stroke` draws the outline; `fill` fills the interior via scanline. +pub fn draw_ellipse( + pixels: &mut [u8], w: u32, h: u32, + cx: f32, cy: f32, rx: f32, ry: f32, + stroke: Option, fill: Option, thickness: f32, +) { + if rx <= 0.0 || ry <= 0.0 { return; } + + if let Some(fc) = fill { + let py0 = (cy - ry).ceil() as i32; + let py1 = (cy + ry).floor() as i32; + for py in py0..=py1 { + let dy = py as f32 - cy; + let t = 1.0 - (dy / ry).powi(2); + if t <= 0.0 { continue; } + let x_ext = rx * t.sqrt(); + let px0 = (cx - x_ext).ceil() as i32; + let px1 = (cx + x_ext).floor() as i32; + for px in px0..=px1 { + put(pixels, w, h, px, py, fc, 1.0); + } + } + } + + if let Some(sc) = stroke { + let radius = (thickness / 2.0).max(0.5); + // Ramanujan's perimeter approximation for step count. + let perim = std::f32::consts::PI + * (3.0 * (rx + ry) - ((3.0 * rx + ry) * (rx + 3.0 * ry)).sqrt()); + let steps = ((perim * 2.0).ceil() as i32).max(16); + for i in 0..steps { + let t = i as f32 / steps as f32 * std::f32::consts::TAU; + draw_disk(pixels, w, h, cx + rx * t.cos(), cy + ry * t.sin(), radius, sc); + } + } +} + +/// Draw a closed polygon given world-space `vertices` (at least 2). +/// +/// `stroke` draws the outline; `fill` fills the interior via scanline. +pub fn draw_polygon( + pixels: &mut [u8], w: u32, h: u32, + vertices: &[(f32, f32)], + stroke: Option, fill: Option, thickness: f32, +) { + let n = vertices.len(); + if n < 2 { return; } + + if let Some(fc) = fill { + let min_y = vertices.iter().map(|v| v.1).fold(f32::MAX, f32::min).ceil() as i32; + let max_y = vertices.iter().map(|v| v.1).fold(f32::MIN, f32::max).floor() as i32; + let mut xs: Vec = Vec::with_capacity(n); + for py in min_y..=max_y { + xs.clear(); + let scan_y = py as f32 + 0.5; + for i in 0..n { + let (x0, y0) = vertices[i]; + let (x1, y1) = vertices[(i + 1) % n]; + if (y0 <= scan_y && scan_y < y1) || (y1 <= scan_y && scan_y < y0) { + xs.push(x0 + (scan_y - y0) / (y1 - y0) * (x1 - x0)); + } + } + xs.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)); + let mut j = 0; + while j + 1 < xs.len() { + let px0 = xs[j].ceil() as i32; + let px1 = xs[j + 1].floor() as i32; + for px in px0..=px1 { + put(pixels, w, h, px, py, fc, 1.0); + } + j += 2; + } + } + } + + if let Some(sc) = stroke { + for i in 0..n { + let (x0, y0) = vertices[i]; + let (x1, y1) = vertices[(i + 1) % n]; + draw_line(pixels, w, h, x0, y0, x1, y1, sc, thickness); + } + } +} diff --git a/lightningbeam-ui/lightningbeam-core/src/renderer.rs b/lightningbeam-ui/lightningbeam-core/src/renderer.rs index b4c5daf..5bf7879 100644 --- a/lightningbeam-ui/lightningbeam-core/src/renderer.rs +++ b/lightningbeam-ui/lightningbeam-core/src/renderer.rs @@ -18,7 +18,7 @@ use std::collections::HashMap; use std::sync::Arc; use uuid::Uuid; use vello::kurbo::Rect; -use vello::peniko::{Blob, Fill, ImageAlphaType, ImageBrush, ImageData, ImageFormat}; +use vello::peniko::{Blob, Fill, ImageAlphaType, ImageBrush, ImageData, ImageFormat, ImageQuality}; use vello::Scene; /// Cache for decoded image data to avoid re-decoding every frame @@ -363,7 +363,7 @@ fn render_raster_layer_to_scene( // decode the sRGB channels without premultiplying again. alpha_type: ImageAlphaType::AlphaPremultiplied, }; - let brush = ImageBrush::new(image_data); + let brush = ImageBrush::new(image_data).with_quality(ImageQuality::Low); let canvas_rect = Rect::new(0.0, 0.0, kf.width as f64, kf.height as f64); scene.fill(Fill::NonZero, base_transform, &brush, None, &canvas_rect); } diff --git a/lightningbeam-ui/lightningbeam-core/src/tool.rs b/lightningbeam-ui/lightningbeam-core/src/tool.rs index abe8ad9..c62ce9d 100644 --- a/lightningbeam-ui/lightningbeam-core/src/tool.rs +++ b/lightningbeam-ui/lightningbeam-core/src/tool.rs @@ -391,7 +391,7 @@ impl Tool { Tool::PaintBucket, Tool::Gradient, Tool::Rectangle, Tool::Ellipse, Tool::Polygon, Tool::Line, Tool::CustomShape, // Selection - Tool::Select, Tool::SelectEllipse, Tool::SelectLasso, + Tool::Select, Tool::SelectLasso, Tool::MagicWand, Tool::QuickSelect, // Transform Tool::Transform, Tool::Warp, Tool::Liquify, diff --git a/lightningbeam-ui/lightningbeam-editor/src/gpu_brush.rs b/lightningbeam-ui/lightningbeam-editor/src/gpu_brush.rs index 3d2a831..5cb3760 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/gpu_brush.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/gpu_brush.rs @@ -939,8 +939,8 @@ impl CanvasBlitPipeline { address_mode_u: wgpu::AddressMode::ClampToEdge, address_mode_v: wgpu::AddressMode::ClampToEdge, address_mode_w: wgpu::AddressMode::ClampToEdge, - mag_filter: wgpu::FilterMode::Linear, - min_filter: wgpu::FilterMode::Linear, + mag_filter: wgpu::FilterMode::Nearest, + min_filter: wgpu::FilterMode::Nearest, mipmap_filter: wgpu::FilterMode::Nearest, ..Default::default() }); diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/infopanel.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/infopanel.rs index d617aff..a1593b8 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/infopanel.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/infopanel.rs @@ -182,7 +182,13 @@ impl InfopanelPane { && matches!(tool, Tool::Transform) && shared.selection.raster_floating.is_some(); - let has_options = is_vector_tool || is_raster_paint_tool || is_raster_transform || matches!( + let is_raster_select = active_is_raster && matches!(tool, Tool::Select); + let is_raster_shape = active_is_raster && matches!( + tool, + Tool::Rectangle | Tool::Ellipse | Tool::Line | Tool::Polygon + ); + let has_options = is_vector_tool || is_raster_paint_tool || is_raster_transform + || is_raster_select || is_raster_shape || matches!( tool, Tool::PaintBucket | Tool::RegionSelect | Tool::MagicWand | Tool::QuickSelect ); @@ -326,6 +332,23 @@ impl InfopanelPane { } } + Tool::Select if is_raster_select => { + use crate::tools::SelectionShape; + ui.horizontal(|ui| { + ui.label("Shape:"); + ui.selectable_value( + &mut shared.raster_settings.select_shape, + SelectionShape::Rect, + "Rectangle", + ); + ui.selectable_value( + &mut shared.raster_settings.select_shape, + SelectionShape::Ellipse, + "Ellipse", + ); + }); + } + Tool::MagicWand => { use crate::tools::FillThresholdMode; ui.horizontal(|ui| { diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs index 33bf058..2299849 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs @@ -1602,300 +1602,6 @@ impl egui_wgpu::CallbackTrait for VelloCallback { } - // 3. Draw rectangle creation preview - if let lightningbeam_core::tool::ToolState::CreatingRectangle { ref start_point, ref current_point, centered, constrain_square, .. } = self.ctx.tool_state { - use vello::kurbo::Point; - - // Calculate rectangle bounds based on mode (same logic as in handler) - let (width, height, position) = if centered { - let dx = current_point.x - start_point.x; - let dy = current_point.y - start_point.y; - - let (w, h) = if constrain_square { - let size = dx.abs().max(dy.abs()) * 2.0; - (size, size) - } else { - (dx.abs() * 2.0, dy.abs() * 2.0) - }; - - let pos = Point::new(start_point.x - w / 2.0, start_point.y - h / 2.0); - (w, h, pos) - } else { - let mut min_x = start_point.x.min(current_point.x); - let mut min_y = start_point.y.min(current_point.y); - let mut max_x = start_point.x.max(current_point.x); - let mut max_y = start_point.y.max(current_point.y); - - if constrain_square { - let width = max_x - min_x; - let height = max_y - min_y; - let size = width.max(height); - - if current_point.x > start_point.x { - max_x = min_x + size; - } else { - min_x = max_x - size; - } - - if current_point.y > start_point.y { - max_y = min_y + size; - } else { - min_y = max_y - size; - } - } - - (max_x - min_x, max_y - min_y, Point::new(min_x, min_y)) - }; - - if width > 0.0 && height > 0.0 { - let rect = KurboRect::new(0.0, 0.0, width, height); - let preview_transform = overlay_transform * Affine::translate((position.x, position.y)); - - if self.ctx.fill_enabled { - let fill_color = Color::from_rgba8( - self.ctx.fill_color.r(), - self.ctx.fill_color.g(), - self.ctx.fill_color.b(), - self.ctx.fill_color.a(), - ); - scene.fill( - Fill::NonZero, - preview_transform, - fill_color, - None, - &rect, - ); - } - - let stroke_color = Color::from_rgba8( - self.ctx.stroke_color.r(), - self.ctx.stroke_color.g(), - self.ctx.stroke_color.b(), - self.ctx.stroke_color.a(), - ); - scene.stroke( - &Stroke::new(self.ctx.stroke_width), - preview_transform, - stroke_color, - None, - &rect, - ); - } - } - - // 4. Draw ellipse creation preview - if let lightningbeam_core::tool::ToolState::CreatingEllipse { ref start_point, ref current_point, corner_mode, constrain_circle, .. } = self.ctx.tool_state { - use vello::kurbo::{Point, Circle as KurboCircle, Ellipse}; - - // Calculate ellipse parameters based on mode (same logic as in handler) - let (rx, ry, position) = if corner_mode { - let min_x = start_point.x.min(current_point.x); - let min_y = start_point.y.min(current_point.y); - let max_x = start_point.x.max(current_point.x); - let max_y = start_point.y.max(current_point.y); - - let width = max_x - min_x; - let height = max_y - min_y; - - let (rx, ry) = if constrain_circle { - let radius = width.max(height) / 2.0; - (radius, radius) - } else { - (width / 2.0, height / 2.0) - }; - - let position = Point::new(min_x + rx, min_y + ry); - - (rx, ry, position) - } else { - let dx = (current_point.x - start_point.x).abs(); - let dy = (current_point.y - start_point.y).abs(); - - let (rx, ry) = if constrain_circle { - let radius = (dx * dx + dy * dy).sqrt(); - (radius, radius) - } else { - (dx, dy) - }; - - (rx, ry, *start_point) - }; - - if rx > 0.0 && ry > 0.0 { - let preview_transform = overlay_transform * Affine::translate((position.x, position.y)); - - let fill_color = Color::from_rgba8( - self.ctx.fill_color.r(), - self.ctx.fill_color.g(), - self.ctx.fill_color.b(), - self.ctx.fill_color.a(), - ); - let stroke_color = Color::from_rgba8( - self.ctx.stroke_color.r(), - self.ctx.stroke_color.g(), - self.ctx.stroke_color.b(), - self.ctx.stroke_color.a(), - ); - - if rx == ry { - let circle = KurboCircle::new((0.0, 0.0), rx); - if self.ctx.fill_enabled { - scene.fill(Fill::NonZero, preview_transform, fill_color, None, &circle); - } - scene.stroke(&Stroke::new(self.ctx.stroke_width), preview_transform, stroke_color, None, &circle); - } else { - let ellipse = Ellipse::new((0.0, 0.0), (rx, ry), 0.0); - if self.ctx.fill_enabled { - scene.fill(Fill::NonZero, preview_transform, fill_color, None, &ellipse); - } - scene.stroke(&Stroke::new(self.ctx.stroke_width), preview_transform, stroke_color, None, &ellipse); - } - } - } - - // 5. Draw line creation preview - if let lightningbeam_core::tool::ToolState::CreatingLine { ref start_point, ref current_point, .. } = self.ctx.tool_state { - use vello::kurbo::Line; - - // Calculate line length - let dx = current_point.x - start_point.x; - let dy = current_point.y - start_point.y; - let length = (dx * dx + dy * dy).sqrt(); - - if length > 0.0 { - // Use actual stroke color for line preview - let stroke_color = Color::from_rgba8( - self.ctx.stroke_color.r(), - self.ctx.stroke_color.g(), - self.ctx.stroke_color.b(), - self.ctx.stroke_color.a(), - ); - - // Draw the line directly - let line = Line::new(*start_point, *current_point); - scene.stroke( - &Stroke::new(2.0), - overlay_transform, - stroke_color, - None, - &line, - ); - } - } - - // 6. Draw polygon creation preview - if let lightningbeam_core::tool::ToolState::CreatingPolygon { ref center, ref current_point, num_sides, .. } = self.ctx.tool_state { - use vello::kurbo::{BezPath, Point}; - use std::f64::consts::PI; - - // Calculate radius - let dx = current_point.x - center.x; - let dy = current_point.y - center.y; - let radius = (dx * dx + dy * dy).sqrt(); - - if radius > 5.0 && num_sides >= 3 { - let preview_transform = overlay_transform * Affine::translate((center.x, center.y)); - - // Use actual fill color (same as final shape) - let fill_color = Color::from_rgba8( - self.ctx.fill_color.r(), - self.ctx.fill_color.g(), - self.ctx.fill_color.b(), - self.ctx.fill_color.a(), - ); - - // Create the polygon path inline - let mut path = BezPath::new(); - let angle_step = 2.0 * PI / num_sides as f64; - let start_angle = -PI / 2.0; - - // First vertex - let first_x = radius * start_angle.cos(); - let first_y = radius * start_angle.sin(); - path.move_to(Point::new(first_x, first_y)); - - // Add remaining vertices - for i in 1..num_sides { - let angle = start_angle + angle_step * i as f64; - let x = radius * angle.cos(); - let y = radius * angle.sin(); - path.line_to(Point::new(x, y)); - } - - path.close_path(); - - if self.ctx.fill_enabled { - scene.fill( - Fill::NonZero, - preview_transform, - fill_color, - None, - &path, - ); - } - - let stroke_color = Color::from_rgba8( - self.ctx.stroke_color.r(), - self.ctx.stroke_color.g(), - self.ctx.stroke_color.b(), - self.ctx.stroke_color.a(), - ); - scene.stroke( - &Stroke::new(self.ctx.stroke_width), - preview_transform, - stroke_color, - None, - &path, - ); - } - } - - // 7. Draw path drawing preview - if let lightningbeam_core::tool::ToolState::DrawingPath { ref points, .. } = self.ctx.tool_state { - use vello::kurbo::BezPath; - - if points.len() >= 2 { - // Build a simple line path from the raw points for preview - let mut preview_path = BezPath::new(); - preview_path.move_to(points[0]); - for point in &points[1..] { - preview_path.line_to(*point); - } - - // Draw fill if enabled - if self.ctx.fill_enabled { - let fill_color = Color::from_rgba8( - self.ctx.fill_color.r(), - self.ctx.fill_color.g(), - self.ctx.fill_color.b(), - self.ctx.fill_color.a(), - ); - scene.fill( - Fill::NonZero, - overlay_transform, - fill_color, - None, - &preview_path, - ); - } - - let stroke_color = Color::from_rgba8( - self.ctx.stroke_color.r(), - self.ctx.stroke_color.g(), - self.ctx.stroke_color.b(), - self.ctx.stroke_color.a(), - ); - - scene.stroke( - &Stroke::new(self.ctx.stroke_width), - overlay_transform, - stroke_color, - None, - &preview_path, - ); - } - } - // 8. Vector editing preview: DCEL edits are applied live to the document, // so the normal DCEL render path draws the current state. No separate // preview rendering is needed. @@ -2248,6 +1954,145 @@ impl egui_wgpu::CallbackTrait for VelloCallback { } } + // Shape / path creation previews — drawn regardless of layer type so raster layers + // also see the live outline during drag. + { + use vello::peniko::{Color, Fill}; + use vello::kurbo::{Rect as KurboRect, Stroke}; + + // Rectangle preview + if let lightningbeam_core::tool::ToolState::CreatingRectangle { ref start_point, ref current_point, centered, constrain_square, .. } = self.ctx.tool_state { + use vello::kurbo::Point; + let (width, height, position) = if centered { + let dx = current_point.x - start_point.x; + let dy = current_point.y - start_point.y; + let (w, h) = if constrain_square { + let size = dx.abs().max(dy.abs()) * 2.0; + (size, size) + } else { + (dx.abs() * 2.0, dy.abs() * 2.0) + }; + let pos = Point::new(start_point.x - w / 2.0, start_point.y - h / 2.0); + (w, h, pos) + } else { + let mut min_x = start_point.x.min(current_point.x); + let mut min_y = start_point.y.min(current_point.y); + let mut max_x = start_point.x.max(current_point.x); + let mut max_y = start_point.y.max(current_point.y); + if constrain_square { + let size = (max_x - min_x).max(max_y - min_y); + if current_point.x > start_point.x { max_x = min_x + size; } else { min_x = max_x - size; } + if current_point.y > start_point.y { max_y = min_y + size; } else { min_y = max_y - size; } + } + (max_x - min_x, max_y - min_y, Point::new(min_x, min_y)) + }; + if width > 0.0 && height > 0.0 { + let rect = KurboRect::new(0.0, 0.0, width, height); + let preview_transform = overlay_transform * Affine::translate((position.x, position.y)); + if self.ctx.fill_enabled { + let fc = self.ctx.fill_color; + scene.fill(Fill::NonZero, preview_transform, Color::from_rgba8(fc.r(), fc.g(), fc.b(), fc.a()), None, &rect); + } + let sc = self.ctx.stroke_color; + scene.stroke(&Stroke::new(self.ctx.stroke_width), preview_transform, Color::from_rgba8(sc.r(), sc.g(), sc.b(), sc.a()), None, &rect); + } + } + + // Ellipse preview + if let lightningbeam_core::tool::ToolState::CreatingEllipse { ref start_point, ref current_point, corner_mode, constrain_circle, .. } = self.ctx.tool_state { + use vello::kurbo::{Point, Circle as KurboCircle, Ellipse}; + let (rx, ry, position) = if corner_mode { + let min_x = start_point.x.min(current_point.x); + let min_y = start_point.y.min(current_point.y); + let max_x = start_point.x.max(current_point.x); + let max_y = start_point.y.max(current_point.y); + let (rx, ry) = if constrain_circle { + let r = (max_x - min_x).max(max_y - min_y) / 2.0; + (r, r) + } else { ((max_x - min_x) / 2.0, (max_y - min_y) / 2.0) }; + (rx, ry, Point::new(min_x + rx, min_y + ry)) + } else { + let dx = (current_point.x - start_point.x).abs(); + let dy = (current_point.y - start_point.y).abs(); + let (rx, ry) = if constrain_circle { + let r = (dx * dx + dy * dy).sqrt(); (r, r) + } else { (dx, dy) }; + (rx, ry, *start_point) + }; + if rx > 0.0 && ry > 0.0 { + let preview_transform = overlay_transform * Affine::translate((position.x, position.y)); + let fc = self.ctx.fill_color; + let fill_color = Color::from_rgba8(fc.r(), fc.g(), fc.b(), fc.a()); + let sc = self.ctx.stroke_color; + let stroke_color = Color::from_rgba8(sc.r(), sc.g(), sc.b(), sc.a()); + if rx == ry { + let circle = KurboCircle::new((0.0, 0.0), rx); + if self.ctx.fill_enabled { scene.fill(Fill::NonZero, preview_transform, fill_color, None, &circle); } + scene.stroke(&Stroke::new(self.ctx.stroke_width), preview_transform, stroke_color, None, &circle); + } else { + let ellipse = Ellipse::new((0.0, 0.0), (rx, ry), 0.0); + if self.ctx.fill_enabled { scene.fill(Fill::NonZero, preview_transform, fill_color, None, &ellipse); } + scene.stroke(&Stroke::new(self.ctx.stroke_width), preview_transform, stroke_color, None, &ellipse); + } + } + } + + // Line preview + if let lightningbeam_core::tool::ToolState::CreatingLine { ref start_point, ref current_point, .. } = self.ctx.tool_state { + use vello::kurbo::Line; + let dx = current_point.x - start_point.x; + let dy = current_point.y - start_point.y; + if (dx * dx + dy * dy).sqrt() > 0.0 { + let sc = self.ctx.stroke_color; + let line = Line::new(*start_point, *current_point); + scene.stroke(&Stroke::new(2.0), overlay_transform, Color::from_rgba8(sc.r(), sc.g(), sc.b(), sc.a()), None, &line); + } + } + + // Polygon preview + if let lightningbeam_core::tool::ToolState::CreatingPolygon { ref center, ref current_point, num_sides, .. } = self.ctx.tool_state { + use vello::kurbo::{BezPath, Point}; + use std::f64::consts::PI; + let dx = current_point.x - center.x; + let dy = current_point.y - center.y; + let radius = (dx * dx + dy * dy).sqrt(); + if radius > 5.0 && num_sides >= 3 { + let preview_transform = overlay_transform * Affine::translate((center.x, center.y)); + let angle_step = 2.0 * PI / num_sides as f64; + let start_angle = -PI / 2.0; + let mut path = BezPath::new(); + path.move_to(Point::new(radius * start_angle.cos(), radius * start_angle.sin())); + for i in 1..num_sides { + let angle = start_angle + angle_step * i as f64; + path.line_to(Point::new(radius * angle.cos(), radius * angle.sin())); + } + path.close_path(); + if self.ctx.fill_enabled { + let fc = self.ctx.fill_color; + scene.fill(Fill::NonZero, preview_transform, Color::from_rgba8(fc.r(), fc.g(), fc.b(), fc.a()), None, &path); + } + let sc = self.ctx.stroke_color; + scene.stroke(&Stroke::new(self.ctx.stroke_width), preview_transform, Color::from_rgba8(sc.r(), sc.g(), sc.b(), sc.a()), None, &path); + } + } + + // Freehand path preview + if let lightningbeam_core::tool::ToolState::DrawingPath { ref points, .. } = self.ctx.tool_state { + use vello::kurbo::BezPath; + if points.len() >= 2 { + let mut preview_path = BezPath::new(); + preview_path.move_to(points[0]); + for point in &points[1..] { preview_path.line_to(*point); } + if self.ctx.fill_enabled { + let fc = self.ctx.fill_color; + scene.fill(Fill::NonZero, overlay_transform, Color::from_rgba8(fc.r(), fc.g(), fc.b(), fc.a()), None, &preview_path); + } + let sc = self.ctx.stroke_color; + scene.stroke(&Stroke::new(self.ctx.stroke_width), overlay_transform, Color::from_rgba8(sc.r(), sc.g(), sc.b(), sc.a()), None, &preview_path); + } + } + } + // Render scene to texture using shared renderer if let Some(texture_view) = &instance_resources.texture_view { if USE_HDR_COMPOSITING { @@ -3854,21 +3699,18 @@ impl StagePane { use lightningbeam_core::layer::AnyLayer; use vello::kurbo::Point; - // Check if we have an active vector layer let active_layer_id = match *shared.active_layer_id { Some(id) => id, None => return, }; - let active_layer = match shared.action_executor.document().get_layer(&active_layer_id) { - Some(layer) => layer, - None => return, - }; - - // Only work on VectorLayer - if !matches!(active_layer, AnyLayer::Vector(_)) { - return; - } + let is_raster = shared.action_executor.document() + .get_layer(&active_layer_id) + .map_or(false, |l| matches!(l, AnyLayer::Raster(_))); + let is_vector = shared.action_executor.document() + .get_layer(&active_layer_id) + .map_or(false, |l| matches!(l, AnyLayer::Vector(_))); + if !is_raster && !is_vector { return; } let point = self.snap_point(Point::new(world_pos.x as f64, world_pos.y as f64), shared); @@ -3945,28 +3787,43 @@ impl StagePane { // Only create shape if rectangle has non-zero size if width > 1.0 && height > 1.0 { - use lightningbeam_core::shape::{ShapeColor, StrokeStyle}; - use lightningbeam_core::actions::AddShapeAction; - - let path = Self::create_rectangle_path(min_x, min_y, max_x, max_y); - - let fill_color = if *shared.fill_enabled { - Some(ShapeColor::from_egui(*shared.fill_color)) + if is_raster { + let sc = *shared.stroke_color; + let fc = *shared.fill_color; + let fill_en = *shared.fill_enabled; + let thickness = *shared.stroke_width as f32; + // Subtract 0.5 to align with Vello's pixel-center convention + // (ImageBrush displays pixel (px,py) centered at world (px+0.5, py+0.5)) + let (x0, y0, x1, y1) = (min_x as f32 - 0.5, min_y as f32 - 0.5, max_x as f32 - 0.5, max_y as f32 - 0.5); + let stroke_rgba = [sc.r(), sc.g(), sc.b(), sc.a()]; + let fill_rgba = fill_en.then(|| [fc.r(), fc.g(), fc.b(), fc.a()]); + Self::apply_raster_pixel_edit(shared, active_layer_id, "Draw rectangle", |pixels, w, h| { + lightningbeam_core::raster_draw::draw_rect( + pixels, w, h, x0, y0, x1, y1, + Some(stroke_rgba), fill_rgba, thickness, + ); + }); } else { - None - }; - - let action = AddShapeAction::new( - active_layer_id, - *shared.playback_time, - path, - Some(StrokeStyle { width: *shared.stroke_width, ..Default::default() }), - Some(ShapeColor::from_egui(*shared.stroke_color)), - fill_color, - true, // closed - ).with_description("Add rectangle"); - let _ = shared.action_executor.execute(Box::new(action)); + use lightningbeam_core::shape::{ShapeColor, StrokeStyle}; + use lightningbeam_core::actions::AddShapeAction; + let path = Self::create_rectangle_path(min_x, min_y, max_x, max_y); + let fill_color = if *shared.fill_enabled { + Some(ShapeColor::from_egui(*shared.fill_color)) + } else { + None + }; + let action = AddShapeAction::new( + active_layer_id, + *shared.playback_time, + path, + Some(StrokeStyle { width: *shared.stroke_width, ..Default::default() }), + Some(ShapeColor::from_egui(*shared.stroke_color)), + fill_color, + true, // closed + ).with_description("Add rectangle"); + let _ = shared.action_executor.execute(Box::new(action)); + } // Clear tool state to stop preview rendering *shared.tool_state = ToolState::Idle; } @@ -3987,21 +3844,18 @@ impl StagePane { use lightningbeam_core::layer::AnyLayer; use vello::kurbo::Point; - // Check if we have an active vector layer let active_layer_id = match *shared.active_layer_id { Some(id) => id, None => return, }; - let active_layer = match shared.action_executor.document().get_layer(&active_layer_id) { - Some(layer) => layer, - None => return, - }; - - // Only work on VectorLayer - if !matches!(active_layer, AnyLayer::Vector(_)) { - return; - } + let is_raster = shared.action_executor.document() + .get_layer(&active_layer_id) + .map_or(false, |l| matches!(l, AnyLayer::Raster(_))); + let is_vector = shared.action_executor.document() + .get_layer(&active_layer_id) + .map_or(false, |l| matches!(l, AnyLayer::Vector(_))); + if !is_raster && !is_vector { return; } let point = self.snap_point(Point::new(world_pos.x as f64, world_pos.y as f64), shared); @@ -4069,28 +3923,42 @@ impl StagePane { // Only create shape if ellipse has non-zero size if rx > 1.0 && ry > 1.0 { - use lightningbeam_core::shape::{ShapeColor, StrokeStyle}; - use lightningbeam_core::actions::AddShapeAction; - - let path = Self::create_ellipse_path(position.x, position.y, rx, ry); - - let fill_color = if *shared.fill_enabled { - Some(ShapeColor::from_egui(*shared.fill_color)) + if is_raster { + let sc = *shared.stroke_color; + let fc = *shared.fill_color; + let fill_en = *shared.fill_enabled; + let thickness = *shared.stroke_width as f32; + let (cx, cy) = (position.x as f32 - 0.5, position.y as f32 - 0.5); + let (erx, ery) = (rx as f32, ry as f32); + let stroke_rgba = [sc.r(), sc.g(), sc.b(), sc.a()]; + let fill_rgba = fill_en.then(|| [fc.r(), fc.g(), fc.b(), fc.a()]); + Self::apply_raster_pixel_edit(shared, active_layer_id, "Draw ellipse", |pixels, w, h| { + lightningbeam_core::raster_draw::draw_ellipse( + pixels, w, h, cx, cy, erx, ery, + Some(stroke_rgba), fill_rgba, thickness, + ); + }); } else { - None - }; - - let action = AddShapeAction::new( - active_layer_id, - *shared.playback_time, - path, - Some(StrokeStyle { width: *shared.stroke_width, ..Default::default() }), - Some(ShapeColor::from_egui(*shared.stroke_color)), - fill_color, - true, // closed - ).with_description("Add ellipse"); - let _ = shared.action_executor.execute(Box::new(action)); + use lightningbeam_core::shape::{ShapeColor, StrokeStyle}; + use lightningbeam_core::actions::AddShapeAction; + let path = Self::create_ellipse_path(position.x, position.y, rx, ry); + let fill_color = if *shared.fill_enabled { + Some(ShapeColor::from_egui(*shared.fill_color)) + } else { + None + }; + let action = AddShapeAction::new( + active_layer_id, + *shared.playback_time, + path, + Some(StrokeStyle { width: *shared.stroke_width, ..Default::default() }), + Some(ShapeColor::from_egui(*shared.stroke_color)), + fill_color, + true, // closed + ).with_description("Add ellipse"); + let _ = shared.action_executor.execute(Box::new(action)); + } // Clear tool state to stop preview rendering *shared.tool_state = ToolState::Idle; } @@ -4103,7 +3971,7 @@ impl StagePane { ui: &mut egui::Ui, response: &egui::Response, world_pos: egui::Vec2, - _shift_held: bool, + shift_held: bool, _ctrl_held: bool, shared: &mut SharedPaneState, ) { @@ -4111,24 +3979,37 @@ impl StagePane { use lightningbeam_core::layer::AnyLayer; use vello::kurbo::Point; - // Check if we have an active vector layer let active_layer_id = match *shared.active_layer_id { Some(id) => id, None => return, }; - let active_layer = match shared.action_executor.document().get_layer(&active_layer_id) { - Some(layer) => layer, - None => return, - }; + let is_raster = shared.action_executor.document() + .get_layer(&active_layer_id) + .map_or(false, |l| matches!(l, AnyLayer::Raster(_))); + let is_vector = shared.action_executor.document() + .get_layer(&active_layer_id) + .map_or(false, |l| matches!(l, AnyLayer::Vector(_))); + if !is_raster && !is_vector { return; } - // Only work on VectorLayer - if !matches!(active_layer, AnyLayer::Vector(_)) { - return; + let mut point = self.snap_point(Point::new(world_pos.x as f64, world_pos.y as f64), shared); + + // Shift: snap to 45° angle increments (raster; also applied to vector for consistency). + if shift_held { + if let ToolState::CreatingLine { start_point, .. } = shared.tool_state { + let dx = point.x - start_point.x; + let dy = point.y - start_point.y; + let len = (dx * dx + dy * dy).sqrt(); + let angle = (dy as f32).atan2(dx as f32); + let snapped = (angle / (std::f32::consts::PI / 4.0)).round() + * (std::f32::consts::PI / 4.0); + point = Point::new( + start_point.x + len * snapped.cos() as f64, + start_point.y + len * snapped.sin() as f64, + ); + } } - let point = self.snap_point(Point::new(world_pos.x as f64, world_pos.y as f64), shared); - // Mouse down: start creating line if self.rsp_drag_started(response) || self.rsp_clicked(response) { *shared.tool_state = ToolState::CreatingLine { @@ -4150,30 +4031,38 @@ impl StagePane { // Mouse up: create the line shape if self.rsp_drag_stopped(response) || (self.rsp_any_released(ui) && matches!(shared.tool_state, ToolState::CreatingLine { .. })) { if let ToolState::CreatingLine { start_point, current_point } = shared.tool_state.clone() { - // Calculate line length to ensure it's not too small let dx = current_point.x - start_point.x; let dy = current_point.y - start_point.y; let length = (dx * dx + dy * dy).sqrt(); - // Only create shape if line has reasonable length if length > 1.0 { - use lightningbeam_core::shape::{ShapeColor, StrokeStyle}; - use lightningbeam_core::actions::AddShapeAction; + if is_raster { + let sc = *shared.stroke_color; + let thickness = *shared.stroke_width as f32; + let (ax, ay) = (start_point.x as f32 - 0.5, start_point.y as f32 - 0.5); + let (bx, by) = (current_point.x as f32 - 0.5, current_point.y as f32 - 0.5); + let stroke_rgba = [sc.r(), sc.g(), sc.b(), sc.a()]; + Self::apply_raster_pixel_edit(shared, active_layer_id, "Draw line", |pixels, w, h| { + lightningbeam_core::raster_draw::draw_line( + pixels, w, h, ax, ay, bx, by, stroke_rgba, thickness, + ); + }); + } else { + use lightningbeam_core::shape::{ShapeColor, StrokeStyle}; + use lightningbeam_core::actions::AddShapeAction; - let path = Self::create_line_path(start_point, current_point); - - let action = AddShapeAction::new( - active_layer_id, - *shared.playback_time, - path, - Some(StrokeStyle { width: *shared.stroke_width, ..Default::default() }), - Some(ShapeColor::from_egui(*shared.stroke_color)), - None, // no fill for lines - false, // not closed - ).with_description("Add line"); - let _ = shared.action_executor.execute(Box::new(action)); - - // Clear tool state to stop preview rendering + let path = Self::create_line_path(start_point, current_point); + let action = AddShapeAction::new( + active_layer_id, + *shared.playback_time, + path, + Some(StrokeStyle { width: *shared.stroke_width, ..Default::default() }), + Some(ShapeColor::from_egui(*shared.stroke_color)), + None, // no fill for lines + false, // not closed + ).with_description("Add line"); + let _ = shared.action_executor.execute(Box::new(action)); + } *shared.tool_state = ToolState::Idle; } } @@ -4193,7 +4082,6 @@ impl StagePane { use lightningbeam_core::layer::AnyLayer; use vello::kurbo::Point; - // Check if we have an active vector layer let active_layer_id = match *shared.active_layer_id { Some(id) => id, None => return, @@ -4204,11 +4092,13 @@ impl StagePane { None => return, }; - // Only work on VectorLayer - if !matches!(active_layer, AnyLayer::Vector(_)) { + let is_raster = matches!(active_layer, AnyLayer::Raster(_)); + let is_vector = matches!(active_layer, AnyLayer::Vector(_)); + if !is_raster && !is_vector { return; } + let num_sides = *shared.polygon_sides; let point = self.snap_point(Point::new(world_pos.x as f64, world_pos.y as f64), shared); // Mouse down: start creating polygon (center point) @@ -4216,7 +4106,7 @@ impl StagePane { *shared.tool_state = ToolState::CreatingPolygon { center: point, current_point: point, - num_sides: 5, // Default to 5 sides (pentagon) + num_sides, }; } @@ -4241,27 +4131,55 @@ impl StagePane { // Only create shape if polygon has reasonable size if radius > 5.0 { - use lightningbeam_core::shape::{ShapeColor, StrokeStyle}; - use lightningbeam_core::actions::AddShapeAction; + if is_raster { + use lightningbeam_core::raster_draw; + use std::f64::consts::TAU; - let path = Self::create_polygon_path(center, num_sides, radius); + let cx = center.x as f32 - 0.5; + let cy = center.y as f32 - 0.5; + let r = radius as f32; + let n = num_sides as usize; + let vertices: Vec<(f32, f32)> = (0..n).map(|i| { + let angle = (i as f64 / n as f64) * TAU - std::f64::consts::FRAC_PI_2; + (cx + r * angle.cos() as f32, cy + r * angle.sin() as f32) + }).collect(); - let fill_color = if *shared.fill_enabled { - Some(ShapeColor::from_egui(*shared.fill_color)) + let stroke_color = shared.stroke_color.to_array(); + let stroke_rgba = [stroke_color[0], stroke_color[1], stroke_color[2], stroke_color[3]]; + let fill_rgba = if *shared.fill_enabled { + let fc = shared.fill_color.to_array(); + Some([fc[0], fc[1], fc[2], fc[3]]) + } else { + None + }; + let thickness = *shared.stroke_width as f32; + + let _ = Self::apply_raster_pixel_edit(shared, active_layer_id, "Add polygon", |pixels, w, h| { + raster_draw::draw_polygon(pixels, w, h, &vertices, Some(stroke_rgba), fill_rgba, thickness); + }); } else { - None - }; + use lightningbeam_core::shape::{ShapeColor, StrokeStyle}; + use lightningbeam_core::actions::AddShapeAction; - let action = AddShapeAction::new( - active_layer_id, - *shared.playback_time, - path, - Some(StrokeStyle { width: *shared.stroke_width, ..Default::default() }), - Some(ShapeColor::from_egui(*shared.stroke_color)), - fill_color, - true, // closed - ).with_description("Add polygon"); - let _ = shared.action_executor.execute(Box::new(action)); + let path = Self::create_polygon_path(center, num_sides, radius); + + let fill_color = if *shared.fill_enabled { + Some(ShapeColor::from_egui(*shared.fill_color)) + } else { + None + }; + + let action = AddShapeAction::new( + active_layer_id, + *shared.playback_time, + path, + Some(StrokeStyle { width: *shared.stroke_width, ..Default::default() }), + Some(ShapeColor::from_egui(*shared.stroke_color)), + fill_color, + true, // closed + ).with_description("Add polygon"); + let _ = shared.action_executor.execute(Box::new(action)); + } // Clear tool state to stop preview rendering *shared.tool_state = ToolState::Idle; @@ -5456,6 +5374,88 @@ impl StagePane { } /// Rectangular marquee selection tool for raster layers. + /// Snapshot the active raster keyframe pixels, pass them to `draw_fn` to + /// modify the buffer, then apply the result as an undoable `RasterFillAction`. + /// + /// Returns `false` if the layer or keyframe is not available. + fn apply_raster_pixel_edit( + shared: &mut SharedPaneState, + layer_id: uuid::Uuid, + description: &'static str, + draw_fn: F, + ) -> bool + where + F: FnOnce(&mut [u8], u32, u32), + { + use lightningbeam_core::layer::AnyLayer; + use lightningbeam_core::actions::RasterFillAction; + + let time = *shared.playback_time; + // Canvas dimensions (to create keyframe if needed). + let (doc_w, doc_h) = { + let doc = shared.action_executor.document(); + (doc.width as u32, doc.height as u32) + }; + // Ensure a keyframe exists at the current time. + { + let doc = shared.action_executor.document_mut(); + if let Some(AnyLayer::Raster(rl)) = doc.get_layer_mut(&layer_id) { + rl.ensure_keyframe_at(time, doc_w, doc_h); + } + } + // Snapshot the pixel buffer before drawing. + let (buffer_before, w, h) = { + let doc = shared.action_executor.document(); + match doc.get_layer(&layer_id) { + Some(AnyLayer::Raster(rl)) => match rl.keyframe_at(time) { + Some(kf) => { + let expected = (kf.width * kf.height * 4) as usize; + let buf = if kf.raw_pixels.len() == expected { + kf.raw_pixels.clone() + } else { + vec![0u8; expected] + }; + (buf, kf.width, kf.height) + } + None => return false, + }, + _ => return false, + } + }; + let mut buffer_after = buffer_before.clone(); + draw_fn(&mut buffer_after, w, h); + let action = RasterFillAction::new(layer_id, time, buffer_before, buffer_after, w, h) + .with_description(description); + let _ = shared.action_executor.execute(Box::new(action)); + true + } + + /// Build a per-pixel boolean mask for an ellipse inscribed in the given + /// axis-aligned bounding box. Used by the elliptical marquee mode. + fn make_ellipse_mask(x0: i32, y0: i32, x1: i32, y1: i32) -> lightningbeam_core::selection::RasterSelection { + use lightningbeam_core::selection::RasterSelection; + let w = (x1 - x0) as u32; + let h = (y1 - y0) as u32; + if w == 0 || h == 0 { + return RasterSelection::Mask { data: vec![], width: 0, height: 0, origin_x: x0, origin_y: y0 }; + } + // Center in local pixel space. Add 0.5 to radii so the ellipse + // touches every edge pixel without cutting them off. + let cx = (w as f32 - 1.0) / 2.0; + let cy = (h as f32 - 1.0) / 2.0; + let rx = cx + 0.5; + let ry = cy + 0.5; + let mut data = vec![false; (w * h) as usize]; + for row in 0..h { + for col in 0..w { + let dx = (col as f32 - cx) / rx; + let dy = (row as f32 - cy) / ry; + data[(row * w + col) as usize] = dx * dx + dy * dy <= 1.0; + } + } + RasterSelection::Mask { data, width: w, height: h, origin_x: x0, origin_y: y0 } + } + fn handle_raster_select_tool( &mut self, ui: &mut egui::Ui, @@ -5466,6 +5466,7 @@ impl StagePane { use lightningbeam_core::layer::AnyLayer; use lightningbeam_core::selection::RasterSelection; use lightningbeam_core::tool::ToolState; + use crate::tools::SelectionShape; let Some(layer_id) = *shared.active_layer_id else { return }; let doc = shared.action_executor.document(); @@ -5473,6 +5474,7 @@ impl StagePane { if let AnyLayer::Raster(rl) = l { rl.keyframe_at(*shared.playback_time) } else { None } }) else { return }; let (canvas_w, canvas_h) = (kf.width as i32, kf.height as i32); + let ellipse = shared.raster_settings.select_shape == SelectionShape::Ellipse; if self.rsp_drag_started(response) { let (px, py) = (world_pos.x as i32, world_pos.y as i32); @@ -5504,7 +5506,11 @@ impl StagePane { *current = (px, py); let (x0, x1) = (start.0.min(px).max(0), start.0.max(px).min(canvas_w)); let (y0, y1) = (start.1.min(py).max(0), start.1.max(py).min(canvas_h)); - shared.selection.raster_selection = Some(RasterSelection::Rect(x0, y0, x1, y1)); + shared.selection.raster_selection = Some(if ellipse { + Self::make_ellipse_mask(x0, y0, x1, y1) + } else { + RasterSelection::Rect(x0, y0, x1, y1) + }); } ToolState::MovingRasterSelection { ref mut last } => { let (dx, dy) = (px - last.0, py - last.1); @@ -5541,7 +5547,11 @@ impl StagePane { let (x0, x1) = (start.0.min(current.0).max(0), start.0.max(current.0).min(canvas_w)); let (y0, y1) = (start.1.min(current.1).max(0), start.1.max(current.1).min(canvas_h)); if x1 > x0 && y1 > y0 { - shared.selection.raster_selection = Some(RasterSelection::Rect(x0, y0, x1, y1)); + shared.selection.raster_selection = Some(if ellipse { + Self::make_ellipse_mask(x0, y0, x1, y1) + } else { + RasterSelection::Rect(x0, y0, x1, y1) + }); Self::lift_selection_to_float(shared); } else { shared.selection.raster_selection = None; diff --git a/lightningbeam-ui/lightningbeam-editor/src/tools/mod.rs b/lightningbeam-ui/lightningbeam-editor/src/tools/mod.rs index 75390c9..415f140 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/tools/mod.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/tools/mod.rs @@ -96,6 +96,17 @@ pub struct RasterToolSettings { /// Whether to compare each pixel to the seed pixel (Absolute) or to its BFS /// parent pixel (Relative, spreads across gradients). pub fill_threshold_mode: FillThresholdMode, + // --- Marquee select shape --- + /// Whether the rectangular select tool draws a rect or an ellipse. + pub select_shape: SelectionShape, +} + +/// Shape mode for the rectangular-select tool. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum SelectionShape { + #[default] + Rect, + Ellipse, } /// Threshold comparison mode for the raster flood fill. @@ -156,6 +167,7 @@ impl Default for RasterToolSettings { fill_softness: 0.0, fill_threshold_mode: FillThresholdMode::Absolute, quick_select_radius: 20.0, + select_shape: SelectionShape::Rect, } } }