From 0d2609c064acf713fc06c690c3cbd243d9877bc6 Mon Sep 17 00:00:00 2001 From: Skyler Lehmkuhl Date: Sat, 7 Mar 2026 16:55:38 -0500 Subject: [PATCH] work on raster tools --- .../lightningbeam-core/src/actions/mod.rs | 2 + .../src/actions/set_fill_paint.rs | 127 ++ .../lightningbeam-core/src/dcel2/mod.rs | 4 + .../lightningbeam-core/src/gradient.rs | 178 +++ .../lightningbeam-core/src/lib.rs | 1 + .../lightningbeam-core/src/raster_layer.rs | 6 + .../lightningbeam-core/src/renderer.rs | 36 +- .../lightningbeam-editor/src/gpu_brush.rs | 708 ++++++++- .../src/panes/gradient_editor.rs | 261 ++++ .../src/panes/infopanel.rs | 163 ++- .../lightningbeam-editor/src/panes/mod.rs | 1 + .../src/panes/shaders/gradient_fill.wgsl | 137 ++ .../src/panes/shaders/liquify_brush.wgsl | 92 ++ .../src/panes/shaders/warp_apply.wgsl | 103 ++ .../lightningbeam-editor/src/panes/stage.rs | 1300 +++++++++++++++++ .../lightningbeam-editor/src/tools/mod.rs | 40 + 16 files changed, 3142 insertions(+), 17 deletions(-) create mode 100644 lightningbeam-ui/lightningbeam-core/src/actions/set_fill_paint.rs create mode 100644 lightningbeam-ui/lightningbeam-core/src/gradient.rs create mode 100644 lightningbeam-ui/lightningbeam-editor/src/panes/gradient_editor.rs create mode 100644 lightningbeam-ui/lightningbeam-editor/src/panes/shaders/gradient_fill.wgsl create mode 100644 lightningbeam-ui/lightningbeam-editor/src/panes/shaders/liquify_brush.wgsl create mode 100644 lightningbeam-ui/lightningbeam-editor/src/panes/shaders/warp_apply.wgsl diff --git a/lightningbeam-ui/lightningbeam-core/src/actions/mod.rs b/lightningbeam-ui/lightningbeam-core/src/actions/mod.rs index 352bae3..bd0d375 100644 --- a/lightningbeam-ui/lightningbeam-core/src/actions/mod.rs +++ b/lightningbeam-ui/lightningbeam-core/src/actions/mod.rs @@ -34,6 +34,7 @@ pub mod group_layers; pub mod raster_stroke; pub mod raster_fill; pub mod move_layer; +pub mod set_fill_paint; pub use add_clip_instance::AddClipInstanceAction; pub use add_effect::AddEffectAction; @@ -66,3 +67,4 @@ pub use group_layers::GroupLayersAction; pub use raster_stroke::RasterStrokeAction; pub use raster_fill::RasterFillAction; pub use move_layer::MoveLayerAction; +pub use set_fill_paint::SetFillPaintAction; diff --git a/lightningbeam-ui/lightningbeam-core/src/actions/set_fill_paint.rs b/lightningbeam-ui/lightningbeam-core/src/actions/set_fill_paint.rs new file mode 100644 index 0000000..a69c02e --- /dev/null +++ b/lightningbeam-ui/lightningbeam-core/src/actions/set_fill_paint.rs @@ -0,0 +1,127 @@ +//! Action that changes the fill of one or more DCEL faces. +//! +//! Handles both solid-colour and gradient fills, clearing the other type so they +//! don't coexist on a face. + +use crate::action::Action; +use crate::dcel::FaceId; +use crate::document::Document; +use crate::gradient::ShapeGradient; +use crate::layer::AnyLayer; +use crate::shape::ShapeColor; +use uuid::Uuid; + +/// Snapshot of one face's fill state (both types) for undo. +#[derive(Clone)] +struct OldFill { + face_id: FaceId, + color: Option, + gradient: Option, +} + +/// Action that sets a solid-colour *or* gradient fill on a set of faces, +/// clearing the other fill type. +pub struct SetFillPaintAction { + layer_id: Uuid, + time: f64, + face_ids: Vec, + new_color: Option, + new_gradient: Option, + old_fills: Vec, + description: &'static str, +} + +impl SetFillPaintAction { + /// Set a solid fill (clears any gradient on the same faces). + pub fn solid( + layer_id: Uuid, + time: f64, + face_ids: Vec, + color: Option, + ) -> Self { + Self { + layer_id, + time, + face_ids, + new_color: color, + new_gradient: None, + old_fills: Vec::new(), + description: "Set fill colour", + } + } + + /// Set a gradient fill (clears any solid colour on the same faces). + pub fn gradient( + layer_id: Uuid, + time: f64, + face_ids: Vec, + gradient: Option, + ) -> Self { + Self { + layer_id, + time, + face_ids, + new_color: None, + new_gradient: gradient, + old_fills: Vec::new(), + description: "Set gradient fill", + } + } + + fn get_dcel_mut<'a>( + document: &'a mut Document, + layer_id: &Uuid, + time: f64, + ) -> Result<&'a mut crate::dcel::Dcel, String> { + let layer = document + .get_layer_mut(layer_id) + .ok_or_else(|| format!("Layer {} not found", layer_id))?; + match layer { + AnyLayer::Vector(vl) => vl + .dcel_at_time_mut(time) + .ok_or_else(|| format!("No keyframe at time {}", time)), + _ => Err("Not a vector layer".to_string()), + } + } +} + +impl Action for SetFillPaintAction { + fn execute(&mut self, document: &mut Document) -> Result<(), String> { + let dcel = Self::get_dcel_mut(document, &self.layer_id, self.time)?; + self.old_fills.clear(); + + for &fid in &self.face_ids { + let face = dcel.face(fid); + self.old_fills.push(OldFill { + face_id: fid, + color: face.fill_color, + gradient: face.gradient_fill.clone(), + }); + + let face_mut = dcel.face_mut(fid); + // Setting a gradient clears solid colour and vice-versa. + if self.new_gradient.is_some() || self.new_color.is_none() { + face_mut.fill_color = self.new_color; + face_mut.gradient_fill = self.new_gradient.clone(); + } else { + face_mut.fill_color = self.new_color; + face_mut.gradient_fill = None; + } + } + Ok(()) + } + + fn rollback(&mut self, document: &mut Document) -> Result<(), String> { + let dcel = Self::get_dcel_mut(document, &self.layer_id, self.time)?; + for old in &self.old_fills { + let face = dcel.face_mut(old.face_id); + face.fill_color = old.color; + face.gradient_fill = old.gradient.clone(); + } + Ok(()) + } + + fn description(&self) -> String { + self.description.to_string() + } +} diff --git a/lightningbeam-ui/lightningbeam-core/src/dcel2/mod.rs b/lightningbeam-ui/lightningbeam-core/src/dcel2/mod.rs index 14e580f..69cc415 100644 --- a/lightningbeam-ui/lightningbeam-core/src/dcel2/mod.rs +++ b/lightningbeam-ui/lightningbeam-core/src/dcel2/mod.rs @@ -114,6 +114,8 @@ pub struct Face { pub image_fill: Option, pub fill_rule: FillRule, #[serde(default)] + pub gradient_fill: Option, + #[serde(default)] pub deleted: bool, } @@ -241,6 +243,7 @@ impl Dcel { fill_color: None, image_fill: None, fill_rule: FillRule::NonZero, + gradient_fill: None, deleted: false, }; let debug_recorder = if std::env::var("DAW_DCEL_RECORD").is_ok() { @@ -372,6 +375,7 @@ impl Dcel { fill_color: None, image_fill: None, fill_rule: FillRule::NonZero, + gradient_fill: None, deleted: false, }; if let Some(idx) = self.free_faces.pop() { diff --git a/lightningbeam-ui/lightningbeam-core/src/gradient.rs b/lightningbeam-ui/lightningbeam-core/src/gradient.rs new file mode 100644 index 0000000..b4d10c2 --- /dev/null +++ b/lightningbeam-ui/lightningbeam-core/src/gradient.rs @@ -0,0 +1,178 @@ +//! Gradient types for vector and raster fills. + +use crate::shape::ShapeColor; +use kurbo::Point; +use serde::{Deserialize, Serialize}; +use vello::peniko::{self, Brush, Extend, Gradient}; + +// ── Stop ──────────────────────────────────────────────────────────────────── + +/// One colour stop in a gradient. +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +pub struct GradientStop { + /// Normalised position in [0.0, 1.0]. + pub position: f32, + pub color: ShapeColor, +} + +// ── Kind / Extend ──────────────────────────────────────────────────────────── + +/// Whether the gradient transitions along a line or radiates from a point. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)] +pub enum GradientType { + #[default] + Linear, + Radial, +} + +/// Behaviour outside the gradient's natural [0, 1] range. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)] +pub enum GradientExtend { + /// Clamp to edge colour (default). + #[default] + Pad, + /// Mirror the gradient. + Reflect, + /// Repeat the gradient. + Repeat, +} + +impl From for Extend { + fn from(e: GradientExtend) -> Self { + match e { + GradientExtend::Pad => Extend::Pad, + GradientExtend::Reflect => Extend::Reflect, + GradientExtend::Repeat => Extend::Repeat, + } + } +} + +// ── ShapeGradient ──────────────────────────────────────────────────────────── + +/// A serialisable gradient description. +/// +/// Stops are kept sorted by position (ascending). There are always ≥ 2 stops. +/// +/// *Rendering*: call [`to_peniko_brush`](ShapeGradient::to_peniko_brush) with +/// explicit start/end canvas-space points. For vector faces the caller derives +/// the points from the bounding box + `angle`; for the raster tool the caller +/// uses the drag start/end directly. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct ShapeGradient { + pub kind: GradientType, + /// Colour stops, sorted by position. + pub stops: Vec, + /// Angle in degrees for Linear (0 = left→right, 90 = top→bottom). + /// Ignored for Radial. + pub angle: f32, + pub extend: GradientExtend, +} + +impl Default for ShapeGradient { + fn default() -> Self { + Self { + kind: GradientType::Linear, + stops: vec![ + GradientStop { position: 0.0, color: ShapeColor::rgba(0, 0, 0, 255) }, + GradientStop { position: 1.0, color: ShapeColor::rgba(0, 0, 0, 0) }, + ], + angle: 0.0, + extend: GradientExtend::Pad, + } + } +} + +impl ShapeGradient { + // ── CPU evaluation ─────────────────────────────────────────────────────── + + /// Sample RGBA at `t ∈ [0,1]` by linear interpolation between adjacent stops. + /// Stops must be sorted ascending by position. + pub fn eval(&self, t: f32) -> [u8; 4] { + let t = t.clamp(0.0, 1.0); + if self.stops.is_empty() { + return [0, 0, 0, 0]; + } + if self.stops.len() == 1 { + let c = self.stops[0].color; + return [c.r, c.g, c.b, c.a]; + } + // Find first stop with position > t + let i = self.stops.partition_point(|s| s.position <= t); + if i == 0 { + let c = self.stops[0].color; + return [c.r, c.g, c.b, c.a]; + } + if i >= self.stops.len() { + let c = self.stops.last().unwrap().color; + return [c.r, c.g, c.b, c.a]; + } + let s0 = self.stops[i - 1]; + let s1 = self.stops[i]; + let span = s1.position - s0.position; + let f = if span <= 0.0 { 0.0 } else { (t - s0.position) / span }; + fn lerp(a: u8, b: u8, f: f32) -> u8 { + (a as f32 + (b as f32 - a as f32) * f).round().clamp(0.0, 255.0) as u8 + } + [ + lerp(s0.color.r, s1.color.r, f), + lerp(s0.color.g, s1.color.g, f), + lerp(s0.color.b, s1.color.b, f), + lerp(s0.color.a, s1.color.a, f), + ] + } + + /// Apply `extend` mode to a raw t value, returning t ∈ [0,1]. + pub fn apply_extend(&self, t_raw: f32) -> f32 { + match self.extend { + GradientExtend::Pad => t_raw.clamp(0.0, 1.0), + GradientExtend::Repeat => { + let t = t_raw.rem_euclid(1.0); + if t < 0.0 { t + 1.0 } else { t } + } + GradientExtend::Reflect => { + let t = t_raw.rem_euclid(2.0).abs(); + if t > 1.0 { 2.0 - t } else { t } + } + } + } + + // ── GPU / peniko rendering ─────────────────────────────────────────────── + + /// Build a `peniko::Brush` from explicit start/end canvas-coordinate points. + /// + /// `opacity` in [0,1] is multiplied into all stop alphas. + pub fn to_peniko_brush(&self, start: Point, end: Point, opacity: f32) -> Brush { + // Convert stops to peniko tuples. + let peniko_stops: Vec<(f32, peniko::Color)> = self.stops.iter().map(|s| { + let a_scaled = (s.color.a as f32 * opacity).round().clamp(0.0, 255.0) as u8; + let col = peniko::Color::from_rgba8(s.color.r, s.color.g, s.color.b, a_scaled); + (s.position, col) + }).collect(); + + let extend: Extend = self.extend.into(); + + match self.kind { + GradientType::Linear => { + Brush::Gradient( + Gradient::new_linear(start, end) + .with_extend(extend) + .with_stops(peniko_stops.as_slice()), + ) + } + GradientType::Radial => { + let mid = Point::new( + (start.x + end.x) * 0.5, + (start.y + end.y) * 0.5, + ); + let dx = end.x - start.x; + let dy = end.y - start.y; + let radius = ((dx * dx + dy * dy).sqrt() * 0.5) as f32; + Brush::Gradient( + Gradient::new_radial(mid, radius) + .with_extend(extend) + .with_stops(peniko_stops.as_slice()), + ) + } + } + } +} diff --git a/lightningbeam-ui/lightningbeam-core/src/lib.rs b/lightningbeam-ui/lightningbeam-core/src/lib.rs index fcc11fd..f163a5f 100644 --- a/lightningbeam-ui/lightningbeam-core/src/lib.rs +++ b/lightningbeam-ui/lightningbeam-core/src/lib.rs @@ -53,6 +53,7 @@ pub mod raster_layer; pub mod brush_settings; pub mod brush_engine; pub mod raster_draw; +pub mod gradient; #[cfg(debug_assertions)] pub mod test_mode; diff --git a/lightningbeam-ui/lightningbeam-core/src/raster_layer.rs b/lightningbeam-ui/lightningbeam-core/src/raster_layer.rs index 4bac3e7..6b23b43 100644 --- a/lightningbeam-ui/lightningbeam-core/src/raster_layer.rs +++ b/lightningbeam-ui/lightningbeam-core/src/raster_layer.rs @@ -113,8 +113,14 @@ pub struct RasterKeyframe { /// and encoded back to PNG on save. An empty Vec means the canvas is blank (transparent). #[serde(skip)] pub raw_pixels: Vec, + /// Set to `true` whenever `raw_pixels` changes so the GPU texture cache can re-upload. + /// Always `true` after load; cleared by the renderer after uploading. + #[serde(skip, default = "default_true")] + pub texture_dirty: bool, } +fn default_true() -> bool { true } + impl RasterKeyframe { /// Returns true when the pixel buffer has been initialised (non-blank). pub fn has_pixels(&self) -> bool { diff --git a/lightningbeam-ui/lightningbeam-core/src/renderer.rs b/lightningbeam-ui/lightningbeam-core/src/renderer.rs index 5bf7879..dc4a83b 100644 --- a/lightningbeam-ui/lightningbeam-core/src/renderer.rs +++ b/lightningbeam-ui/lightningbeam-core/src/renderer.rs @@ -923,7 +923,25 @@ fn render_video_layer( } } -/// Render a vector layer with all its clip instances and shape instances +/// Compute start/end canvas points for a linear gradient across a bounding box. +/// +/// The axis is centred on the bbox midpoint and oriented at `angle_deg` degrees +/// (0 = left→right, 90 = top→bottom). The axis extends ± half the bbox diagonal +/// so the gradient covers the entire shape regardless of angle. +fn gradient_bbox_endpoints(angle_deg: f32, bbox: kurbo::Rect) -> (kurbo::Point, kurbo::Point) { + let cx = bbox.center().x; + let cy = bbox.center().y; + let dx = bbox.width(); + let dy = bbox.height(); + // Use half the diagonal so the full gradient fits at any angle. + let half_len = (dx * dx + dy * dy).sqrt() * 0.5; + let rad = (angle_deg as f64).to_radians(); + let (sin, cos) = (rad.sin(), rad.cos()); + let start = kurbo::Point::new(cx - cos * half_len, cy - sin * half_len); + let end = kurbo::Point::new(cx + cos * half_len, cy + sin * half_len); + (start, end) +} + /// Render a DCEL to a Vello scene. /// /// Walks faces for fills and edges for strokes. @@ -942,7 +960,7 @@ pub fn render_dcel( if face.deleted || i == 0 { continue; // Skip unbounded face and deleted faces } - if face.fill_color.is_none() && face.image_fill.is_none() { + if face.fill_color.is_none() && face.image_fill.is_none() && face.gradient_fill.is_none() { continue; // No fill to render } @@ -963,7 +981,19 @@ pub fn render_dcel( } } - // Color fill + // Gradient fill (takes priority over solid colour fill) + if !filled { + if let Some(ref grad) = face.gradient_fill { + use kurbo::{Point, Rect}; + let bbox: Rect = vello::kurbo::Shape::bounding_box(&path); + let (start, end) = gradient_bbox_endpoints(grad.angle, bbox); + let brush = grad.to_peniko_brush(start, end, opacity_f32); + scene.fill(fill_rule, base_transform, &brush, None, &path); + filled = true; + } + } + + // Solid colour fill if !filled { if let Some(fill_color) = &face.fill_color { let alpha = ((fill_color.a as f32 / 255.0) * opacity_f32 * 255.0) as u8; diff --git a/lightningbeam-ui/lightningbeam-editor/src/gpu_brush.rs b/lightningbeam-ui/lightningbeam-editor/src/gpu_brush.rs index 5cb3760..7a50c95 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/gpu_brush.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/gpu_brush.rs @@ -290,6 +290,491 @@ impl RasterTransformPipeline { } // --------------------------------------------------------------------------- +// Displacement buffer (Warp / Liquify) +// --------------------------------------------------------------------------- + +/// Per-pixel displacement map stored as a GPU buffer of `vec2f` values. +/// +/// Each entry `disp[y * width + x]` stores `(dx, dy)` in canvas pixels. +/// Used by both the Warp tool (bilinear grid warp) and the Liquify tool +/// (brush-based freeform displacement). +pub struct DisplacementBuffer { + pub buf: wgpu::Buffer, + pub width: u32, + pub height: u32, +} + +// --------------------------------------------------------------------------- +// Warp-apply pipeline +// --------------------------------------------------------------------------- + +/// CPU-side parameters uniform for `warp_apply.wgsl`. +/// Must match the `Params` struct in the shader (32 bytes, 16-byte aligned). +/// grid_cols == 0 → per-pixel displacement buffer mode (Liquify). +/// grid_cols > 0 → control-point grid mode (Warp); disp[] has grid_cols*grid_rows entries. +#[repr(C)] +#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)] +pub struct WarpApplyParams { + pub src_w: u32, + pub src_h: u32, + pub dst_w: u32, + pub dst_h: u32, + pub grid_cols: u32, + pub grid_rows: u32, + pub _pad0: u32, + pub _pad1: u32, +} + +/// Compute pipeline that reads a displacement buffer + source texture → warped output. +/// Shared by the Warp tool and the Liquify tool's preview/commit pass. +struct WarpApplyPipeline { + pipeline: wgpu::ComputePipeline, + bg_layout: wgpu::BindGroupLayout, +} + +impl WarpApplyPipeline { + fn new(device: &wgpu::Device) -> Self { + let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("warp_apply_shader"), + source: wgpu::ShaderSource::Wgsl( + include_str!("panes/shaders/warp_apply.wgsl").into(), + ), + }); + + let bg_layout = device.create_bind_group_layout( + &wgpu::BindGroupLayoutDescriptor { + label: Some("warp_apply_bgl"), + entries: &[ + // 0: params uniform + wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::COMPUTE, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Uniform, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, + }, + // 1: source texture (anchor canvas, sampled) + wgpu::BindGroupLayoutEntry { + binding: 1, + visibility: wgpu::ShaderStages::COMPUTE, + ty: wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Float { filterable: true }, + view_dimension: wgpu::TextureViewDimension::D2, + multisampled: false, + }, + count: None, + }, + // 2: displacement buffer (read-only storage) + wgpu::BindGroupLayoutEntry { + binding: 2, + visibility: wgpu::ShaderStages::COMPUTE, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Storage { read_only: true }, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, + }, + // 3: destination texture (display canvas, write-only storage) + wgpu::BindGroupLayoutEntry { + binding: 3, + visibility: wgpu::ShaderStages::COMPUTE, + ty: wgpu::BindingType::StorageTexture { + access: wgpu::StorageTextureAccess::WriteOnly, + format: wgpu::TextureFormat::Rgba8Unorm, + view_dimension: wgpu::TextureViewDimension::D2, + }, + count: None, + }, + ], + }, + ); + + let pipeline_layout = device.create_pipeline_layout( + &wgpu::PipelineLayoutDescriptor { + label: Some("warp_apply_pl"), + bind_group_layouts: &[&bg_layout], + push_constant_ranges: &[], + }, + ); + + let pipeline = device.create_compute_pipeline( + &wgpu::ComputePipelineDescriptor { + label: Some("warp_apply_pipeline"), + layout: Some(&pipeline_layout), + module: &shader, + entry_point: Some("main"), + compilation_options: Default::default(), + cache: None, + }, + ); + + Self { pipeline, bg_layout } + } + + fn apply( + &self, + device: &wgpu::Device, + queue: &wgpu::Queue, + src_view: &wgpu::TextureView, + disp_buf: &wgpu::Buffer, + dst_view: &wgpu::TextureView, + params: WarpApplyParams, + ) { + use wgpu::util::DeviceExt; + + let uniform_buf = device.create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: Some("warp_apply_params"), + contents: bytemuck::bytes_of(¶ms), + usage: wgpu::BufferUsages::UNIFORM, + }); + + let bg = device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("warp_apply_bg"), + layout: &self.bg_layout, + entries: &[ + wgpu::BindGroupEntry { binding: 0, resource: uniform_buf.as_entire_binding() }, + wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::TextureView(src_view) }, + wgpu::BindGroupEntry { binding: 2, resource: disp_buf.as_entire_binding() }, + wgpu::BindGroupEntry { binding: 3, resource: wgpu::BindingResource::TextureView(dst_view) }, + ], + }); + + let mut encoder = device.create_command_encoder( + &wgpu::CommandEncoderDescriptor { label: Some("warp_apply_enc") }, + ); + { + let mut pass = encoder.begin_compute_pass( + &wgpu::ComputePassDescriptor { label: Some("warp_apply_pass"), timestamp_writes: None }, + ); + pass.set_pipeline(&self.pipeline); + pass.set_bind_group(0, &bg, &[]); + pass.dispatch_workgroups(params.dst_w.div_ceil(8), params.dst_h.div_ceil(8), 1); + } + queue.submit(Some(encoder.finish())); + } +} + +// --------------------------------------------------------------------------- +// Liquify-brush pipeline +// --------------------------------------------------------------------------- + +/// CPU-side parameters uniform for `liquify_brush.wgsl`. +/// Must match the `Params` struct in the shader (48 bytes, 16-byte aligned). +#[repr(C)] +#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)] +pub struct LiquifyBrushParams { + pub cx: f32, + pub cy: f32, + pub radius: f32, + pub strength: f32, + pub dx: f32, + pub dy: f32, + pub mode: u32, + pub map_w: u32, + pub map_h: u32, + pub _pad0: u32, + pub _pad1: u32, + pub _pad2: u32, +} + +/// Compute pipeline that updates a displacement map from a single brush step. +struct LiquifyBrushPipeline { + pipeline: wgpu::ComputePipeline, + bg_layout: wgpu::BindGroupLayout, +} + +impl LiquifyBrushPipeline { + fn new(device: &wgpu::Device) -> Self { + let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("liquify_brush_shader"), + source: wgpu::ShaderSource::Wgsl( + include_str!("panes/shaders/liquify_brush.wgsl").into(), + ), + }); + + let bg_layout = device.create_bind_group_layout( + &wgpu::BindGroupLayoutDescriptor { + label: Some("liquify_brush_bgl"), + entries: &[ + // 0: params uniform + wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::COMPUTE, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Uniform, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, + }, + // 1: displacement buffer (read-write storage) + wgpu::BindGroupLayoutEntry { + binding: 1, + visibility: wgpu::ShaderStages::COMPUTE, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Storage { read_only: false }, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, + }, + ], + }, + ); + + let pipeline_layout = device.create_pipeline_layout( + &wgpu::PipelineLayoutDescriptor { + label: Some("liquify_brush_pl"), + bind_group_layouts: &[&bg_layout], + push_constant_ranges: &[], + }, + ); + + let pipeline = device.create_compute_pipeline( + &wgpu::ComputePipelineDescriptor { + label: Some("liquify_brush_pipeline"), + layout: Some(&pipeline_layout), + module: &shader, + entry_point: Some("main"), + compilation_options: Default::default(), + cache: None, + }, + ); + + Self { pipeline, bg_layout } + } + + fn update_displacement( + &self, + device: &wgpu::Device, + queue: &wgpu::Queue, + disp_buf: &wgpu::Buffer, + params: LiquifyBrushParams, + ) { + use wgpu::util::DeviceExt; + + let uniform_buf = device.create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: Some("liquify_brush_params"), + contents: bytemuck::bytes_of(¶ms), + usage: wgpu::BufferUsages::UNIFORM, + }); + + let bg = device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("liquify_brush_bg"), + layout: &self.bg_layout, + entries: &[ + wgpu::BindGroupEntry { binding: 0, resource: uniform_buf.as_entire_binding() }, + wgpu::BindGroupEntry { binding: 1, resource: disp_buf.as_entire_binding() }, + ], + }); + + let r = params.radius.ceil() as u32; + let wg_x = (2 * r + 1).div_ceil(8).max(1); + let wg_y = (2 * r + 1).div_ceil(8).max(1); + + let mut encoder = device.create_command_encoder( + &wgpu::CommandEncoderDescriptor { label: Some("liquify_brush_enc") }, + ); + { + let mut pass = encoder.begin_compute_pass( + &wgpu::ComputePassDescriptor { label: Some("liquify_brush_pass"), timestamp_writes: None }, + ); + pass.set_pipeline(&self.pipeline); + pass.set_bind_group(0, &bg, &[]); + pass.dispatch_workgroups(wg_x, wg_y, 1); + } + queue.submit(Some(encoder.finish())); + } +} + +// --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- +// Gradient-fill pipeline +// --------------------------------------------------------------------------- + +/// One gradient stop on the GPU side. Colors are linear straight-alpha [0..1]. +/// Must be 32 bytes (8 × f32) to match `GradientStop` in `gradient_fill.wgsl`. +#[repr(C)] +#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)] +pub struct GpuGradientStop { + pub position: f32, + pub r: f32, + pub g: f32, + pub b: f32, + pub a: f32, + pub _pad: [f32; 3], +} + +impl GpuGradientStop { + /// Construct from sRGB u8 bytes (as stored in `ShapeColor`). + /// RGB is converted to linear; alpha is kept linear (not gamma-encoded). + pub fn from_srgb_u8(position: f32, r: u8, g: u8, b: u8, a: u8) -> Self { + Self { + position, + r: srgb_to_linear(r as f32 / 255.0), + g: srgb_to_linear(g as f32 / 255.0), + b: srgb_to_linear(b as f32 / 255.0), + a: a as f32 / 255.0, + _pad: [0.0; 3], + } + } +} + +/// CPU-side parameters uniform for `gradient_fill.wgsl`. +/// Must be 48 bytes (12 × u32/f32), 16-byte aligned. +#[repr(C)] +#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)] +struct GradientFillParams { + canvas_w: u32, + canvas_h: u32, + start_x: f32, + start_y: f32, + end_x: f32, + end_y: f32, + opacity: f32, + extend_mode: u32, // 0 = Pad, 1 = Reflect, 2 = Repeat + num_stops: u32, + kind: u32, // 0 = Linear, 1 = Radial + _pad1: u32, + _pad2: u32, +} + +/// Compute pipeline: composites a gradient over an anchor canvas → display canvas. +struct GradientFillPipeline { + pipeline: wgpu::ComputePipeline, + bg_layout: wgpu::BindGroupLayout, +} + +impl GradientFillPipeline { + fn new(device: &wgpu::Device) -> Self { + let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("gradient_fill_shader"), + source: wgpu::ShaderSource::Wgsl( + include_str!("panes/shaders/gradient_fill.wgsl").into(), + ), + }); + + let bg_layout = device.create_bind_group_layout( + &wgpu::BindGroupLayoutDescriptor { + label: Some("gradient_fill_bgl"), + entries: &[ + // 0: params uniform + wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::COMPUTE, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Uniform, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, + }, + // 1: anchor (source) canvas + wgpu::BindGroupLayoutEntry { + binding: 1, + visibility: wgpu::ShaderStages::COMPUTE, + ty: wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Float { filterable: false }, + view_dimension: wgpu::TextureViewDimension::D2, + multisampled: false, + }, + count: None, + }, + // 2: gradient stops (read-only storage buffer) + wgpu::BindGroupLayoutEntry { + binding: 2, + visibility: wgpu::ShaderStages::COMPUTE, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Storage { read_only: true }, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, + }, + // 3: display (destination) canvas — write-only storage texture + wgpu::BindGroupLayoutEntry { + binding: 3, + visibility: wgpu::ShaderStages::COMPUTE, + ty: wgpu::BindingType::StorageTexture { + access: wgpu::StorageTextureAccess::WriteOnly, + format: wgpu::TextureFormat::Rgba8Unorm, + view_dimension: wgpu::TextureViewDimension::D2, + }, + count: None, + }, + ], + }, + ); + + let pipeline_layout = device.create_pipeline_layout( + &wgpu::PipelineLayoutDescriptor { + label: Some("gradient_fill_pl"), + bind_group_layouts: &[&bg_layout], + push_constant_ranges: &[], + }, + ); + + let pipeline = device.create_compute_pipeline( + &wgpu::ComputePipelineDescriptor { + label: Some("gradient_fill_pipeline"), + layout: Some(&pipeline_layout), + module: &shader, + entry_point: Some("main"), + compilation_options: Default::default(), + cache: None, + }, + ); + + Self { pipeline, bg_layout } + } + + fn apply( + &self, + device: &wgpu::Device, + queue: &wgpu::Queue, + src_view: &wgpu::TextureView, + stops_buf: &wgpu::Buffer, + dst_view: &wgpu::TextureView, + params: GradientFillParams, + ) { + use wgpu::util::DeviceExt; + + let uniform_buf = device.create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: Some("gradient_fill_params"), + contents: bytemuck::bytes_of(¶ms), + usage: wgpu::BufferUsages::UNIFORM, + }); + + let bg = device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("gradient_fill_bg"), + layout: &self.bg_layout, + entries: &[ + wgpu::BindGroupEntry { binding: 0, resource: uniform_buf.as_entire_binding() }, + wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::TextureView(src_view) }, + wgpu::BindGroupEntry { binding: 2, resource: stops_buf.as_entire_binding() }, + wgpu::BindGroupEntry { binding: 3, resource: wgpu::BindingResource::TextureView(dst_view) }, + ], + }); + + let mut encoder = device.create_command_encoder( + &wgpu::CommandEncoderDescriptor { label: Some("gradient_fill_enc") }, + ); + { + let mut pass = encoder.begin_compute_pass( + &wgpu::ComputePassDescriptor { label: Some("gradient_fill_pass"), timestamp_writes: None }, + ); + pass.set_pipeline(&self.pipeline); + pass.set_bind_group(0, &bg, &[]); + pass.dispatch_workgroups(params.canvas_w.div_ceil(8), params.canvas_h.div_ceil(8), 1); + } + queue.submit(Some(encoder.finish())); + } +} + // GpuBrushEngine // --------------------------------------------------------------------------- @@ -301,8 +786,18 @@ pub struct GpuBrushEngine { /// Lazily created on first raster transform use. transform_pipeline: Option, + /// Lazily created on first warp/liquify use. + warp_apply_pipeline: Option, + /// Lazily created on first liquify brush use. + liquify_brush_pipeline: Option, + /// Lazily created on first gradient fill use. + gradient_fill_pipeline: Option, + /// Canvas texture pairs keyed by keyframe UUID. pub canvases: HashMap, + + /// Displacement map buffers keyed by a caller-supplied UUID. + pub displacement_bufs: HashMap, } /// CPU-side parameters uniform for the compute shader. @@ -404,8 +899,12 @@ impl GpuBrushEngine { Self { compute_pipeline, compute_bg_layout, - transform_pipeline: None, - canvases: HashMap::new(), + transform_pipeline: None, + warp_apply_pipeline: None, + liquify_brush_pipeline: None, + gradient_fill_pipeline: None, + canvases: HashMap::new(), + displacement_bufs: HashMap::new(), } } @@ -803,6 +1302,211 @@ impl GpuBrushEngine { } } } + + // ----------------------------------------------------------------------- + // Displacement buffer management + // ----------------------------------------------------------------------- + + /// Create a zero-initialised displacement buffer of `width × height` vec2f entries. + /// Returns the UUID under which it is stored. + pub fn create_displacement_buf( + &mut self, + device: &wgpu::Device, + id: Uuid, + width: u32, + height: u32, + ) { + let byte_len = (width * height * 8) as u64; // 2 × f32 per pixel + let buf = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("displacement_buf"), + size: byte_len, + usage: wgpu::BufferUsages::STORAGE + | wgpu::BufferUsages::COPY_DST + | wgpu::BufferUsages::COPY_SRC, + mapped_at_creation: false, + }); + self.displacement_bufs.insert(id, DisplacementBuffer { buf, width, height }); + } + + /// Overwrite the displacement buffer contents with the provided data. + pub fn upload_displacement_buf( + &self, + queue: &wgpu::Queue, + id: &Uuid, + data: &[[f32; 2]], + ) { + if let Some(db) = self.displacement_bufs.get(id) { + queue.write_buffer(&db.buf, 0, bytemuck::cast_slice(data)); + } + } + + /// Zero out a displacement buffer (reset all displacements to (0,0)). + pub fn clear_displacement_buf(&self, queue: &wgpu::Queue, id: &Uuid) { + if let Some(db) = self.displacement_bufs.get(id) { + let zeros = vec![0u8; (db.width * db.height * 8) as usize]; + queue.write_buffer(&db.buf, 0, &zeros); + } + } + + /// Remove a displacement buffer (e.g. when the warp/liquify operation ends). + pub fn remove_displacement_buf(&mut self, id: &Uuid) { + self.displacement_bufs.remove(id); + } + + // ----------------------------------------------------------------------- + // Warp apply (shared by Warp and Liquify tools) + // ----------------------------------------------------------------------- + + /// Upload `disp_data` to the displacement buffer and then run the warp-apply + /// shader from `anchor_id` → `display_id`. The display canvas is swapped after. + /// + /// If `disp_data` is `None` the buffer is not re-uploaded (used by Liquify which + /// updates the buffer in-place via `liquify_brush_step`). + /// Apply warp displacement to produce the display canvas. + /// + /// `disp_data`: if `Some`, upload this data to the displacement buffer before running. + /// `grid_cols/grid_rows`: if > 0, the disp buffer contains only that many vec2f entries + /// (control-point grid mode). The shader does bilinear interpolation per pixel. + /// If 0, the buffer is a full per-pixel map (Liquify mode). + pub fn apply_warp( + &mut self, + device: &wgpu::Device, + queue: &wgpu::Queue, + anchor_id: &Uuid, + disp_id: &Uuid, + display_id: &Uuid, + disp_data: Option<&[[f32; 2]]>, + grid_cols: u32, + grid_rows: u32, + ) { + // Upload new displacement data if provided. + if let Some(data) = disp_data { + if let Some(db) = self.displacement_bufs.get(disp_id) { + queue.write_buffer(&db.buf, 0, bytemuck::cast_slice(data)); + } + } + + let pipeline = self.warp_apply_pipeline + .get_or_insert_with(|| WarpApplyPipeline::new(device)); + + let dispatched = { + let anchor = self.canvases.get(anchor_id); + let display = self.canvases.get(display_id); + let disp_b = self.displacement_bufs.get(disp_id); + if let (Some(anchor), Some(display), Some(db)) = (anchor, display, disp_b) { + let params = WarpApplyParams { + src_w: anchor.width, + src_h: anchor.height, + dst_w: display.width, + dst_h: display.height, + grid_cols, + grid_rows, + _pad0: 0, + _pad1: 0, + }; + pipeline.apply(device, queue, anchor.src_view(), &db.buf, display.dst_view(), params); + true + } else { + false + } + }; + + if dispatched { + if let Some(display) = self.canvases.get_mut(display_id) { + display.swap(); + } + } + } + + // ----------------------------------------------------------------------- + // Liquify brush step + // ----------------------------------------------------------------------- + + // ----------------------------------------------------------------------- + // Gradient fill + // ----------------------------------------------------------------------- + + /// Composite a gradient over the anchor canvas into the display canvas. + /// + /// - `anchor_id`: canvas holding the original pixels (read-only each frame). + /// - `display_id`: canvas to write the gradient result into. + /// - `stops`: gradient stops (linear straight-alpha, converted from sRGB by caller). + /// - `start`, `end`: gradient axis endpoints in canvas pixels. + /// - `opacity`: overall tool opacity [0..1]. + /// - `extend_mode`: 0 = Pad, 1 = Reflect, 2 = Repeat. + pub fn apply_gradient_fill( + &mut self, + device: &wgpu::Device, + queue: &wgpu::Queue, + anchor_id: &Uuid, + display_id: &Uuid, + stops: &[GpuGradientStop], + start: (f32, f32), + end: (f32, f32), + opacity: f32, + extend_mode: u32, + kind: u32, + ) { + use wgpu::util::DeviceExt; + + let pipeline = self.gradient_fill_pipeline + .get_or_insert_with(|| GradientFillPipeline::new(device)); + + // Build the stops storage buffer. + let stops_buf = device.create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: Some("gradient_stops_buf"), + contents: bytemuck::cast_slice(stops), + usage: wgpu::BufferUsages::STORAGE, + }); + + let dispatched = { + let anchor = self.canvases.get(anchor_id); + let display = self.canvases.get(display_id); + if let (Some(anchor), Some(display)) = (anchor, display) { + let params = GradientFillParams { + canvas_w: anchor.width, + canvas_h: anchor.height, + start_x: start.0, + start_y: start.1, + end_x: end.0, + end_y: end.1, + opacity, + extend_mode, + num_stops: stops.len() as u32, + kind, + _pad1: 0, _pad2: 0, + }; + pipeline.apply(device, queue, anchor.src_view(), &stops_buf, display.dst_view(), params); + true + } else { + false + } + }; + + if dispatched { + if let Some(display) = self.canvases.get_mut(display_id) { + display.swap(); + } + } + } + + /// Dispatch the liquify-brush compute shader to update the displacement map. + pub fn liquify_brush_step( + &mut self, + device: &wgpu::Device, + queue: &wgpu::Queue, + disp_id: &Uuid, + params: LiquifyBrushParams, + ) { + if !self.displacement_bufs.contains_key(disp_id) { return; } + + let pipeline = self.liquify_brush_pipeline + .get_or_insert_with(|| LiquifyBrushPipeline::new(device)); + + if let Some(db) = self.displacement_bufs.get(disp_id) { + pipeline.update_displacement(device, queue, &db.buf, params); + } + } } // --------------------------------------------------------------------------- diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/gradient_editor.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/gradient_editor.rs new file mode 100644 index 0000000..b7da34d --- /dev/null +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/gradient_editor.rs @@ -0,0 +1,261 @@ +//! Gradient stop editor widget. +//! +//! Call [`gradient_stop_editor`] inside any egui layout; it returns `true` when +//! `gradient` was modified. + +use eframe::egui::{self, Color32, DragValue, Painter, Rect, Sense, Stroke, Vec2}; +use lightningbeam_core::gradient::{GradientExtend, GradientStop, GradientType, ShapeGradient}; +use lightningbeam_core::shape::ShapeColor; + +// ── Public entry point ─────────────────────────────────────────────────────── + +/// Render an inline gradient editor. +/// +/// * `gradient` – the gradient being edited (mutated in place). +/// * `selected_stop` – index of the currently selected stop (persisted by caller). +/// +/// Returns `true` if anything changed. +pub fn gradient_stop_editor( + ui: &mut egui::Ui, + gradient: &mut ShapeGradient, + selected_stop: &mut Option, +) -> bool { + let mut changed = false; + + // ── Row 1: Kind + angle ─────────────────────────────────────────────── + ui.horizontal(|ui| { + let was_linear = gradient.kind == GradientType::Linear; + if ui.selectable_label(was_linear, "Linear").clicked() && !was_linear { + gradient.kind = GradientType::Linear; + changed = true; + } + if ui.selectable_label(!was_linear, "Radial").clicked() && was_linear { + gradient.kind = GradientType::Radial; + changed = true; + } + if gradient.kind == GradientType::Linear { + ui.add_space(8.0); + ui.label("Angle:"); + if ui.add( + DragValue::new(&mut gradient.angle) + .speed(1.0) + .range(-360.0..=360.0) + .suffix("°"), + ).changed() { + changed = true; + } + } + }); + + // ── Gradient bar + handles ──────────────────────────────────────────── + let bar_height = 22.0_f32; + let handle_h = 14.0_f32; + let total_height = bar_height + handle_h + 4.0; + let avail_w = ui.available_width(); + + let (bar_rect, bar_resp) = ui.allocate_exact_size( + Vec2::new(avail_w, total_height), + Sense::click(), + ); + let painter = ui.painter_at(bar_rect); + + let bar = Rect::from_min_size(bar_rect.min, Vec2::new(avail_w, bar_height)); + let track = Rect::from_min_size( + egui::pos2(bar_rect.min.x, bar_rect.min.y + bar_height + 2.0), + Vec2::new(avail_w, handle_h), + ); + + // Draw checkerboard background (transparent indicator). + draw_checker(&painter, bar); + + // Draw gradient bar as N segments. + let seg = 128_usize; + for i in 0..seg { + let t0 = i as f32 / seg as f32; + let t1 = (i + 1) as f32 / seg as f32; + let t = (t0 + t1) * 0.5; + let [r, g, b, a] = gradient.eval(t); + let col = Color32::from_rgba_unmultiplied(r, g, b, a); + let x0 = bar.min.x + t0 * bar.width(); + let x1 = bar.min.x + t1 * bar.width(); + let seg_rect = Rect::from_min_max( + egui::pos2(x0, bar.min.y), + egui::pos2(x1, bar.max.y), + ); + painter.rect_filled(seg_rect, 0.0, col); + } + // Outline. + painter.rect_stroke(bar, 2.0, Stroke::new(1.0, Color32::from_gray(60)), eframe::egui::StrokeKind::Middle); + + // Click on bar → add stop. + if bar_resp.clicked() { + if let Some(pos) = bar_resp.interact_pointer_pos() { + if bar.contains(pos) { + let t = ((pos.x - bar.min.x) / bar.width()).clamp(0.0, 1.0); + let [r, g, b, a] = gradient.eval(t); + gradient.stops.push(GradientStop { + position: t, + color: ShapeColor::rgba(r, g, b, a), + }); + gradient.stops.sort_by(|a, b| a.position.partial_cmp(&b.position).unwrap()); + *selected_stop = gradient.stops.iter().position(|s| s.position == t); + changed = true; + } + } + } + + // Draw stop handles. + // We need to detect drags per-handle, so allocate individual rects with the + // regular egui input model. To avoid borrow conflicts we collect interactions + // before mutating. + let handle_w = 10.0_f32; + let n_stops = gradient.stops.len(); + + let mut drag_idx: Option = None; + let mut drag_delta: f32 = 0.0; + let mut click_idx: Option = None; + + // To render handles after collecting, remember their rects. + let handle_rects: Vec = (0..n_stops).map(|i| { + let cx = track.min.x + gradient.stops[i].position * track.width(); + Rect::from_center_size( + egui::pos2(cx, track.center().y), + Vec2::new(handle_w, handle_h), + ) + }).collect(); + + for (i, &h_rect) in handle_rects.iter().enumerate() { + let resp = ui.interact(h_rect, ui.id().with(("grad_handle", i)), Sense::click_and_drag()); + if resp.dragged() { + drag_idx = Some(i); + drag_delta = resp.drag_delta().x / track.width(); + } + if resp.clicked() { + click_idx = Some(i); + } + } + + // Apply drag. + if let (Some(i), delta) = (drag_idx, drag_delta) { + if delta != 0.0 { + let new_pos = (gradient.stops[i].position + delta).clamp(0.0, 1.0); + gradient.stops[i].position = new_pos; + // Re-sort and track the moved stop. + gradient.stops.sort_by(|a, b| a.position.partial_cmp(&b.position).unwrap()); + // Find new index of the moved stop (closest position match). + if let Some(ref mut sel) = *selected_stop { + // Re-find by position proximity. + *sel = gradient.stops.iter().enumerate() + .min_by(|(_, a), (_, b)| { + let pa = (a.position - (gradient.stops.get(i).map_or(0.0, |s| s.position))).abs(); + let pb = (b.position - (gradient.stops.get(i).map_or(0.0, |s| s.position))).abs(); + pa.partial_cmp(&pb).unwrap() + }) + .map(|(idx, _)| idx) + .unwrap_or(0); + } + changed = true; + } + } + + if let Some(i) = click_idx { + *selected_stop = Some(i); + } + + // Paint handles on top (after interaction so they visually react). + for (i, h_rect) in handle_rects.iter().enumerate() { + let col = ShapeColor_to_Color32(gradient.stops[i].color); + let is_selected = *selected_stop == Some(i); + + // Draw a downward-pointing triangle. + let cx = h_rect.center().x; + let top = h_rect.min.y; + let bot = h_rect.max.y; + let hw = h_rect.width() * 0.5; + let tri = vec![ + egui::pos2(cx, bot), + egui::pos2(cx - hw, top), + egui::pos2(cx + hw, top), + ]; + painter.add(egui::Shape::convex_polygon( + tri, + col, + Stroke::new(if is_selected { 2.0 } else { 1.0 }, + if is_selected { Color32::WHITE } else { Color32::from_gray(100) }), + )); + } + + // ── Selected stop detail ────────────────────────────────────────────── + if let Some(i) = *selected_stop { + if i < gradient.stops.len() { + ui.separator(); + ui.horizontal(|ui| { + let stop = &mut gradient.stops[i]; + let mut rgba = [stop.color.r, stop.color.g, stop.color.b, stop.color.a]; + if ui.color_edit_button_srgba_unmultiplied(&mut rgba).changed() { + stop.color = ShapeColor::rgba(rgba[0], rgba[1], rgba[2], rgba[3]); + changed = true; + } + ui.label("Position:"); + if ui.add( + DragValue::new(&mut stop.position) + .speed(0.005) + .range(0.0..=1.0), + ).changed() { + gradient.stops.sort_by(|a, b| a.position.partial_cmp(&b.position).unwrap()); + changed = true; + } + let can_remove = gradient.stops.len() > 2; + if ui.add_enabled(can_remove, egui::Button::new("− Remove")).clicked() { + gradient.stops.remove(i); + *selected_stop = None; + changed = true; + } + }); + } else { + *selected_stop = None; + } + } + + // ── Extend mode ─────────────────────────────────────────────────────── + ui.horizontal(|ui| { + ui.label("Extend:"); + if ui.selectable_label(gradient.extend == GradientExtend::Pad, "Pad").clicked() { + gradient.extend = GradientExtend::Pad; changed = true; + } + if ui.selectable_label(gradient.extend == GradientExtend::Reflect, "Reflect").clicked() { + gradient.extend = GradientExtend::Reflect; changed = true; + } + if ui.selectable_label(gradient.extend == GradientExtend::Repeat, "Repeat").clicked() { + gradient.extend = GradientExtend::Repeat; changed = true; + } + }); + + changed +} + +// ── Helpers ────────────────────────────────────────────────────────────────── + +fn ShapeColor_to_Color32(c: ShapeColor) -> Color32 { + Color32::from_rgba_unmultiplied(c.r, c.g, c.b, c.a) +} + +/// Draw a small grey/white checkerboard inside `rect`. +fn draw_checker(painter: &Painter, rect: Rect) { + let cell = 6.0_f32; + let cols = ((rect.width() / cell).ceil() as u32).max(1); + let rows = ((rect.height() / cell).ceil() as u32).max(1); + for row in 0..rows { + for col in 0..cols { + let light = (row + col) % 2 == 0; + let col32 = if light { Color32::from_gray(200) } else { Color32::from_gray(140) }; + let x = rect.min.x + col as f32 * cell; + let y = rect.min.y + row as f32 * cell; + let r = Rect::from_min_size( + egui::pos2(x, y), + Vec2::splat(cell), + ).intersect(rect); + painter.rect_filled(r, 0.0, col32); + } + } +} diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/infopanel.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/infopanel.rs index a1593b8..55bf6c2 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/infopanel.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/infopanel.rs @@ -12,12 +12,14 @@ use eframe::egui::{self, DragValue, Ui}; use lightningbeam_core::brush_settings::{bundled_brushes, BrushSettings}; -use lightningbeam_core::actions::{SetDocumentPropertiesAction, SetShapePropertiesAction}; +use lightningbeam_core::actions::{SetDocumentPropertiesAction, SetShapePropertiesAction, SetFillPaintAction}; +use lightningbeam_core::gradient::ShapeGradient; use lightningbeam_core::layer::{AnyLayer, LayerTrait}; use lightningbeam_core::selection::FocusSelection; use lightningbeam_core::shape::ShapeColor; use lightningbeam_core::tool::{SimplifyMode, Tool}; use super::{NodePath, PaneRenderer, SharedPaneState}; +use super::gradient_editor::gradient_stop_editor; use uuid::Uuid; /// Info panel pane state @@ -36,6 +38,10 @@ pub struct InfopanelPane { eraser_picker_expanded: bool, /// Cached preview textures, one per preset (populated lazily). brush_preview_textures: Vec, + /// Selected stop index for gradient editor in shape section. + selected_shape_gradient_stop: Option, + /// Selected stop index for gradient editor in tool section (gradient tool). + selected_tool_gradient_stop: Option, } impl InfopanelPane { @@ -50,6 +56,8 @@ impl InfopanelPane { selected_eraser_preset: default_eraser_idx, eraser_picker_expanded: false, brush_preview_textures: Vec::new(), + selected_shape_gradient_stop: None, + selected_tool_gradient_stop: None, } } } @@ -65,6 +73,8 @@ struct SelectionInfo { // Shape property values (None = mixed) fill_color: Option>, + /// None = mixed across selection; Some(None) = no gradient; Some(Some(g)) = all same gradient + fill_gradient: Option>, stroke_color: Option>, stroke_width: Option, } @@ -76,6 +86,7 @@ impl Default for SelectionInfo { dcel_count: 0, layer_id: None, fill_color: None, + fill_gradient: None, stroke_color: None, stroke_width: None, } @@ -138,21 +149,32 @@ impl InfopanelPane { // Gather fill properties from selected faces let mut first_fill_color: Option> = None; let mut fill_color_mixed = false; + let mut first_fill_gradient: Option> = None; + let mut fill_gradient_mixed = false; for &fid in shared.selection.selected_faces() { let face = dcel.face(fid); let fc = face.fill_color; + let fg = face.gradient_fill.clone(); match first_fill_color { None => first_fill_color = Some(fc), Some(prev) if prev != fc => fill_color_mixed = true, _ => {} } + match &first_fill_gradient { + None => first_fill_gradient = Some(fg), + Some(prev) if *prev != fg => fill_gradient_mixed = true, + _ => {} + } } if !fill_color_mixed { info.fill_color = first_fill_color; } + if !fill_gradient_mixed { + info.fill_gradient = first_fill_gradient; + } } } } @@ -191,6 +213,7 @@ impl InfopanelPane { || is_raster_select || is_raster_shape || matches!( tool, Tool::PaintBucket | Tool::RegionSelect | Tool::MagicWand | Tool::QuickSelect + | Tool::Warp | Tool::Liquify | Tool::Gradient ); if !has_options { @@ -414,6 +437,62 @@ impl InfopanelPane { }); } + Tool::Warp => { + ui.horizontal(|ui| { + ui.label("Grid:"); + let cols = shared.raster_settings.warp_grid_cols; + let rows = shared.raster_settings.warp_grid_rows; + for (label, c, r) in [("3×3", 3u32, 3u32), ("4×4", 4, 4), ("5×5", 5, 5), ("8×8", 8, 8)] { + let selected = cols == c && rows == r; + if ui.selectable_label(selected, label).clicked() { + shared.raster_settings.warp_grid_cols = c; + shared.raster_settings.warp_grid_rows = r; + } + } + }); + ui.small("Enter to commit · Escape to cancel"); + } + + Tool::Liquify => { + use crate::tools::LiquifyMode; + ui.horizontal(|ui| { + ui.label("Mode:"); + for (label, mode) in [ + ("Push", LiquifyMode::Push), + ("Pucker", LiquifyMode::Pucker), + ("Bloat", LiquifyMode::Bloat), + ("Smooth", LiquifyMode::Smooth), + ("Reconstruct", LiquifyMode::Reconstruct), + ] { + let selected = shared.raster_settings.liquify_mode == mode; + if ui.selectable_label(selected, label).clicked() { + shared.raster_settings.liquify_mode = mode; + } + } + }); + ui.horizontal(|ui| { + ui.label("Radius:"); + ui.add( + egui::Slider::new( + &mut shared.raster_settings.liquify_radius, + 5.0_f32..=500.0, + ) + .step_by(1.0), + ); + }); + ui.horizontal(|ui| { + ui.label("Strength:"); + ui.add( + egui::Slider::new( + &mut shared.raster_settings.liquify_strength, + 0.01_f32..=1.0, + ) + .step_by(0.01), + ); + }); + ui.small("Enter to commit · Escape to cancel"); + } + Tool::Polygon => { // Number of sides ui.horizontal(|ui| { @@ -461,6 +540,22 @@ impl InfopanelPane { }); } + Tool::Gradient if active_is_raster => { + ui.horizontal(|ui| { + ui.label("Opacity:"); + ui.add(egui::Slider::new( + &mut shared.raster_settings.gradient_opacity, + 0.0_f32..=1.0, + ).custom_formatter(|v, _| format!("{:.0}%", v * 100.0))); + }); + ui.add_space(4.0); + gradient_stop_editor( + ui, + &mut shared.raster_settings.gradient, + &mut self.selected_tool_gradient_stop, + ); + } + _ => {} } @@ -680,28 +775,72 @@ impl InfopanelPane { self.shape_section_open = true; ui.add_space(4.0); - // Fill color + // Fill — determine current fill type + let has_gradient = matches!(&info.fill_gradient, Some(Some(_))); + let has_solid = matches!(&info.fill_color, Some(Some(_))); + let fill_is_none = matches!(&info.fill_color, Some(None)) + && matches!(&info.fill_gradient, Some(None)); + let fill_mixed = info.fill_color.is_none() && info.fill_gradient.is_none(); + + // Fill type toggle row ui.horizontal(|ui| { ui.label("Fill:"); - match info.fill_color { - Some(Some(color)) => { + if fill_mixed { + ui.label("--"); + } else { + if ui.selectable_label(fill_is_none, "None").clicked() && !fill_is_none { + let action = SetFillPaintAction::solid( + layer_id, time, face_ids.clone(), None, + ); + shared.pending_actions.push(Box::new(action)); + } + if ui.selectable_label(has_solid || (!has_gradient && !fill_is_none), "Solid").clicked() && !has_solid { + // Switch to solid: use existing color or default to black + let color = info.fill_color.flatten() + .unwrap_or(ShapeColor::rgba(0, 0, 0, 255)); + let action = SetFillPaintAction::solid( + layer_id, time, face_ids.clone(), Some(color), + ); + shared.pending_actions.push(Box::new(action)); + } + if ui.selectable_label(has_gradient, "Gradient").clicked() && !has_gradient { + let grad = info.fill_gradient.clone().flatten() + .unwrap_or_default(); + let action = SetFillPaintAction::gradient( + layer_id, time, face_ids.clone(), Some(grad), + ); + shared.pending_actions.push(Box::new(action)); + } + } + }); + + // Solid fill color editor + if !fill_mixed && has_solid { + if let Some(Some(color)) = info.fill_color { + ui.horizontal(|ui| { let mut rgba = [color.r, color.g, color.b, color.a]; if ui.color_edit_button_srgba_unmultiplied(&mut rgba).changed() { let new_color = ShapeColor::rgba(rgba[0], rgba[1], rgba[2], rgba[3]); - let action = SetShapePropertiesAction::set_fill_color( + let action = SetFillPaintAction::solid( layer_id, time, face_ids.clone(), Some(new_color), ); shared.pending_actions.push(Box::new(action)); } - } - Some(None) => { - ui.label("None"); - } - None => { - ui.label("--"); + }); + } + } + + // Gradient fill editor + if !fill_mixed && has_gradient { + if let Some(Some(mut grad)) = info.fill_gradient.clone() { + if gradient_stop_editor(ui, &mut grad, &mut self.selected_shape_gradient_stop) { + let action = SetFillPaintAction::gradient( + layer_id, time, face_ids.clone(), Some(grad), + ); + shared.pending_actions.push(Box::new(action)); } } - }); + } // Stroke color ui.horizontal(|ui| { diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/mod.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/mod.rs index 27e20b3..ea0fb37 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/mod.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/mod.rs @@ -68,6 +68,7 @@ pub enum WebcamRecordCommand { pub mod toolbar; pub mod stage; +pub mod gradient_editor; pub mod timeline; pub mod infopanel; pub mod outliner; diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/shaders/gradient_fill.wgsl b/lightningbeam-ui/lightningbeam-editor/src/panes/shaders/gradient_fill.wgsl new file mode 100644 index 0000000..19e99db --- /dev/null +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/shaders/gradient_fill.wgsl @@ -0,0 +1,137 @@ +// GPU gradient fill shader. +// +// Reads the anchor canvas (before_pixels), composites a gradient over it, and +// writes the result to the display canvas. All color values in the canvas are +// linear premultiplied RGBA. The stop colors passed via `stops` are linear +// straight-alpha [0..1] (sRGB→linear conversion is done on the CPU). +// +// Dispatch: ceil(canvas_w / 8) × ceil(canvas_h / 8) × 1 + +struct Params { + canvas_w: u32, + canvas_h: u32, + start_x: f32, + start_y: f32, + end_x: f32, + end_y: f32, + opacity: f32, + extend_mode: u32, // 0 = Pad, 1 = Reflect, 2 = Repeat + num_stops: u32, + kind: u32, // 0 = Linear, 1 = Radial + _pad1: u32, + _pad2: u32, +} + +// 32 bytes per stop (8 × f32), matching `GpuGradientStop` on the Rust side. +struct GradientStop { + position: f32, + r: f32, // linear [0..1], straight-alpha + g: f32, + b: f32, + a: f32, + _pad0: f32, + _pad1: f32, + _pad2: f32, +} + +@group(0) @binding(0) var params: Params; +@group(0) @binding(1) var src: texture_2d; +@group(0) @binding(2) var stops: array; +@group(0) @binding(3) var dst: texture_storage_2d; + +fn apply_extend(t: f32) -> f32 { + if params.extend_mode == 0u { + // Pad: clamp to [0, 1] + return clamp(t, 0.0, 1.0); + } else if params.extend_mode == 1u { + // Reflect: 0→1→0→1→... + let t_abs = abs(t); + let period = floor(t_abs); + let frac = t_abs - period; + if (u32(period) & 1u) == 0u { + return frac; + } else { + return 1.0 - frac; + } + } else { + // Repeat: tile [0, 1) + return t - floor(t); + } +} + +fn eval_gradient(t: f32) -> vec4 { + let n = params.num_stops; + if n == 0u { return vec4(0.0); } + + let s0 = stops[0]; + if t <= s0.position { + return vec4(s0.r, s0.g, s0.b, s0.a); + } + + let sn = stops[n - 1u]; + if t >= sn.position { + return vec4(sn.r, sn.g, sn.b, sn.a); + } + + for (var i = 0u; i < n - 1u; i++) { + let sa = stops[i]; + let sb = stops[i + 1u]; + if t >= sa.position && t <= sb.position { + let span = sb.position - sa.position; + let f = select(0.0, (t - sa.position) / span, span > 0.0001); + return mix( + vec4(sa.r, sa.g, sa.b, sa.a), + vec4(sb.r, sb.g, sb.b, sb.a), + f, + ); + } + } + + return vec4(sn.r, sn.g, sn.b, sn.a); +} + +@compute @workgroup_size(8, 8) +fn main(@builtin(global_invocation_id) gid: vec3) { + if gid.x >= params.canvas_w || gid.y >= params.canvas_h { return; } + + // Anchor pixel (linear premultiplied RGBA). + let src_px = textureLoad(src, vec2(i32(gid.x), i32(gid.y)), 0); + + let dx = params.end_x - params.start_x; + let dy = params.end_y - params.start_y; + let px = f32(gid.x) + 0.5; + let py = f32(gid.y) + 0.5; + + var t_raw: f32 = 0.0; + if params.kind == 1u { + // Radial: center at start point, radius = |end-start|. + let radius = sqrt(dx * dx + dy * dy); + if radius >= 0.5 { + let pdx = px - params.start_x; + let pdy = py - params.start_y; + t_raw = sqrt(pdx * pdx + pdy * pdy) / radius; + } + } else { + // Linear: project pixel centre onto gradient axis (start → end). + let len2 = dx * dx + dy * dy; + if len2 >= 1.0 { + let fx = px - params.start_x; + let fy = py - params.start_y; + t_raw = (fx * dx + fy * dy) / len2; + } + } + + let t = apply_extend(t_raw); + let grad = eval_gradient(t); // straight-alpha linear RGBA + + // Effective alpha: gradient alpha × tool opacity. + let a = grad.a * params.opacity; + + // Alpha-over composite. + // src_px.rgb is premultiplied (= straight_rgb * src_a). + // Output is also premultiplied. + let out_a = a + src_px.a * (1.0 - a); + let out_rgb = grad.rgb * a + src_px.rgb * (1.0 - a); + + textureStore(dst, vec2(i32(gid.x), i32(gid.y)), vec4(out_rgb, out_a)); +} diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/shaders/liquify_brush.wgsl b/lightningbeam-ui/lightningbeam-editor/src/panes/shaders/liquify_brush.wgsl new file mode 100644 index 0000000..64de264 --- /dev/null +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/shaders/liquify_brush.wgsl @@ -0,0 +1,92 @@ +// GPU liquify-brush shader. +// +// Updates a per-pixel displacement map (array of vec2f) for one brush step. +// Each pixel within the brush radius receives a displacement contribution +// weighted by a Gaussian falloff. +// +// Modes: +// 0 = Push — displace in brush-drag direction (dx, dy) +// 1 = Pucker — pull toward brush center +// 2 = Bloat — push away from brush center +// 3 = Smooth — blend toward average of 4 cardinal neighbours +// 4 = Reconstruct — blend toward zero (gradually undo) +// +// Dispatch: ceil((2*radius+1) / 8) × ceil((2*radius+1) / 8) × 1 +// The CPU clips invocation IDs to the valid map range. + +struct Params { + cx: f32, // brush center x (canvas pixels) + cy: f32, // brush center y + radius: f32, // brush radius (canvas pixels) + strength: f32, // effect strength [0..1] + dx: f32, // push direction x (normalised by caller, Push mode only) + dy: f32, // push direction y + mode: u32, // 0=Push 1=Pucker 2=Bloat 3=Smooth 4=Reconstruct + map_w: u32, + map_h: u32, + _pad0: u32, + _pad1: u32, +} + +@group(0) @binding(0) var params: Params; +@group(0) @binding(1) var disp: array; + +@compute @workgroup_size(8, 8) +fn main(@builtin(global_invocation_id) gid: vec3) { + // Offset invocation into the brush bounding box so gid(0,0) = (cx-r, cy-r). + let base_x = floor(params.cx - params.radius); + let base_y = floor(params.cy - params.radius); + let px = base_x + f32(gid.x); + let py = base_y + f32(gid.y); + + // Clip to displacement map bounds. + if px < 0.0 || py < 0.0 { return; } + let map_x = u32(px); + let map_y = u32(py); + if map_x >= params.map_w || map_y >= params.map_h { return; } + + let ddx = px - params.cx; + let ddy = py - params.cy; + let dist2 = ddx * ddx + ddy * ddy; + let r2 = params.radius * params.radius; + + if dist2 > r2 { return; } + + // Gaussian influence: 1 at center, ~0.01 at edge (sigma = radius/2.15) + let influence = params.strength * exp(-dist2 / (r2 * 0.2)); + + let idx = map_y * params.map_w + map_x; + var d = disp[idx]; + + switch params.mode { + case 0u: { // Push + d = d + vec2f(params.dx, params.dy) * influence * params.radius; + } + case 1u: { // Pucker — toward center + let len = sqrt(dist2) + 0.0001; + d = d + vec2f(-ddx / len, -ddy / len) * influence * params.radius; + } + case 2u: { // Bloat — away from center + let len = sqrt(dist2) + 0.0001; + d = d + vec2f(ddx / len, ddy / len) * influence * params.radius; + } + case 3u: { // Smooth — blend toward average of 4 neighbours + let xi = i32(map_x); + let yi = i32(map_y); + let w = i32(params.map_w); + let h = i32(params.map_h); + let l = disp[u32(clamp(yi, 0, h-1)) * params.map_w + u32(clamp(xi - 1, 0, w-1))]; + let r = disp[u32(clamp(yi, 0, h-1)) * params.map_w + u32(clamp(xi + 1, 0, w-1))]; + let u = disp[u32(clamp(yi - 1, 0, h-1)) * params.map_w + u32(clamp(xi, 0, w-1))]; + let dn = disp[u32(clamp(yi + 1, 0, h-1)) * params.map_w + u32(clamp(xi, 0, w-1))]; + let avg = (l + r + u + dn) * 0.25; + d = mix(d, avg, influence * 0.5); + } + case 4u: { // Reconstruct — blend toward zero + d = mix(d, vec2f(0.0), influence * 0.5); + } + default: {} + } + + disp[idx] = d; +} diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/shaders/warp_apply.wgsl b/lightningbeam-ui/lightningbeam-editor/src/panes/shaders/warp_apply.wgsl new file mode 100644 index 0000000..1bd04f3 --- /dev/null +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/shaders/warp_apply.wgsl @@ -0,0 +1,103 @@ +// GPU warp-apply shader. +// +// Two modes selected by grid_cols / grid_rows: +// +// grid_cols == 0 (Liquify / per-pixel mode) +// disp[] is a full canvas-sized array. Each pixel reads its own entry. +// +// grid_cols > 0 (Warp control-point mode) +// disp[] contains only grid_cols * grid_rows vec2f displacements (one per +// control point). The shader bilinearly interpolates them so the CPU never +// needs to build or upload the full per-pixel buffer. +// +// Dispatch: ceil(dst_w / 8) × ceil(dst_h / 8) × 1 + +struct Params { + src_w: u32, + src_h: u32, + dst_w: u32, + dst_h: u32, + grid_cols: u32, // 0 = per-pixel mode + grid_rows: u32, + _pad0: u32, + _pad1: u32, +} + +@group(0) @binding(0) var params: Params; +@group(0) @binding(1) var src: texture_2d; +@group(0) @binding(2) var disp: array; +@group(0) @binding(3) var dst: texture_storage_2d; + +// Manual bilinear sample with clamp-to-edge (textureSample forbidden in compute shaders). +fn bilinear_sample(px: f32, py: f32) -> vec4 { + let sw = i32(params.src_w); + let sh = i32(params.src_h); + + let ix = i32(floor(px - 0.5)); + let iy = i32(floor(py - 0.5)); + let fx = fract(px - 0.5); + let fy = fract(py - 0.5); + + let x0 = clamp(ix, 0, sw - 1); + let x1 = clamp(ix + 1, 0, sw - 1); + let y0 = clamp(iy, 0, sh - 1); + let y1 = clamp(iy + 1, 0, sh - 1); + + let s00 = textureLoad(src, vec2(x0, y0), 0); + let s10 = textureLoad(src, vec2(x1, y0), 0); + let s01 = textureLoad(src, vec2(x0, y1), 0); + let s11 = textureLoad(src, vec2(x1, y1), 0); + + return mix(mix(s00, s10, fx), mix(s01, s11, fx), fy); +} + +// Bilinearly interpolate the control-point displacement grid. +fn grid_displacement(px: u32, py: u32) -> vec2f { + let cols = params.grid_cols; + let rows = params.grid_rows; + + // Normalised position in grid space [0 .. cols-1] × [0 .. rows-1]. + let gx = f32(px) / f32(params.dst_w - 1u) * f32(cols - 1u); + let gy = f32(py) / f32(params.dst_h - 1u) * f32(rows - 1u); + + let col0 = u32(floor(gx)); + let row0 = u32(floor(gy)); + let col1 = min(col0 + 1u, cols - 1u); + let row1 = min(row0 + 1u, rows - 1u); + let fx = gx - floor(gx); + let fy = gy - floor(gy); + + let d00 = disp[row0 * cols + col0]; + let d10 = disp[row0 * cols + col1]; + let d01 = disp[row1 * cols + col0]; + let d11 = disp[row1 * cols + col1]; + + return d00 * (1.0 - fx) * (1.0 - fy) + + d10 * fx * (1.0 - fy) + + d01 * (1.0 - fx) * fy + + d11 * fx * fy; +} + +@compute @workgroup_size(8, 8) +fn main(@builtin(global_invocation_id) gid: vec3) { + if gid.x >= params.dst_w || gid.y >= params.dst_h { return; } + + var d: vec2f; + if params.grid_cols > 0u { + d = grid_displacement(gid.x, gid.y); + } else { + d = disp[gid.y * params.dst_w + gid.x]; + } + + let sx = f32(gid.x) + d.x; + let sy = f32(gid.y) + d.y; + + var color: vec4; + if sx < 0.0 || sy < 0.0 || sx >= f32(params.src_w) || sy >= f32(params.src_h) { + color = vec4(0.0); + } else { + color = bilinear_sample(sx + 0.5, sy + 0.5); + } + + textureStore(dst, vec2(i32(gid.x), i32(gid.y)), color); +} diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs index 2299849..5530c7f 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs @@ -406,6 +406,12 @@ struct VelloRenderContext { pending_transform_dispatch: Option, /// When Some, override the float canvas blit with the display canvas during transform. transform_display: Option, + /// GPU ops for Warp/Liquify tools to dispatch in prepare(). + pending_warp_ops: Vec, + /// When Some, override the layer's raster blit with the warp display canvas. + warp_display: Option<(uuid::Uuid, uuid::Uuid)>, // (layer_id, display_canvas_id) + /// Pending GPU gradient fill dispatch for next prepare() frame. + pending_gradient_op: Option, /// Instance ID (for storing readback results in the global map). instance_id_for_readback: u64, /// The (layer_id, keyframe_id) of the raster layer with a live GPU canvas. @@ -634,6 +640,113 @@ impl egui_wgpu::CallbackTrait for VelloCallback { } } + // --- Gradient fill GPU dispatch --- + if let Some(ref op) = self.ctx.pending_gradient_op { + if let Ok(mut gpu_brush) = shared.gpu_brush.lock() { + // Ensure both canvases exist. + gpu_brush.ensure_canvas(device, op.anchor_canvas_id, op.w, op.h); + gpu_brush.ensure_canvas(device, op.display_canvas_id, op.w, op.h); + // Upload anchor pixels on the first frame (drag start). + if let Some(ref pixels) = op.anchor_pixels { + if let Some(canvas) = gpu_brush.canvases.get(&op.anchor_canvas_id) { + canvas.upload(queue, pixels); + } + } + // Dispatch gradient fill shader. + gpu_brush.apply_gradient_fill( + device, queue, + &op.anchor_canvas_id, + &op.display_canvas_id, + &op.stops, + (op.start_x, op.start_y), + (op.end_x, op.end_y), + op.opacity, + op.extend_mode, + op.kind, + ); + } + } + + // --- Warp / Liquify GPU dispatch --- + if !self.ctx.pending_warp_ops.is_empty() { + if let Ok(mut gpu_brush) = shared.gpu_brush.lock() { + let mut final_commit_result: Option = None; + + for op in self.ctx.pending_warp_ops.iter() { + match op { + PendingWarpOp::Init { anchor_canvas_id, display_canvas_id, disp_buf_id, w, h, anchor_pixels, is_liquify } => { + let (w, h) = (*w, *h); + // Always upload anchor_pixels: the GPU canvas may be stale + // (e.g. merge-down updated kf.raw_pixels but left GPU canvas with old content). + gpu_brush.ensure_canvas(device, *anchor_canvas_id, w, h); + if let Some(canvas) = gpu_brush.canvases.get(anchor_canvas_id) { + canvas.upload(queue, anchor_pixels); + } + gpu_brush.ensure_canvas(device, *display_canvas_id, w, h); + // Initialise displacement buffer and populate display canvas = anchor. + if !gpu_brush.displacement_bufs.contains_key(disp_buf_id) { + if *is_liquify { + // Liquify needs a full per-pixel buffer. + gpu_brush.create_displacement_buf(device, *disp_buf_id, w, h); + } else { + // Warp uses a 1×1 grid buffer (zero = identity). + gpu_brush.create_displacement_buf(device, *disp_buf_id, 1, 1); + } + gpu_brush.clear_displacement_buf(queue, disp_buf_id); + } + // Apply identity warp so display canvas immediately shows the anchor. + let (gc, gr) = if *is_liquify { (0, 0) } else { (1, 1) }; + gpu_brush.apply_warp(device, queue, anchor_canvas_id, disp_buf_id, display_canvas_id, None, gc, gr); + } + PendingWarpOp::WarpApply { anchor_canvas_id, disp_buf_id, display_canvas_id, disp_data, grid_cols, grid_rows, final_commit, layer_id, time, is_float_warp, .. } => { + // Resize displacement buffer if grid dimensions changed. + let needs_resize = gpu_brush.displacement_bufs.get(disp_buf_id) + .map_or(true, |db| db.width != *grid_cols || db.height != *grid_rows); + if needs_resize { + gpu_brush.remove_displacement_buf(disp_buf_id); + gpu_brush.create_displacement_buf(device, *disp_buf_id, *grid_cols, *grid_rows); + } + gpu_brush.apply_warp(device, queue, anchor_canvas_id, disp_buf_id, display_canvas_id, disp_data.as_deref(), *grid_cols, *grid_rows); + if *final_commit { + let after_pixels = gpu_brush.readback_canvas(device, queue, *display_canvas_id); + let before_pixels = gpu_brush.readback_canvas(device, queue, *anchor_canvas_id); + if let (Some(after), Some(before)) = (after_pixels, before_pixels) { + let canvas = gpu_brush.canvases.get(display_canvas_id); + let (fw, fh) = canvas.map(|c| (c.width, c.height)).unwrap_or((0, 0)); + final_commit_result = Some(WarpReadbackResult { layer_id: *layer_id, time: *time, before_pixels: before, after_pixels: after, width: fw, height: fh, display_canvas_id: *display_canvas_id, anchor_canvas_id: *anchor_canvas_id, is_float_warp: *is_float_warp }); + } + } + } + PendingWarpOp::LiquifyBrushStep { disp_buf_id, params } => { + gpu_brush.liquify_brush_step(device, queue, disp_buf_id, *params); + } + PendingWarpOp::LiquifyApply { anchor_canvas_id, disp_buf_id, display_canvas_id, final_commit, layer_id, time, is_float_warp, .. } => { + // Per-pixel mode: grid_cols = 0. + gpu_brush.apply_warp(device, queue, anchor_canvas_id, disp_buf_id, display_canvas_id, None, 0, 0); + if *final_commit { + let after_pixels = gpu_brush.readback_canvas(device, queue, *display_canvas_id); + let before_pixels = gpu_brush.readback_canvas(device, queue, *anchor_canvas_id); + if let (Some(after), Some(before)) = (after_pixels, before_pixels) { + let canvas = gpu_brush.canvases.get(display_canvas_id); + let (fw, fh) = canvas.map(|c| (c.width, c.height)).unwrap_or((0, 0)); + final_commit_result = Some(WarpReadbackResult { layer_id: *layer_id, time: *time, before_pixels: before, after_pixels: after, width: fw, height: fh, display_canvas_id: *display_canvas_id, anchor_canvas_id: *anchor_canvas_id, is_float_warp: *is_float_warp }); + } + } + } + } + } + + if let Some(result) = final_commit_result { + let results = WARP_READBACK_RESULTS.get_or_init(|| { + Arc::new(Mutex::new(std::collections::HashMap::new())) + }); + if let Ok(mut map) = results.lock() { + map.insert(self.ctx.instance_id_for_readback, result); + } + } + } + } + // Generate brush preview thumbnails on first use (one-time, blocking readback). if let Ok(mut previews) = self.ctx.brush_preview_pixels.try_lock() { if previews.is_empty() { @@ -836,6 +949,10 @@ impl egui_wgpu::CallbackTrait for VelloCallback { self.ctx.painting_canvas .filter(|(layer_id, _)| *layer_id == rendered_layer.layer_id) .map(|(_, kf_id)| kf_id) + // Warp/Liquify: show display canvas in place of layer. + .or_else(|| self.ctx.warp_display + .filter(|(layer_id, _)| *layer_id == rendered_layer.layer_id) + .map(|(_, display_id)| display_id)) }; if !rendered_layer.has_content && gpu_canvas_kf.is_none() { @@ -2390,6 +2507,16 @@ pub struct StagePane { pending_transform_dispatch: Option, /// Accumulated state for the quick-select brush tool. quick_select_state: Option, + /// Live state for the Warp tool. + warp_state: Option, + /// Live state for the Liquify tool. + liquify_state: Option, + /// Live state for the Gradient fill tool. + gradient_state: Option, + /// GPU gradient fill dispatch to run next prepare() frame. + pending_gradient_op: Option, + /// GPU ops for Warp/Liquify to dispatch in prepare(). + pending_warp_ops: Vec, /// Synthetic drag/click override for test mode replay (debug builds only) #[cfg(debug_assertions)] replay_override: Option, @@ -2416,6 +2543,144 @@ struct QuickSelectState { last_pos: (i32, i32), } +/// Live state for an ongoing raster Warp operation. +struct WarpState { + layer_id: uuid::Uuid, + time: f64, + /// Anchor canvas: existing keyframe GPU canvas (kf.id), read-only during warp. + anchor_canvas_id: uuid::Uuid, + /// Display canvas: warp-shader output shown in place of the layer. + display_canvas_id: uuid::Uuid, + /// Displacement map buffer (zero = no deformation). + disp_buf_id: uuid::Uuid, + anchor_w: u32, + anchor_h: u32, + grid_cols: u32, + grid_rows: u32, + /// Per-control-point state: [home_x, home_y, displaced_x, displaced_y]. + /// Coordinates are in world space (canvas pixels, offset by float_offset if float warp). + control_points: Vec<[f32; 4]>, + /// Index of the control point being dragged (if any). + active_point: Option, + /// Index of the control point the cursor is currently over. + hovered_point: Option, + /// True when control points changed and a GPU re-apply is needed. + dirty: bool, + /// True once the first warp dispatch has been sent (display canvas has content). + warp_applied: bool, + /// True after Enter: waiting for final readback. + wants_commit: bool, + /// When warping a floating selection: its world-space top-left offset. + /// None = warping the full layer canvas. + float_offset: Option<(i32, i32)>, +} + +/// Live state for an ongoing raster Liquify operation. +struct LiquifyState { + layer_id: uuid::Uuid, + time: f64, + /// Anchor canvas: existing keyframe GPU canvas (kf.id), read-only during liquify. + anchor_canvas_id: uuid::Uuid, + display_canvas_id: uuid::Uuid, + disp_buf_id: uuid::Uuid, + anchor_w: u32, + anchor_h: u32, + /// Last brush position (canvas pixels) for debouncing. + last_brush_pos: Option<(f32, f32)>, + /// True once the first brush step has been applied. + liquify_applied: bool, + /// True after Enter: waiting for final readback. + wants_commit: bool, + /// When liquifying a floating selection: its world-space top-left offset. None = full layer. + float_offset: Option<(i32, i32)>, +} + +/// Live state for an ongoing raster Gradient fill drag. +struct GradientState { + layer_id: uuid::Uuid, + time: f64, + start: egui::Vec2, + end: egui::Vec2, + /// Snapshot of canvas pixels at drag start (used for CPU commit path). + before_pixels: Vec, + canvas_w: u32, + canvas_h: u32, + /// Anchor canvas: holds before_pixels (read-only by gradient shader each frame). + anchor_canvas_id: uuid::Uuid, + /// Display canvas: gradient shader writes here each frame; shown via painting_canvas or float path. + display_canvas_id: uuid::Uuid, + /// True when painting onto a floating selection instead of the layer canvas. + is_float: bool, + /// World-space top-left of the float in canvas pixels (None for non-float). + float_offset: Option<(f32, f32)>, +} + +/// GPU ops queued by the Warp/Liquify handlers for `prepare()`. +enum PendingWarpOp { + /// Upload control-point grid displacements and run warp-apply shader. + /// disp_data: one vec2 per control point (grid_cols * grid_rows entries). + /// None = reuse existing buffer (e.g. for final-commit re-apply). + WarpApply { + anchor_canvas_id: uuid::Uuid, + disp_buf_id: uuid::Uuid, + display_canvas_id: uuid::Uuid, + disp_data: Option>, + grid_cols: u32, + grid_rows: u32, + w: u32, h: u32, + final_commit: bool, + layer_id: uuid::Uuid, + time: f64, + /// True when warping a floating selection. + is_float_warp: bool, + }, + /// Update the displacement map from one brush step (Liquify tool). + LiquifyBrushStep { + disp_buf_id: uuid::Uuid, + params: crate::gpu_brush::LiquifyBrushParams, + }, + /// Run warp-apply shader (Liquify tool — displacement already updated). + LiquifyApply { + anchor_canvas_id: uuid::Uuid, + disp_buf_id: uuid::Uuid, + display_canvas_id: uuid::Uuid, + w: u32, h: u32, + final_commit: bool, + layer_id: uuid::Uuid, + time: f64, + /// True when liquifying a floating selection. + is_float_warp: bool, + }, + /// Initialise GPU resources for a new warp/liquify operation. + /// anchor_canvas_id = kf.id (reuses existing GPU canvas; ensure_canvas is a no-op if present). + /// anchor_pixels: uploaded to anchor canvas only if it was missing (e.g. after stroke commit). + /// is_liquify: if true, displacement buffer is full w×h (per-pixel); otherwise 1×1 (grid mode init). + Init { + anchor_canvas_id: uuid::Uuid, + display_canvas_id: uuid::Uuid, + disp_buf_id: uuid::Uuid, + w: u32, h: u32, + anchor_pixels: Vec, + is_liquify: bool, + }, +} + +/// Result stored by `prepare()` after a warp/liquify commit readback. +struct WarpReadbackResult { + layer_id: uuid::Uuid, + time: f64, + before_pixels: Vec, + after_pixels: Vec, + width: u32, + height: u32, + display_canvas_id: uuid::Uuid, + anchor_canvas_id: uuid::Uuid, + /// True when warping a floating selection (don't write to kf.raw_pixels). + is_float_warp: bool, +} + +static WARP_READBACK_RESULTS: OnceLock>>> = OnceLock::new(); + /// Cached DCEL snapshot for undo when editing vertices, curves, or control points #[derive(Clone)] struct DcelEditingCache { @@ -2529,6 +2794,24 @@ struct PendingTransformDispatch { is_final_commit: bool, } +/// Pending GPU dispatch for the gradient fill tool. +struct PendingGradientOp { + anchor_canvas_id: uuid::Uuid, + display_canvas_id: uuid::Uuid, + w: u32, + h: u32, + /// If Some: upload these sRGB-premultiplied pixels to the anchor canvas first. + anchor_pixels: Option>, + start_x: f32, + start_y: f32, + end_x: f32, + end_y: f32, + opacity: f32, + extend_mode: u32, + kind: u32, // 0 = Linear, 1 = Radial + stops: Vec, +} + /// Pixels read back from the transformed display canvas, stored per-instance. struct TransformReadbackResult { pixels: Vec, @@ -2618,6 +2901,11 @@ impl StagePane { raster_transform_state: None, pending_transform_dispatch: None, quick_select_state: None, + warp_state: None, + liquify_state: None, + gradient_state: None, + pending_gradient_op: None, + pending_warp_ops: Vec::new(), #[cfg(debug_assertions)] replay_override: None, } @@ -7372,6 +7660,890 @@ impl StagePane { } } + // ----------------------------------------------------------------------- + // Warp tool + // ----------------------------------------------------------------------- + + fn handle_raster_warp_tool( + &mut self, + ui: &mut egui::Ui, + response: &egui::Response, + world_pos: egui::Vec2, + shared: &mut SharedPaneState, + ) { + use lightningbeam_core::tool::Tool; + use uuid::Uuid; + + // Ensure we're on a raster layer. + let Some(layer_id) = *shared.active_layer_id else { return; }; + let is_raster = shared.action_executor.document().get_layer(&layer_id) + .map_or(false, |l| matches!(l, lightningbeam_core::layer::AnyLayer::Raster(_))); + if !is_raster { return; } + + let grid_cols = shared.raster_settings.warp_grid_cols.max(2); + let grid_rows = shared.raster_settings.warp_grid_rows.max(2); + + // ---- Keyboard: Enter = commit, Escape = cancel ---- + let enter = ui.input(|i| i.key_pressed(egui::Key::Enter)); + let escape = ui.input(|i| i.key_pressed(egui::Key::Escape)); + + if escape { + if let Some(ws) = self.warp_state.take() { + // Schedule cleanup of display canvas. + self.pending_canvas_removal = Some(ws.display_canvas_id); + self.painting_canvas = None; + let _ = (ws.anchor_canvas_id, ws.disp_buf_id); + } + return; + } + + if enter { + if let Some(ref mut ws) = self.warp_state { + if !ws.wants_commit { + ws.wants_commit = true; + let disp_data = Self::extract_grid_disps(&ws.control_points); + self.pending_warp_ops.push(PendingWarpOp::WarpApply { + anchor_canvas_id: ws.anchor_canvas_id, + disp_buf_id: ws.disp_buf_id, + display_canvas_id: ws.display_canvas_id, + disp_data: Some(disp_data), + grid_cols: ws.grid_cols, + grid_rows: ws.grid_rows, + w: ws.anchor_w, h: ws.anchor_h, + final_commit: true, + layer_id: ws.layer_id, + time: ws.time, + is_float_warp: ws.float_offset.is_some(), + }); + } + } + return; + } + + // ---- Lazy init (first time Warp tool is active on this layer) ---- + let time = *shared.playback_time; + let needs_init = self.warp_state.as_ref() + .map_or(true, |ws| ws.layer_id != layer_id); + + if needs_init { + // Clean up old state if switching layers. + if let Some(old) = self.warp_state.take() { + self.pending_canvas_removal = Some(old.display_canvas_id); + self.painting_canvas = None; + } + + // Determine anchor source: floating selection on this layer, or the keyframe. + let float_offset: Option<(i32, i32)>; + let anchor_canvas_id: uuid::Uuid; + let anchor_pixels: Vec; + let w: u32; + let h: u32; + + if let Some(float_sel) = shared.selection.raster_floating.as_ref() + .filter(|f| f.layer_id == layer_id) + { + // Warp the floating selection. + float_offset = Some((float_sel.x, float_sel.y)); + anchor_canvas_id = float_sel.canvas_id; + w = float_sel.width; + h = float_sel.height; + anchor_pixels = if float_sel.pixels.is_empty() { + vec![0u8; (w * h * 4) as usize] + } else { + float_sel.pixels.clone() + }; + } else { + // Warp the full keyframe canvas. + float_offset = None; + let doc = shared.action_executor.document(); + let (kf_id, kw, kh, raw_pix) = doc.get_layer(&layer_id) + .and_then(|l| if let lightningbeam_core::layer::AnyLayer::Raster(rl) = l { + rl.keyframe_at(time).map(|kf| { + let expected = (kf.width * kf.height * 4) as usize; + let mut pix = kf.raw_pixels.clone(); + if pix.len() != expected { pix.resize(expected, 0); } + (kf.id, kf.width, kf.height, pix) + }) + } else { None }) + .unwrap_or_else(|| { + let dw = 1920u32; let dh = 1080u32; + (Uuid::new_v4(), dw, dh, vec![0u8; (dw * dh * 4) as usize]) + }); + anchor_canvas_id = kf_id; + w = kw; + h = kh; + anchor_pixels = raw_pix; + } + + let display_canvas_id = Uuid::new_v4(); + let disp_buf_id = Uuid::new_v4(); + + // Build evenly-spaced control point grid in world space. + // For a float, control points are offset by float_offset so they align with the float. + let (ox, oy) = float_offset + .map(|(x, y)| (x as f32, y as f32)) + .unwrap_or((0.0, 0.0)); + let num_pts = (grid_cols * grid_rows) as usize; + let mut control_points = Vec::with_capacity(num_pts); + for row in 0..grid_rows { + for col in 0..grid_cols { + let hx = ox + col as f32 / (grid_cols - 1) as f32 * w as f32; + let hy = oy + row as f32 / (grid_rows - 1) as f32 * h as f32; + control_points.push([hx, hy, hx, hy]); + } + } + + // Queue GPU init. + self.pending_warp_ops.push(PendingWarpOp::Init { + anchor_canvas_id, + display_canvas_id, + disp_buf_id, + w, h, + anchor_pixels, + is_liquify: false, + }); + + self.warp_state = Some(WarpState { + layer_id, + time, + anchor_canvas_id, + display_canvas_id, + disp_buf_id, + anchor_w: w, + anchor_h: h, + grid_cols, + grid_rows, + float_offset, + control_points, + active_point: None, + hovered_point: None, + dirty: false, + warp_applied: false, + wants_commit: false, + }); + } + + // Pre-check drag states before taking the warp_state borrow. + let drag_started = self.rsp_drag_started(response); + let dragged = self.rsp_dragged(response); + let drag_stopped = self.rsp_drag_stopped(response); + let drag_delta = response.drag_delta() / self.zoom; + + let ws = match self.warp_state.as_mut() { + Some(ws) => ws, + None => return, + }; + + // Update painting_canvas each frame (in case it was cleared). + // NOTE: Can't write to self.painting_canvas here while ws borrows self.warp_state. + // Set painting_canvas after the ws block via a flag. + + // ---- Draw grid overlay ---- + // Use Order::Foreground so the grid renders on top of the GPU canvas paint callback. + let rect = response.rect; + let mut painter = ui.ctx().layer_painter(egui::LayerId::new( + egui::Order::Foreground, + egui::Id::new("warp_grid_overlay"), + )); + painter.set_clip_rect(rect); + let to_screen = |cx: f32, cy: f32| -> egui::Pos2 { + egui::pos2( + rect.min.x + self.pan_offset.x + cx * self.zoom, + rect.min.y + self.pan_offset.y + cy * self.zoom, + ) + }; + // Horizontal lines + for row in 0..ws.grid_rows { + for col in 0..ws.grid_cols - 1 { + let a = &ws.control_points[(row * ws.grid_cols + col) as usize]; + let b = &ws.control_points[(row * ws.grid_cols + col + 1) as usize]; + painter.line_segment([to_screen(a[2], a[3]), to_screen(b[2], b[3])], + egui::Stroke::new(1.0, egui::Color32::from_rgba_unmultiplied(180, 180, 180, 180))); + } + } + // Vertical lines + for row in 0..ws.grid_rows - 1 { + for col in 0..ws.grid_cols { + let a = &ws.control_points[(row * ws.grid_cols + col) as usize]; + let b = &ws.control_points[((row + 1) * ws.grid_cols + col) as usize]; + painter.line_segment([to_screen(a[2], a[3]), to_screen(b[2], b[3])], + egui::Stroke::new(1.0, egui::Color32::from_rgba_unmultiplied(180, 180, 180, 180))); + } + } + + // ---- Hit-test control points (hover uses current pos; drag-start uses press_origin) ---- + let hover_r = 10.0_f32; + let mouse_screen = egui::pos2( + rect.min.x + self.pan_offset.x + world_pos.x * self.zoom, + rect.min.y + self.pan_offset.y + world_pos.y * self.zoom, + ); + let mut new_hover: Option = None; + for (i, pt) in ws.control_points.iter().enumerate() { + let screen_pt = to_screen(pt[2], pt[3]); + if screen_pt.distance(mouse_screen) < hover_r { + new_hover = Some(i); + break; + } + } + ws.hovered_point = new_hover; + + // Draw control points + for (i, pt) in ws.control_points.iter().enumerate() { + let screen_pt = to_screen(pt[2], pt[3]); + let (size, color) = if ws.active_point == Some(i) { + (5.0, egui::Color32::WHITE) + } else if ws.hovered_point == Some(i) { + (4.0, egui::Color32::from_rgb(255, 220, 80)) + } else { + (3.0, egui::Color32::from_rgba_unmultiplied(220, 220, 220, 200)) + }; + painter.rect_filled(egui::Rect::from_center_size(screen_pt, egui::Vec2::splat(size * 2.0)), 0.0, color); + painter.rect_stroke(egui::Rect::from_center_size(screen_pt, egui::Vec2::splat(size * 2.0)), + 0.0, egui::Stroke::new(1.0, egui::Color32::from_rgba_unmultiplied(60, 60, 60, 200)), egui::StrokeKind::Inside); + } + + // ---- Drag handling ---- + if drag_started { + // Use press_origin for hit-testing — drag_started fires after the threshold, + // so world_pos is already offset from where the user actually clicked. + let click_screen = ui.input(|i| i.pointer.press_origin()) + .unwrap_or_else(|| egui::pos2(mouse_screen.x, mouse_screen.y)); + ws.active_point = ws.control_points.iter().enumerate() + .find(|(_, pt)| to_screen(pt[2], pt[3]).distance(click_screen) < hover_r) + .map(|(i, _)| i); + } + if dragged { + if let Some(idx) = ws.active_point { + ws.control_points[idx][2] += drag_delta.x; + ws.control_points[idx][3] += drag_delta.y; + ws.dirty = true; + } + } + if drag_stopped { + ws.active_point = None; + } + + // ---- Collect pending warp op data before releasing ws borrow ---- + let pending_op = if ws.dirty && !ws.wants_commit { + ws.dirty = false; + ws.warp_applied = true; + let disp_data = Self::extract_grid_disps(&ws.control_points); + Some(PendingWarpOp::WarpApply { + anchor_canvas_id: ws.anchor_canvas_id, + disp_buf_id: ws.disp_buf_id, + display_canvas_id: ws.display_canvas_id, + disp_data: Some(disp_data), + grid_cols: ws.grid_cols, + grid_rows: ws.grid_rows, + w: ws.anchor_w, h: ws.anchor_h, + final_commit: false, + layer_id: ws.layer_id, + time: ws.time, + is_float_warp: ws.float_offset.is_some(), + }) + } else { + None + }; + let (ws_layer_id, ws_display_id, ws_float_offset) = (ws.layer_id, ws.display_canvas_id, ws.float_offset); + drop(ws); // release borrow of warp_state + + // Display canvas is initialised by Init (zero-displacement apply), so it always + // has valid content. For full-layer warp, override the layer blit unconditionally. + // For float warp the override is done via transform_display in render_content(). + if ws_float_offset.is_none() { + self.painting_canvas = Some((ws_layer_id, ws_display_id)); + } + if let Some(op) = pending_op { + self.pending_warp_ops.push(op); + ui.ctx().request_repaint(); + } + } + + /// Compute a per-pixel displacement map from a warp control-point grid. + /// + /// For each pixel (x, y) we find its fractional grid position, then bilinearly + /// interpolate the displacements of the surrounding 4 grid points. + /// Extract per-control-point displacements (displaced - home) from the control point array. + /// Returns a tiny vec (grid_cols * grid_rows entries) uploaded to the GPU displacement buffer. + /// The shader does bilinear interpolation per pixel, so no per-pixel CPU work is needed. + fn extract_grid_disps(control_points: &[[f32; 4]]) -> Vec<[f32; 2]> { + // The warp shader is an inverse warp: output pixel (x,y) samples source at (x+d.x, y+d.y). + // So to make content follow the handle (forward warp), negate: d = home - displaced. + control_points.iter() + .map(|p| [p[0] - p[2], p[1] - p[3]]) + .collect() + } + + // ----------------------------------------------------------------------- + // Liquify tool + // ----------------------------------------------------------------------- + + fn handle_raster_liquify_tool( + &mut self, + ui: &mut egui::Ui, + response: &egui::Response, + world_pos: egui::Vec2, + shared: &mut SharedPaneState, + ) { + use uuid::Uuid; + + // Ensure we're on a raster layer. + let Some(layer_id) = *shared.active_layer_id else { return; }; + let is_raster = shared.action_executor.document().get_layer(&layer_id) + .map_or(false, |l| matches!(l, lightningbeam_core::layer::AnyLayer::Raster(_))); + if !is_raster { return; } + + let radius = shared.raster_settings.liquify_radius; + let strength = shared.raster_settings.liquify_strength; + let mode = shared.raster_settings.liquify_mode.as_u32(); + + // ---- Keyboard: Enter = commit, Escape = cancel ---- + let enter = ui.input(|i| i.key_pressed(egui::Key::Enter)); + let escape = ui.input(|i| i.key_pressed(egui::Key::Escape)); + + if escape { + if let Some(ls) = self.liquify_state.take() { + self.pending_canvas_removal = Some(ls.display_canvas_id); + self.painting_canvas = None; + let _ = (ls.anchor_canvas_id, ls.disp_buf_id); + } + return; + } + + if enter { + if let Some(ref mut ls) = self.liquify_state { + if !ls.wants_commit { + ls.wants_commit = true; + self.pending_warp_ops.push(PendingWarpOp::LiquifyApply { + anchor_canvas_id: ls.anchor_canvas_id, + disp_buf_id: ls.disp_buf_id, + display_canvas_id: ls.display_canvas_id, + w: ls.anchor_w, h: ls.anchor_h, + final_commit: true, + layer_id: ls.layer_id, + time: ls.time, + is_float_warp: ls.float_offset.is_some(), + }); + } + } + return; + } + + // ---- Draw brush cursor ---- + let liq_rect = response.rect; + let screen_cx = liq_rect.min.x + self.pan_offset.x + world_pos.x * self.zoom; + let screen_cy = liq_rect.min.y + self.pan_offset.y + world_pos.y * self.zoom; + let screen_r = radius * self.zoom; + let painter = ui.painter_at(liq_rect); + let time = ui.input(|i| i.time) as f32; + let phase = (time * 8.0).rem_euclid(8.0); + + let pts: Vec = (0..64).map(|i| { + let a = i as f32 / 64.0 * std::f32::consts::TAU; + egui::pos2(screen_cx + a.cos() * screen_r, + screen_cy + a.sin() * screen_r) + }).collect(); + Self::draw_marching_ants(&painter, &pts, phase); + + // ---- Lazy init ---- + let playhead_time = *shared.playback_time; + let needs_init = self.liquify_state.as_ref() + .map_or(true, |ls| ls.layer_id != layer_id); + + if needs_init { + if let Some(old) = self.liquify_state.take() { + self.pending_canvas_removal = Some(old.display_canvas_id); + self.painting_canvas = None; + } + + // Determine anchor: floating selection on this layer, or the keyframe. + let float_offset: Option<(i32, i32)>; + let anchor_canvas_id: uuid::Uuid; + let anchor_pixels: Vec; + let w: u32; + let h: u32; + + if let Some(float_sel) = shared.selection.raster_floating.as_ref() + .filter(|f| f.layer_id == layer_id) + { + float_offset = Some((float_sel.x, float_sel.y)); + anchor_canvas_id = float_sel.canvas_id; + w = float_sel.width; + h = float_sel.height; + anchor_pixels = if float_sel.pixels.is_empty() { + vec![0u8; (w * h * 4) as usize] + } else { + float_sel.pixels.clone() + }; + } else { + float_offset = None; + let doc = shared.action_executor.document(); + let (kf_id, kw, kh, raw_pix) = doc.get_layer(&layer_id) + .and_then(|l| if let lightningbeam_core::layer::AnyLayer::Raster(rl) = l { + rl.keyframe_at(playhead_time).map(|kf| { + let expected = (kf.width * kf.height * 4) as usize; + let mut pix = kf.raw_pixels.clone(); + if pix.len() != expected { pix.resize(expected, 0); } + (kf.id, kf.width, kf.height, pix) + }) + } else { None }) + .unwrap_or_else(|| { + let dw = 1920u32; let dh = 1080u32; + (Uuid::new_v4(), dw, dh, vec![0u8; (dw * dh * 4) as usize]) + }); + anchor_canvas_id = kf_id; + w = kw; + h = kh; + anchor_pixels = raw_pix; + } + + let display_canvas_id = Uuid::new_v4(); + let disp_buf_id = Uuid::new_v4(); + + self.pending_warp_ops.push(PendingWarpOp::Init { + anchor_canvas_id, + display_canvas_id, + disp_buf_id, + w, h, + anchor_pixels, + is_liquify: true, + }); + + self.liquify_state = Some(LiquifyState { + layer_id, + time: playhead_time, + anchor_canvas_id, + display_canvas_id, + disp_buf_id, + anchor_w: w, + anchor_h: h, + last_brush_pos: None, + liquify_applied: false, + wants_commit: false, + float_offset, + }); + } + + // Pre-check drag states before taking the liquify_state borrow. + let drag_started_l = self.rsp_drag_started(response); + let dragged_l = self.rsp_dragged(response); + let drag_stopped_l = self.rsp_drag_stopped(response); + + // Extract what we need from liquify_state and update it, then release borrow. + // Returns (layer_id, display_id, brush_op) where brush_op is Some if we should + // push GPU ops this frame. + let brush_op = { + let ls = match self.liquify_state.as_mut() { + Some(ls) => ls, + None => return, + }; + + let mut op: Option<(uuid::Uuid, uuid::Uuid, uuid::Uuid, u32, u32, f64, f32, f32, f32, f32)> = None; + + if drag_started_l { + ls.last_brush_pos = Some((world_pos.x, world_pos.y)); + ls.liquify_applied = true; + op = Some((ls.anchor_canvas_id, ls.disp_buf_id, ls.display_canvas_id, + ls.anchor_w, ls.anchor_h, ls.time, + world_pos.x, world_pos.y, 0.0, 0.0)); + } else if dragged_l { + if let Some((lx, ly)) = ls.last_brush_pos { + let dx = world_pos.x - lx; + let dy = world_pos.y - ly; + let dist2 = dx * dx + dy * dy; + let min_step = (radius / 4.0).max(1.0); + if dist2 >= min_step * min_step { + let len = dist2.sqrt().max(0.001); + ls.last_brush_pos = Some((world_pos.x, world_pos.y)); + op = Some((ls.anchor_canvas_id, ls.disp_buf_id, ls.display_canvas_id, + ls.anchor_w, ls.anchor_h, ls.time, + world_pos.x, world_pos.y, dx / len, dy / len)); + } + } + } + if drag_stopped_l { + ls.last_brush_pos = None; + } + let is_float = ls.float_offset.is_some(); + op.map(|o| (ls.layer_id, is_float, o)) + }; + + // For full-layer liquify: override layer blit with display canvas. + // For float liquify: override the float blit via transform_display in render_content(). + if let Some(ls) = self.liquify_state.as_ref() { + if ls.float_offset.is_none() { + self.painting_canvas = Some((ls.layer_id, ls.display_canvas_id)); + } + } + + if let Some((ls_layer_id, is_float_warp, (anchor_id, disp_buf, display_id, w, h, time, cx, cy, dx, dy))) = brush_op { + self.pending_warp_ops.push(PendingWarpOp::LiquifyBrushStep { + disp_buf_id: disp_buf, + params: crate::gpu_brush::LiquifyBrushParams { + cx, cy, radius, strength, + dx, dy, mode, + map_w: w, map_h: h, + _pad0: 0, _pad1: 0, _pad2: 0, + }, + }); + self.pending_warp_ops.push(PendingWarpOp::LiquifyApply { + anchor_canvas_id: anchor_id, + disp_buf_id: disp_buf, + display_canvas_id: display_id, + w, h, + final_commit: false, + layer_id: ls_layer_id, + time, + is_float_warp, + }); + ui.ctx().request_repaint(); + } + } + + fn handle_raster_gradient_tool( + &mut self, + ui: &mut egui::Ui, + response: &egui::Response, + world_pos: egui::Vec2, + shared: &mut SharedPaneState, + ) { + use lightningbeam_core::actions::RasterFillAction; + use lightningbeam_core::layer::AnyLayer; + + let active_layer_id = match *shared.active_layer_id { + Some(id) => id, + None => return, + }; + + let drag_started = response.drag_started(); + let dragged = response.dragged(); + let drag_stopped = response.drag_stopped(); + + // ── Drag started: snapshot pixels, create GPU canvases ─────────────── + if drag_started { + // Determine whether we're painting on the floating selection or the layer. + // Float: gradient writes into float.canvas_id (shown by the float path). + // Layer: gradient writes into a new display canvas shown via painting_canvas. + let float_info = shared.selection.raster_floating.as_ref().map(|f| { + let pixels = if f.pixels.is_empty() { + vec![0u8; (f.width * f.height * 4) as usize] + } else { + f.pixels.clone() + }; + (pixels, f.width, f.height, f.time, f.canvas_id, f.x as f32, f.y as f32, f.layer_id) + }); + + let layer_result = if float_info.is_none() { + let doc = shared.action_executor.document(); + let r = if let Some(layer) = doc.get_layer(&active_layer_id) { + if let AnyLayer::Raster(rl) = layer { + let time = *shared.playback_time; + if let Some(kf) = rl.keyframe_at(time) { + let w = doc.width as u32; + let h = doc.height as u32; + let pixels = if kf.raw_pixels.is_empty() { + vec![0u8; (w * h * 4) as usize] + } else { kf.raw_pixels.clone() }; + Some((pixels, w, h, kf.time)) + } else { None } + } else { None } + } else { None }; + drop(doc); + r + } else { None }; + + // Unpack into a common set of fields. + let setup = if let Some((pixels, w, h, time, fid, fx, fy, flid)) = float_info { + Some((pixels, w, h, time, flid, true, Some(fid), Some((fx, fy)))) + } else if let Some((pixels, w, h, time)) = layer_result { + Some((pixels, w, h, time, active_layer_id, false, None, None)) + } else { None }; + + if let Some((before_pixels, canvas_w, canvas_h, kf_time, + target_layer_id, is_float, + existing_display_id, float_offset)) = setup + { + let anchor_canvas_id = uuid::Uuid::new_v4(); + let display_canvas_id = existing_display_id.unwrap_or_else(uuid::Uuid::new_v4); + + // Convert world drag-start to canvas-local coords. + let (sx, sy) = if let Some((fx, fy)) = float_offset { + (world_pos.x - fx, world_pos.y - fy) + } else { + (world_pos.x, world_pos.y) + }; + + let gpu_stops = Self::gradient_to_gpu_stops(&shared.raster_settings.gradient); + let gradient = &shared.raster_settings.gradient; + + self.gradient_state = Some(GradientState { + layer_id: target_layer_id, + time: kf_time, + start: world_pos, + end: world_pos, + before_pixels: before_pixels.clone(), + canvas_w, + canvas_h, + anchor_canvas_id, + display_canvas_id, + is_float, + float_offset, + }); + + self.pending_gradient_op = Some(PendingGradientOp { + anchor_canvas_id, + display_canvas_id, + w: canvas_w, + h: canvas_h, + anchor_pixels: Some(before_pixels), + start_x: sx, start_y: sy, + end_x: sx, end_y: sy, + opacity: shared.raster_settings.gradient_opacity, + extend_mode: Self::gradient_extend_to_u32(gradient.extend), + kind: Self::gradient_kind_to_u32(gradient.kind), + stops: gpu_stops, + }); + + // For layer gradient show a separate display canvas via painting_canvas. + // For float gradient the float's own canvas_id IS display_canvas_id + // and is already shown by the float rendering path. + if !is_float { + self.painting_canvas = Some((target_layer_id, display_canvas_id)); + } + ui.ctx().request_repaint(); + } + } + + // ── Dragged: update end point, queue GPU dispatch ───────────────────── + // Skip on the same frame as drag_started — that block already queued the initial + // GPU op with anchor_pixels = Some(...). Overwriting it here would lose the upload. + if dragged && !drag_started { + if let Some(ref mut gs) = self.gradient_state { + gs.end = world_pos; + } + if let Some(ref gs) = self.gradient_state { + let gradient = &shared.raster_settings.gradient; + // Convert world coords to canvas-local (subtract float offset if needed). + let to_local = |v: egui::Vec2| -> (f32, f32) { + if let Some((fx, fy)) = gs.float_offset { + (v.x - fx, v.y - fy) + } else { + (v.x, v.y) + } + }; + let (sx, sy) = to_local(gs.start); + let (ex, ey) = to_local(gs.end); + self.pending_gradient_op = Some(PendingGradientOp { + anchor_canvas_id: gs.anchor_canvas_id, + display_canvas_id: gs.display_canvas_id, + w: gs.canvas_w, + h: gs.canvas_h, + anchor_pixels: None, // already on GPU + start_x: sx, start_y: sy, + end_x: ex, end_y: ey, + opacity: shared.raster_settings.gradient_opacity, + extend_mode: Self::gradient_extend_to_u32(gradient.extend), + kind: Self::gradient_kind_to_u32(gradient.kind), + stops: Self::gradient_to_gpu_stops(gradient), + }); + ui.ctx().request_repaint(); + } + } + + // ── Drag stopped: commit ────────────────────────────────────────────── + if drag_stopped { + if let Some(ref mut gs) = self.gradient_state { + gs.end = world_pos; + } + if let Some(ref gs) = self.gradient_state { + let after_pixels = Self::compute_gradient_pixels(gs, shared); + if gs.is_float { + // Update the float's pixel buffer in place. + // The float's GPU canvas (display_canvas_id) already shows the result. + if let Some(ref mut float) = shared.selection.raster_floating { + float.pixels = after_pixels; + } + } else { + let action = RasterFillAction::new( + gs.layer_id, gs.time, + gs.before_pixels.clone(), after_pixels, + gs.canvas_w, gs.canvas_h, + ).with_description("Gradient Fill"); + let _ = shared.action_executor.execute(Box::new(action)); + } + } + if let Some(gs) = self.gradient_state.take() { + // Always remove the anchor canvas (temporary scratch). + // For layer gradient, also remove the display canvas. + // For float gradient, display_canvas_id IS the float's canvas — keep it. + if gs.is_float { + self.pending_canvas_removal = Some(gs.anchor_canvas_id); + } else { + self.pending_canvas_removal = Some(gs.display_canvas_id); + // Anchor leaks here (pre-existing behaviour); acceptable for now. + } + } + self.painting_canvas = None; + } + + // Keep painting_canvas pointing at the display canvas each frame (layer gradient only). + if let Some(ref gs) = self.gradient_state { + if !gs.is_float { + self.painting_canvas = Some((gs.layer_id, gs.display_canvas_id)); + } + } + + // Draw direction line overlay. + if let Some(ref gs) = self.gradient_state { + let zoom = self.zoom; + let pan = self.pan_offset; + let world_to_screen = |v: egui::Vec2| egui::pos2(v.x * zoom + pan.x, v.y * zoom + pan.y); + let p0 = world_to_screen(gs.start); + let p1 = world_to_screen(gs.end); + let painter = ui.painter(); + painter.line_segment( + [p0, p1], + egui::Stroke::new(1.5, egui::Color32::WHITE), + ); + painter.circle_filled(p0, 5.0, egui::Color32::WHITE); + painter.circle_filled(p1, 5.0, egui::Color32::WHITE); + painter.circle_stroke(p0, 5.0, egui::Stroke::new(1.0, egui::Color32::DARK_GRAY)); + painter.circle_stroke(p1, 5.0, egui::Stroke::new(1.0, egui::Color32::DARK_GRAY)); + } + } + + fn gradient_extend_to_u32(extend: lightningbeam_core::gradient::GradientExtend) -> u32 { + use lightningbeam_core::gradient::GradientExtend; + match extend { + GradientExtend::Pad => 0, + GradientExtend::Reflect => 1, + GradientExtend::Repeat => 2, + } + } + + fn gradient_kind_to_u32(kind: lightningbeam_core::gradient::GradientType) -> u32 { + use lightningbeam_core::gradient::GradientType; + match kind { + GradientType::Linear => 0, + GradientType::Radial => 1, + } + } + + /// Convert gradient stops to GPU-ready form (sRGB u8 → linear f32). + fn gradient_to_gpu_stops(gradient: &lightningbeam_core::gradient::ShapeGradient) -> Vec { + gradient.stops.iter().map(|s| { + crate::gpu_brush::GpuGradientStop::from_srgb_u8( + s.position, s.color.r, s.color.g, s.color.b, s.color.a, + ) + }).collect() + } + + /// Compute gradient-filled pixel buffer (CPU), respecting active selection. + /// + /// All blending is done in linear premultiplied space to match the GPU shader. + fn compute_gradient_pixels(gs: &GradientState, shared: &SharedPaneState) -> Vec { + let w = gs.canvas_w; + let h = gs.canvas_h; + let gradient = &shared.raster_settings.gradient; + let opacity = shared.raster_settings.gradient_opacity; + + // Selection confinement (not applicable to float — the float IS the selection). + let sel = if gs.is_float { None } else { shared.selection.raster_selection.as_ref() }; + + // Convert world start/end to canvas-local coords (subtract float offset if any). + let (start_x, start_y) = if let Some((fx, fy)) = gs.float_offset { + (gs.start.x - fx, gs.start.y - fy) + } else { + (gs.start.x, gs.start.y) + }; + let (end_x, end_y) = if let Some((fx, fy)) = gs.float_offset { + (gs.end.x - fx, gs.end.y - fy) + } else { + (gs.end.x, gs.end.y) + }; + + let dx = end_x - start_x; + let dy = end_y - start_y; + let len2 = dx * dx + dy * dy; + let is_radial = gradient.kind == lightningbeam_core::gradient::GradientType::Radial; + + // sRGB ↔ linear helpers (match gpu_brush.rs). + let srgb_to_linear = |c: f32| -> f32 { + if c <= 0.04045 { c / 12.92 } else { ((c + 0.055) / 1.055).powf(2.4) } + }; + let linear_to_srgb = |c: f32| -> f32 { + let c = c.clamp(0.0, 1.0); + if c <= 0.0031308 { c * 12.92 } else { 1.055 * c.powf(1.0 / 2.4) - 0.055 } + }; + + let mut out = gs.before_pixels.clone(); + + for py in 0..h { + for px in 0..w { + let idx = ((py * w + px) * 4) as usize; + + let cx_f = px as f32 + 0.5; + let cy_f = py as f32 + 0.5; + let t_raw = if is_radial { + // Radial: center at start point, radius = |end-start|. + let radius = len2.sqrt(); + if radius < 0.5 { 0.0f32 } else { + let pdx = cx_f - start_x; + let pdy = cy_f - start_y; + (pdx * pdx + pdy * pdy).sqrt() / radius + } + } else { + // Linear: project pixel centre onto gradient axis. + if len2 < 1.0 { 0.0f32 } else { + let fx = cx_f - start_x; + let fy = cy_f - start_y; + (fx * dx + fy * dy) / len2 + } + }; + + let t = gradient.apply_extend(t_raw); + let [gr, gg, gb, ga] = gradient.eval(t); + + // Selection confinement. + if let Some(s) = sel { + if !s.contains_pixel(px as i32, py as i32) { + continue; + } + } + + // Effective alpha: gradient alpha × tool opacity (straight-alpha [0,1]). + let a = ga as f32 / 255.0 * opacity; + + // Convert gradient RGB from sRGB straight-alpha to linear straight-alpha. + let gr_lin = srgb_to_linear(gr as f32 / 255.0); + let gg_lin = srgb_to_linear(gg as f32 / 255.0); + let gb_lin = srgb_to_linear(gb as f32 / 255.0); + + // Source pixel: sRGB premultiplied bytes → linear premultiplied floats. + // (upload() does the same conversion for the GPU anchor canvas.) + let src_r_lin = srgb_to_linear(out[idx] as f32 / 255.0); + let src_g_lin = srgb_to_linear(out[idx + 1] as f32 / 255.0); + let src_b_lin = srgb_to_linear(out[idx + 2] as f32 / 255.0); + let src_a = out[idx + 3] as f32 / 255.0; + + // Alpha-over in linear premultiplied space (matches GPU shader exactly). + let out_a = a + src_a * (1.0 - a); + let out_r_lin = gr_lin * a + src_r_lin * (1.0 - a); + let out_g_lin = gg_lin * a + src_g_lin * (1.0 - a); + let out_b_lin = gb_lin * a + src_b_lin * (1.0 - a); + + // Convert linear premultiplied → sRGB premultiplied bytes. + out[idx] = (linear_to_srgb(out_r_lin) * 255.0 + 0.5) as u8; + out[idx + 1] = (linear_to_srgb(out_g_lin) * 255.0 + 0.5) as u8; + out[idx + 2] = (linear_to_srgb(out_b_lin) * 255.0 + 0.5) as u8; + out[idx + 3] = (out_a * 255.0).clamp(0.0, 255.0) as u8; + } + } + + out + } + + /// Compute gradient pixels and queue upload to the preview GPU canvas for next prepare(). fn handle_transform_tool( &mut self, ui: &mut egui::Ui, @@ -8739,6 +9911,15 @@ impl StagePane { Tool::RegionSelect => { self.handle_region_select_tool(ui, &response, world_pos, shared); } + Tool::Warp => { + self.handle_raster_warp_tool(ui, &response, world_pos, shared); + } + Tool::Liquify => { + self.handle_raster_liquify_tool(ui, &response, world_pos, shared); + } + Tool::Gradient => { + self.handle_raster_gradient_tool(ui, &response, world_pos, shared); + } _ => { // Other tools not implemented yet } @@ -9431,6 +10612,45 @@ impl PaneRenderer for StagePane { } } + // Consume warp/liquify readback results: create RasterFillAction and clean up. + if let Ok(mut results) = WARP_READBACK_RESULTS + .get_or_init(|| Arc::new(Mutex::new(std::collections::HashMap::new()))) + .lock() + { + if let Some(rb) = results.remove(&self.instance_id) { + if rb.is_float_warp { + // Float warp: update the floating selection's pixel data and GPU canvas. + // Do NOT write to kf.raw_pixels (it belongs to the full-canvas keyframe). + if let Some(float_sel) = shared.selection.raster_floating.as_mut() { + float_sel.pixels = rb.after_pixels; + float_sel.canvas_id = rb.display_canvas_id; + } + // Release the old anchor canvas (float's original canvas_id, now replaced). + self.pending_canvas_removal = Some(rb.anchor_canvas_id); + } else { + use lightningbeam_core::actions::raster_fill::RasterFillAction; + let action = RasterFillAction::new( + rb.layer_id, rb.time, + rb.before_pixels, rb.after_pixels, + rb.width, rb.height, + ).with_description("Warp"); + let _ = shared.action_executor.execute(Box::new(action)); + + // Clean up display canvas (deferred: keep alive this frame to avoid flash). + self.pending_canvas_removal = Some(rb.display_canvas_id); + } + + self.painting_canvas = None; + // Clear tool state. + if let Some(ws) = self.warp_state.take() { + let _ = (ws.anchor_canvas_id, ws.disp_buf_id); + } + if let Some(ls) = self.liquify_state.take() { + let _ = (ls.anchor_canvas_id, ls.disp_buf_id); + } + } + } + // Clear transform state if the float was committed externally (by another tool), // or if the user switched away from the Transform tool without finishing. { @@ -9466,6 +10686,59 @@ impl PaneRenderer for StagePane { } } + // Clear warp/liquify state if user switched away without committing. + { + use lightningbeam_core::tool::Tool; + let not_warp = !matches!(*shared.selected_tool, Tool::Warp); + let not_liquify = !matches!(*shared.selected_tool, Tool::Liquify); + + if not_warp && self.warp_state.is_some() { + if let Some(ws) = self.warp_state.take() { + if ws.warp_applied && !ws.wants_commit { + // Queue final commit so work isn't lost. + let disp_data = Self::extract_grid_disps(&ws.control_points); + self.pending_warp_ops.push(PendingWarpOp::WarpApply { + anchor_canvas_id: ws.anchor_canvas_id, + disp_buf_id: ws.disp_buf_id, + display_canvas_id: ws.display_canvas_id, + disp_data: Some(disp_data), + grid_cols: ws.grid_cols, + grid_rows: ws.grid_rows, + w: ws.anchor_w, h: ws.anchor_h, + final_commit: true, + layer_id: ws.layer_id, + time: ws.time, + is_float_warp: ws.float_offset.is_some(), + }); + } else { + // No changes or already committing — just discard. + self.pending_canvas_removal = Some(ws.display_canvas_id); + self.painting_canvas = None; + } + } + } + + if not_liquify && self.liquify_state.is_some() { + if let Some(ls) = self.liquify_state.take() { + if ls.liquify_applied && !ls.wants_commit { + self.pending_warp_ops.push(PendingWarpOp::LiquifyApply { + anchor_canvas_id: ls.anchor_canvas_id, + disp_buf_id: ls.disp_buf_id, + display_canvas_id: ls.display_canvas_id, + w: ls.anchor_w, h: ls.anchor_h, + final_commit: true, + layer_id: ls.layer_id, + time: ls.time, + is_float_warp: ls.float_offset.is_some(), + }); + } else { + self.pending_canvas_removal = Some(ls.display_canvas_id); + self.painting_canvas = None; + } + } + } + } + // Handle input for pan/zoom and tool controls self.handle_input(ui, rect, shared); @@ -9781,6 +11054,30 @@ impl PaneRenderer for StagePane { } }); + // Compute warp_display: show the warp/liquify display canvas in place of the layer + // (for full-layer warp) or as float blit override (for float warp via transform_display). + let warp_display = self.warp_state.as_ref() + .filter(|ws| ws.warp_applied && ws.float_offset.is_none()) + .map(|ws| (ws.layer_id, ws.display_canvas_id)) + .or_else(|| self.liquify_state.as_ref() + .filter(|ls| ls.liquify_applied && ls.float_offset.is_none()) + .map(|ls| (ls.layer_id, ls.display_canvas_id))); + + // For float warp/liquify: override the float blit with the display canvas. + let transform_display = transform_display.or_else(|| { + self.warp_state.as_ref() + .and_then(|ws| ws.float_offset.map(|(ox, oy)| TransformDisplayInfo { + display_canvas_id: ws.display_canvas_id, + x: ox, y: oy, w: ws.anchor_w, h: ws.anchor_h, + })) + }).or_else(|| { + self.liquify_state.as_ref() + .and_then(|ls| ls.float_offset.map(|(ox, oy)| TransformDisplayInfo { + display_canvas_id: ls.display_canvas_id, + x: ox, y: oy, w: ls.anchor_w, h: ls.anchor_h, + })) + }); + // Use egui's custom painting callback for Vello // document_arc() returns Arc - cheap pointer copy, not deep clone let callback = VelloCallback { ctx: VelloRenderContext { @@ -9811,6 +11108,9 @@ impl PaneRenderer for StagePane { pending_raster_dabs: self.pending_raster_dabs.take(), pending_transform_dispatch: self.pending_transform_dispatch.take(), transform_display, + pending_warp_ops: std::mem::take(&mut self.pending_warp_ops), + warp_display, + pending_gradient_op: self.pending_gradient_op.take(), instance_id_for_readback: self.instance_id, painting_canvas: self.painting_canvas, pending_canvas_removal: self.pending_canvas_removal.take(), diff --git a/lightningbeam-ui/lightningbeam-editor/src/tools/mod.rs b/lightningbeam-ui/lightningbeam-editor/src/tools/mod.rs index 415f140..de4c0c9 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/tools/mod.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/tools/mod.rs @@ -99,6 +99,39 @@ pub struct RasterToolSettings { // --- Marquee select shape --- /// Whether the rectangular select tool draws a rect or an ellipse. pub select_shape: SelectionShape, + // --- Warp --- + pub warp_grid_cols: u32, + pub warp_grid_rows: u32, + // --- Liquify --- + pub liquify_mode: LiquifyMode, + pub liquify_radius: f32, + pub liquify_strength: f32, + // --- Gradient --- + pub gradient: lightningbeam_core::gradient::ShapeGradient, + pub gradient_opacity: f32, +} + +/// Brush mode for the Liquify tool. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum LiquifyMode { + #[default] + Push, + Pucker, + Bloat, + Smooth, + Reconstruct, +} + +impl LiquifyMode { + pub fn as_u32(self) -> u32 { + match self { + LiquifyMode::Push => 0, + LiquifyMode::Pucker => 1, + LiquifyMode::Bloat => 2, + LiquifyMode::Smooth => 3, + LiquifyMode::Reconstruct => 4, + } + } } /// Shape mode for the rectangular-select tool. @@ -168,6 +201,13 @@ impl Default for RasterToolSettings { fill_threshold_mode: FillThresholdMode::Absolute, quick_select_radius: 20.0, select_shape: SelectionShape::Rect, + warp_grid_cols: 4, + warp_grid_rows: 4, + liquify_mode: LiquifyMode::Push, + liquify_radius: 50.0, + liquify_strength: 0.5, + gradient: lightningbeam_core::gradient::ShapeGradient::default(), + gradient_opacity: 1.0, } } }