diff --git a/Changelog.md b/Changelog.md index 82ea304..56ffe11 100644 --- a/Changelog.md +++ b/Changelog.md @@ -1,3 +1,27 @@ +# 1.0.2-alpha: +Changes: +- All vector shapes on a layer go into a unified shape rather than separate shapes +- Keyboard shortcuts are now user-configurable +- Added webcam support in video editor +- Background can now be transparent +- Video thumbnails are now displayed on the clip +- Virtual keyboard, piano roll and node editor now have a quick switcher +- Add electric guitar preset +- Layers can now be grouped +- Layers can be reordered by dragging +- Added VU meters to audio layers and mix +- Added raster image editing +- Added brush, airbrush, dodge/burn, sponge, pattern stamp, healing brush, clone stamp, blur/sharpen, magic wand and quick select tools +- Added support for MyPaint .myb brushes +- UI now uses CSS styling to support future user styles +- Added image export + +Bugfixes: +- Toolbar now only shows tools that can be used on the current layer +- Fix NAM model loading +- Fix menu width and mouse following +- Export dialog now remembers the previous export filename + # 1.0.1-alpha: Changes: - Added real-time amp simulation via NAM diff --git a/lightningbeam-ui/lightningbeam-core/src/actions/mod.rs b/lightningbeam-ui/lightningbeam-core/src/actions/mod.rs index 34bac39..bd0d375 100644 --- a/lightningbeam-ui/lightningbeam-core/src/actions/mod.rs +++ b/lightningbeam-ui/lightningbeam-core/src/actions/mod.rs @@ -32,7 +32,9 @@ pub mod region_split; pub mod toggle_group_expansion; 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; @@ -63,4 +65,6 @@ pub use region_split::RegionSplitAction; pub use toggle_group_expansion::ToggleGroupExpansionAction; 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/raster_fill.rs b/lightningbeam-ui/lightningbeam-core/src/actions/raster_fill.rs new file mode 100644 index 0000000..4e2fc41 --- /dev/null +++ b/lightningbeam-ui/lightningbeam-core/src/actions/raster_fill.rs @@ -0,0 +1,66 @@ +//! Raster flood-fill action — records and undoes a paint bucket fill on a RasterLayer. + +use crate::action::Action; +use crate::document::Document; +use crate::layer::AnyLayer; +use uuid::Uuid; + +pub struct RasterFillAction { + layer_id: Uuid, + time: f64, + buffer_before: Vec, + buffer_after: Vec, + width: u32, + height: u32, + name: String, +} + +impl RasterFillAction { + pub fn new( + layer_id: Uuid, + time: f64, + buffer_before: Vec, + buffer_after: Vec, + width: u32, + height: u32, + ) -> Self { + Self { layer_id, time, buffer_before, buffer_after, width, height, name: "Flood fill".to_string() } + } + + pub fn with_description(mut self, name: &str) -> Self { + self.name = name.to_string(); + self + } +} + +impl Action for RasterFillAction { + fn execute(&mut self, document: &mut Document) -> Result<(), String> { + let layer = document.get_layer_mut(&self.layer_id) + .ok_or_else(|| format!("Layer {} not found", self.layer_id))?; + let raster = match layer { + AnyLayer::Raster(rl) => rl, + _ => return Err("Not a raster layer".to_string()), + }; + let kf = raster.ensure_keyframe_at(self.time, self.width, self.height); + kf.raw_pixels = self.buffer_after.clone(); + kf.texture_dirty = true; + Ok(()) + } + + fn rollback(&mut self, document: &mut Document) -> Result<(), String> { + let layer = document.get_layer_mut(&self.layer_id) + .ok_or_else(|| format!("Layer {} not found", self.layer_id))?; + let raster = match layer { + AnyLayer::Raster(rl) => rl, + _ => return Err("Not a raster layer".to_string()), + }; + let kf = raster.ensure_keyframe_at(self.time, self.width, self.height); + kf.raw_pixels = self.buffer_before.clone(); + kf.texture_dirty = true; + Ok(()) + } + + fn description(&self) -> String { + self.name.clone() + } +} diff --git a/lightningbeam-ui/lightningbeam-core/src/actions/raster_stroke.rs b/lightningbeam-ui/lightningbeam-core/src/actions/raster_stroke.rs index 547809b..3c63808 100644 --- a/lightningbeam-ui/lightningbeam-core/src/actions/raster_stroke.rs +++ b/lightningbeam-ui/lightningbeam-core/src/actions/raster_stroke.rs @@ -49,12 +49,14 @@ impl Action for RasterStrokeAction { fn execute(&mut self, document: &mut Document) -> Result<(), String> { let kf = get_keyframe_mut(document, &self.layer_id, self.time, self.width, self.height)?; kf.raw_pixels = self.buffer_after.clone(); + kf.texture_dirty = true; Ok(()) } fn rollback(&mut self, document: &mut Document) -> Result<(), String> { let kf = get_keyframe_mut(document, &self.layer_id, self.time, self.width, self.height)?; kf.raw_pixels = self.buffer_before.clone(); + kf.texture_dirty = true; Ok(()) } 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/brush_engine.rs b/lightningbeam-ui/lightningbeam-core/src/brush_engine.rs index 465d8a9..b8e83bf 100644 --- a/lightningbeam-ui/lightningbeam-core/src/brush_engine.rs +++ b/lightningbeam-ui/lightningbeam-core/src/brush_engine.rs @@ -15,17 +15,26 @@ //! The GPU compute shader (`brush_dab.wgsl`) is the authoritative implementation. //! //! ### Dab placement -//! Dabs are placed along the stroke polyline at intervals of -//! `spacing = radius * dabs_per_radius`. Fractional remainder is tracked across -//! consecutive calls via `StrokeState`. +//! Follows the libmypaint model: distance-based and time-based contributions are +//! **summed** into a single `partial_dabs` accumulator. A dab fires whenever the +//! accumulator reaches 1.0. +//! +//! Rate (dabs per pixel) = dabs_per_actual_radius / actual_radius +//! + dabs_per_basic_radius / base_radius +//! Time contribution added per call = dt × dabs_per_second +//! +//! ### Opacity +//! Matches libmypaint's `opaque_linearize` formula. `dabs_per_pixel` is a fixed +//! brush-level estimate of how many dabs overlap at any pixel: +//! +//! `dabs_per_pixel = 1 + opaque_linearize × ((dabs_per_actual + dabs_per_basic) × 2 - 1)` +//! `per_dab_alpha = 1 - (1 - raw_opacity) ^ (1 / dabs_per_pixel)` +//! +//! With `opaque_linearize = 0` the raw opacity is used directly per dab. //! //! ### Blending -//! Normal mode uses the standard "over" operator on premultiplied RGBA: -//! ```text -//! result_a = opa_a + (1 - opa_a) * bottom_a -//! result_rgb = opa_a * top_rgb + (1 - opa_a) * bottom_rgb -//! ``` -//! Erase mode: subtract `opa_a` from the destination alpha and premultiply. +//! Normal mode uses the standard "over" operator on premultiplied RGBA. +//! Erase mode subtracts from destination alpha. use image::RgbaImage; use crate::raster_layer::{RasterBlendMode, StrokeRecord}; @@ -65,20 +74,48 @@ pub struct GpuDab { /// Blend mode: 0 = Normal, 1 = Erase, 2 = Smudge pub blend_mode: u32, - pub _pad0: u32, - pub _pad1: u32, - pub _pad2: u32, + /// Elliptical dab aspect ratio (1.0 = circle) + pub elliptical_dab_ratio: f32, + /// Elliptical dab rotation angle in radians + pub elliptical_dab_angle: f32, + /// Lock alpha: 0.0 = modify alpha normally, 1.0 = don't modify destination alpha + pub lock_alpha: f32, } -/// Transient brush stroke state (tracks partial dab position between segments) +/// Transient brush stroke state (tracks position and randomness between segments) pub struct StrokeState { - /// Distance along the path already "consumed" toward the next dab (in pixels) - pub distance_since_last_dab: f32, + /// Fractional dab accumulator — reaches 1.0 when the next dab should fire. + /// Initialised to 1.0 so the very first call always emits at least one dab. + pub partial_dabs: f32, + /// Exponentially-smoothed cursor X for slow_tracking + pub smooth_x: f32, + /// Exponentially-smoothed cursor Y for slow_tracking + pub smooth_y: f32, + /// Whether smooth_x/y have been initialised yet + pub smooth_initialized: bool, + /// xorshift32 seed for jitter and radius variation + pub rng_seed: u32, + /// Accumulated per-dab hue shift + pub color_h_phase: f32, + /// Accumulated per-dab value shift + pub color_v_phase: f32, + /// Accumulated per-dab saturation shift + pub color_s_phase: f32, } impl StrokeState { pub fn new() -> Self { - Self { distance_since_last_dab: 0.0 } + Self { + // Start at 1.0 so the first call always emits the stroke-start dab. + partial_dabs: 1.0, + smooth_x: 0.0, + smooth_y: 0.0, + smooth_initialized: false, + rng_seed: 0xDEAD_BEEF, + color_h_phase: 0.0, + color_v_phase: 0.0, + color_s_phase: 0.0, + } } } @@ -86,37 +123,199 @@ impl Default for StrokeState { fn default() -> Self { Self::new() } } +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/// xorshift32 — fast, no-alloc PRNG. Returns a value in [0, 1). +#[inline] +fn xorshift(seed: &mut u32) -> f32 { + let mut s = *seed; + s ^= s << 13; + s ^= s >> 17; + s ^= s << 5; + *seed = s; + (s as f32) / (u32::MAX as f32) +} + +/// Box-Muller Gaussian sample with mean 0 and std-dev 1. +/// Consumes two xorshift samples; the second half of the pair is discarded +/// (acceptable for brush jitter which doesn't need correlated pairs). +#[inline] +fn gaussian(seed: &mut u32) -> f32 { + let u1 = xorshift(seed).max(1e-7); // avoid ln(0) + let u2 = xorshift(seed); + (-2.0 * u1.ln()).sqrt() * (2.0 * std::f32::consts::PI * u2).cos() +} + +/// Convert linear RGB (not premultiplied) to HSV. +fn rgb_to_hsv(r: f32, g: f32, b: f32) -> (f32, f32, f32) { + let max = r.max(g).max(b); + let min = r.min(g).min(b); + let delta = max - min; + let v = max; + let s = if max > 1e-6 { delta / max } else { 0.0 }; + let h = if delta < 1e-6 { + 0.0 + } else if max == r { + ((g - b) / delta).rem_euclid(6.0) / 6.0 + } else if max == g { + ((b - r) / delta + 2.0) / 6.0 + } else { + ((r - g) / delta + 4.0) / 6.0 + }; + (h, s, v) +} + +/// Convert HSV to linear RGB. +fn hsv_to_rgb(h: f32, s: f32, v: f32) -> (f32, f32, f32) { + let h6 = h.rem_euclid(1.0) * 6.0; + let i = h6.floor() as i32; + let f = h6 - i as f32; + let p = v * (1.0 - s); + let q = v * (1.0 - s * f); + let t = v * (1.0 - s * (1.0 - f)); + match i % 6 { + 0 => (v, t, p), + 1 => (q, v, p), + 2 => (p, v, t), + 3 => (p, q, v), + 4 => (t, p, v), + _ => (v, p, q), + } +} + +// --------------------------------------------------------------------------- +// Per-dab effects helper +// --------------------------------------------------------------------------- + +/// Apply per-dab randomness and color-shift effects, matching libmypaint. +/// +/// Returns `(ex, ey, radius, opacity, cr, cg, cb)` ready for the dab emitter. +/// +/// Opacity uses the `opaque_linearize` formula (same fixed brush-level estimate +/// whether called from the single-point path or the drag path). +/// +/// Jitter uses Gaussian noise (matching libmypaint), not uniform. +/// +/// Radius jitter applies an opacity correction `× (base_r / jittered_r)²` to +/// keep perceived ink-amount constant as radius varies (matches libmypaint). +fn apply_dab_effects( + state: &mut StrokeState, + bs: &crate::brush_settings::BrushSettings, + x: f32, y: f32, + base_radius: f32, // radius_at_pressure(pressure), before jitter + pressure: f32, + color: [f32; 4], +) -> (f32, f32, f32, f32, f32, f32, f32) { + // ---- Opacity (libmypaint opaque_linearize formula) -------------------- + // Estimate average dab overlap per pixel from brush settings (fixed, not + // speed-dependent), then convert stroke-level opacity to per-dab alpha. + let raw_dpp = ((bs.dabs_per_actual_radius + bs.dabs_per_radius) * 2.0).max(1.0); + let dabs_per_pixel = (1.0 + bs.opaque_linearize * (raw_dpp - 1.0)).max(1.0); + let raw_o = bs.opacity_at_pressure(pressure); + let mut opacity = 1.0 - (1.0 - raw_o).powf(1.0 / dabs_per_pixel); + + // ---- Radius jitter (Gaussian in log-space, matching libmypaint) ------- + let mut radius = base_radius; + if bs.radius_by_random != 0.0 { + let noise = gaussian(&mut state.rng_seed) * bs.radius_by_random; + let jittered_log = bs.radius_log + noise; + radius = jittered_log.exp().clamp(0.5, 500.0); + // Opacity correction: keep ink-amount constant as radius varies. + let alpha_correction = (base_radius / radius).powi(2); + opacity = (opacity * alpha_correction).clamp(0.0, 1.0); + } + + // ---- Position jitter + fixed offset (Gaussian, matching libmypaint) --- + let mut ex = x; + let mut ey = y; + if bs.offset_by_random != 0.0 || bs.offset_x != 0.0 || bs.offset_y != 0.0 { + // libmypaint uses base_radius (no-pressure) for the jitter scale. + let base_r_fixed = bs.radius_log.exp(); + ex += gaussian(&mut state.rng_seed) * bs.offset_by_random * base_r_fixed + + bs.offset_x * base_r_fixed; + ey += gaussian(&mut state.rng_seed) * bs.offset_by_random * base_r_fixed + + bs.offset_y * base_r_fixed; + } + + // ---- Per-dab color phase shifts --------------------------------------- + state.color_h_phase += bs.change_color_h; + state.color_v_phase += bs.change_color_v; + state.color_s_phase += bs.change_color_hsv_s; + + let (mut cr, mut cg, mut cb) = (color[0], color[1], color[2]); + let ca = color[3]; + if ca > 1e-6 + && (bs.change_color_h != 0.0 + || bs.change_color_v != 0.0 + || bs.change_color_hsv_s != 0.0) + { + let (ur, ug, ub) = (cr / ca, cg / ca, cb / ca); + let (mut h, mut s, mut v) = rgb_to_hsv(ur, ug, ub); + h = (h + state.color_h_phase).rem_euclid(1.0); + v = (v + state.color_v_phase).clamp(0.0, 1.0); + s = (s + state.color_s_phase).clamp(0.0, 1.0); + let (r2, g2, b2) = hsv_to_rgb(h, s, v); + cr = r2 * ca; + cg = g2 * ca; + cb = b2 * ca; + } + + (ex, ey, radius, opacity.clamp(0.0, 1.0), cr, cg, cb) +} + +// --------------------------------------------------------------------------- +// Brush engine +// --------------------------------------------------------------------------- + /// Pure-Rust MyPaint-style Gaussian dab brush engine pub struct BrushEngine; impl BrushEngine { /// Compute the list of GPU dabs for a stroke segment. /// - /// Uses the same dab-spacing logic as [`apply_stroke_with_state`] but produces - /// [`GpuDab`] structs for upload to the GPU compute pipeline instead of painting - /// into a pixel buffer. + /// `dt` is the elapsed time in seconds since the previous call for this + /// stroke. Pass `0.0` on the very first call (stroke start). /// - /// Also returns the union bounding box of all dabs as `(x0, y0, x1, y1)` in - /// integer canvas pixel coordinates (clamped to non-negative values; `x0==i32::MAX` - /// when the returned Vec is empty). + /// Follows the libmypaint spacing model: distance-based and time-based + /// contributions are **summed** in a single `partial_dabs` accumulator. + /// A dab is emitted whenever `partial_dabs` reaches 1.0. + /// + /// Also returns the union bounding box of all dabs as `(x0, y0, x1, y1)`. pub fn compute_dabs( stroke: &StrokeRecord, state: &mut StrokeState, + dt: f32, ) -> (Vec, (i32, i32, i32, i32)) { let mut dabs: Vec = Vec::new(); let mut bbox = (i32::MAX, i32::MAX, i32::MIN, i32::MIN); + let bs = &stroke.brush_settings; - let blend_mode_u = match stroke.blend_mode { - RasterBlendMode::Normal => 0u32, - RasterBlendMode::Erase => 1u32, - RasterBlendMode::Smudge => 2u32, + // Determine blend mode, allowing brush settings to override Normal + let base_blend = match stroke.blend_mode { + RasterBlendMode::Normal if bs.eraser > 0.5 => RasterBlendMode::Erase, + RasterBlendMode::Normal if bs.smudge > 0.5 => RasterBlendMode::Smudge, + other => other, + }; + let blend_mode_u = match base_blend { + RasterBlendMode::Normal => 0u32, + RasterBlendMode::Erase => 1u32, + RasterBlendMode::Smudge => 2u32, + RasterBlendMode::CloneStamp => 3u32, + RasterBlendMode::Healing => 4u32, + RasterBlendMode::PatternStamp => 5u32, + RasterBlendMode::DodgeBurn => 6u32, + RasterBlendMode::Sponge => 7u32, + RasterBlendMode::BlurSharpen => 8u32, }; let push_dab = |dabs: &mut Vec, - bbox: &mut (i32, i32, i32, i32), - x: f32, y: f32, - radius: f32, opacity: f32, - ndx: f32, ndy: f32, smudge_dist: f32| { + bbox: &mut (i32, i32, i32, i32), + x: f32, y: f32, + radius: f32, opacity: f32, + cr: f32, cg: f32, cb: f32, + ndx: f32, ndy: f32, smudge_dist: f32| { let r_fringe = radius + 1.0; bbox.0 = bbox.0.min((x - r_fringe).floor() as i32); bbox.1 = bbox.1.min((y - r_fringe).floor() as i32); @@ -124,32 +323,83 @@ impl BrushEngine { bbox.3 = bbox.3.max((y + r_fringe).ceil() as i32); dabs.push(GpuDab { x, y, radius, - hardness: stroke.brush_settings.hardness, + hardness: bs.hardness, opacity, - color_r: stroke.color[0], - color_g: stroke.color[1], - color_b: stroke.color[2], - color_a: stroke.color[3], + color_r: cr, + color_g: cg, + color_b: cb, + // Clone stamp: color_r/color_g hold source canvas X/Y, so color_a = 1.0 + // (blend strength is opa_weight × opacity × 1.0 in the shader). + color_a: if base_blend.uses_brush_color() { stroke.color[3] } else { 1.0 }, ndx, ndy, smudge_dist, blend_mode: blend_mode_u, - _pad0: 0, _pad1: 0, _pad2: 0, + elliptical_dab_ratio: bs.elliptical_dab_ratio.max(1.0), + elliptical_dab_angle: bs.elliptical_dab_angle.to_radians(), + lock_alpha: bs.lock_alpha, }); }; + // Time-based accumulation: dt × dabs_per_second contributes to partial_dabs + // regardless of whether the cursor moved. + // Cap dt to 0.1 s to avoid a burst of dabs after a long pause. + let dt_capped = dt.min(0.1); + state.partial_dabs += dt_capped * bs.dabs_per_second; + + // ---------------------------------------------------------------- + // Single-point path: emit time-based (and stroke-start) dabs. + // The caller is responsible for timing; we just fire whenever + // partial_dabs ≥ 1.0. + // ---------------------------------------------------------------- if stroke.points.len() < 2 { if let Some(pt) = stroke.points.first() { - let r = stroke.brush_settings.radius_at_pressure(pt.pressure); - let raw_o = stroke.brush_settings.opacity_at_pressure(pt.pressure); - let o = 1.0 - (1.0 - raw_o).powf(stroke.brush_settings.dabs_per_radius * 0.5); - // Single-tap smudge has no direction — skip (same as CPU engine) - if !matches!(stroke.blend_mode, RasterBlendMode::Smudge) { - push_dab(&mut dabs, &mut bbox, pt.x, pt.y, r, o, 0.0, 0.0, 0.0); + if !state.smooth_initialized { + state.smooth_x = pt.x; + state.smooth_y = pt.y; + state.smooth_initialized = true; + } + while state.partial_dabs >= 1.0 { + state.partial_dabs -= 1.0; + let base_r = bs.radius_at_pressure(pt.pressure); + let (ex, ey, r, o, cr, cg, cb) = apply_dab_effects( + state, bs, pt.x, pt.y, base_r, pt.pressure, stroke.color, + ); + if !matches!(base_blend, RasterBlendMode::Smudge) { + let tp = &stroke.tool_params; + let (cr2, cg2, cb2, ndx2, ndy2) = match base_blend { + RasterBlendMode::CloneStamp | RasterBlendMode::Healing => + (tp[0], tp[1], 0.0, 0.0, 0.0), + RasterBlendMode::PatternStamp => + (cr, cg, cb, tp[0], tp[1]), + RasterBlendMode::DodgeBurn | RasterBlendMode::Sponge => + (tp[0], 0.0, 0.0, 0.0, 0.0), + RasterBlendMode::BlurSharpen => + (tp[0], 0.0, 0.0, tp[1], 0.0), + _ => (cr, cg, cb, 0.0, 0.0), + }; + push_dab(&mut dabs, &mut bbox, ex, ey, r, o, cr2, cg2, cb2, + ndx2, ndy2, 0.0); + } } - state.distance_since_last_dab = 0.0; } return (dabs, bbox); } + // ---------------------------------------------------------------- + // Drag path: walk the polyline, accumulating partial_dabs from + // both distance-based and time-based contributions. + // ---------------------------------------------------------------- + + // Track the last smoothed position so that any residual time-based + // dabs can be emitted at the end of the segment walk. + let mut last_smooth_x = state.smooth_x; + let mut last_smooth_y = state.smooth_y; + let mut last_pressure = stroke.points.last() + .map(|p| p.pressure) + .unwrap_or(1.0); + + // Fixed base radius (no pressure) used for the basic-radius spacing rate. + let base_radius_fixed = bs.radius_log.exp(); + for window in stroke.points.windows(2) { let p0 = &window[0]; let p1 = &window[1]; @@ -159,45 +409,143 @@ impl BrushEngine { let seg_len = (dx * dx + dy * dy).sqrt(); if seg_len < 1e-4 { continue; } + last_pressure = p1.pressure; + let mut t = 0.0f32; while t < 1.0 { let pressure = p0.pressure + t * (p1.pressure - p0.pressure); - let radius = stroke.brush_settings.radius_at_pressure(pressure); - let spacing = (radius * stroke.brush_settings.dabs_per_radius).max(0.5); + let radius_for_rate = bs.radius_at_pressure(pressure); - let dist_to_next = spacing - state.distance_since_last_dab; - let seg_t_to_next = (dist_to_next / seg_len).max(0.0); + // Dab rate = sum of distance-based contributions (dabs per pixel). + // Matches libmypaint: dabs_per_actual/actual_r + dabs_per_basic/base_r. + // For elliptical brushes use the minor-axis radius so dabs connect + // when moving perpendicular to the major axis. + let eff_radius = if bs.elliptical_dab_ratio > 1.001 { + radius_for_rate / bs.elliptical_dab_ratio + } else { + radius_for_rate + }; + let rate_actual = if bs.dabs_per_actual_radius > 0.0 { + bs.dabs_per_actual_radius / eff_radius + } else { 0.0 }; + let rate_basic = if bs.dabs_per_radius > 0.0 { + bs.dabs_per_radius / base_radius_fixed + } else { 0.0 }; + let rate = rate_actual + rate_basic; // dabs per pixel + + let remaining = 1.0 - state.partial_dabs; + let pixels_to_next = if rate > 1e-8 { remaining / rate } else { f32::MAX }; + let seg_t_to_next = (pixels_to_next / seg_len).max(0.0); if seg_t_to_next > 1.0 - t { - state.distance_since_last_dab += seg_len * (1.0 - t); + // Won't reach the next dab within this segment. + if rate > 1e-8 { + state.partial_dabs += (1.0 - t) * seg_len * rate; + } break; } t += seg_t_to_next; - let x2 = p0.x + t * dx; - let y2 = p0.y + t * dy; let pressure2 = p0.pressure + t * (p1.pressure - p0.pressure); - let radius2 = stroke.brush_settings.radius_at_pressure(pressure2); - let raw_opacity = stroke.brush_settings.opacity_at_pressure(pressure2); - // Normalize per-dab opacity so dense dabs don't saturate faster than sparse ones. - // Formula: per_dab = 1 − (1 − raw)^(dabs_per_radius / 2) - // Derivation: N = 2/dabs_per_radius dabs cover one full diameter at the centre; - // accumulated = 1 − (1 − per_dab)^N = raw → per_dab = 1 − (1−raw)^(dabs_per_radius/2) - let opacity2 = 1.0 - (1.0 - raw_opacity).powf(stroke.brush_settings.dabs_per_radius * 0.5); - if matches!(stroke.blend_mode, RasterBlendMode::Smudge) { - let ndx = dx / seg_len; - let ndy = dy / seg_len; - let smudge_dist = - (radius2 * stroke.brush_settings.dabs_per_radius).max(1.0); - push_dab(&mut dabs, &mut bbox, - x2, y2, radius2, opacity2, ndx, ndy, smudge_dist); - } else { - push_dab(&mut dabs, &mut bbox, - x2, y2, radius2, opacity2, 0.0, 0.0, 0.0); + // Stroke threshold gating + if pressure2 < bs.stroke_threshold { + state.partial_dabs = 0.0; + continue; } - state.distance_since_last_dab = 0.0; + let base_r2 = bs.radius_at_pressure(pressure2); + + // Slow tracking: exponential position smoothing + let x2 = p0.x + t * dx; + let y2 = p0.y + t * dy; + if !state.smooth_initialized { + state.smooth_x = x2; state.smooth_y = y2; + state.smooth_initialized = true; + } + // spacing_px ≈ 1 / rate (pixels per dab), used as time-constant scale + let spacing_px = if rate > 1e-8 { 1.0 / rate } else { 1.0 }; + let k = if bs.slow_tracking > 0.0 { + (-spacing_px / bs.slow_tracking.max(0.1)).exp() + } else { 0.0 }; + state.smooth_x = state.smooth_x * k + x2 * (1.0 - k); + state.smooth_y = state.smooth_y * k + y2 * (1.0 - k); + last_smooth_x = state.smooth_x; + last_smooth_y = state.smooth_y; + + let (sx, sy) = (state.smooth_x, state.smooth_y); + let (ex, ey, radius2, opacity2, cr, cg, cb) = apply_dab_effects( + state, bs, sx, sy, base_r2, pressure2, stroke.color, + ); + + if matches!(base_blend, RasterBlendMode::Smudge) { + let ndx = dx / seg_len; + let ndy = dy / seg_len; + // strength=1.0 → sample from 1 dab back (drag pixels with us). + // strength=0.0 → sample from current position (no change). + // smudge_radius_log is repurposed as a linear [0,1] strength value here. + let smudge_dist = spacing_px * bs.smudge_radius_log.clamp(0.0, 1.0); + push_dab(&mut dabs, &mut bbox, + ex, ey, radius2, opacity2, cr, cg, cb, + ndx, ndy, smudge_dist); + } else { + let tp = &stroke.tool_params; + let (cr2, cg2, cb2, ndx2, ndy2) = match base_blend { + RasterBlendMode::CloneStamp | RasterBlendMode::Healing => + (tp[0], tp[1], 0.0, 0.0, 0.0), + RasterBlendMode::PatternStamp => + (cr, cg, cb, tp[0], tp[1]), + RasterBlendMode::DodgeBurn | RasterBlendMode::Sponge => + (tp[0], 0.0, 0.0, 0.0, 0.0), + RasterBlendMode::BlurSharpen => + (tp[0], 0.0, 0.0, tp[1], 0.0), + _ => (cr, cg, cb, 0.0, 0.0), + }; + push_dab(&mut dabs, &mut bbox, + ex, ey, radius2, opacity2, cr2, cg2, cb2, + ndx2, ndy2, 0.0); + } + + state.partial_dabs = 0.0; + } + } + + // Emit any residual time-based dabs (partial_dabs ≥ 1.0 from the dt + // contribution not consumed by distance-based movement) at the last + // known cursor position. + if state.partial_dabs >= 1.0 && !matches!(base_blend, RasterBlendMode::Smudge) { + // Initialise smooth position if we never entered the segment loop. + if !state.smooth_initialized { + if let Some(pt) = stroke.points.last() { + state.smooth_x = pt.x; + state.smooth_y = pt.y; + state.smooth_initialized = true; + last_smooth_x = state.smooth_x; + last_smooth_y = state.smooth_y; + } + } + while state.partial_dabs >= 1.0 { + state.partial_dabs -= 1.0; + let base_r = bs.radius_at_pressure(last_pressure); + let (ex, ey, r, o, cr, cg, cb) = apply_dab_effects( + state, bs, + last_smooth_x, last_smooth_y, + base_r, last_pressure, stroke.color, + ); + let tp = &stroke.tool_params; + let (cr2, cg2, cb2, ndx2, ndy2) = match base_blend { + RasterBlendMode::CloneStamp | RasterBlendMode::Healing => + (tp[0], tp[1], 0.0, 0.0, 0.0), + RasterBlendMode::PatternStamp => + (cr, cg, cb, tp[0], tp[1]), + RasterBlendMode::DodgeBurn | RasterBlendMode::Sponge => + (tp[0], 0.0, 0.0, 0.0, 0.0), + RasterBlendMode::BlurSharpen => + (tp[0], 0.0, 0.0, tp[1], 0.0), + _ => (cr, cg, cb, 0.0, 0.0), + }; + push_dab(&mut dabs, &mut bbox, ex, ey, r, o, cr2, cg2, cb2, + ndx2, ndy2, 0.0); } } diff --git a/lightningbeam-ui/lightningbeam-core/src/brush_settings.rs b/lightningbeam-ui/lightningbeam-core/src/brush_settings.rs index 2f3f96e..e17844f 100644 --- a/lightningbeam-ui/lightningbeam-core/src/brush_settings.rs +++ b/lightningbeam-ui/lightningbeam-core/src/brush_settings.rs @@ -1,63 +1,215 @@ //! Brush settings for the raster paint engine //! //! Settings that describe the appearance and behavior of a paint brush. -//! Compatible with MyPaint .myb brush file format (subset). +//! Compatible with MyPaint .myb brush file format. use serde::{Deserialize, Serialize}; +use std::sync::OnceLock; -/// Settings for a paint brush +/// Settings for a paint brush — mirrors the MyPaint .myb settings schema. +/// +/// All fields correspond directly to MyPaint JSON keys. Fields marked +/// "parse-only" are stored so that .myb files round-trip cleanly; they will +/// be used when the dynamic-input system is wired up in a future task. #[derive(Clone, Debug, Serialize, Deserialize)] pub struct BrushSettings { + // ── Core shape ────────────────────────────────────────────────────────── /// log(radius) base value; actual radius = exp(radius_log) pub radius_log: f32, /// Edge hardness 0.0 (fully soft/gaussian) to 1.0 (hard edge) pub hardness: f32, /// Base opacity 0.0–1.0 pub opaque: f32, - /// Dab spacing as fraction of radius (smaller = denser strokes) + /// Additional opacity multiplier (opaque_multiply) + pub opaque_multiply: f32, + /// Dabs per basic_radius distance (MyPaint: dabs_per_basic_radius) pub dabs_per_radius: f32, + /// Dabs per actual (pressure-modified) radius distance + pub dabs_per_actual_radius: f32, + + // ── Elliptical dab ────────────────────────────────────────────────────── + /// Dab aspect ratio ≥ 1.0 (1.0 = circle, 3.0 = 3:1 ellipse) + pub elliptical_dab_ratio: f32, + /// Elliptical dab rotation angle in degrees (0–180) + pub elliptical_dab_angle: f32, + + // ── Jitter / offset ───────────────────────────────────────────────────── + /// Random radius variation (log-scale, 0 = none) + pub radius_by_random: f32, + /// Random positional jitter in units of radius + pub offset_by_random: f32, + /// Fixed X offset in units of radius + pub offset_x: f32, + /// Fixed Y offset in units of radius + pub offset_y: f32, + + // ── Position tracking ─────────────────────────────────────────────────── + /// Slow position tracking — higher = brush lags behind cursor more + pub slow_tracking: f32, + /// Per-dab position tracking smoothing + pub slow_tracking_per_dab: f32, + + // ── Color ─────────────────────────────────────────────────────────────── /// HSV hue (0.0–1.0); usually overridden by stroke color pub color_h: f32, /// HSV saturation (0.0–1.0) pub color_s: f32, /// HSV value (0.0–1.0) pub color_v: f32, + /// Per-dab hue shift (accumulates over the stroke) + pub change_color_h: f32, + /// Per-dab HSV value shift + pub change_color_v: f32, + /// Per-dab HSV saturation shift + pub change_color_hsv_s: f32, + /// Per-dab HSL lightness shift + pub change_color_l: f32, + /// Per-dab HSL saturation shift + pub change_color_hsl_s: f32, + + // ── Blend ─────────────────────────────────────────────────────────────── + /// Lock alpha channel (0 = off, 1 = on — don't modify destination alpha) + pub lock_alpha: f32, + /// Eraser strength (>0.5 activates erase blend when tool mode is Normal) + pub eraser: f32, + + // ── Smudge ────────────────────────────────────────────────────────────── + /// Smudge amount (>0.5 activates smudge blend when tool mode is Normal) + pub smudge: f32, + /// How quickly the smudge color updates (0 = instant, 1 = slow) + pub smudge_length: f32, + /// Smudge pickup radius offset (log-scale added to radius_log) + pub smudge_radius_log: f32, + + // ── Stroke gating ─────────────────────────────────────────────────────── + /// Minimum pressure required to emit dabs (0 = always emit) + pub stroke_threshold: f32, + + // ── Pressure dynamics ─────────────────────────────────────────────────── /// How much pressure increases/decreases radius - /// Final radius = exp(radius_log + pressure_radius_gain * pressure) pub pressure_radius_gain: f32, /// How much pressure increases/decreases opacity - /// Final opacity = opaque * (1 + pressure_opacity_gain * (pressure - 0.5)) pub pressure_opacity_gain: f32, + + // ── Parse-only: future input curve system ─────────────────────────────── + pub opaque_linearize: f32, + pub anti_aliasing: f32, + pub dabs_per_second: f32, + pub offset_by_speed: f32, + pub offset_by_speed_slowness: f32, + pub speed1_slowness: f32, + pub speed2_slowness: f32, + pub speed1_gamma: f32, + pub speed2_gamma: f32, + pub direction_filter: f32, + pub stroke_duration_log: f32, + pub stroke_holdtime: f32, + pub pressure_gain_log: f32, + pub smudge_transparency: f32, + pub smudge_length_log: f32, + pub smudge_bucket: f32, + pub paint_mode: f32, + pub colorize: f32, + pub posterize: f32, + pub posterize_num: f32, + pub snap_to_pixel: f32, + pub custom_input: f32, + pub custom_input_slowness: f32, + pub gridmap_scale: f32, + pub gridmap_scale_x: f32, + pub gridmap_scale_y: f32, + pub restore_color: f32, + pub offset_angle: f32, + pub offset_angle_asc: f32, + pub offset_angle_view: f32, + pub offset_angle_2: f32, + pub offset_angle_2_asc: f32, + pub offset_angle_2_view: f32, + pub offset_angle_adj: f32, + pub offset_multiplier: f32, } impl BrushSettings { /// Default soft round brush (smooth Gaussian falloff) pub fn default_round_soft() -> Self { Self { - radius_log: 2.0, // radius ≈ 7.4 px + radius_log: 2.0, hardness: 0.1, opaque: 0.8, - dabs_per_radius: 0.25, + opaque_multiply: 0.0, + dabs_per_radius: 2.0, + dabs_per_actual_radius: 2.0, + elliptical_dab_ratio: 1.0, + elliptical_dab_angle: 90.0, + radius_by_random: 0.0, + offset_by_random: 0.0, + offset_x: 0.0, + offset_y: 0.0, + slow_tracking: 0.0, + slow_tracking_per_dab: 0.0, color_h: 0.0, color_s: 0.0, color_v: 0.0, + change_color_h: 0.0, + change_color_v: 0.0, + change_color_hsv_s: 0.0, + change_color_l: 0.0, + change_color_hsl_s: 0.0, + lock_alpha: 0.0, + eraser: 0.0, + smudge: 0.0, + smudge_length: 0.5, + smudge_radius_log: 0.0, + stroke_threshold: 0.0, pressure_radius_gain: 0.5, pressure_opacity_gain: 1.0, + opaque_linearize: 0.9, + anti_aliasing: 1.0, + dabs_per_second: 0.0, + offset_by_speed: 0.0, + offset_by_speed_slowness: 1.0, + speed1_slowness: 0.04, + speed2_slowness: 0.8, + speed1_gamma: 4.0, + speed2_gamma: 4.0, + direction_filter: 2.0, + stroke_duration_log: 4.0, + stroke_holdtime: 0.0, + pressure_gain_log: 0.0, + smudge_transparency: 0.0, + smudge_length_log: 0.0, + smudge_bucket: 0.0, + paint_mode: 1.0, + colorize: 0.0, + posterize: 0.0, + posterize_num: 0.05, + snap_to_pixel: 0.0, + custom_input: 0.0, + custom_input_slowness: 0.0, + gridmap_scale: 0.0, + gridmap_scale_x: 1.0, + gridmap_scale_y: 1.0, + restore_color: 0.0, + offset_angle: 0.0, + offset_angle_asc: 0.0, + offset_angle_view: 0.0, + offset_angle_2: 0.0, + offset_angle_2_asc: 0.0, + offset_angle_2_view: 0.0, + offset_angle_adj: 0.0, + offset_multiplier: 0.0, } } /// Default hard round brush (sharp edge) pub fn default_round_hard() -> Self { Self { - radius_log: 2.0, hardness: 0.9, opaque: 1.0, - dabs_per_radius: 0.2, - color_h: 0.0, - color_s: 0.0, - color_v: 0.0, + dabs_per_radius: 2.0, pressure_radius_gain: 0.3, pressure_opacity_gain: 0.8, + ..Self::default_round_soft() } } @@ -73,10 +225,10 @@ impl BrushSettings { o.clamp(0.0, 1.0) } - /// Parse a MyPaint .myb JSON brush file (subset). + /// Parse a MyPaint .myb JSON brush file. /// - /// Reads `radius_logarithmic`, `hardness`, `opaque`, `dabs_per_basic_radius`, - /// `color_h`, `color_s`, `color_v` from the `settings` key's `base_value` fields. + /// Reads all known settings from `settings[key].base_value`. + /// Unknown keys are silently ignored for forward compatibility. pub fn from_myb(json: &str) -> Result { let v: serde_json::Value = serde_json::from_str(json).map_err(|e| format!("JSON parse error: {e}"))?; @@ -92,15 +244,13 @@ impl BrushSettings { .unwrap_or(default) }; - // Pressure dynamics: read from the "inputs" mapping of radius/opacity - // For simplicity, look for the pressure input point in radius_logarithmic + // Pressure dynamics: approximate from the pressure input curve endpoints let pressure_radius_gain = settings .get("radius_logarithmic") .and_then(|s| s.get("inputs")) .and_then(|inp| inp.get("pressure")) .and_then(|pts| pts.as_array()) .and_then(|arr| { - // arr = [[x0,y0],[x1,y1],...] – approximate as linear gain at x=1.0 if arr.len() >= 2 { let y0 = arr[0].get(1)?.as_f64()? as f32; let y1 = arr[arr.len() - 1].get(1)?.as_f64()? as f32; @@ -128,15 +278,81 @@ impl BrushSettings { .unwrap_or(1.0); Ok(Self { - radius_log: read_base("radius_logarithmic", 2.0), - hardness: read_base("hardness", 0.5).clamp(0.0, 1.0), - opaque: read_base("opaque", 1.0).clamp(0.0, 1.0), - dabs_per_radius: read_base("dabs_per_basic_radius", 0.25).clamp(0.01, 10.0), - color_h: read_base("color_h", 0.0), - color_s: read_base("color_s", 0.0), - color_v: read_base("color_v", 0.0), + // Core shape + radius_log: read_base("radius_logarithmic", 2.0), + hardness: read_base("hardness", 0.8).clamp(0.0, 1.0), + opaque: read_base("opaque", 1.0).clamp(0.0, 2.0), + opaque_multiply: read_base("opaque_multiply", 0.0), + dabs_per_radius: read_base("dabs_per_basic_radius", 0.0).max(0.0), + dabs_per_actual_radius: read_base("dabs_per_actual_radius", 2.0).max(0.0), + // Elliptical dab + elliptical_dab_ratio: read_base("elliptical_dab_ratio", 1.0).max(1.0), + elliptical_dab_angle: read_base("elliptical_dab_angle", 90.0), + // Jitter / offset + radius_by_random: read_base("radius_by_random", 0.0), + offset_by_random: read_base("offset_by_random", 0.0), + offset_x: read_base("offset_x", 0.0), + offset_y: read_base("offset_y", 0.0), + // Tracking + slow_tracking: read_base("slow_tracking", 0.0), + slow_tracking_per_dab: read_base("slow_tracking_per_dab", 0.0), + // Color + color_h: read_base("color_h", 0.0), + color_s: read_base("color_s", 0.0), + color_v: read_base("color_v", 0.0), + change_color_h: read_base("change_color_h", 0.0), + change_color_v: read_base("change_color_v", 0.0), + change_color_hsv_s: read_base("change_color_hsv_s", 0.0), + change_color_l: read_base("change_color_l", 0.0), + change_color_hsl_s: read_base("change_color_hsl_s", 0.0), + // Blend + lock_alpha: read_base("lock_alpha", 0.0).clamp(0.0, 1.0), + eraser: read_base("eraser", 0.0).clamp(0.0, 1.0), + // Smudge + smudge: read_base("smudge", 0.0).clamp(0.0, 1.0), + smudge_length: read_base("smudge_length", 0.5).clamp(0.0, 1.0), + smudge_radius_log: read_base("smudge_radius_log", 0.0), + // Stroke gating + stroke_threshold: read_base("stroke_threshold", 0.0).clamp(0.0, 0.5), + // Pressure dynamics pressure_radius_gain, pressure_opacity_gain, + // Parse-only + opaque_linearize: read_base("opaque_linearize", 0.9), + anti_aliasing: read_base("anti_aliasing", 1.0), + dabs_per_second: read_base("dabs_per_second", 0.0), + offset_by_speed: read_base("offset_by_speed", 0.0), + offset_by_speed_slowness: read_base("offset_by_speed_slowness", 1.0), + speed1_slowness: read_base("speed1_slowness", 0.04), + speed2_slowness: read_base("speed2_slowness", 0.8), + speed1_gamma: read_base("speed1_gamma", 4.0), + speed2_gamma: read_base("speed2_gamma", 4.0), + direction_filter: read_base("direction_filter", 2.0), + stroke_duration_log: read_base("stroke_duration_logarithmic", 4.0), + stroke_holdtime: read_base("stroke_holdtime", 0.0), + pressure_gain_log: read_base("pressure_gain_log", 0.0), + smudge_transparency: read_base("smudge_transparency", 0.0), + smudge_length_log: read_base("smudge_length_log", 0.0), + smudge_bucket: read_base("smudge_bucket", 0.0), + paint_mode: read_base("paint_mode", 1.0), + colorize: read_base("colorize", 0.0), + posterize: read_base("posterize", 0.0), + posterize_num: read_base("posterize_num", 0.05), + snap_to_pixel: read_base("snap_to_pixel", 0.0), + custom_input: read_base("custom_input", 0.0), + custom_input_slowness: read_base("custom_input_slowness", 0.0), + gridmap_scale: read_base("gridmap_scale", 0.0), + gridmap_scale_x: read_base("gridmap_scale_x", 1.0), + gridmap_scale_y: read_base("gridmap_scale_y", 1.0), + restore_color: read_base("restore_color", 0.0), + offset_angle: read_base("offset_angle", 0.0), + offset_angle_asc: read_base("offset_angle_asc", 0.0), + offset_angle_view: read_base("offset_angle_view", 0.0), + offset_angle_2: read_base("offset_angle_2", 0.0), + offset_angle_2_asc: read_base("offset_angle_2_asc", 0.0), + offset_angle_2_view: read_base("offset_angle_2_view", 0.0), + offset_angle_adj: read_base("offset_angle_adj", 0.0), + offset_multiplier: read_base("offset_multiplier", 0.0), }) } } @@ -146,3 +362,41 @@ impl Default for BrushSettings { Self::default_round_soft() } } + +// --------------------------------------------------------------------------- +// Bundled brush presets +// --------------------------------------------------------------------------- + +/// A named brush preset backed by a bundled .myb file. +pub struct BrushPreset { + pub name: &'static str, + pub settings: BrushSettings, +} + +/// Returns the list of bundled brush presets (parsed once from embedded .myb files). +/// +/// Sources: mypaint/mypaint-brushes — CC0 1.0 Universal (Public Domain) +pub fn bundled_brushes() -> &'static [BrushPreset] { + static CACHE: OnceLock> = OnceLock::new(); + CACHE.get_or_init(|| { + let mut v = Vec::new(); + macro_rules! brush { + ($name:literal, $path:literal) => { + if let Ok(s) = BrushSettings::from_myb(include_str!($path)) { + v.push(BrushPreset { name: $name, settings: s }); + } + }; + } + brush!("Pencil", "../../../src/assets/brushes/pencil.myb"); + brush!("Pen", "../../../src/assets/brushes/pen.myb"); + brush!("Charcoal", "../../../src/assets/brushes/charcoal.myb"); + brush!("Brush", "../../../src/assets/brushes/brush.myb"); + brush!("Dry Brush", "../../../src/assets/brushes/dry_brush.myb"); + brush!("Ink", "../../../src/assets/brushes/ink_blot.myb"); + brush!("Calligraphy", "../../../src/assets/brushes/calligraphy.myb"); + brush!("Airbrush", "../../../src/assets/brushes/airbrush.myb"); + brush!("Chalk", "../../../src/assets/brushes/chalk.myb"); + brush!("Liner", "../../../src/assets/brushes/liner.myb"); + v + }) +} diff --git a/lightningbeam-ui/lightningbeam-core/src/dcel2/mod.rs b/lightningbeam-ui/lightningbeam-core/src/dcel2/mod.rs index ecc4ad2..3a347e6 100644 --- a/lightningbeam-ui/lightningbeam-core/src/dcel2/mod.rs +++ b/lightningbeam-ui/lightningbeam-core/src/dcel2/mod.rs @@ -117,6 +117,8 @@ pub struct Face { pub image_fill: Option, pub fill_rule: FillRule, #[serde(default)] + pub gradient_fill: Option, + #[serde(default)] pub deleted: bool, } @@ -244,6 +246,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() { @@ -375,6 +378,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/export.rs b/lightningbeam-ui/lightningbeam-core/src/export.rs index 0905756..9c95045 100644 --- a/lightningbeam-ui/lightningbeam-core/src/export.rs +++ b/lightningbeam-ui/lightningbeam-core/src/export.rs @@ -390,6 +390,59 @@ impl VideoExportSettings { } } +// ── Image export ───────────────────────────────────────────────────────────── + +/// Image export formats (single-frame still image) +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum ImageFormat { + Png, + Jpeg, + WebP, +} + +impl ImageFormat { + pub fn name(self) -> &'static str { + match self { Self::Png => "PNG", Self::Jpeg => "JPEG", Self::WebP => "WebP" } + } + pub fn extension(self) -> &'static str { + match self { Self::Png => "png", Self::Jpeg => "jpg", Self::WebP => "webp" } + } + /// Whether quality (1–100) applies to this format. + pub fn has_quality(self) -> bool { matches!(self, Self::Jpeg | Self::WebP) } +} + +/// Settings for exporting a single frame as a still image. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ImageExportSettings { + pub format: ImageFormat, + /// Document time (seconds) of the frame to render. + pub time: f64, + /// Override width; None = use document canvas width. + pub width: Option, + /// Override height; None = use document canvas height. + pub height: Option, + /// Encode quality 1–100 (JPEG / WebP only). + pub quality: u8, + /// Preserve the alpha channel in the output (respect document background alpha). + /// When false, the image is composited onto an opaque background before encoding. + /// Only meaningful for formats that support alpha (PNG, WebP). + pub allow_transparency: bool, +} + +impl Default for ImageExportSettings { + fn default() -> Self { + Self { format: ImageFormat::Png, time: 0.0, width: None, height: None, quality: 90, allow_transparency: false } + } +} + +impl ImageExportSettings { + pub fn validate(&self) -> Result<(), String> { + if let Some(w) = self.width { if w == 0 { return Err("Width must be > 0".into()); } } + if let Some(h) = self.height { if h == 0 { return Err("Height must be > 0".into()); } } + Ok(()) + } +} + /// Progress updates during export #[derive(Debug, Clone)] pub enum ExportProgress { diff --git a/lightningbeam-ui/lightningbeam-core/src/flood_fill.rs b/lightningbeam-ui/lightningbeam-core/src/flood_fill.rs index 023cea2..1b439b8 100644 --- a/lightningbeam-ui/lightningbeam-core/src/flood_fill.rs +++ b/lightningbeam-ui/lightningbeam-core/src/flood_fill.rs @@ -1,8 +1,198 @@ -//! Flood fill algorithm for paint bucket tool +//! Flood fill algorithms for paint bucket tool //! -//! This module implements a flood fill that tracks which curves each point -//! touches. Instead of filling with pixels, it returns boundary points that -//! can be used to construct a filled shape from exact curve geometry. +//! This module contains two fill implementations: +//! - `flood_fill` — vector curve-boundary fill (used by vector paint bucket) +//! - `raster_flood_fill` — pixel BFS fill with configurable threshold, soft +//! edge, and optional selection clipping (used by raster paint bucket) + +// ── Raster flood fill ───────────────────────────────────────────────────────── + +/// Which pixel to compare against when deciding if a neighbor should be filled. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FillThresholdMode { + /// Compare each candidate pixel to the original seed pixel (Photoshop default). + Absolute, + /// Compare each candidate pixel to the pixel it was reached from (spreads + /// through gradients without a global seed-color reference). + Relative, +} + +/// BFS / global scan flood fill mask. +/// +/// Returns a `Vec>` of length `width × height`: +/// - `Some(d)` — pixel is within the fill region; `d` is the color distance +/// from its comparison color (0.0 at seed, up to `threshold` at the edge). +/// - `None` — pixel is outside the fill region. +/// +/// # Parameters +/// - `pixels` – raw RGBA buffer (read-only) +/// - `width/height` – canvas dimensions +/// - `seed_x/y` – click coordinates (canvas pixel indices, 0-based) +/// - `threshold` – max color distance to include +/// - `mode` – Absolute = compare to seed; Relative = compare to BFS parent +/// - `contiguous` – true = BFS from seed (connected region only); +/// false = scan every pixel against seed color globally +/// - `selection` – optional clip mask; pixels outside are never included +pub fn raster_fill_mask( + pixels: &[u8], + width: u32, + height: u32, + seed_x: i32, + seed_y: i32, + threshold: f32, + mode: FillThresholdMode, + contiguous: bool, + selection: Option<&crate::selection::RasterSelection>, +) -> Vec> { + use std::collections::VecDeque; + + let w = width as i32; + let h = height as i32; + let n = (width * height) as usize; + + let mut dist_map: Vec> = vec![None; n]; + + if seed_x < 0 || seed_y < 0 || seed_x >= w || seed_y >= h { + return dist_map; + } + + let seed_idx = (seed_y * w + seed_x) as usize; + let seed_color = [ + pixels[seed_idx * 4], + pixels[seed_idx * 4 + 1], + pixels[seed_idx * 4 + 2], + pixels[seed_idx * 4 + 3], + ]; + + if contiguous { + // BFS: only connected pixels within threshold. + let mut parent_color: Vec<[u8; 4]> = vec![[0; 4]; n]; + let mut queue: VecDeque<(i32, i32)> = VecDeque::new(); + + dist_map[seed_idx] = Some(0.0); + parent_color[seed_idx] = seed_color; + queue.push_back((seed_x, seed_y)); + + let dirs: [(i32, i32); 4] = [(0, -1), (0, 1), (-1, 0), (1, 0)]; + + while let Some((cx, cy)) = queue.pop_front() { + let ci = (cy * w + cx) as usize; + let compare_color = match mode { + FillThresholdMode::Absolute => seed_color, + FillThresholdMode::Relative => parent_color[ci], + }; + for (dx, dy) in dirs { + let nx = cx + dx; + let ny = cy + dy; + if nx < 0 || ny < 0 || nx >= w || ny >= h { continue; } + let ni = (ny * w + nx) as usize; + if dist_map[ni].is_some() { continue; } + if let Some(sel) = selection { + if !sel.contains_pixel(nx, ny) { continue; } + } + let npx = [pixels[ni*4], pixels[ni*4+1], pixels[ni*4+2], pixels[ni*4+3]]; + let d = color_distance(npx, compare_color); + if d <= threshold { + dist_map[ni] = Some(d); + parent_color[ni] = npx; + queue.push_back((nx, ny)); + } + } + } + } else { + // Global scan: every pixel compared against seed color (Absolute mode). + for row in 0..h { + for col in 0..w { + if let Some(sel) = selection { + if !sel.contains_pixel(col, row) { continue; } + } + let ni = (row * w + col) as usize; + let npx = [pixels[ni*4], pixels[ni*4+1], pixels[ni*4+2], pixels[ni*4+3]]; + let d = color_distance(npx, seed_color); + if d <= threshold { + dist_map[ni] = Some(d); + } + } + } + } + + dist_map +} + +/// Pixel flood fill for the raster paint bucket tool. +/// +/// Calls [`raster_fill_mask`] then alpha-composites `fill_color` over each +/// matched pixel. `softness` controls a fade zone near the fill boundary. +pub fn raster_flood_fill( + pixels: &mut Vec, + width: u32, + height: u32, + seed_x: i32, + seed_y: i32, + fill_color: [u8; 4], + threshold: f32, + softness: f32, + mode: FillThresholdMode, + contiguous: bool, + selection: Option<&crate::selection::RasterSelection>, +) { + let dist_map = raster_fill_mask(pixels, width, height, seed_x, seed_y, + threshold, mode, contiguous, selection); + let n = (width * height) as usize; + + let fr = fill_color[0] as f32 / 255.0; + let fg = fill_color[1] as f32 / 255.0; + let fb = fill_color[2] as f32 / 255.0; + let fa_base = fill_color[3] as f32 / 255.0; + + let falloff_start = if softness <= 0.0 || threshold <= 0.0 { + 1.0_f32 + } else { + 1.0 - softness / 100.0 + }; + + for i in 0..n { + if let Some(d) = dist_map[i] { + let alpha = if threshold <= 0.0 { + fa_base + } else { + let t = d / threshold; + if t <= falloff_start { + fa_base + } else { + let frac = (t - falloff_start) / (1.0 - falloff_start).max(1e-6); + fa_base * (1.0 - frac) + } + }; + if alpha <= 0.0 { continue; } + + let dst_r = pixels[i * 4 ] as f32 / 255.0; + let dst_g = pixels[i * 4 + 1] as f32 / 255.0; + let dst_b = pixels[i * 4 + 2] as f32 / 255.0; + let dst_a = pixels[i * 4 + 3] as f32 / 255.0; + let inv_a = 1.0 - alpha; + let out_a = alpha + dst_a * inv_a; + if out_a > 0.0 { + pixels[i*4 ] = ((fr * alpha + dst_r * dst_a * inv_a) / out_a * 255.0).round() as u8; + pixels[i*4+1] = ((fg * alpha + dst_g * dst_a * inv_a) / out_a * 255.0).round() as u8; + pixels[i*4+2] = ((fb * alpha + dst_b * dst_a * inv_a) / out_a * 255.0).round() as u8; + pixels[i*4+3] = (out_a * 255.0).round() as u8; + } + } + } +} + +fn color_distance(a: [u8; 4], b: [u8; 4]) -> f32 { + let dr = a[0] as f32 - b[0] as f32; + let dg = a[1] as f32 - b[1] as f32; + let db = a[2] as f32 - b[2] as f32; + let da = a[3] as f32 - b[3] as f32; + (dr * dr + dg * dg + db * db + da * da).sqrt() +} + +// ── Vector (curve-boundary) flood fill ─────────────────────────────────────── +// The following is the original vector-layer flood fill, kept for the vector +// paint bucket tool. use crate::curve_segment::CurveSegment; use crate::quadtree::{BoundingBox, Quadtree}; 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 3556b2f..049203f 100644 --- a/lightningbeam-ui/lightningbeam-core/src/lib.rs +++ b/lightningbeam-ui/lightningbeam-core/src/lib.rs @@ -53,6 +53,8 @@ pub mod webcam; 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_draw.rs b/lightningbeam-ui/lightningbeam-core/src/raster_draw.rs new file mode 100644 index 0000000..7602341 --- /dev/null +++ b/lightningbeam-ui/lightningbeam-core/src/raster_draw.rs @@ -0,0 +1,194 @@ +//! CPU-side raster drawing primitives for geometric shapes on raster layers. +//! +//! All coordinates are in canvas pixels (f32). The pixel buffer is RGBA u8, +//! 4 bytes per pixel, row-major, top-left origin. + +/// RGBA color as `[R, G, B, A]` bytes. +pub type Rgba = [u8; 4]; + +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- + +/// Alpha-composite `color` (RGBA) onto `pixels[idx..idx+4]` with an extra +/// `coverage` factor (0.0 = transparent, 1.0 = full color alpha). +#[inline] +fn blend_at(pixels: &mut [u8], idx: usize, color: Rgba, coverage: f32) { + let a = (color[3] as f32 / 255.0) * coverage; + if a <= 0.0 { return; } + let inv = 1.0 - a; + pixels[idx] = (color[0] as f32 * a + pixels[idx] as f32 * inv) as u8; + pixels[idx + 1] = (color[1] as f32 * a + pixels[idx + 1] as f32 * inv) as u8; + pixels[idx + 2] = (color[2] as f32 * a + pixels[idx + 2] as f32 * inv) as u8; + pixels[idx + 3] = ((a + pixels[idx + 3] as f32 / 255.0 * inv) * 255.0).min(255.0) as u8; +} + +/// Write a pixel at integer canvas coordinates, clipped to canvas bounds. +#[inline] +fn put(pixels: &mut [u8], w: u32, h: u32, x: i32, y: i32, color: Rgba, coverage: f32) { + if x < 0 || y < 0 || x >= w as i32 || y >= h as i32 { return; } + let idx = (y as u32 * w + x as u32) as usize * 4; + blend_at(pixels, idx, color, coverage); +} + +/// Draw an anti-aliased filled disk at (`cx`, `cy`) with the given `radius`. +fn draw_disk(pixels: &mut [u8], w: u32, h: u32, cx: f32, cy: f32, radius: f32, color: Rgba) { + let r = (radius + 1.0) as i32; + let ix = cx as i32; + let iy = cy as i32; + for dy in -r..=r { + for dx in -r..=r { + let px = ix + dx; + let py = iy + dy; + let dist = ((px as f32 - cx).powi(2) + (py as f32 - cy).powi(2)).sqrt(); + let cov = (radius + 0.5 - dist).clamp(0.0, 1.0); + if cov > 0.0 { + put(pixels, w, h, px, py, color, cov); + } + } + } +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/// Draw a thick line from (`x0`, `y0`) to (`x1`, `y1`) by stamping +/// anti-aliased disks of radius `thickness / 2` at every half-pixel step. +pub fn draw_line( + pixels: &mut [u8], w: u32, h: u32, + x0: f32, y0: f32, x1: f32, y1: f32, + color: Rgba, thickness: f32, +) { + let radius = (thickness / 2.0).max(0.5); + let dx = x1 - x0; + let dy = y1 - y0; + let len = (dx * dx + dy * dy).sqrt(); + if len < 0.5 { + draw_disk(pixels, w, h, x0, y0, radius, color); + return; + } + let steps = ((len * 2.0).ceil() as i32).max(1); + for i in 0..=steps { + let t = i as f32 / steps as f32; + draw_disk(pixels, w, h, x0 + dx * t, y0 + dy * t, radius, color); + } +} + +/// Draw a rectangle with corners (`x0`, `y0`) and (`x1`, `y1`). +/// +/// `stroke` draws the four edges; `fill` fills the interior. Either may be +/// `None` to skip that part. +pub fn draw_rect( + pixels: &mut [u8], w: u32, h: u32, + x0: f32, y0: f32, x1: f32, y1: f32, + stroke: Option, fill: Option, thickness: f32, +) { + let (lx, rx) = if x0 <= x1 { (x0, x1) } else { (x1, x0) }; + let (ty, by) = if y0 <= y1 { (y0, y1) } else { (y1, y0) }; + + if let Some(fc) = fill { + let px0 = lx.ceil() as i32; + let py0 = ty.ceil() as i32; + let px1 = rx.floor() as i32; + let py1 = by.floor() as i32; + for py in py0..=py1 { + for px in px0..=px1 { + put(pixels, w, h, px, py, fc, 1.0); + } + } + } + + if let Some(sc) = stroke { + draw_line(pixels, w, h, lx, ty, rx, ty, sc, thickness); // top + draw_line(pixels, w, h, rx, ty, rx, by, sc, thickness); // right + draw_line(pixels, w, h, rx, by, lx, by, sc, thickness); // bottom + draw_line(pixels, w, h, lx, by, lx, ty, sc, thickness); // left + } +} + +/// Draw an ellipse centred at (`cx`, `cy`) with semi-axes `rx` and `ry`. +/// +/// `stroke` draws the outline; `fill` fills the interior via scanline. +pub fn draw_ellipse( + pixels: &mut [u8], w: u32, h: u32, + cx: f32, cy: f32, rx: f32, ry: f32, + stroke: Option, fill: Option, thickness: f32, +) { + if rx <= 0.0 || ry <= 0.0 { return; } + + if let Some(fc) = fill { + let py0 = (cy - ry).ceil() as i32; + let py1 = (cy + ry).floor() as i32; + for py in py0..=py1 { + let dy = py as f32 - cy; + let t = 1.0 - (dy / ry).powi(2); + if t <= 0.0 { continue; } + let x_ext = rx * t.sqrt(); + let px0 = (cx - x_ext).ceil() as i32; + let px1 = (cx + x_ext).floor() as i32; + for px in px0..=px1 { + put(pixels, w, h, px, py, fc, 1.0); + } + } + } + + if let Some(sc) = stroke { + let radius = (thickness / 2.0).max(0.5); + // Ramanujan's perimeter approximation for step count. + let perim = std::f32::consts::PI + * (3.0 * (rx + ry) - ((3.0 * rx + ry) * (rx + 3.0 * ry)).sqrt()); + let steps = ((perim * 2.0).ceil() as i32).max(16); + for i in 0..steps { + let t = i as f32 / steps as f32 * std::f32::consts::TAU; + draw_disk(pixels, w, h, cx + rx * t.cos(), cy + ry * t.sin(), radius, sc); + } + } +} + +/// Draw a closed polygon given world-space `vertices` (at least 2). +/// +/// `stroke` draws the outline; `fill` fills the interior via scanline. +pub fn draw_polygon( + pixels: &mut [u8], w: u32, h: u32, + vertices: &[(f32, f32)], + stroke: Option, fill: Option, thickness: f32, +) { + let n = vertices.len(); + if n < 2 { return; } + + if let Some(fc) = fill { + let min_y = vertices.iter().map(|v| v.1).fold(f32::MAX, f32::min).ceil() as i32; + let max_y = vertices.iter().map(|v| v.1).fold(f32::MIN, f32::max).floor() as i32; + let mut xs: Vec = Vec::with_capacity(n); + for py in min_y..=max_y { + xs.clear(); + let scan_y = py as f32 + 0.5; + for i in 0..n { + let (x0, y0) = vertices[i]; + let (x1, y1) = vertices[(i + 1) % n]; + if (y0 <= scan_y && scan_y < y1) || (y1 <= scan_y && scan_y < y0) { + xs.push(x0 + (scan_y - y0) / (y1 - y0) * (x1 - x0)); + } + } + xs.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)); + let mut j = 0; + while j + 1 < xs.len() { + let px0 = xs[j].ceil() as i32; + let px1 = xs[j + 1].floor() as i32; + for px in px0..=px1 { + put(pixels, w, h, px, py, fc, 1.0); + } + j += 2; + } + } + } + + if let Some(sc) = stroke { + for i in 0..n { + let (x0, y0) = vertices[i]; + let (x1, y1) = vertices[(i + 1) % n]; + draw_line(pixels, w, h, x0, y0, x1, y1, sc, thickness); + } + } +} diff --git a/lightningbeam-ui/lightningbeam-core/src/raster_layer.rs b/lightningbeam-ui/lightningbeam-core/src/raster_layer.rs index 9f6f293..52f3ac7 100644 --- a/lightningbeam-ui/lightningbeam-core/src/raster_layer.rs +++ b/lightningbeam-ui/lightningbeam-core/src/raster_layer.rs @@ -18,6 +18,18 @@ pub enum RasterBlendMode { Erase, /// Smudge / blend surrounding pixels Smudge, + /// Clone stamp: copy pixels from a source region + CloneStamp, + /// Healing brush: color-corrected clone stamp (preserves source texture, shifts color to match destination) + Healing, + /// Pattern stamp: paint with a repeating procedural tile pattern + PatternStamp, + /// Dodge / Burn: lighten (dodge) or darken (burn) existing pixels + DodgeBurn, + /// Sponge: saturate or desaturate existing pixels + Sponge, + /// Blur / Sharpen: soften or crisp up existing pixels + BlurSharpen, } impl Default for RasterBlendMode { @@ -26,6 +38,15 @@ impl Default for RasterBlendMode { } } +impl RasterBlendMode { + /// Returns false for blend modes that operate on existing pixels and don't + /// use the brush color at all (clone, heal, dodge/burn, sponge). + /// Used by brush_engine.rs to decide whether color_a should be 1.0 or stroke.color[3]. + pub fn uses_brush_color(self) -> bool { + !matches!(self, Self::CloneStamp | Self::Healing | Self::DodgeBurn | Self::Sponge | Self::BlurSharpen) + } +} + /// A single point along a stroke #[derive(Clone, Debug, Serialize, Deserialize)] pub struct StrokePoint { @@ -48,6 +69,13 @@ pub struct StrokeRecord { /// RGBA linear color [r, g, b, a] pub color: [f32; 4], pub blend_mode: RasterBlendMode, + /// Generic tool parameters — encoding depends on blend_mode: + /// - CloneStamp / Healing: [offset_x, offset_y, 0, 0] + /// - PatternStamp: [pattern_type, pattern_scale, 0, 0] + /// - DodgeBurn / Sponge: [mode, 0, 0, 0] + /// - all others: [0, 0, 0, 0] + #[serde(default)] + pub tool_params: [f32; 4], pub points: Vec, } @@ -85,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 { @@ -105,6 +139,7 @@ impl RasterKeyframe { stroke_log: Vec::new(), tween_after: TweenType::Hold, raw_pixels: Vec::new(), + texture_dirty: true, } } } diff --git a/lightningbeam-ui/lightningbeam-core/src/renderer.rs b/lightningbeam-ui/lightningbeam-core/src/renderer.rs index b4c5daf..1c3d226 100644 --- a/lightningbeam-ui/lightningbeam-core/src/renderer.rs +++ b/lightningbeam-ui/lightningbeam-core/src/renderer.rs @@ -18,7 +18,7 @@ use std::collections::HashMap; use std::sync::Arc; use uuid::Uuid; use vello::kurbo::Rect; -use vello::peniko::{Blob, Fill, ImageAlphaType, ImageBrush, ImageData, ImageFormat}; +use vello::peniko::{Blob, Fill, ImageAlphaType, ImageBrush, ImageData, ImageFormat, ImageQuality}; use vello::Scene; /// Cache for decoded image data to avoid re-decoding every frame @@ -88,14 +88,53 @@ fn decode_image_asset(asset: &ImageAsset) -> Option { // Per-Layer Rendering for HDR Compositing Pipeline // ============================================================================ +/// A single decoded video frame ready for GPU upload, with its document-space transform. +pub struct VideoRenderInstance { + /// sRGB RGBA8 pixel data (straight alpha — as decoded by ffmpeg). + pub rgba_data: Arc>, + pub width: u32, + pub height: u32, + /// Affine transform that maps from video-pixel space to document space. + /// Composed from the clip's animated position/rotation/scale properties. + pub transform: Affine, + /// Final opacity [0,1] after cascading layer and instance opacity. + pub opacity: f32, +} + /// Type of rendered layer for compositor handling -#[derive(Clone, Debug)] pub enum RenderedLayerType { - /// Regular content layer (vector, video) - composite its scene - Content, - /// Effect layer - apply effects to current composite state + /// Vector / group layer — Vello scene in `RenderedLayer::scene` is used. + Vector, + /// Raster keyframe — bypass Vello; compositor uploads pixels via GPU texture cache. + Raster { + kf_id: Uuid, + width: u32, + height: u32, + /// True when `raw_pixels` changed since the last upload; forces a cache re-upload. + dirty: bool, + /// Accumulated parent-clip affine (IDENTITY for top-level layers). + /// Compositor composes this with the camera into the blit matrix. + transform: Affine, + }, + /// Video layer — bypass Vello; each active clip instance carries decoded frame data. + Video { + instances: Vec, + }, + /// Floating raster selection — blitted immediately above its parent layer. + Float { + canvas_id: Uuid, + x: i32, + y: i32, + width: u32, + height: u32, + /// Accumulated parent-clip affine (IDENTITY for top-level layers). + transform: Affine, + /// CPU pixel data (sRGB-premultiplied RGBA8). Arc so the per-frame clone is O(1). + /// Used by the export compositor; the live compositor reads the GPU canvas directly. + pixels: std::sync::Arc>, + }, + /// Effect layer — applied as a post-process pass on the HDR accumulator. Effect { - /// Active effect instances at the current time effect_instances: Vec, }, } @@ -104,7 +143,7 @@ pub enum RenderedLayerType { pub struct RenderedLayer { /// The layer's unique identifier pub layer_id: Uuid, - /// The Vello scene containing the layer's rendered content + /// Vello scene — only populated for `RenderedLayerType::Vector`. pub scene: Scene, /// Layer opacity (0.0 to 1.0) pub opacity: f32, @@ -112,12 +151,12 @@ pub struct RenderedLayer { pub blend_mode: BlendMode, /// Whether this layer has any visible content pub has_content: bool, - /// Type of layer for compositor (content vs effect) + /// Layer variant — determines how the compositor renders this entry. pub layer_type: RenderedLayerType, } impl RenderedLayer { - /// Create a new rendered layer with default settings + /// Create a new vector layer with default settings. pub fn new(layer_id: Uuid) -> Self { Self { layer_id, @@ -125,11 +164,11 @@ impl RenderedLayer { opacity: 1.0, blend_mode: BlendMode::Normal, has_content: false, - layer_type: RenderedLayerType::Content, + layer_type: RenderedLayerType::Vector, } } - /// Create with specific opacity and blend mode + /// Create a vector layer with specific opacity and blend mode. pub fn with_settings(layer_id: Uuid, opacity: f32, blend_mode: BlendMode) -> Self { Self { layer_id, @@ -137,11 +176,11 @@ impl RenderedLayer { opacity, blend_mode, has_content: false, - layer_type: RenderedLayerType::Content, + layer_type: RenderedLayerType::Vector, } } - /// Create an effect layer with active effect instances + /// Create an effect layer with active effect instances. pub fn effect_layer(layer_id: Uuid, opacity: f32, effect_instances: Vec) -> Self { let has_content = !effect_instances.is_empty(); Self { @@ -179,12 +218,14 @@ pub fn render_document_for_compositing( image_cache: &mut ImageCache, video_manager: &std::sync::Arc>, camera_frame: Option<&crate::webcam::CaptureFrame>, + floating_selection: Option<&crate::selection::RasterFloatingSelection>, + draw_checkerboard: bool, ) -> CompositeRenderResult { let time = document.current_time; // Render background to its own scene let mut background = Scene::new(); - render_background(document, &mut background, base_transform); + render_background(document, &mut background, base_transform, draw_checkerboard); // Check if any layers are soloed let any_soloed = document.visible_layers().any(|layer| layer.soloed()); @@ -217,6 +258,36 @@ pub fn render_document_for_compositing( rendered_layers.push(rendered); } + // Insert the floating raster selection immediately above its parent layer. + // This ensures it composites at the correct z-position in both edit and export. + if let Some(float_sel) = floating_selection { + if let Some(pos) = rendered_layers.iter().position(|l| l.layer_id == float_sel.layer_id) { + // Inherit the parent layer's transform so the float follows it into + // any transformed clip context. + let parent_transform = match &rendered_layers[pos].layer_type { + RenderedLayerType::Raster { transform, .. } => *transform, + _ => Affine::IDENTITY, + }; + let float_entry = RenderedLayer { + layer_id: Uuid::nil(), // sentinel — not a real document layer + scene: Scene::new(), + opacity: 1.0, + blend_mode: crate::gpu::BlendMode::Normal, + has_content: !float_sel.pixels.is_empty(), + layer_type: RenderedLayerType::Float { + canvas_id: float_sel.canvas_id, + x: float_sel.x, + y: float_sel.y, + width: float_sel.width, + height: float_sel.height, + transform: parent_transform, + pixels: std::sync::Arc::clone(&float_sel.pixels), + }, + }; + rendered_layers.insert(pos + 1, float_entry); + } + } + CompositeRenderResult { background, layers: rendered_layers, @@ -269,21 +340,74 @@ pub fn render_layer_isolated( rendered.has_content = false; } AnyLayer::Video(video_layer) => { + use crate::animation::TransformProperty; + let layer_opacity = layer.opacity(); let mut video_mgr = video_manager.lock().unwrap(); - // Only pass camera_frame for the layer that has camera enabled - let layer_camera_frame = if video_layer.camera_enabled { camera_frame } else { None }; - render_video_layer_to_scene( - document, - time, - video_layer, - &mut rendered.scene, - base_transform, - 1.0, // Full opacity - layer opacity handled in compositing - &mut video_mgr, - layer_camera_frame, - ); - rendered.has_content = !video_layer.clip_instances.is_empty() - || (video_layer.camera_enabled && camera_frame.is_some()); + let mut instances = Vec::new(); + + for clip_instance in &video_layer.clip_instances { + let Some(video_clip) = document.video_clips.get(&clip_instance.clip_id) else { continue }; + let Some(clip_time) = clip_instance.remap_time(time, video_clip.duration) else { continue }; + let Some(frame) = video_mgr.get_frame(&clip_instance.clip_id, clip_time) else { continue }; + + // Evaluate animated transform properties. + let anim = &video_layer.layer.animation_data; + let id = clip_instance.id; + let t = &clip_instance.transform; + let x = anim.eval(&crate::animation::AnimationTarget::Object { id, property: TransformProperty::X }, time, t.x); + let y = anim.eval(&crate::animation::AnimationTarget::Object { id, property: TransformProperty::Y }, time, t.y); + let rotation = anim.eval(&crate::animation::AnimationTarget::Object { id, property: TransformProperty::Rotation }, time, t.rotation); + let scale_x = anim.eval(&crate::animation::AnimationTarget::Object { id, property: TransformProperty::ScaleX }, time, t.scale_x); + let scale_y = anim.eval(&crate::animation::AnimationTarget::Object { id, property: TransformProperty::ScaleY }, time, t.scale_y); + let skew_x = anim.eval(&crate::animation::AnimationTarget::Object { id, property: TransformProperty::SkewX }, time, t.skew_x); + let skew_y = anim.eval(&crate::animation::AnimationTarget::Object { id, property: TransformProperty::SkewY }, time, t.skew_y); + let inst_opacity = anim.eval(&crate::animation::AnimationTarget::Object { id, property: TransformProperty::Opacity }, time, clip_instance.opacity); + + let cx = video_clip.width / 2.0; + let cy = video_clip.height / 2.0; + let skew_transform = if skew_x != 0.0 || skew_y != 0.0 { + let sx = if skew_x != 0.0 { Affine::new([1.0, 0.0, skew_x.to_radians().tan(), 1.0, 0.0, 0.0]) } else { Affine::IDENTITY }; + let sy = if skew_y != 0.0 { Affine::new([1.0, skew_y.to_radians().tan(), 0.0, 1.0, 0.0, 0.0]) } else { Affine::IDENTITY }; + Affine::translate((cx, cy)) * sx * sy * Affine::translate((-cx, -cy)) + } else { Affine::IDENTITY }; + + let clip_transform = Affine::translate((x, y)) + * Affine::rotate(rotation.to_radians()) + * Affine::scale_non_uniform(scale_x, scale_y) + * skew_transform; + + instances.push(VideoRenderInstance { + rgba_data: frame.rgba_data.clone(), + width: frame.width, + height: frame.height, + transform: base_transform * clip_transform, + opacity: (layer_opacity * inst_opacity) as f32, + }); + } + + // Camera / webcam frame. + if instances.is_empty() && video_layer.camera_enabled { + if let Some(frame) = camera_frame { + let vw = frame.width as f64; + let vh = frame.height as f64; + let scale = (document.width / vw).min(document.height / vh); + let ox = (document.width - vw * scale) / 2.0; + let oy = (document.height - vh * scale) / 2.0; + let cam_transform = base_transform + * Affine::translate((ox, oy)) + * Affine::scale(scale); + instances.push(VideoRenderInstance { + rgba_data: frame.rgba_data.clone(), + width: frame.width, + height: frame.height, + transform: cam_transform, + opacity: layer_opacity as f32, + }); + } + } + + rendered.has_content = !instances.is_empty(); + rendered.layer_type = RenderedLayerType::Video { instances }; } AnyLayer::Effect(effect_layer) => { // Effect layers are processed during compositing, not rendered to scene @@ -307,9 +431,16 @@ pub fn render_layer_isolated( rendered.has_content = !group_layer.children.is_empty(); } AnyLayer::Raster(raster_layer) => { - render_raster_layer_to_scene(raster_layer, time, &mut rendered.scene, base_transform); - rendered.has_content = raster_layer.keyframe_at(time) - .map_or(false, |kf| kf.has_pixels()); + if let Some(kf) = raster_layer.keyframe_at(time) { + rendered.has_content = kf.has_pixels(); + rendered.layer_type = RenderedLayerType::Raster { + kf_id: kf.id, + width: kf.width, + height: kf.height, + dirty: kf.texture_dirty, + transform: base_transform, + }; + } } } @@ -363,35 +494,11 @@ fn render_raster_layer_to_scene( // decode the sRGB channels without premultiplying again. alpha_type: ImageAlphaType::AlphaPremultiplied, }; - let brush = ImageBrush::new(image_data); + let brush = ImageBrush::new(image_data).with_quality(ImageQuality::Low); let canvas_rect = Rect::new(0.0, 0.0, kf.width as f64, kf.height as f64); scene.fill(Fill::NonZero, base_transform, &brush, None, &canvas_rect); } -/// Render a video layer to an isolated scene (for compositing pipeline) -fn render_video_layer_to_scene( - document: &Document, - time: f64, - layer: &crate::layer::VideoLayer, - scene: &mut Scene, - base_transform: Affine, - parent_opacity: f64, - video_manager: &mut crate::video::VideoManager, - camera_frame: Option<&crate::webcam::CaptureFrame>, -) { - // Render using the existing function but to this isolated scene - render_video_layer( - document, - time, - layer, - scene, - base_transform, - parent_opacity, - video_manager, - camera_frame, - ); -} - // ============================================================================ // Legacy Single-Scene Rendering (kept for backwards compatibility) // ============================================================================ @@ -415,8 +522,8 @@ pub fn render_document_with_transform( image_cache: &mut ImageCache, video_manager: &std::sync::Arc>, ) { - // 1. Draw background - render_background(document, scene, base_transform); + // 1. Draw background (with checkerboard for transparent backgrounds — UI path) + render_background(document, scene, base_transform, true); // 2. Recursively render the root graphics object at current time let time = document.current_time; @@ -436,12 +543,12 @@ pub fn render_document_with_transform( } /// Draw the document background -fn render_background(document: &Document, scene: &mut Scene, base_transform: Affine) { +fn render_background(document: &Document, scene: &mut Scene, base_transform: Affine, draw_checkerboard: bool) { let background_rect = Rect::new(0.0, 0.0, document.width, document.height); let bg = &document.background_color; - // Draw checkerboard behind transparent backgrounds - if bg.a < 255 { + // Draw checkerboard behind transparent backgrounds (UI-only; skip in export) + if draw_checkerboard && bg.a < 255 { use vello::peniko::{Blob, Color, Extend, ImageAlphaType, ImageData, ImageQuality}; // 2x2 pixel checkerboard pattern: light/dark alternating let light: [u8; 4] = [204, 204, 204, 255]; @@ -923,7 +1030,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 +1067,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 +1088,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-core/src/selection.rs b/lightningbeam-ui/lightningbeam-core/src/selection.rs index 7b2906b..ed4308c 100644 --- a/lightningbeam-ui/lightningbeam-core/src/selection.rs +++ b/lightningbeam-ui/lightningbeam-core/src/selection.rs @@ -15,6 +15,16 @@ pub enum RasterSelection { Rect(i32, i32, i32, i32), /// Closed freehand lasso polygon. Lasso(Vec<(i32, i32)>), + /// Per-pixel boolean mask (e.g. from magic wand flood fill). + /// `data` is row-major, length = width × height. + Mask { + data: Vec, + width: u32, + height: u32, + /// Top-left canvas pixel of the mask's bounding canvas region. + origin_x: i32, + origin_y: i32, + }, } impl RasterSelection { @@ -29,6 +39,23 @@ impl RasterSelection { let y1 = pts.iter().map(|p| p.1).max().unwrap_or(0); (x0, y0, x1, y1) } + Self::Mask { data, width, height, origin_x, origin_y } => { + let w = *width as i32; + let mut bx0 = i32::MAX; let mut by0 = i32::MAX; + let mut bx1 = i32::MIN; let mut by1 = i32::MIN; + for row in 0..*height as i32 { + for col in 0..w { + if data[(row * w + col) as usize] { + bx0 = bx0.min(origin_x + col); + by0 = by0.min(origin_y + row); + bx1 = bx1.max(origin_x + col + 1); + by1 = by1.max(origin_y + row + 1); + } + } + } + if bx0 == i32::MAX { (*origin_x, *origin_y, *origin_x, *origin_y) } + else { (bx0, by0, bx1, by1) } + } } } @@ -37,6 +64,14 @@ impl RasterSelection { match self { Self::Rect(x0, y0, x1, y1) => px >= *x0 && px < *x1 && py >= *y0 && py < *y1, Self::Lasso(pts) => point_in_polygon(px, py, pts), + Self::Mask { data, width, height, origin_x, origin_y } => { + let lx = px - origin_x; + let ly = py - origin_y; + if lx < 0 || ly < 0 || lx >= *width as i32 || ly >= *height as i32 { + return false; + } + data[(ly * *width as i32 + lx) as usize] + } } } } @@ -69,7 +104,9 @@ fn point_in_polygon(px: i32, py: i32, polygon: &[(i32, i32)]) -> bool { #[derive(Clone, Debug)] pub struct RasterFloatingSelection { /// sRGB-encoded premultiplied RGBA, width × height × 4 bytes. - pub pixels: Vec, + /// Wrapped in Arc so the renderer can clone a reference each frame (O(1)) + /// instead of copying megabytes of pixel data. + pub pixels: std::sync::Arc>, pub width: u32, pub height: u32, /// Top-left position in canvas pixel coordinates. @@ -81,7 +118,7 @@ pub struct RasterFloatingSelection { /// Snapshot of `raw_pixels` before the cut/paste was initiated, used for /// undo (via `RasterStrokeAction`) when the float is committed, and for /// Cancel (Escape) to restore the canvas without creating an undo entry. - pub canvas_before: Vec, + pub canvas_before: std::sync::Arc>, /// Key for this float's GPU canvas in `GpuBrushEngine::canvases`. /// Allows painting strokes directly onto the float buffer (B) without /// touching the layer canvas (A). diff --git a/lightningbeam-ui/lightningbeam-core/src/tool.rs b/lightningbeam-ui/lightningbeam-core/src/tool.rs index d70f9ea..c62ce9d 100644 --- a/lightningbeam-ui/lightningbeam-core/src/tool.rs +++ b/lightningbeam-ui/lightningbeam-core/src/tool.rs @@ -11,9 +11,10 @@ use vello::kurbo::Point; #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub enum Tool { + // ── Vector / shared tools ────────────────────────────────────────────── /// Selection tool - select and move objects Select, - /// Draw/Pen tool - freehand drawing + /// Draw/Brush tool - freehand drawing (vector) / paintbrush (raster) Draw, /// Transform tool - scale, rotate, skew Transform, @@ -37,12 +38,48 @@ pub enum Tool { RegionSelect, /// Split tool - split audio/video clips at a point Split, + // ── Raster brush tools ──────────────────────────────────────────────── + /// Pencil tool - hard-edged raster brush + Pencil, + /// Pen tool - pressure-sensitive raster pen + Pen, + /// Airbrush tool - soft spray raster brush + Airbrush, /// Erase tool - erase raster pixels Erase, /// Smudge tool - smudge/blend raster pixels Smudge, - /// Lasso select tool - freehand selection on raster layers + /// Clone Stamp - copy pixels from a source point + CloneStamp, + /// Healing Brush - content-aware pixel repair + HealingBrush, + /// Pattern Stamp - paint with a repeating pattern + PatternStamp, + /// Dodge/Burn - lighten or darken pixels + DodgeBurn, + /// Sponge - saturate or desaturate pixels + Sponge, + /// Blur/Sharpen - blur or sharpen pixel regions + BlurSharpen, + // ── Raster fill / shape ─────────────────────────────────────────────── + /// Gradient tool - fill with a gradient + Gradient, + /// Custom Shape tool - draw from a shape library + CustomShape, + // ── Raster selection tools ──────────────────────────────────────────── + /// Elliptical marquee selection + SelectEllipse, + /// Lasso select tool - freehand / polygonal / magnetic selection SelectLasso, + /// Magic Wand - select by colour similarity + MagicWand, + /// Quick Select - brush-based smart selection + QuickSelect, + // ── Raster transform tools ──────────────────────────────────────────── + /// Warp / perspective transform + Warp, + /// Liquify - freeform pixel warping + Liquify, } /// Region select mode @@ -60,6 +97,23 @@ impl Default for RegionSelectMode { } } +/// Lasso selection sub-mode +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum LassoMode { + /// Freehand lasso (existing, implemented) + Freehand, + /// Click-to-place polygonal lasso + Polygonal, + /// Magnetically snaps to edges + Magnetic, +} + +impl Default for LassoMode { + fn default() -> Self { + Self::Freehand + } +} + /// Tool state tracking for interactive operations #[derive(Debug, Clone)] pub enum ToolState { @@ -229,44 +283,77 @@ impl Tool { /// Get display name for the tool pub fn display_name(self) -> &'static str { match self { - Tool::Select => "Select", - Tool::Draw => "Draw", - Tool::Transform => "Transform", - Tool::Rectangle => "Rectangle", - Tool::Ellipse => "Ellipse", - Tool::PaintBucket => "Paint Bucket", - Tool::Eyedropper => "Eyedropper", - Tool::Line => "Line", - Tool::Polygon => "Polygon", - Tool::BezierEdit => "Bezier Edit", - Tool::Text => "Text", - Tool::RegionSelect => "Region Select", - Tool::Split => "Split", - Tool::Erase => "Erase", - Tool::Smudge => "Smudge", - Tool::SelectLasso => "Lasso Select", + Tool::Select => "Select", + Tool::Draw => "Brush", + Tool::Transform => "Transform", + Tool::Rectangle => "Rectangle", + Tool::Ellipse => "Ellipse", + Tool::PaintBucket => "Paint Bucket", + Tool::Eyedropper => "Eyedropper", + Tool::Line => "Line", + Tool::Polygon => "Polygon", + Tool::BezierEdit => "Bezier Edit", + Tool::Text => "Text", + Tool::RegionSelect => "Region Select", + Tool::Split => "Split", + Tool::Pencil => "Pencil", + Tool::Pen => "Pen", + Tool::Airbrush => "Airbrush", + Tool::Erase => "Eraser", + Tool::Smudge => "Smudge", + Tool::CloneStamp => "Clone Stamp", + Tool::HealingBrush => "Healing Brush", + Tool::PatternStamp => "Pattern Stamp", + Tool::DodgeBurn => "Dodge / Burn", + Tool::Sponge => "Sponge", + Tool::BlurSharpen => "Blur / Sharpen", + Tool::Gradient => "Gradient", + Tool::CustomShape => "Custom Shape", + Tool::SelectEllipse => "Elliptical Select", + Tool::SelectLasso => "Lasso Select", + Tool::MagicWand => "Magic Wand", + Tool::QuickSelect => "Quick Select", + Tool::Warp => "Warp", + Tool::Liquify => "Liquify", } } /// Get SVG icon file name for the tool pub fn icon_file(self) -> &'static str { match self { - Tool::Select => "select.svg", - Tool::Draw => "draw.svg", - Tool::Transform => "transform.svg", - Tool::Rectangle => "rectangle.svg", - Tool::Ellipse => "ellipse.svg", - Tool::PaintBucket => "paint_bucket.svg", - Tool::Eyedropper => "eyedropper.svg", - Tool::Line => "line.svg", - Tool::Polygon => "polygon.svg", - Tool::BezierEdit => "bezier_edit.svg", - Tool::Text => "text.svg", - Tool::RegionSelect => "region_select.svg", - Tool::Split => "split.svg", - Tool::Erase => "erase.svg", - Tool::Smudge => "smudge.svg", - Tool::SelectLasso => "lasso.svg", + Tool::Select => "select.svg", + Tool::Draw => "draw.svg", + Tool::Transform => "transform.svg", + Tool::Rectangle => "rectangle.svg", + Tool::Ellipse => "ellipse.svg", + Tool::PaintBucket => "paint_bucket.svg", + Tool::Eyedropper => "eyedropper.svg", + Tool::Line => "line.svg", + Tool::Polygon => "polygon.svg", + Tool::BezierEdit => "bezier_edit.svg", + Tool::Text => "text.svg", + Tool::RegionSelect => "region_select.svg", + Tool::Split => "split.svg", + Tool::Erase => "erase.svg", + Tool::Smudge => "smudge.svg", + Tool::SelectLasso => "lasso.svg", + // Not yet implemented — use the placeholder icon + Tool::Pencil + | Tool::Pen + | Tool::Airbrush + | Tool::CloneStamp + | Tool::HealingBrush + | Tool::PatternStamp + | Tool::DodgeBurn + | Tool::Sponge + | Tool::BlurSharpen + | Tool::Gradient + | Tool::CustomShape + | Tool::SelectEllipse + | Tool::MagicWand + | Tool::QuickSelect + | Tool::Warp + | Tool::Liquify => "todo.svg", } } @@ -294,7 +381,23 @@ impl Tool { match layer_type { None | Some(LayerType::Vector) => Tool::all(), Some(LayerType::Audio) | Some(LayerType::Video) => &[Tool::Select, Tool::Split], - Some(LayerType::Raster) => &[Tool::Select, Tool::SelectLasso, Tool::Draw, Tool::Erase, Tool::Smudge, Tool::Eyedropper], + Some(LayerType::Raster) => &[ + // Brush tools + Tool::Draw, Tool::Pencil, Tool::Pen, Tool::Airbrush, + Tool::Erase, Tool::Smudge, + Tool::CloneStamp, Tool::HealingBrush, Tool::PatternStamp, + Tool::DodgeBurn, Tool::Sponge, Tool::BlurSharpen, + // Fill / shape + Tool::PaintBucket, Tool::Gradient, + Tool::Rectangle, Tool::Ellipse, Tool::Polygon, Tool::Line, Tool::CustomShape, + // Selection + Tool::Select, Tool::SelectLasso, + Tool::MagicWand, Tool::QuickSelect, + // Transform + Tool::Transform, Tool::Warp, Tool::Liquify, + // Utility + Tool::Eyedropper, + ], _ => &[Tool::Select], } } diff --git a/lightningbeam-ui/lightningbeam-editor/Cargo.toml b/lightningbeam-ui/lightningbeam-editor/Cargo.toml index d08777f..b94e935 100644 --- a/lightningbeam-ui/lightningbeam-editor/Cargo.toml +++ b/lightningbeam-ui/lightningbeam-editor/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "lightningbeam-editor" -version = "1.0.1-alpha" +version = "1.0.2-alpha" edition = "2021" description = "Multimedia editor for audio, video and 2D animation" license = "GPL-3.0-or-later" diff --git a/lightningbeam-ui/lightningbeam-editor/assets/layouts.json b/lightningbeam-ui/lightningbeam-editor/assets/layouts.json index 366cbf5..c1a211a 100644 --- a/lightningbeam-ui/lightningbeam-editor/assets/layouts.json +++ b/lightningbeam-ui/lightningbeam-editor/assets/layouts.json @@ -124,7 +124,7 @@ "children": [ { "type": "vertical-grid", - "percent": 30, + "percent": 67, "children": [ { "type": "pane", "name": "toolbar" }, { "type": "pane", "name": "infopanel" } diff --git a/lightningbeam-ui/lightningbeam-editor/src/custom_cursor.rs b/lightningbeam-ui/lightningbeam-editor/src/custom_cursor.rs index 8739fb0..c4ae419 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/custom_cursor.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/custom_cursor.rs @@ -32,22 +32,41 @@ impl CustomCursor { /// Convert a Tool enum to the corresponding custom cursor pub fn from_tool(tool: Tool) -> Self { match tool { - Tool::Select => CustomCursor::Select, - Tool::Draw => CustomCursor::Draw, - Tool::Transform => CustomCursor::Transform, - Tool::Rectangle => CustomCursor::Rectangle, - Tool::Ellipse => CustomCursor::Ellipse, - Tool::PaintBucket => CustomCursor::PaintBucket, - Tool::Eyedropper => CustomCursor::Eyedropper, - Tool::Line => CustomCursor::Line, - Tool::Polygon => CustomCursor::Polygon, - Tool::BezierEdit => CustomCursor::BezierEdit, - Tool::Text => CustomCursor::Text, - Tool::RegionSelect => CustomCursor::Select, // Reuse select cursor for now - Tool::Split => CustomCursor::Select, // Reuse select cursor for now - Tool::Erase => CustomCursor::Draw, // Reuse draw cursor for raster erase - Tool::Smudge => CustomCursor::Draw, // Reuse draw cursor for raster smudge - Tool::SelectLasso => CustomCursor::Select, // Reuse select cursor for lasso + Tool::Select => CustomCursor::Select, + Tool::Draw => CustomCursor::Draw, + Tool::Transform => CustomCursor::Transform, + Tool::Rectangle => CustomCursor::Rectangle, + Tool::Ellipse => CustomCursor::Ellipse, + Tool::PaintBucket => CustomCursor::PaintBucket, + Tool::Eyedropper => CustomCursor::Eyedropper, + Tool::Line => CustomCursor::Line, + Tool::Polygon => CustomCursor::Polygon, + Tool::BezierEdit => CustomCursor::BezierEdit, + Tool::Text => CustomCursor::Text, + Tool::RegionSelect => CustomCursor::Select, + Tool::Split => CustomCursor::Select, + Tool::Erase => CustomCursor::Draw, + Tool::Smudge => CustomCursor::Draw, + Tool::SelectLasso => CustomCursor::Select, + // Raster brush tools — use draw cursor until implemented + Tool::Pencil + | Tool::Pen + | Tool::Airbrush + | Tool::CloneStamp + | Tool::HealingBrush + | Tool::PatternStamp + | Tool::DodgeBurn + | Tool::Sponge + | Tool::BlurSharpen => CustomCursor::Draw, + // Selection tools — use select cursor until implemented + Tool::SelectEllipse + | Tool::MagicWand + | Tool::QuickSelect => CustomCursor::Select, + // Other tools — use select cursor until implemented + Tool::Gradient + | Tool::CustomShape + | Tool::Warp + | Tool::Liquify => CustomCursor::Select, } } diff --git a/lightningbeam-ui/lightningbeam-editor/src/debug_overlay.rs b/lightningbeam-ui/lightningbeam-editor/src/debug_overlay.rs index 518dd49..5dc3a87 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/debug_overlay.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/debug_overlay.rs @@ -5,9 +5,40 @@ use eframe::egui; use std::collections::VecDeque; +use std::sync::{Mutex, OnceLock}; use std::time::{Duration, Instant}; const FRAME_HISTORY_SIZE: usize = 60; // Track last 60 frames for FPS stats + +/// Timing breakdown for the GPU prepare() pass, written by the render thread. +#[derive(Debug, Clone, Default)] +pub struct PrepareTiming { + pub total_ms: f64, + pub removals_ms: f64, + pub gpu_dispatches_ms: f64, + pub scene_build_ms: f64, + pub composite_ms: f64, +} + +static LAST_PREPARE_TIMING: OnceLock> = OnceLock::new(); + +/// Called from `VelloCallback::prepare()` every frame to update the timing snapshot. +pub fn update_prepare_timing( + total_ms: f64, + removals_ms: f64, + gpu_dispatches_ms: f64, + scene_build_ms: f64, + composite_ms: f64, +) { + let cell = LAST_PREPARE_TIMING.get_or_init(|| Mutex::new(PrepareTiming::default())); + if let Ok(mut t) = cell.lock() { + t.total_ms = total_ms; + t.removals_ms = removals_ms; + t.gpu_dispatches_ms = gpu_dispatches_ms; + t.scene_build_ms = scene_build_ms; + t.composite_ms = composite_ms; + } +} const DEVICE_REFRESH_INTERVAL: Duration = Duration::from_secs(2); // Refresh devices every 2 seconds const MEMORY_REFRESH_INTERVAL: Duration = Duration::from_millis(500); // Refresh memory every 500ms @@ -28,6 +59,9 @@ pub struct DebugStats { pub audio_input_devices: Vec, pub has_pointer: bool, + // GPU prepare() timing breakdown (from render thread) + pub prepare_timing: PrepareTiming, + // Performance metrics for each section pub timing_memory_us: u64, pub timing_gpu_us: u64, @@ -170,6 +204,12 @@ impl DebugStatsCollector { let timing_total_us = collection_start.elapsed().as_micros() as u64; + let prepare_timing = LAST_PREPARE_TIMING + .get() + .and_then(|m| m.lock().ok()) + .map(|t| t.clone()) + .unwrap_or_default(); + DebugStats { fps_current, fps_min, @@ -184,6 +224,7 @@ impl DebugStatsCollector { midi_devices, audio_input_devices, has_pointer, + prepare_timing, timing_memory_us, timing_gpu_us, timing_midi_us, @@ -231,6 +272,16 @@ pub fn render_debug_overlay(ctx: &egui::Context, stats: &DebugStats) { ui.add_space(8.0); + // GPU prepare() timing section + let pt = &stats.prepare_timing; + ui.colored_label(egui::Color32::YELLOW, format!("GPU prepare: {:.2} ms", pt.total_ms)); + ui.label(format!(" removals: {:.2} ms", pt.removals_ms)); + ui.label(format!(" gpu_dispatch: {:.2} ms", pt.gpu_dispatches_ms)); + ui.label(format!(" scene_build: {:.2} ms", pt.scene_build_ms)); + ui.label(format!(" composite: {:.2} ms", pt.composite_ms)); + + ui.add_space(8.0); + // Memory section with timing ui.colored_label(egui::Color32::YELLOW, format!("Memory: ({}µs)", stats.timing_memory_us)); ui.label(format!("Physical: {} MB", stats.memory_physical_mb)); diff --git a/lightningbeam-ui/lightningbeam-editor/src/export/dialog.rs b/lightningbeam-ui/lightningbeam-editor/src/export/dialog.rs index 9f90f0e..3f68aa2 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/export/dialog.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/export/dialog.rs @@ -3,13 +3,29 @@ //! Provides a user interface for configuring and starting audio/video exports. use eframe::egui; -use lightningbeam_core::export::{AudioExportSettings, AudioFormat, VideoExportSettings, VideoCodec, VideoQuality}; +use lightningbeam_core::export::{ + AudioExportSettings, AudioFormat, + ImageExportSettings, ImageFormat, + VideoExportSettings, VideoCodec, VideoQuality, +}; use std::path::PathBuf; +/// Hint about document content, used to pick a smart default export type. +pub struct DocumentHint { + pub has_video: bool, + pub has_audio: bool, + pub has_raster: bool, + pub has_vector: bool, + pub current_time: f64, + pub doc_width: u32, + pub doc_height: u32, +} + /// Export type selection #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ExportType { Audio, + Image, Video, } @@ -17,6 +33,7 @@ pub enum ExportType { #[derive(Debug, Clone)] pub enum ExportResult { AudioOnly(AudioExportSettings, PathBuf), + Image(ImageExportSettings, PathBuf), VideoOnly(VideoExportSettings, PathBuf), VideoWithAudio(VideoExportSettings, AudioExportSettings, PathBuf), } @@ -32,6 +49,9 @@ pub struct ExportDialog { /// Audio export settings pub audio_settings: AudioExportSettings, + /// Image export settings + pub image_settings: ImageExportSettings, + /// Video export settings pub video_settings: VideoExportSettings, @@ -55,6 +75,15 @@ pub struct ExportDialog { /// Output directory pub output_dir: PathBuf, + + /// Project name from the last `open()` call — used to detect file switches. + current_project: String, + + /// Export type used the last time the user actually clicked Export for `current_project`. + last_export_type: Option, + + /// Full path of the most recent successful export. Restored as the default on next open. + last_exported_path: Option, } impl Default for ExportDialog { @@ -71,6 +100,7 @@ impl Default for ExportDialog { open: false, export_type: ExportType::Audio, audio_settings: AudioExportSettings::standard_mp3(), + image_settings: ImageExportSettings::default(), video_settings: VideoExportSettings::default(), include_audio: true, output_path: None, @@ -78,23 +108,56 @@ impl Default for ExportDialog { show_advanced: false, selected_video_preset: 0, output_filename: String::new(), + current_project: String::new(), + last_export_type: None, + last_exported_path: None, output_dir: music_dir, } } } impl ExportDialog { - /// Open the dialog with default settings - pub fn open(&mut self, timeline_duration: f64, project_name: &str) { + /// Open the dialog with default settings, using `hint` to pick a smart default tab. + pub fn open(&mut self, timeline_duration: f64, project_name: &str, hint: &DocumentHint) { self.open = true; self.audio_settings.end_time = timeline_duration; self.video_settings.end_time = timeline_duration; + self.image_settings.time = hint.current_time; + // Propagate document dimensions as defaults (None means "use doc size"). + self.image_settings.width = None; + self.image_settings.height = None; self.error_message = None; - // Pre-populate filename from project name if not already set - if self.output_filename.is_empty() || !self.output_filename.contains(project_name) { - let ext = self.audio_settings.format.extension(); - self.output_filename = format!("{}.{}", project_name, ext); + // Determine export type: prefer the type used last time for this file, + // then fall back to document-content hints. + let same_project = self.current_project == project_name; + self.export_type = if same_project && self.last_export_type.is_some() { + self.last_export_type.unwrap() + } else { + let only_audio = hint.has_audio && !hint.has_video && !hint.has_raster && !hint.has_vector; + let only_raster = hint.has_raster && !hint.has_video && !hint.has_audio && !hint.has_vector; + if hint.has_video { ExportType::Video } + else if only_audio { ExportType::Audio } + else if only_raster { ExportType::Image } + else { self.export_type } // keep current as fallback + }; + self.current_project = project_name.to_owned(); + + // Restore the last exported path if available; otherwise default to project name. + if let Some(ref last) = self.last_exported_path.clone() { + if let Some(dir) = last.parent() { self.output_dir = dir.to_path_buf(); } + if let Some(name) = last.file_name() { self.output_filename = name.to_string_lossy().into_owned(); } + } else if self.output_filename.is_empty() || !self.output_filename.contains(project_name) { + self.output_filename = format!("{}.{}", project_name, self.current_extension()); + } + } + + /// Extension for the currently selected export type. + fn current_extension(&self) -> &'static str { + match self.export_type { + ExportType::Audio => self.audio_settings.format.extension(), + ExportType::Image => self.image_settings.format.extension(), + ExportType::Video => self.video_settings.codec.container_format(), } } @@ -106,10 +169,7 @@ impl ExportDialog { /// Update the filename extension to match the current format fn update_filename_extension(&mut self) { - let ext = match self.export_type { - ExportType::Audio => self.audio_settings.format.extension(), - ExportType::Video => self.video_settings.codec.container_format(), - }; + let ext = self.current_extension(); // Replace extension in filename if let Some(dot_pos) = self.output_filename.rfind('.') { self.output_filename.truncate(dot_pos + 1); @@ -138,6 +198,7 @@ impl ExportDialog { let window_title = match self.export_type { ExportType::Audio => "Export Audio", + ExportType::Image => "Export Image", ExportType::Video => "Export Video", }; @@ -156,11 +217,14 @@ impl ExportDialog { // Export type selection (tabs) ui.horizontal(|ui| { - if ui.selectable_value(&mut self.export_type, ExportType::Audio, "Audio").clicked() { - self.update_filename_extension(); - } - if ui.selectable_value(&mut self.export_type, ExportType::Video, "Video").clicked() { - self.update_filename_extension(); + for (variant, label) in [ + (ExportType::Audio, "Audio"), + (ExportType::Image, "Image"), + (ExportType::Video, "Video"), + ] { + if ui.selectable_value(&mut self.export_type, variant, label).clicked() { + self.update_filename_extension(); + } } }); @@ -171,6 +235,7 @@ impl ExportDialog { // Basic settings match self.export_type { ExportType::Audio => self.render_audio_basic(ui), + ExportType::Image => self.render_image_settings(ui), ExportType::Video => self.render_video_basic(ui), } @@ -188,6 +253,7 @@ impl ExportDialog { ui.add_space(8.0); match self.export_type { ExportType::Audio => self.render_audio_advanced(ui), + ExportType::Image => self.render_image_advanced(ui), ExportType::Video => self.render_video_advanced(ui), } } @@ -260,6 +326,62 @@ impl ExportDialog { }); } + /// Render basic image export settings (format, quality, transparency). + fn render_image_settings(&mut self, ui: &mut egui::Ui) { + // Format + ui.horizontal(|ui| { + ui.label("Format:"); + let prev = self.image_settings.format; + egui::ComboBox::from_id_salt("image_format") + .selected_text(self.image_settings.format.name()) + .show_ui(ui, |ui| { + ui.selectable_value(&mut self.image_settings.format, ImageFormat::Png, "PNG"); + ui.selectable_value(&mut self.image_settings.format, ImageFormat::Jpeg, "JPEG"); + ui.selectable_value(&mut self.image_settings.format, ImageFormat::WebP, "WebP"); + }); + if self.image_settings.format != prev { + self.update_filename_extension(); + } + }); + + // Quality (JPEG / WebP only) + if self.image_settings.format.has_quality() { + ui.horizontal(|ui| { + ui.label("Quality:"); + ui.add(egui::Slider::new(&mut self.image_settings.quality, 1..=100)); + }); + } + + // Transparency (PNG / WebP only — JPEG has no alpha) + if self.image_settings.format != ImageFormat::Jpeg { + ui.checkbox(&mut self.image_settings.allow_transparency, "Allow transparency"); + } + } + + /// Render advanced image export settings (time, resolution override). + fn render_image_advanced(&mut self, ui: &mut egui::Ui) { + // Time (which frame to export) + ui.horizontal(|ui| { + ui.label("Time:"); + ui.add(egui::DragValue::new(&mut self.image_settings.time) + .speed(0.01) + .range(0.0..=f64::MAX) + .suffix(" s")); + }); + + // Resolution override (None = use document size; 0 means "use doc size") + ui.horizontal(|ui| { + ui.label("Size:"); + let mut w = self.image_settings.width.unwrap_or(0); + let mut h = self.image_settings.height.unwrap_or(0); + let changed_w = ui.add(egui::DragValue::new(&mut w).range(0..=u32::MAX).prefix("W ")).changed(); + let changed_h = ui.add(egui::DragValue::new(&mut h).range(0..=u32::MAX).prefix("H ")).changed(); + if changed_w { self.image_settings.width = if w == 0 { None } else { Some(w) }; } + if changed_h { self.image_settings.height = if h == 0 { None } else { Some(h) }; } + ui.weak("(0 = document size)"); + }); + } + /// Render advanced audio settings (sample rate, channels, bit depth, bitrate, time range) fn render_audio_advanced(&mut self, ui: &mut egui::Ui) { ui.horizontal(|ui| { @@ -419,6 +541,7 @@ impl ExportDialog { fn render_time_range(&mut self, ui: &mut egui::Ui) { let (start_time, end_time) = match self.export_type { ExportType::Audio => (&mut self.audio_settings.start_time, &mut self.audio_settings.end_time), + ExportType::Image => return, // image uses a single time field, not a range ExportType::Video => (&mut self.video_settings.start_time, &mut self.video_settings.end_time), }; @@ -440,26 +563,35 @@ impl ExportDialog { ui.label(format!("Duration: {:.2} seconds", duration)); } - /// Render output file selection UI + /// Render output file selection UI — single OS save-file dialog. fn render_output_selection(&mut self, ui: &mut egui::Ui) { ui.horizontal(|ui| { + // Show the current path (truncated if long). + let full_path = self.build_output_path(); + let path_str = full_path.display().to_string(); ui.label("Save to:"); - let dir_text = self.output_dir.display().to_string(); - ui.label(&dir_text); - if ui.button("Change...").clicked() { - if let Some(dir) = rfd::FileDialog::new() - .set_directory(&self.output_dir) - .pick_folder() - { - self.output_dir = dir; - } - } + ui.add(egui::Label::new( + egui::RichText::new(&path_str).weak() + ).truncate()); }); - ui.horizontal(|ui| { - ui.label("Filename:"); - ui.text_edit_singleline(&mut self.output_filename); - }); + if ui.button("Choose location...").clicked() { + let ext = self.current_extension(); + let mut dialog = rfd::FileDialog::new() + .set_directory(&self.output_dir) + .set_file_name(&self.output_filename) + .add_filter(ext.to_uppercase(), &[ext]); + if let Some(path) = dialog.save_file() { + if let Some(dir) = path.parent() { + self.output_dir = dir.to_path_buf(); + } + if let Some(name) = path.file_name() { + self.output_filename = name.to_string_lossy().into_owned(); + // Ensure the extension matches the selected format. + self.update_filename_extension(); + } + } + } } /// Handle export button click @@ -471,7 +603,18 @@ impl ExportDialog { let output_path = self.output_path.clone().unwrap(); + // Remember this export type and path for next time the dialog is opened. + self.last_export_type = Some(self.export_type); + self.last_exported_path = Some(output_path.clone()); + let result = match self.export_type { + ExportType::Image => { + if let Err(err) = self.image_settings.validate() { + self.error_message = Some(err); + return None; + } + Some(ExportResult::Image(self.image_settings.clone(), output_path)) + } ExportType::Audio => { // Validate audio settings if let Err(err) = self.audio_settings.validate() { diff --git a/lightningbeam-ui/lightningbeam-editor/src/export/image_exporter.rs b/lightningbeam-ui/lightningbeam-editor/src/export/image_exporter.rs new file mode 100644 index 0000000..9352bfe --- /dev/null +++ b/lightningbeam-ui/lightningbeam-editor/src/export/image_exporter.rs @@ -0,0 +1,70 @@ +//! Image encoding — save raw RGBA bytes as PNG / JPEG / WebP. + +use lightningbeam_core::export::ImageFormat; +use std::path::Path; + +/// Encode `pixels` (raw RGBA8, top-left origin) and write to `path`. +/// +/// * `allow_transparency` — when true the alpha channel is preserved (PNG/WebP); +/// when false each pixel is composited onto black before encoding. +pub fn save_rgba_image( + pixels: &[u8], + width: u32, + height: u32, + format: ImageFormat, + quality: u8, + allow_transparency: bool, + path: &Path, +) -> Result<(), String> { + use image::{ImageBuffer, Rgba}; + + let img = ImageBuffer::, _>::from_raw(width, height, pixels.to_vec()) + .ok_or_else(|| "Pixel buffer size mismatch".to_string())?; + + match format { + ImageFormat::Png => { + if allow_transparency { + img.save(path).map_err(|e| format!("PNG save failed: {e}")) + } else { + let flat = flatten_alpha(img); + flat.save(path).map_err(|e| format!("PNG save failed: {e}")) + } + } + ImageFormat::Jpeg => { + use image::codecs::jpeg::JpegEncoder; + use image::DynamicImage; + use std::fs::File; + use std::io::BufWriter; + + // Flatten alpha onto black before JPEG encoding (JPEG has no alpha). + let flat = flatten_alpha(img); + let rgb_img = DynamicImage::ImageRgb8(flat).to_rgb8(); + let file = File::create(path).map_err(|e| format!("Cannot create file: {e}"))?; + let writer = BufWriter::new(file); + let mut encoder = JpegEncoder::new_with_quality(writer, quality); + encoder.encode_image(&rgb_img).map_err(|e| format!("JPEG encode failed: {e}")) + } + ImageFormat::WebP => { + if allow_transparency { + img.save(path).map_err(|e| format!("WebP save failed: {e}")) + } else { + let flat = flatten_alpha(img); + flat.save(path).map_err(|e| format!("WebP save failed: {e}")) + } + } + } +} + +/// Composite RGBA pixels onto an opaque black background, returning an RGB image. +fn flatten_alpha(img: image::ImageBuffer, Vec>) -> image::ImageBuffer, Vec> { + use image::{ImageBuffer, Rgb}; + ImageBuffer::from_fn(img.width(), img.height(), |x, y| { + let p = img.get_pixel(x, y); + let a = p[3] as f32 / 255.0; + Rgb([ + (p[0] as f32 * a) as u8, + (p[1] as f32 * a) as u8, + (p[2] as f32 * a) as u8, + ]) + }) +} diff --git a/lightningbeam-ui/lightningbeam-editor/src/export/mod.rs b/lightningbeam-ui/lightningbeam-editor/src/export/mod.rs index 273ed64..d50f400 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/export/mod.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/export/mod.rs @@ -5,12 +5,13 @@ pub mod audio_exporter; pub mod dialog; +pub mod image_exporter; pub mod video_exporter; pub mod readback_pipeline; pub mod perf_metrics; pub mod cpu_yuv_converter; -use lightningbeam_core::export::{AudioExportSettings, VideoExportSettings, ExportProgress}; +use lightningbeam_core::export::{AudioExportSettings, ImageExportSettings, VideoExportSettings, ExportProgress}; use lightningbeam_core::document::Document; use lightningbeam_core::renderer::ImageCache; use lightningbeam_core::video::VideoManager; @@ -66,6 +67,25 @@ pub struct VideoExportState { perf_metrics: Option, } +/// State for a single-frame image export (runs on the GPU render thread, one frame per update). +pub struct ImageExportState { + pub settings: ImageExportSettings, + pub output_path: PathBuf, + /// Resolved pixel dimensions (after applying any width/height overrides). + pub width: u32, + pub height: u32, + /// True once rendering has been submitted; the next call reads back and encodes. + pub rendered: bool, + /// GPU resources allocated on the first render call. + pub gpu_resources: Option, + /// Output RGBA texture — kept separate from gpu_resources to avoid split-borrow issues. + pub output_texture: Option, + /// View for output_texture. + pub output_texture_view: Option, + /// Staging buffer for synchronous GPU→CPU readback. + pub staging_buffer: Option, +} + /// Export orchestrator that manages the export process pub struct ExportOrchestrator { /// Channel for receiving progress updates (video or audio-only export) @@ -82,6 +102,9 @@ pub struct ExportOrchestrator { /// Parallel audio+video export state parallel_export: Option, + + /// Single-frame image export state + image_state: Option, } /// State for parallel audio+video export @@ -115,6 +138,7 @@ impl ExportOrchestrator { cancel_flag: Arc::new(AtomicBool::new(false)), video_state: None, parallel_export: None, + image_state: None, } } @@ -446,12 +470,8 @@ impl ExportOrchestrator { /// Check if an export is in progress pub fn is_exporting(&self) -> bool { - // Check parallel export first - if self.parallel_export.is_some() { - return true; - } - - // Check single export + if self.parallel_export.is_some() { return true; } + if self.image_state.is_some() { return true; } if let Some(handle) = &self.thread_handle { !handle.is_finished() } else { @@ -459,6 +479,171 @@ impl ExportOrchestrator { } } + /// Enqueue a single-frame image export. Call `render_image_frame()` from the + /// egui update loop (where the wgpu device/queue are available) to complete it. + pub fn start_image_export( + &mut self, + settings: ImageExportSettings, + output_path: PathBuf, + doc_width: u32, + doc_height: u32, + ) { + self.cancel_flag.store(false, Ordering::Relaxed); + let width = settings.width.unwrap_or(doc_width).max(1); + let height = settings.height.unwrap_or(doc_height).max(1); + self.image_state = Some(ImageExportState { + settings, + output_path, + width, + height, + rendered: false, + gpu_resources: None, + output_texture: None, + output_texture_view: None, + staging_buffer: None, + }); + } + + /// Drive the single-frame image export. Returns `Ok(true)` when done (success or + /// cancelled), `Ok(false)` if another call is needed next frame. + pub fn render_image_frame( + &mut self, + document: &mut Document, + device: &wgpu::Device, + queue: &wgpu::Queue, + renderer: &mut vello::Renderer, + image_cache: &mut ImageCache, + video_manager: &Arc>, + floating_selection: Option<&lightningbeam_core::selection::RasterFloatingSelection>, + ) -> Result { + if self.cancel_flag.load(Ordering::Relaxed) { + self.image_state = None; + return Ok(true); + } + + let state = match self.image_state.as_mut() { + Some(s) => s, + None => return Ok(true), + }; + + if !state.rendered { + // ── First call: render the frame to the GPU output texture ──────── + let w = state.width; + let h = state.height; + + if state.gpu_resources.is_none() { + state.gpu_resources = Some(video_exporter::ExportGpuResources::new(device, w, h)); + } + if state.output_texture.is_none() { + let tex = device.create_texture(&wgpu::TextureDescriptor { + label: Some("image_export_output"), + size: wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: wgpu::TextureFormat::Rgba8Unorm, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC, + view_formats: &[], + }); + state.output_texture_view = Some(tex.create_view(&wgpu::TextureViewDescriptor::default())); + state.output_texture = Some(tex); + } + + // Borrow separately to avoid a split-borrow conflict (gpu mutably, view immutably). + let gpu = state.gpu_resources.as_mut().unwrap(); + let output_view = state.output_texture_view.as_ref().unwrap(); + + let mut encoder = video_exporter::render_frame_to_gpu_rgba( + document, + state.settings.time, + w, h, + device, queue, renderer, image_cache, video_manager, + gpu, + output_view, + floating_selection, + state.settings.allow_transparency, + )?; + queue.submit(Some(encoder.finish())); + + // Create a staging buffer for synchronous readback. + // wgpu requires bytes_per_row to be a multiple of 256. + let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT; + let bytes_per_row = (w * 4 + align - 1) / align * align; + let staging = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("image_export_staging"), + size: (bytes_per_row * h) as u64, + usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ, + mapped_at_creation: false, + }); + + let mut copy_enc = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { + label: Some("image_export_copy"), + }); + let output_tex = state.output_texture.as_ref().unwrap(); + copy_enc.copy_texture_to_buffer( + wgpu::TexelCopyTextureInfo { + texture: output_tex, + mip_level: 0, + origin: wgpu::Origin3d::ZERO, + aspect: wgpu::TextureAspect::All, + }, + wgpu::TexelCopyBufferInfo { + buffer: &staging, + layout: wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(bytes_per_row), + rows_per_image: Some(h), + }, + }, + wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 }, + ); + queue.submit(Some(copy_enc.finish())); + + state.staging_buffer = Some(staging); + state.rendered = true; + return Ok(false); // Come back next frame to read the result. + } + + // ── Second call: map the staging buffer, encode, and save ───────────── + let staging = match state.staging_buffer.as_ref() { + Some(b) => b, + None => { self.image_state = None; return Ok(true); } + }; + + // Map synchronously. + let slice = staging.slice(..); + slice.map_async(wgpu::MapMode::Read, |_| {}); + let _ = device.poll(wgpu::PollType::wait_indefinitely()); + + let w = state.width; + let h = state.height; + let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT; + let bytes_per_row = (w * 4 + align - 1) / align * align; + + let pixels: Vec = { + let mapped = slice.get_mapped_range(); + // Strip row padding: copy only w*4 bytes from each bytes_per_row-wide row. + let mut out = Vec::with_capacity((w * h * 4) as usize); + for row in 0..h { + let start = (row * bytes_per_row) as usize; + out.extend_from_slice(&mapped[start..start + (w * 4) as usize]); + } + out + }; + staging.unmap(); + + let result = image_exporter::save_rgba_image( + &pixels, w, h, + state.settings.format, + state.settings.quality, + state.settings.allow_transparency, + &state.output_path, + ); + + self.image_state = None; + result.map(|_| true) + } + /// Wait for the export to complete /// /// This blocks until the export thread finishes. @@ -924,6 +1109,8 @@ impl ExportOrchestrator { document, timestamp, width, height, device, queue, renderer, image_cache, video_manager, gpu_resources, &acquired.rgba_texture_view, + None, // No floating selection during video export + false, // Video export is never transparent )?; let render_end = Instant::now(); diff --git a/lightningbeam-ui/lightningbeam-editor/src/export/video_exporter.rs b/lightningbeam-ui/lightningbeam-editor/src/export/video_exporter.rs index 27c15f8..783b836 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/export/video_exporter.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/export/video_exporter.rs @@ -79,6 +79,10 @@ pub struct ExportGpuResources { pub linear_to_srgb_bind_group_layout: wgpu::BindGroupLayout, /// Sampler for linear to sRGB conversion pub linear_to_srgb_sampler: wgpu::Sampler, + /// Canvas blit pipeline for raster/video/float layers (bypasses Vello). + pub canvas_blit: crate::gpu_brush::CanvasBlitPipeline, + /// Per-keyframe GPU texture cache for raster layers during export. + pub raster_cache: std::collections::HashMap, } impl ExportGpuResources { @@ -235,6 +239,8 @@ impl ExportGpuResources { ..Default::default() }); + let canvas_blit = crate::gpu_brush::CanvasBlitPipeline::new(device); + Self { buffer_pool, compositor, @@ -251,6 +257,8 @@ impl ExportGpuResources { linear_to_srgb_pipeline, linear_to_srgb_bind_group_layout, linear_to_srgb_sampler, + canvas_blit, + raster_cache: std::collections::HashMap::new(), } } @@ -702,6 +710,233 @@ pub fn render_frame_to_rgba( Ok(()) } +/// Composite all layers from `composite_result` into `gpu_resources.hdr_texture_view`. +/// +/// Shared by both export functions. Handles every layer type: +/// - Vector/Group: Vello scene → sRGB → linear → composite +/// - Raster: upload pixels to `raster_cache` (if needed) → GPU blit → composite +/// - Video: sRGB straight-alpha → linear premultiplied → transient GPU texture → blit → composite +/// - Float: sRGB-premultiplied → linear → transient GPU texture → blit → composite +/// - Effect: apply post-process on the HDR accumulator +fn composite_document_to_hdr( + composite_result: &lightningbeam_core::renderer::CompositeRenderResult, + document: &Document, + device: &wgpu::Device, + queue: &wgpu::Queue, + renderer: &mut vello::Renderer, + gpu_resources: &mut ExportGpuResources, + width: u32, + height: u32, + allow_transparency: bool, +) -> Result<(), String> { + use vello::kurbo::Affine; + + let layer_spec = BufferSpec::new(width, height, BufferFormat::Rgba8Srgb); + let hdr_spec = BufferSpec::new(width, height, BufferFormat::Rgba16Float); + let layer_render_params = vello::RenderParams { + base_color: vello::peniko::Color::TRANSPARENT, + width, height, + antialiasing_method: vello::AaConfig::Area, + }; + + // --- Background --- + let bg_srgb = gpu_resources.buffer_pool.acquire(device, layer_spec); + let bg_hdr = gpu_resources.buffer_pool.acquire(device, hdr_spec); + if let (Some(bg_srgb_view), Some(bg_hdr_view)) = ( + gpu_resources.buffer_pool.get_view(bg_srgb), + gpu_resources.buffer_pool.get_view(bg_hdr), + ) { + renderer.render_to_texture(device, queue, &composite_result.background, bg_srgb_view, &layer_render_params) + .map_err(|e| format!("Failed to render background: {e}"))?; + let mut enc = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("export_bg_srgb_to_linear") }); + gpu_resources.srgb_to_linear.convert(device, &mut enc, bg_srgb_view, bg_hdr_view); + queue.submit(Some(enc.finish())); + let bg_layer = CompositorLayer::normal(bg_hdr, 1.0); + let mut enc = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("export_bg_composite") }); + // When transparency is allowed, start from transparent black so the background's + // native alpha is preserved. Otherwise force an opaque black underlay. + let clear = if allow_transparency { [0.0, 0.0, 0.0, 0.0] } else { [0.0, 0.0, 0.0, 1.0] }; + gpu_resources.compositor.composite(device, queue, &mut enc, &[bg_layer], + &gpu_resources.buffer_pool, &gpu_resources.hdr_texture_view, Some(clear)); + queue.submit(Some(enc.finish())); + } + gpu_resources.buffer_pool.release(bg_srgb); + gpu_resources.buffer_pool.release(bg_hdr); + + // --- Layers --- + for rendered_layer in &composite_result.layers { + if !rendered_layer.has_content { continue; } + + match &rendered_layer.layer_type { + RenderedLayerType::Vector => { + let srgb_handle = gpu_resources.buffer_pool.acquire(device, layer_spec); + let hdr_layer_handle = gpu_resources.buffer_pool.acquire(device, hdr_spec); + if let (Some(srgb_view), Some(hdr_layer_view)) = ( + gpu_resources.buffer_pool.get_view(srgb_handle), + gpu_resources.buffer_pool.get_view(hdr_layer_handle), + ) { + renderer.render_to_texture(device, queue, &rendered_layer.scene, srgb_view, &layer_render_params) + .map_err(|e| format!("Failed to render layer: {e}"))?; + let mut enc = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("export_layer_srgb_to_linear") }); + gpu_resources.srgb_to_linear.convert(device, &mut enc, srgb_view, hdr_layer_view); + queue.submit(Some(enc.finish())); + let compositor_layer = CompositorLayer::new(hdr_layer_handle, rendered_layer.opacity, rendered_layer.blend_mode); + let mut enc = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("export_layer_composite") }); + gpu_resources.compositor.composite(device, queue, &mut enc, &[compositor_layer], &gpu_resources.buffer_pool, &gpu_resources.hdr_texture_view, None); + queue.submit(Some(enc.finish())); + } + gpu_resources.buffer_pool.release(srgb_handle); + gpu_resources.buffer_pool.release(hdr_layer_handle); + } + RenderedLayerType::Raster { kf_id, width: cw, height: ch, transform: layer_transform, dirty: _ } => { + let raw_pixels = document.get_layer(&rendered_layer.layer_id) + .and_then(|l| match l { + lightningbeam_core::layer::AnyLayer::Raster(rl) => rl.keyframe_at(document.current_time), + _ => None, + }) + .filter(|kf| !kf.raw_pixels.is_empty()) + .map(|kf| kf.raw_pixels.clone()); + if let Some(pixels) = raw_pixels { + if !gpu_resources.raster_cache.contains_key(kf_id) { + let canvas = crate::gpu_brush::CanvasPair::new(device, *cw, *ch); + canvas.upload(queue, &pixels); + gpu_resources.raster_cache.insert(*kf_id, canvas); + } + if let Some(canvas) = gpu_resources.raster_cache.get(kf_id) { + let hdr_layer_handle = gpu_resources.buffer_pool.acquire(device, hdr_spec); + if let Some(hdr_layer_view) = gpu_resources.buffer_pool.get_view(hdr_layer_handle) { + let bt = crate::gpu_brush::BlitTransform::new(*layer_transform, *cw, *ch, width, height); + gpu_resources.canvas_blit.blit(device, queue, canvas.src_view(), hdr_layer_view, &bt, None); + let compositor_layer = CompositorLayer::new(hdr_layer_handle, rendered_layer.opacity, rendered_layer.blend_mode); + let mut enc = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("export_raster_composite") }); + gpu_resources.compositor.composite(device, queue, &mut enc, &[compositor_layer], &gpu_resources.buffer_pool, &gpu_resources.hdr_texture_view, None); + queue.submit(Some(enc.finish())); + } + gpu_resources.buffer_pool.release(hdr_layer_handle); + } + } + } + RenderedLayerType::Video { instances } => { + for inst in instances { + if inst.rgba_data.is_empty() { continue; } + let hdr_layer_handle = gpu_resources.buffer_pool.acquire(device, hdr_spec); + if let Some(hdr_layer_view) = gpu_resources.buffer_pool.get_view(hdr_layer_handle) { + // sRGB straight-alpha → linear premultiplied + let linear: Vec = inst.rgba_data.chunks_exact(4).flat_map(|p| { + let a = p[3] as f32 / 255.0; + let lin = |c: u8| -> f32 { + let f = c as f32 / 255.0; + if f <= 0.04045 { f / 12.92 } else { ((f + 0.055) / 1.055).powf(2.4) } + }; + let r = (lin(p[0]) * a * 255.0 + 0.5) as u8; + let g = (lin(p[1]) * a * 255.0 + 0.5) as u8; + let b = (lin(p[2]) * a * 255.0 + 0.5) as u8; + [r, g, b, p[3]] + }).collect(); + let tex = upload_transient_texture(device, queue, &linear, inst.width, inst.height, Some("export_video_frame_tex")); + let tex_view = tex.create_view(&Default::default()); + let bt = crate::gpu_brush::BlitTransform::new(inst.transform, inst.width, inst.height, width, height); + gpu_resources.canvas_blit.blit(device, queue, &tex_view, hdr_layer_view, &bt, None); + let compositor_layer = CompositorLayer::new(hdr_layer_handle, inst.opacity, lightningbeam_core::gpu::BlendMode::Normal); + let mut enc = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("export_video_composite") }); + gpu_resources.compositor.composite(device, queue, &mut enc, &[compositor_layer], &gpu_resources.buffer_pool, &gpu_resources.hdr_texture_view, None); + queue.submit(Some(enc.finish())); + } + gpu_resources.buffer_pool.release(hdr_layer_handle); + } + } + RenderedLayerType::Float { x: float_x, y: float_y, width: fw, height: fh, transform: layer_transform, pixels, .. } => { + if !pixels.is_empty() { + // sRGB-premultiplied → linear-premultiplied + let linear: Vec = pixels.chunks_exact(4).flat_map(|p| { + let lin = |c: u8| -> u8 { + let f = c as f32 / 255.0; + let l = if f <= 0.04045 { f / 12.92 } else { ((f + 0.055) / 1.055).powf(2.4) }; + (l * 255.0 + 0.5) as u8 + }; + [lin(p[0]), lin(p[1]), lin(p[2]), p[3]] + }).collect(); + let tex = upload_transient_texture(device, queue, &linear, *fw, *fh, Some("export_float_tex")); + let tex_view = tex.create_view(&Default::default()); + let hdr_layer_handle = gpu_resources.buffer_pool.acquire(device, hdr_spec); + if let Some(hdr_layer_view) = gpu_resources.buffer_pool.get_view(hdr_layer_handle) { + let float_to_vp = *layer_transform * Affine::translate((*float_x as f64, *float_y as f64)); + let bt = crate::gpu_brush::BlitTransform::new(float_to_vp, *fw, *fh, width, height); + gpu_resources.canvas_blit.blit(device, queue, &tex_view, hdr_layer_view, &bt, None); + let compositor_layer = CompositorLayer::normal(hdr_layer_handle, 1.0); + let mut enc = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("export_float_composite") }); + gpu_resources.compositor.composite(device, queue, &mut enc, &[compositor_layer], &gpu_resources.buffer_pool, &gpu_resources.hdr_texture_view, None); + queue.submit(Some(enc.finish())); + } + gpu_resources.buffer_pool.release(hdr_layer_handle); + } + } + RenderedLayerType::Effect { effect_instances } => { + let current_time = document.current_time; + for effect_instance in effect_instances { + let Some(effect_def) = document.get_effect_definition(&effect_instance.clip_id) else { continue; }; + if !gpu_resources.effect_processor.is_compiled(&effect_def.id) { + let success = gpu_resources.effect_processor.compile_effect(device, effect_def); + if !success { eprintln!("Failed to compile effect: {}", effect_def.name); continue; } + } + let effect_inst = lightningbeam_core::effect::EffectInstance::new( + effect_def, + effect_instance.timeline_start, + effect_instance.timeline_start + effect_instance.effective_duration(lightningbeam_core::effect::EFFECT_DURATION), + ); + let effect_output_handle = gpu_resources.buffer_pool.acquire(device, hdr_spec); + if let Some(effect_output_view) = gpu_resources.buffer_pool.get_view(effect_output_handle) { + let mut enc = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("export_effect") }); + let applied = gpu_resources.effect_processor.apply_effect( + device, queue, &mut enc, effect_def, &effect_inst, + &gpu_resources.hdr_texture_view, effect_output_view, width, height, current_time, + ); + if applied { + queue.submit(Some(enc.finish())); + let effect_layer = CompositorLayer::normal(effect_output_handle, rendered_layer.opacity); + let mut copy_enc = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("export_effect_copy") }); + // Replace the accumulator with the processed result. + gpu_resources.compositor.composite(device, queue, &mut copy_enc, &[effect_layer], &gpu_resources.buffer_pool, &gpu_resources.hdr_texture_view, Some([0.0, 0.0, 0.0, 0.0])); + queue.submit(Some(copy_enc.finish())); + } + } + gpu_resources.buffer_pool.release(effect_output_handle); + } + } + } + } + + gpu_resources.buffer_pool.next_frame(); + Ok(()) +} + +/// Upload `pixels` to a transient `Rgba8Unorm` GPU texture (TEXTURE_BINDING | COPY_DST). +fn upload_transient_texture( + device: &wgpu::Device, + queue: &wgpu::Queue, + pixels: &[u8], + width: u32, + height: u32, + label: Option<&'static str>, +) -> wgpu::Texture { + let tex = device.create_texture(&wgpu::TextureDescriptor { + label, + size: wgpu::Extent3d { width, height, depth_or_array_layers: 1 }, + mip_level_count: 1, sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: wgpu::TextureFormat::Rgba8Unorm, + usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST, + view_formats: &[], + }); + queue.write_texture( + wgpu::TexelCopyTextureInfo { texture: &tex, mip_level: 0, origin: wgpu::Origin3d::ZERO, aspect: wgpu::TextureAspect::All }, + pixels, + wgpu::TexelCopyBufferLayout { offset: 0, bytes_per_row: Some(width * 4), rows_per_image: Some(height) }, + wgpu::Extent3d { width, height, depth_or_array_layers: 1 }, + ); + tex +} + /// Render a document frame using the HDR compositing pipeline with effects /// /// This function uses the same rendering pipeline as the stage preview, @@ -748,193 +983,12 @@ pub fn render_frame_to_rgba_hdr( image_cache, video_manager, None, // No webcam during export + None, // No floating selection during export + false, // No checkerboard in export ); - // Buffer specs for layer rendering - let layer_spec = BufferSpec::new(width, height, BufferFormat::Rgba8Srgb); - let hdr_spec = BufferSpec::new(width, height, BufferFormat::Rgba16Float); - - // Render parameters for Vello (transparent background for layers) - let layer_render_params = vello::RenderParams { - base_color: vello::peniko::Color::TRANSPARENT, - width, - height, - antialiasing_method: vello::AaConfig::Area, - }; - - // First, render background and composite it - let bg_srgb_handle = gpu_resources.buffer_pool.acquire(device, layer_spec); - let bg_hdr_handle = gpu_resources.buffer_pool.acquire(device, hdr_spec); - - if let (Some(bg_srgb_view), Some(bg_hdr_view)) = ( - gpu_resources.buffer_pool.get_view(bg_srgb_handle), - gpu_resources.buffer_pool.get_view(bg_hdr_handle), - ) { - // Render background scene - renderer.render_to_texture(device, queue, &composite_result.background, bg_srgb_view, &layer_render_params) - .map_err(|e| format!("Failed to render background: {}", e))?; - - // Convert sRGB to linear HDR - let mut convert_encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { - label: Some("export_bg_srgb_to_linear_encoder"), - }); - gpu_resources.srgb_to_linear.convert(device, &mut convert_encoder, bg_srgb_view, bg_hdr_view); - queue.submit(Some(convert_encoder.finish())); - - // Composite background onto HDR texture (first layer, clears to black for export) - let bg_compositor_layer = CompositorLayer::normal(bg_hdr_handle, 1.0); - let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { - label: Some("export_bg_composite_encoder"), - }); - // Clear to black for export (unlike stage preview which has gray background) - gpu_resources.compositor.composite( - device, - queue, - &mut encoder, - &[bg_compositor_layer], - &gpu_resources.buffer_pool, - &gpu_resources.hdr_texture_view, - Some([0.0, 0.0, 0.0, 1.0]), - ); - queue.submit(Some(encoder.finish())); - } - gpu_resources.buffer_pool.release(bg_srgb_handle); - gpu_resources.buffer_pool.release(bg_hdr_handle); - - // Now render and composite each layer incrementally - for rendered_layer in &composite_result.layers { - if !rendered_layer.has_content { - continue; - } - - match &rendered_layer.layer_type { - RenderedLayerType::Content => { - // Regular content layer - render to sRGB, convert to linear, then composite - let srgb_handle = gpu_resources.buffer_pool.acquire(device, layer_spec); - let hdr_layer_handle = gpu_resources.buffer_pool.acquire(device, hdr_spec); - - if let (Some(srgb_view), Some(hdr_layer_view)) = ( - gpu_resources.buffer_pool.get_view(srgb_handle), - gpu_resources.buffer_pool.get_view(hdr_layer_handle), - ) { - // Render layer scene to sRGB buffer - renderer.render_to_texture(device, queue, &rendered_layer.scene, srgb_view, &layer_render_params) - .map_err(|e| format!("Failed to render layer: {}", e))?; - - // Convert sRGB to linear HDR - let mut convert_encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { - label: Some("export_layer_srgb_to_linear_encoder"), - }); - gpu_resources.srgb_to_linear.convert(device, &mut convert_encoder, srgb_view, hdr_layer_view); - queue.submit(Some(convert_encoder.finish())); - - // Composite this layer onto the HDR accumulator with its opacity - let compositor_layer = CompositorLayer::new( - hdr_layer_handle, - rendered_layer.opacity, - rendered_layer.blend_mode, - ); - - let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { - label: Some("export_layer_composite_encoder"), - }); - gpu_resources.compositor.composite( - device, - queue, - &mut encoder, - &[compositor_layer], - &gpu_resources.buffer_pool, - &gpu_resources.hdr_texture_view, - None, // Don't clear - blend onto existing content - ); - queue.submit(Some(encoder.finish())); - } - - gpu_resources.buffer_pool.release(srgb_handle); - gpu_resources.buffer_pool.release(hdr_layer_handle); - } - RenderedLayerType::Effect { effect_instances } => { - // Effect layer - apply effects to the current HDR accumulator - let current_time = document.current_time; - - for effect_instance in effect_instances { - // Get effect definition from document - let Some(effect_def) = document.get_effect_definition(&effect_instance.clip_id) else { - continue; - }; - - // Compile effect if needed - if !gpu_resources.effect_processor.is_compiled(&effect_def.id) { - let success = gpu_resources.effect_processor.compile_effect(device, effect_def); - if !success { - eprintln!("Failed to compile effect: {}", effect_def.name); - continue; - } - } - - // Create EffectInstance from ClipInstance for the processor - let effect_inst = lightningbeam_core::effect::EffectInstance::new( - effect_def, - effect_instance.timeline_start, - effect_instance.timeline_start + effect_instance.effective_duration(lightningbeam_core::effect::EFFECT_DURATION), - ); - - // Acquire temp buffer for effect output (HDR format) - let effect_output_handle = gpu_resources.buffer_pool.acquire(device, hdr_spec); - - if let Some(effect_output_view) = gpu_resources.buffer_pool.get_view(effect_output_handle) { - let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { - label: Some("export_effect_encoder"), - }); - - // Apply effect: HDR accumulator → effect output buffer - let applied = gpu_resources.effect_processor.apply_effect( - device, - queue, - &mut encoder, - effect_def, - &effect_inst, - &gpu_resources.hdr_texture_view, - effect_output_view, - width, - height, - current_time, - ); - - if applied { - queue.submit(Some(encoder.finish())); - - // Copy effect output back to HDR accumulator - let mut copy_encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { - label: Some("export_effect_copy_encoder"), - }); - - // Use compositor to copy (replacing content) - let effect_layer = CompositorLayer::normal( - effect_output_handle, - rendered_layer.opacity, // Apply effect layer opacity - ); - gpu_resources.compositor.composite( - device, - queue, - &mut copy_encoder, - &[effect_layer], - &gpu_resources.buffer_pool, - &gpu_resources.hdr_texture_view, - Some([0.0, 0.0, 0.0, 0.0]), // Clear with transparent (we're replacing) - ); - queue.submit(Some(copy_encoder.finish())); - } - } - - gpu_resources.buffer_pool.release(effect_output_handle); - } - } - } - } - - // Advance frame counter for buffer cleanup - gpu_resources.buffer_pool.next_frame(); + // Video export is never transparent. + composite_document_to_hdr(&composite_result, document, device, queue, renderer, gpu_resources, width, height, false)?; // Use persistent output texture (already created in ExportGpuResources) let output_view = &gpu_resources.output_texture_view; @@ -1118,6 +1172,8 @@ pub fn render_frame_to_gpu_rgba( video_manager: &Arc>, gpu_resources: &mut ExportGpuResources, rgba_texture_view: &wgpu::TextureView, + floating_selection: Option<&lightningbeam_core::selection::RasterFloatingSelection>, + allow_transparency: bool, ) -> Result { use vello::kurbo::Affine; @@ -1134,176 +1190,11 @@ pub fn render_frame_to_gpu_rgba( image_cache, video_manager, None, // No webcam during export + floating_selection, + false, // No checkerboard in export ); - // Buffer specs for layer rendering - let layer_spec = BufferSpec::new(width, height, BufferFormat::Rgba8Srgb); - let hdr_spec = BufferSpec::new(width, height, BufferFormat::Rgba16Float); - - // Render parameters for Vello (transparent background for layers) - let layer_render_params = vello::RenderParams { - base_color: vello::peniko::Color::TRANSPARENT, - width, - height, - antialiasing_method: vello::AaConfig::Area, - }; - - // Render background and composite it - let bg_srgb_handle = gpu_resources.buffer_pool.acquire(device, layer_spec); - let bg_hdr_handle = gpu_resources.buffer_pool.acquire(device, hdr_spec); - - if let (Some(bg_srgb_view), Some(bg_hdr_view)) = ( - gpu_resources.buffer_pool.get_view(bg_srgb_handle), - gpu_resources.buffer_pool.get_view(bg_hdr_handle), - ) { - renderer.render_to_texture(device, queue, &composite_result.background, bg_srgb_view, &layer_render_params) - .map_err(|e| format!("Failed to render background: {}", e))?; - - let mut convert_encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { - label: Some("export_bg_srgb_to_linear_encoder"), - }); - gpu_resources.srgb_to_linear.convert(device, &mut convert_encoder, bg_srgb_view, bg_hdr_view); - queue.submit(Some(convert_encoder.finish())); - - let bg_compositor_layer = CompositorLayer::normal(bg_hdr_handle, 1.0); - let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { - label: Some("export_bg_composite_encoder"), - }); - gpu_resources.compositor.composite( - device, - queue, - &mut encoder, - &[bg_compositor_layer], - &gpu_resources.buffer_pool, - &gpu_resources.hdr_texture_view, - Some([0.0, 0.0, 0.0, 1.0]), - ); - queue.submit(Some(encoder.finish())); - } - gpu_resources.buffer_pool.release(bg_srgb_handle); - gpu_resources.buffer_pool.release(bg_hdr_handle); - - // Render and composite each layer incrementally - for rendered_layer in &composite_result.layers { - if !rendered_layer.has_content { - continue; - } - - match &rendered_layer.layer_type { - RenderedLayerType::Content => { - let srgb_handle = gpu_resources.buffer_pool.acquire(device, layer_spec); - let hdr_layer_handle = gpu_resources.buffer_pool.acquire(device, hdr_spec); - - if let (Some(srgb_view), Some(hdr_layer_view)) = ( - gpu_resources.buffer_pool.get_view(srgb_handle), - gpu_resources.buffer_pool.get_view(hdr_layer_handle), - ) { - renderer.render_to_texture(device, queue, &rendered_layer.scene, srgb_view, &layer_render_params) - .map_err(|e| format!("Failed to render layer: {}", e))?; - - let mut convert_encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { - label: Some("export_layer_srgb_to_linear_encoder"), - }); - gpu_resources.srgb_to_linear.convert(device, &mut convert_encoder, srgb_view, hdr_layer_view); - queue.submit(Some(convert_encoder.finish())); - - let compositor_layer = CompositorLayer::normal(hdr_layer_handle, rendered_layer.opacity); - let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { - label: Some("export_layer_composite_encoder"), - }); - gpu_resources.compositor.composite( - device, - queue, - &mut encoder, - &[compositor_layer], - &gpu_resources.buffer_pool, - &gpu_resources.hdr_texture_view, - None, - ); - queue.submit(Some(encoder.finish())); - } - gpu_resources.buffer_pool.release(srgb_handle); - gpu_resources.buffer_pool.release(hdr_layer_handle); - } - RenderedLayerType::Effect { effect_instances } => { - // Effect layer - apply effects to the current HDR accumulator - let current_time = document.current_time; - - for effect_instance in effect_instances { - // Get effect definition from document - let Some(effect_def) = document.get_effect_definition(&effect_instance.clip_id) else { - continue; - }; - - // Compile effect if needed - if !gpu_resources.effect_processor.is_compiled(&effect_def.id) { - let success = gpu_resources.effect_processor.compile_effect(device, effect_def); - if !success { - eprintln!("Failed to compile effect: {}", effect_def.name); - continue; - } - } - - // Create EffectInstance from ClipInstance for the processor - let effect_inst = lightningbeam_core::effect::EffectInstance::new( - effect_def, - effect_instance.timeline_start, - effect_instance.timeline_start + effect_instance.effective_duration(lightningbeam_core::effect::EFFECT_DURATION), - ); - - // Acquire temp buffer for effect output (HDR format) - let effect_output_handle = gpu_resources.buffer_pool.acquire(device, hdr_spec); - - if let Some(effect_output_view) = gpu_resources.buffer_pool.get_view(effect_output_handle) { - let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { - label: Some("export_effect_encoder"), - }); - - // Apply effect: HDR accumulator → effect output buffer - let applied = gpu_resources.effect_processor.apply_effect( - device, - queue, - &mut encoder, - effect_def, - &effect_inst, - &gpu_resources.hdr_texture_view, - effect_output_view, - width, - height, - current_time, - ); - - if applied { - // Copy effect output back to HDR accumulator - encoder.copy_texture_to_texture( - wgpu::TexelCopyTextureInfo { - texture: gpu_resources.buffer_pool.get_texture(effect_output_handle).unwrap(), - mip_level: 0, - origin: wgpu::Origin3d::ZERO, - aspect: wgpu::TextureAspect::All, - }, - wgpu::TexelCopyTextureInfo { - texture: &gpu_resources.hdr_texture, - mip_level: 0, - origin: wgpu::Origin3d::ZERO, - aspect: wgpu::TextureAspect::All, - }, - wgpu::Extent3d { - width, - height, - depth_or_array_layers: 1, - }, - ); - } - - queue.submit(Some(encoder.finish())); - } - - gpu_resources.buffer_pool.release(effect_output_handle); - } - } - } - } + composite_document_to_hdr(&composite_result, document, device, queue, renderer, gpu_resources, width, height, allow_transparency)?; // Convert HDR to sRGB (linear → sRGB), render directly to external RGBA texture let output_view = rgba_texture_view; diff --git a/lightningbeam-ui/lightningbeam-editor/src/gpu_brush.rs b/lightningbeam-ui/lightningbeam-editor/src/gpu_brush.rs index 5855850..5958737 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/gpu_brush.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/gpu_brush.rs @@ -140,6 +140,706 @@ impl CanvasPair { } // --------------------------------------------------------------------------- +// Raster affine-transform pipeline +// --------------------------------------------------------------------------- + +/// CPU-side parameters for the raster transform compute shader. +/// Must match the `Params` struct in `raster_transform.wgsl` (48 bytes, 16-byte aligned). +#[repr(C)] +#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)] +pub struct RasterTransformGpuParams { + pub a00: f32, pub a01: f32, // row 0 of 2×2 inverse affine matrix + pub a10: f32, pub a11: f32, // row 1 + pub b0: f32, pub b1: f32, // translation (source pixel offset at output (0,0)) + pub src_w: u32, pub src_h: u32, + pub dst_w: u32, pub dst_h: u32, + pub _pad0: u32, pub _pad1: u32, +} + +/// Compute pipeline for GPU-accelerated affine resampling of raster floats. +/// Created lazily on first transform use. +struct RasterTransformPipeline { + pipeline: wgpu::ComputePipeline, + bind_group_layout: wgpu::BindGroupLayout, +} + +impl RasterTransformPipeline { + fn new(device: &wgpu::Device) -> Self { + let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("raster_transform_shader"), + source: wgpu::ShaderSource::Wgsl( + include_str!("panes/shaders/raster_transform.wgsl").into(), + ), + }); + + let bind_group_layout = device.create_bind_group_layout( + &wgpu::BindGroupLayoutDescriptor { + label: Some("raster_transform_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: destination texture (float canvas dst, write-only storage) + wgpu::BindGroupLayoutEntry { + binding: 2, + 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("raster_transform_pl"), + bind_group_layouts: &[&bind_group_layout], + push_constant_ranges: &[], + }, + ); + + let pipeline = device.create_compute_pipeline( + &wgpu::ComputePipelineDescriptor { + label: Some("raster_transform_pipeline"), + layout: Some(&pipeline_layout), + module: &shader, + entry_point: Some("main"), + compilation_options: Default::default(), + cache: None, + }, + ); + + Self { pipeline, bind_group_layout } + } + + /// Dispatch the transform shader: reads from `src_view`, writes to `dst_view`. + /// The caller must call `dst_canvas.swap()` after this returns. + fn render( + &self, + device: &wgpu::Device, + queue: &wgpu::Queue, + src_view: &wgpu::TextureView, + dst_view: &wgpu::TextureView, + params: RasterTransformGpuParams, + ) { + use wgpu::util::DeviceExt; + + let uniform_buf = device.create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: Some("raster_transform_params"), + contents: bytemuck::bytes_of(¶ms), + usage: wgpu::BufferUsages::UNIFORM, + }); + + let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("raster_transform_bg"), + layout: &self.bind_group_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: wgpu::BindingResource::TextureView(dst_view), + }, + ], + }); + + let mut encoder = device.create_command_encoder( + &wgpu::CommandEncoderDescriptor { label: Some("raster_transform_enc") }, + ); + { + let mut pass = encoder.begin_compute_pass( + &wgpu::ComputePassDescriptor { label: Some("raster_transform_pass"), timestamp_writes: None }, + ); + pass.set_pipeline(&self.pipeline); + pass.set_bind_group(0, &bind_group, &[]); + let wg_x = params.dst_w.div_ceil(8); + let wg_y = params.dst_h.div_ceil(8); + pass.dispatch_workgroups(wg_x, wg_y, 1); + } + queue.submit(Some(encoder.finish())); + } +} + +// --------------------------------------------------------------------------- +// 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())); + } +} + +// ── AlphaCompositePipeline ─────────────────────────────────────────────────── + +/// Compute pipeline: composites the scratch buffer C over the source A → output B. +/// +/// Binding layout (see `alpha_composite.wgsl`): +/// 0 = tex_a (texture_2d, Rgba8Unorm, sampled, not filterable) +/// 1 = tex_c (texture_2d, Rgba8Unorm, sampled, not filterable) +/// 2 = tex_b (texture_storage_2d) +struct AlphaCompositePipeline { + pipeline: wgpu::ComputePipeline, + bg_layout: wgpu::BindGroupLayout, +} + +impl AlphaCompositePipeline { + fn new(device: &wgpu::Device) -> Self { + let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("alpha_composite_shader"), + source: wgpu::ShaderSource::Wgsl( + include_str!("panes/shaders/alpha_composite.wgsl").into(), + ), + }); + let sampled_entry = |binding: u32| wgpu::BindGroupLayoutEntry { + binding, + visibility: wgpu::ShaderStages::COMPUTE, + ty: wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Float { filterable: false }, + view_dimension: wgpu::TextureViewDimension::D2, + multisampled: false, + }, + count: None, + }; + let bg_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("alpha_composite_bgl"), + entries: &[ + sampled_entry(0), // tex_a + sampled_entry(1), // tex_c + wgpu::BindGroupLayoutEntry { + binding: 2, + visibility: wgpu::ShaderStages::COMPUTE, + ty: wgpu::BindingType::StorageTexture { + access: wgpu::StorageTextureAccess::WriteOnly, + format: wgpu::TextureFormat::Rgba8Unorm, + view_dimension: wgpu::TextureViewDimension::D2, + }, + count: None, + }, + ], + }); + let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("alpha_composite_layout"), + bind_group_layouts: &[&bg_layout], + push_constant_ranges: &[], + }); + let pipeline = device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor { + label: Some("alpha_composite_pipeline"), + layout: Some(&layout), + module: &shader, + entry_point: Some("main"), + compilation_options: Default::default(), + cache: None, + }); + Self { pipeline, bg_layout } + } +} + // GpuBrushEngine // --------------------------------------------------------------------------- @@ -148,8 +848,30 @@ pub struct GpuBrushEngine { compute_pipeline: wgpu::ComputePipeline, compute_bg_layout: wgpu::BindGroupLayout, + /// 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, + /// Lazily created on first unified-tool composite dispatch. + composite_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, + + /// Persistent `Rgba8Unorm` textures for idle raster layers. + /// + /// Keyed by keyframe UUID (same ID space as `canvases`). Entries are uploaded + /// once when `RasterKeyframe::texture_dirty` is set, then reused every frame. + /// Separate from `canvases` so tool teardown never accidentally removes them. + pub raster_layer_cache: HashMap, } /// CPU-side parameters uniform for the compute shader. @@ -251,7 +973,14 @@ impl GpuBrushEngine { Self { compute_pipeline, compute_bg_layout, - canvases: HashMap::new(), + transform_pipeline: None, + warp_apply_pipeline: None, + liquify_brush_pipeline: None, + gradient_fill_pipeline: None, + composite_pipeline: None, + canvases: HashMap::new(), + displacement_bufs: HashMap::new(), + raster_layer_cache: HashMap::new(), } } @@ -276,9 +1005,9 @@ impl GpuBrushEngine { /// Dispatch the brush compute shader for `dabs` onto the canvas of `keyframe_id`. /// - /// Each dab is dispatched serially: copy the dab's bounding box from src→dst, - /// dispatch the compute shader, then swap. The bbox-only copy is safe because - /// neither normal/erase nor smudge reads outside the current dab's radius. + /// Paint/erase dabs are batched in a single GPU dispatch with a full canvas copy. + /// Smudge dabs are dispatched sequentially (one per dab) with a bbox-only copy + /// so each dab reads the canvas state written by the previous dab. /// /// If `dabs` is empty, does nothing. pub fn render_dabs( @@ -287,100 +1016,189 @@ impl GpuBrushEngine { queue: &wgpu::Queue, keyframe_id: Uuid, dabs: &[GpuDab], - _bbox: (i32, i32, i32, i32), + bbox: (i32, i32, i32, i32), canvas_w: u32, canvas_h: u32, ) { if dabs.is_empty() { return; } - if !self.canvases.contains_key(&keyframe_id) { return; } - for dab in dabs { - let r_fringe = dab.radius + 1.0; - let x0 = ((dab.x - r_fringe).floor() as i32).max(0) as u32; - let y0 = ((dab.y - r_fringe).floor() as i32).max(0) as u32; - let x1 = ((dab.x + r_fringe).ceil() as i32).min(canvas_w as i32) as u32; - let y1 = ((dab.y + r_fringe).ceil() as i32).min(canvas_h as i32) as u32; - if x1 <= x0 || y1 <= y0 { continue; } - - let bbox_w = x1 - x0; - let bbox_h = y1 - y0; - - let canvas = self.canvases.get_mut(&keyframe_id).unwrap(); - - let mut copy_enc = device.create_command_encoder( - &wgpu::CommandEncoderDescriptor { label: Some("canvas_copy_encoder") }, - ); - copy_enc.copy_texture_to_texture( - wgpu::TexelCopyTextureInfo { - texture: canvas.src(), - mip_level: 0, - origin: wgpu::Origin3d { x: x0, y: y0, z: 0 }, - aspect: wgpu::TextureAspect::All, - }, - wgpu::TexelCopyTextureInfo { - texture: canvas.dst(), - mip_level: 0, - origin: wgpu::Origin3d { x: x0, y: y0, z: 0 }, - aspect: wgpu::TextureAspect::All, - }, - wgpu::Extent3d { width: bbox_w, height: bbox_h, depth_or_array_layers: 1 }, - ); - queue.submit(Some(copy_enc.finish())); - - let dab_bytes = bytemuck::bytes_of(dab); - let dab_buf = device.create_buffer(&wgpu::BufferDescriptor { - label: Some("dab_storage_buf"), - size: dab_bytes.len() as u64, - usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST, - mapped_at_creation: false, - }); - queue.write_buffer(&dab_buf, 0, dab_bytes); - - let params = DabParams { - bbox_x0: x0 as i32, - bbox_y0: y0 as i32, - bbox_w, - bbox_h, - num_dabs: 1, - canvas_w, - canvas_h, - _pad: 0, - }; - let params_buf = device.create_buffer(&wgpu::BufferDescriptor { - label: Some("dab_params_buf"), - size: std::mem::size_of::() as u64, - usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, - mapped_at_creation: false, - }); - queue.write_buffer(¶ms_buf, 0, bytemuck::bytes_of(¶ms)); - - let bg = device.create_bind_group(&wgpu::BindGroupDescriptor { - label: Some("brush_dab_bg"), - layout: &self.compute_bg_layout, - entries: &[ - wgpu::BindGroupEntry { binding: 0, resource: dab_buf.as_entire_binding() }, - wgpu::BindGroupEntry { binding: 1, resource: params_buf.as_entire_binding() }, - wgpu::BindGroupEntry { binding: 2, resource: wgpu::BindingResource::TextureView(canvas.src_view()) }, - wgpu::BindGroupEntry { binding: 3, resource: wgpu::BindingResource::TextureView(canvas.dst_view()) }, - ], - }); - - let mut compute_enc = device.create_command_encoder( - &wgpu::CommandEncoderDescriptor { label: Some("brush_dab_encoder") }, - ); - { - let mut pass = compute_enc.begin_compute_pass( - &wgpu::ComputePassDescriptor { label: Some("brush_dab_pass"), timestamp_writes: None }, + // Smudge dabs must be applied one at a time so each dab reads the canvas + // state written by the previous dab. Use bbox-only copies (union of current + // and previous dab) to avoid an expensive full-canvas copy per dab. + let is_smudge = dabs.first().map(|d| d.blend_mode == 2).unwrap_or(false); + if is_smudge { + let mut prev_bbox: Option<(i32, i32, i32, i32)> = None; + for dab in dabs { + let r = dab.radius + 1.0; + let cur_bbox = ( + (dab.x - r).floor() as i32, + (dab.y - r).floor() as i32, + (dab.x + r).ceil() as i32, + (dab.y + r).ceil() as i32, ); - pass.set_pipeline(&self.compute_pipeline); - pass.set_bind_group(0, &bg, &[]); - pass.dispatch_workgroups(bbox_w.div_ceil(8), bbox_h.div_ceil(8), 1); + // Expand copy region to include the previous dab's bbox so the + // pixels it wrote are visible as the source for this dab's smudge. + let copy_bbox = match prev_bbox { + Some(pb) => (cur_bbox.0.min(pb.0), cur_bbox.1.min(pb.1), + cur_bbox.2.max(pb.2), cur_bbox.3.max(pb.3)), + None => cur_bbox, + }; + self.render_dabs_batch(device, queue, keyframe_id, + std::slice::from_ref(dab), cur_bbox, Some(copy_bbox), canvas_w, canvas_h); + prev_bbox = Some(cur_bbox); } - queue.submit(Some(compute_enc.finish())); - canvas.swap(); + } else { + self.render_dabs_batch(device, queue, keyframe_id, dabs, bbox, None, canvas_w, canvas_h); } } + /// Inner batch dispatch. + /// + /// `dispatch_bbox` — region dispatched to the compute shader (usually the union of all dab bboxes). + /// `copy_bbox` — region to copy src→dst before dispatch: + /// - `None` → copy the full canvas (required for paint/erase batches so + /// dabs outside the current frame's region are preserved). + /// - `Some(r)` → copy only region `r` (sufficient for sequential smudge dabs + /// because both textures hold identical data outside previously + /// touched regions, so no full copy is needed). + fn render_dabs_batch( + &mut self, + device: &wgpu::Device, + queue: &wgpu::Queue, + keyframe_id: Uuid, + dabs: &[GpuDab], + dispatch_bbox: (i32, i32, i32, i32), + copy_bbox: Option<(i32, i32, i32, i32)>, + canvas_w: u32, + canvas_h: u32, + ) { + if dabs.is_empty() { return; } + let canvas = match self.canvases.get_mut(&keyframe_id) { + Some(c) => c, + None => return, + }; + + // Clamp the dispatch bounding box to canvas bounds. + let bbox = dispatch_bbox; + let x0 = bbox.0.max(0) as u32; + let y0 = bbox.1.max(0) as u32; + let x1 = (bbox.2 as u32).min(canvas_w); + let y1 = (bbox.3 as u32).min(canvas_h); + if x1 <= x0 || y1 <= y0 { return; } + let bbox_w = x1 - x0; + let bbox_h = y1 - y0; + + // Step 1: Copy src→dst. + // For paint/erase batches (copy_bbox = None): copy the ENTIRE canvas so dst + // starts with all previous dabs — a bbox-only copy would lose dabs outside + // this frame's region after swap. + // For smudge (copy_bbox = Some(r)): copy only the union of the current and + // previous dab bboxes. Outside that region both textures hold identical + // data so no full copy is needed. + let mut copy_enc = device.create_command_encoder( + &wgpu::CommandEncoderDescriptor { label: Some("canvas_copy_encoder") }, + ); + match copy_bbox { + None => { + copy_enc.copy_texture_to_texture( + wgpu::TexelCopyTextureInfo { + texture: canvas.src(), + mip_level: 0, + origin: wgpu::Origin3d::ZERO, + aspect: wgpu::TextureAspect::All, + }, + wgpu::TexelCopyTextureInfo { + texture: canvas.dst(), + mip_level: 0, + origin: wgpu::Origin3d::ZERO, + aspect: wgpu::TextureAspect::All, + }, + wgpu::Extent3d { width: canvas_w, height: canvas_h, depth_or_array_layers: 1 }, + ); + } + Some(cb) => { + let cx0 = cb.0.max(0) as u32; + let cy0 = cb.1.max(0) as u32; + let cx1 = (cb.2 as u32).min(canvas_w); + let cy1 = (cb.3 as u32).min(canvas_h); + if cx1 > cx0 && cy1 > cy0 { + copy_enc.copy_texture_to_texture( + wgpu::TexelCopyTextureInfo { + texture: canvas.src(), + mip_level: 0, + origin: wgpu::Origin3d { x: cx0, y: cy0, z: 0 }, + aspect: wgpu::TextureAspect::All, + }, + wgpu::TexelCopyTextureInfo { + texture: canvas.dst(), + mip_level: 0, + origin: wgpu::Origin3d { x: cx0, y: cy0, z: 0 }, + aspect: wgpu::TextureAspect::All, + }, + wgpu::Extent3d { width: cx1 - cx0, height: cy1 - cy0, depth_or_array_layers: 1 }, + ); + } + } + } + queue.submit(Some(copy_enc.finish())); + + // Step 2: Upload all dabs as a single storage buffer. + let dab_bytes = bytemuck::cast_slice(dabs); + let dab_buf = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("dab_storage_buf"), + size: dab_bytes.len() as u64, + usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + queue.write_buffer(&dab_buf, 0, dab_bytes); + + let params = DabParams { + bbox_x0: x0 as i32, + bbox_y0: y0 as i32, + bbox_w, + bbox_h, + num_dabs: dabs.len() as u32, + canvas_w, + canvas_h, + _pad: 0, + }; + let params_buf = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("dab_params_buf"), + size: std::mem::size_of::() as u64, + usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + queue.write_buffer(¶ms_buf, 0, bytemuck::bytes_of(¶ms)); + + let bg = device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("brush_dab_bg"), + layout: &self.compute_bg_layout, + entries: &[ + wgpu::BindGroupEntry { binding: 0, resource: dab_buf.as_entire_binding() }, + wgpu::BindGroupEntry { binding: 1, resource: params_buf.as_entire_binding() }, + wgpu::BindGroupEntry { binding: 2, resource: wgpu::BindingResource::TextureView(canvas.src_view()) }, + wgpu::BindGroupEntry { binding: 3, resource: wgpu::BindingResource::TextureView(canvas.dst_view()) }, + ], + }); + + // Step 3: Single dispatch over the union bounding box. + let mut compute_enc = device.create_command_encoder( + &wgpu::CommandEncoderDescriptor { label: Some("brush_dab_encoder") }, + ); + { + let mut pass = compute_enc.begin_compute_pass( + &wgpu::ComputePassDescriptor { label: Some("brush_dab_pass"), timestamp_writes: None }, + ); + pass.set_pipeline(&self.compute_pipeline); + pass.set_bind_group(0, &bg, &[]); + pass.dispatch_workgroups(bbox_w.div_ceil(8), bbox_h.div_ceil(8), 1); + } + queue.submit(Some(compute_enc.finish())); + + // Step 4: Swap once — dst (with all dabs applied) becomes the new src. + canvas.swap(); + } + /// Read the current canvas back to a CPU `Vec` (raw RGBA, row-major). /// /// **Blocks** until the GPU work is complete (`Maintain::Wait`). @@ -465,10 +1283,426 @@ impl GpuBrushEngine { Some(pixels) } + /// Render a set of dabs to an offscreen texture and return the raw pixels. + /// + /// This is a **blocking** GPU readback — intended for one-time renders such as + /// brush preview thumbnails. Do not call every frame on the hot path. + /// + /// The returned `Vec` is in **sRGB-encoded premultiplied RGBA** format, + /// suitable for creating an `egui::ColorImage` via + /// `ColorImage::from_rgba_premultiplied`. + /// + /// A dedicated scratch canvas keyed by a fixed UUID is reused across calls so + /// no allocation is needed after the first invocation. + pub fn render_to_image( + &mut self, + device: &wgpu::Device, + queue: &wgpu::Queue, + dabs: &[GpuDab], + width: u32, + height: u32, + ) -> Vec { + use std::sync::OnceLock; + static SCRATCH_ID: OnceLock = OnceLock::new(); + let scratch_id = *SCRATCH_ID.get_or_init(Uuid::new_v4); + + // Ensure a correctly-sized scratch canvas exists. + self.ensure_canvas(device, scratch_id, width, height); + + // Clear to transparent so previous renders don't bleed through. + let blank = vec![0u8; (width * height * 4) as usize]; + if let Some(canvas) = self.canvases.get(&scratch_id) { + canvas.upload(queue, &blank); + } + + if !dabs.is_empty() { + // Compute the union bounding box of all dabs. + let bbox = dabs.iter().fold( + (i32::MAX, i32::MAX, i32::MIN, i32::MIN), + |acc, d| { + let r = d.radius + 1.0; + ( + acc.0.min((d.x - r).floor() as i32), + acc.1.min((d.y - r).floor() as i32), + acc.2.max((d.x + r).ceil() as i32), + acc.3.max((d.y + r).ceil() as i32), + ) + }, + ); + self.render_dabs(device, queue, scratch_id, dabs, bbox, width, height); + } + + self.readback_canvas(device, queue, scratch_id).unwrap_or_default() + } + /// Remove the canvas pair for a keyframe (e.g. when the layer is deleted). pub fn remove_canvas(&mut self, keyframe_id: &Uuid) { self.canvases.remove(keyframe_id); } + + // ── Raster-layer texture cache ──────────────────────────────────────────── + + /// Ensure a cached display texture exists for `kf_id`. + /// + /// If `dirty` is `true` (or no entry exists), the canvas is (re)created and + /// `pixels` is uploaded. Call with `dirty = false` when only checking for + /// existence without re-uploading. + /// + /// `pixels` must be sRGB-premultiplied RGBA with length `w * h * 4`. + /// Panics in debug builds if the length does not match. + pub fn ensure_layer_texture( + &mut self, + device: &wgpu::Device, + queue: &wgpu::Queue, + kf_id: Uuid, + pixels: &[u8], + w: u32, + h: u32, + dirty: bool, + ) { + debug_assert_eq!( + pixels.len(), + (w * h * 4) as usize, + "ensure_layer_texture: pixel buffer length mismatch (got {}, expected {})", + pixels.len(), + w * h * 4, + ); + let needs_new = dirty || self.raster_layer_cache.get(&kf_id) + .map_or(true, |c| c.width != w || c.height != h); + if needs_new { + let canvas = CanvasPair::new(device, w, h); + if !pixels.is_empty() { + canvas.upload(queue, pixels); + } + self.raster_layer_cache.insert(kf_id, canvas); + } + } + + /// Get the cached display texture for a raster layer keyframe. + pub fn get_layer_texture(&self, kf_id: &Uuid) -> Option<&CanvasPair> { + self.raster_layer_cache.get(kf_id) + } + + /// Remove the cached texture for a raster layer keyframe (e.g. when deleted). + pub fn remove_layer_texture(&mut self, kf_id: &Uuid) { + self.raster_layer_cache.remove(kf_id); + } + + /// Composite the accumulated-dab scratch buffer C over the source A, writing the + /// result into B: `B = C + A × (1 − C.a)` (Porter-Duff src-over). + /// + /// All three canvases must already exist in `self.canvases` (created by + /// [`ensure_canvas`] from the [`WorkspaceInitPacket`] in `prepare()`). + /// + /// After dispatch, B's ping-pong index is swapped so `B.src_view()` holds the + /// composite result and the compositor can blit it. + pub fn composite_a_c_to_b( + &mut self, + device: &wgpu::Device, + queue: &wgpu::Queue, + a_id: Uuid, + c_id: Uuid, + b_id: Uuid, + width: u32, + height: u32, + ) { + // Init pipeline lazily. + if self.composite_pipeline.is_none() { + self.composite_pipeline = Some(AlphaCompositePipeline::new(device)); + } + + // Build bind group and command buffer (all immutable borrows of self). + let cmd_buf = { + let pipeline = self.composite_pipeline.as_ref().unwrap(); + let Some(a) = self.canvases.get(&a_id) else { return; }; + let Some(c) = self.canvases.get(&c_id) else { return; }; + let Some(b) = self.canvases.get(&b_id) else { return; }; + + let bg = device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("alpha_composite_bg"), + layout: &pipeline.bg_layout, + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: wgpu::BindingResource::TextureView(a.src_view()), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: wgpu::BindingResource::TextureView(c.src_view()), + }, + wgpu::BindGroupEntry { + binding: 2, + resource: wgpu::BindingResource::TextureView(b.dst_view()), + }, + ], + }); + + let mut enc = device.create_command_encoder( + &wgpu::CommandEncoderDescriptor { label: Some("alpha_composite_enc") }, + ); + { + let mut pass = enc.begin_compute_pass(&wgpu::ComputePassDescriptor { + label: Some("alpha_composite"), + timestamp_writes: None, + }); + pass.set_pipeline(&pipeline.pipeline); + pass.set_bind_group(0, &bg, &[]); + pass.dispatch_workgroups((width + 7) / 8, (height + 7) / 8, 1); + } + enc.finish() + }; // Immutable borrows (pipeline, a, c, b) released here. + + queue.submit(std::iter::once(cmd_buf)); + + // Swap B: src now holds the composite result. + if let Some(b) = self.canvases.get_mut(&b_id) { + b.swap(); + } + } + + /// Dispatch the affine-resample transform shader from `anchor_id` → `float_id`. + /// + /// Reads from the anchor canvas's source view, writes into the float canvas's + /// destination view, then swaps the float canvas so the result becomes the new source. + /// + /// `float_id` must already have been resized to `params.dst_w × params.dst_h` via + /// `ensure_canvas` before calling this. + pub fn render_transform( + &mut self, + device: &wgpu::Device, + queue: &wgpu::Queue, + anchor_id: &Uuid, + float_id: &Uuid, + params: RasterTransformGpuParams, + ) { + // Lazily create the transform pipeline. + let pipeline = self.transform_pipeline + .get_or_insert_with(|| RasterTransformPipeline::new(device)); + + // Borrow src_view and dst_view within a block so the borrows end before + // we call swap() on the float canvas. + let dispatched = { + let anchor = self.canvases.get(anchor_id); + let float = self.canvases.get(float_id); + if let (Some(anchor), Some(float)) = (anchor, float) { + pipeline.render(device, queue, anchor.src_view(), float.dst_view(), params); + true + } else { + false + } + }; + + if dispatched { + if let Some(float) = self.canvases.get_mut(float_id) { + float.swap(); + } + } + } + + // ----------------------------------------------------------------------- + // 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); + } + } } // --------------------------------------------------------------------------- @@ -486,18 +1720,64 @@ pub struct CanvasBlitPipeline { pub mask_sampler: wgpu::Sampler, } -/// Camera parameters uniform for canvas_blit.wgsl. +/// General affine blit transform for canvas_blit.wgsl. +/// +/// Encodes the combined `viewport_uv → canvas_uv` mapping as a column-major 3×3 +/// matrix packed into three `vec4` uniforms (std140 padding). +/// +/// Build with [`BlitTransform::new`] by supplying: +/// * `layer_transform` — affine that maps **canvas pixels → viewport pixels** +/// (= `base_transform` from the renderer; includes camera pan/zoom and any +/// parent-clip affine for nested layers). +/// * `canvas_w`, `canvas_h` — canvas dimensions in pixels. +/// * `vp_w`, `vp_h` — viewport dimensions in pixels. #[repr(C)] #[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)] -pub struct CameraParams { - pub pan_x: f32, - pub pan_y: f32, - pub zoom: f32, - pub canvas_w: f32, - pub canvas_h: f32, - pub viewport_w: f32, - pub viewport_h: f32, - pub _pad: f32, +pub struct BlitTransform { + /// Column 0 of the matrix (+ 1 padding float). + pub col0: [f32; 4], + /// Column 1 of the matrix (+ 1 padding float). + pub col1: [f32; 4], + /// Column 2 — translation column: `[tx, ty, 1.0, 0.0]`. + pub col2: [f32; 4], +} + +impl BlitTransform { + /// Build from a `canvas_px → viewport_px` affine transform. + /// + /// The resulting uniform maps **viewport UV [0,1]² → canvas UV [0,1]²** so + /// the fragment shader only needs a single `mat3x3 * vec3` multiply. + pub fn new( + layer_transform: kurbo::Affine, + canvas_w: u32, + canvas_h: u32, + vp_w: u32, + vp_h: u32, + ) -> Self { + // Combined transform: viewport_uv → canvas_uv + // = scale_canvas_inv * layer_transform.inverse() * scale_vp + // + // scale_vp: viewport UV → viewport px + // layer_transform⁻¹: viewport px → canvas px + // scale_canvas_inv: canvas px → canvas UV + let scale_vp = kurbo::Affine::scale_non_uniform(vp_w as f64, vp_h as f64); + let scale_uv = kurbo::Affine::scale_non_uniform( + 1.0 / canvas_w as f64, + 1.0 / canvas_h as f64, + ); + let combined = scale_uv * layer_transform.inverse() * scale_vp; + + // kurbo::Affine coefficients: [a, b, c, d, e, f] + // x' = a*x + c*y + e + // y' = b*x + d*y + f + // Column-major 3×3: col0=(a,b,0), col1=(c,d,0), col2=(e,f,1) + let [a, b, c, d, e, f] = combined.as_coeffs(); + Self { + col0: [a as f32, b as f32, 0.0, 0.0], + col1: [c as f32, d as f32, 0.0, 0.0], + col2: [e as f32, f as f32, 1.0, 0.0], + } + } } impl CanvasBlitPipeline { @@ -605,8 +1885,8 @@ impl CanvasBlitPipeline { address_mode_u: wgpu::AddressMode::ClampToEdge, address_mode_v: wgpu::AddressMode::ClampToEdge, address_mode_w: wgpu::AddressMode::ClampToEdge, - mag_filter: wgpu::FilterMode::Linear, - min_filter: wgpu::FilterMode::Linear, + mag_filter: wgpu::FilterMode::Nearest, + min_filter: wgpu::FilterMode::Nearest, mipmap_filter: wgpu::FilterMode::Nearest, ..Default::default() }); @@ -636,7 +1916,7 @@ impl CanvasBlitPipeline { queue: &wgpu::Queue, canvas_view: &wgpu::TextureView, target_view: &wgpu::TextureView, - camera: &CameraParams, + transform: &BlitTransform, mask_view: Option<&wgpu::TextureView>, ) { // When no mask is provided, create a temporary 1×1 all-white texture. @@ -671,14 +1951,14 @@ impl CanvasBlitPipeline { &tmp_mask_view } }; - // Upload camera params + // Upload blit transform let cam_buf = device.create_buffer(&wgpu::BufferDescriptor { label: Some("canvas_blit_cam_buf"), - size: std::mem::size_of::() as u64, + size: std::mem::size_of::() as u64, usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, mapped_at_creation: false, }); - queue.write_buffer(&cam_buf, 0, bytemuck::bytes_of(camera)); + queue.write_buffer(&cam_buf, 0, bytemuck::bytes_of(transform)); let bg = device.create_bind_group(&wgpu::BindGroupDescriptor { label: Some("canvas_blit_bg"), diff --git a/lightningbeam-ui/lightningbeam-editor/src/keymap.rs b/lightningbeam-ui/lightningbeam-editor/src/keymap.rs index 77a9716..3f7f147 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/keymap.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/keymap.rs @@ -429,24 +429,26 @@ impl AppAction { /// `Tool::Split` has no tool-shortcut action (it's triggered via the menu). pub fn tool_app_action(tool: lightningbeam_core::tool::Tool) -> Option { use lightningbeam_core::tool::Tool; - Some(match tool { - Tool::Select => AppAction::ToolSelect, - Tool::Draw => AppAction::ToolDraw, - Tool::Transform => AppAction::ToolTransform, - Tool::Rectangle => AppAction::ToolRectangle, - Tool::Ellipse => AppAction::ToolEllipse, - Tool::PaintBucket => AppAction::ToolPaintBucket, - Tool::Eyedropper => AppAction::ToolEyedropper, - Tool::Line => AppAction::ToolLine, - Tool::Polygon => AppAction::ToolPolygon, - Tool::BezierEdit => AppAction::ToolBezierEdit, - Tool::Text => AppAction::ToolText, - Tool::RegionSelect => AppAction::ToolRegionSelect, - Tool::Erase => AppAction::ToolErase, - Tool::Smudge => AppAction::ToolSmudge, - Tool::SelectLasso => AppAction::ToolSelectLasso, - Tool::Split => AppAction::ToolSplit, - }) + match tool { + Tool::Select => Some(AppAction::ToolSelect), + Tool::Draw => Some(AppAction::ToolDraw), + Tool::Transform => Some(AppAction::ToolTransform), + Tool::Rectangle => Some(AppAction::ToolRectangle), + Tool::Ellipse => Some(AppAction::ToolEllipse), + Tool::PaintBucket => Some(AppAction::ToolPaintBucket), + Tool::Eyedropper => Some(AppAction::ToolEyedropper), + Tool::Line => Some(AppAction::ToolLine), + Tool::Polygon => Some(AppAction::ToolPolygon), + Tool::BezierEdit => Some(AppAction::ToolBezierEdit), + Tool::Text => Some(AppAction::ToolText), + Tool::RegionSelect => Some(AppAction::ToolRegionSelect), + Tool::Erase => Some(AppAction::ToolErase), + Tool::Smudge => Some(AppAction::ToolSmudge), + Tool::SelectLasso => Some(AppAction::ToolSelectLasso), + Tool::Split => Some(AppAction::ToolSplit), + // New tools have no keybinding yet + _ => None, + } } // === Default bindings === diff --git a/lightningbeam-ui/lightningbeam-editor/src/main.rs b/lightningbeam-ui/lightningbeam-editor/src/main.rs index 3091df3..1a212c1 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/main.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/main.rs @@ -13,6 +13,8 @@ use uuid::Uuid; mod panes; use panes::{PaneInstance, PaneRenderer}; +mod tools; + mod widgets; mod menu; @@ -26,6 +28,8 @@ mod waveform_gpu; mod cqt_gpu; mod gpu_brush; +mod raster_tool; + mod config; use config::AppConfig; @@ -332,6 +336,7 @@ mod tool_icons { pub static ERASE: &[u8] = include_bytes!("../../../src/assets/erase.svg"); pub static SMUDGE: &[u8] = include_bytes!("../../../src/assets/smudge.svg"); pub static LASSO: &[u8] = include_bytes!("../../../src/assets/lasso.svg"); + pub static TODO: &[u8] = include_bytes!("../../../src/assets/todo.svg"); } /// Embedded focus icon SVGs @@ -399,11 +404,28 @@ impl ToolIconCache { Tool::Polygon => tool_icons::POLYGON, Tool::BezierEdit => tool_icons::BEZIER_EDIT, Tool::Text => tool_icons::TEXT, - Tool::RegionSelect => tool_icons::SELECT, // Reuse select icon for now + Tool::RegionSelect => tool_icons::SELECT, Tool::Split => tool_icons::SPLIT, Tool::Erase => tool_icons::ERASE, Tool::Smudge => tool_icons::SMUDGE, Tool::SelectLasso => tool_icons::LASSO, + // Not yet implemented — use placeholder icon + Tool::Pencil + | Tool::Pen + | Tool::Airbrush + | Tool::CloneStamp + | Tool::HealingBrush + | Tool::PatternStamp + | Tool::DodgeBurn + | Tool::Sponge + | Tool::BlurSharpen + | Tool::Gradient + | Tool::CustomShape + | Tool::SelectEllipse + | Tool::MagicWand + | Tool::QuickSelect + | Tool::Warp + | Tool::Liquify => tool_icons::TODO, }; if let Some(texture) = rasterize_svg(svg_data, tool.icon_file(), 180, ctx) { self.icons.insert(tool, texture); @@ -766,12 +788,10 @@ struct EditorApp { draw_simplify_mode: lightningbeam_core::tool::SimplifyMode, // Current simplification mode for draw tool rdp_tolerance: f64, // RDP simplification tolerance (default: 10.0) schneider_max_error: f64, // Schneider curve fitting max error (default: 30.0) - // Raster brush settings - brush_radius: f32, // brush radius in pixels - brush_opacity: f32, // brush opacity 0.0–1.0 - brush_hardness: f32, // brush hardness 0.0–1.0 - brush_spacing: f32, // dabs_per_radius (fraction of radius per dab) - brush_use_fg: bool, // true = paint with FG (stroke) color, false = BG (fill) color + /// All per-tool raster paint settings (brush, eraser, smudge, clone, pattern, dodge/burn, sponge). + raster_settings: tools::RasterToolSettings, + /// GPU-rendered brush preview pixel buffers, shared with VelloCallback::prepare(). + brush_preview_pixels: std::sync::Arc)>>>, // Audio engine integration #[allow(dead_code)] // Must be kept alive to maintain audio output audio_stream: Option, @@ -841,6 +861,7 @@ struct EditorApp { // Region select state region_selection: Option, region_select_mode: lightningbeam_core::tool::RegionSelectMode, + lasso_mode: lightningbeam_core::tool::LassoMode, // VU meter levels input_level: f32, @@ -935,6 +956,9 @@ impl EditorApp { #[cfg(debug_assertions)] cc.egui_ctx.style_mut(|style| style.debug.show_unaligned = false); + // Disable egui's built-in Ctrl+Plus/Minus zoom — we handle zoom ourselves. + cc.egui_ctx.options_mut(|o| o.zoom_with_keyboard = false); + // Load application config let config = AppConfig::load(); @@ -1049,11 +1073,8 @@ impl EditorApp { draw_simplify_mode: lightningbeam_core::tool::SimplifyMode::Smooth, // Default to smooth curves rdp_tolerance: 10.0, // Default RDP tolerance schneider_max_error: 30.0, // Default Schneider max error - brush_radius: 10.0, - brush_opacity: 1.0, - brush_hardness: 0.5, - brush_spacing: 0.1, - brush_use_fg: true, + raster_settings: tools::RasterToolSettings::default(), + brush_preview_pixels: std::sync::Arc::new(std::sync::Mutex::new(Vec::new())), audio_stream, audio_controller, audio_event_rx, @@ -1097,6 +1118,7 @@ impl EditorApp { polygon_sides: 5, // Default to pentagon region_selection: None, region_select_mode: lightningbeam_core::tool::RegionSelectMode::default(), + lasso_mode: lightningbeam_core::tool::LassoMode::default(), input_level: 0.0, output_level: (0.0, 0.0), track_levels: HashMap::new(), @@ -1852,7 +1874,8 @@ impl EditorApp { let cy = y0 + row; let inside = match sel { RasterSelection::Rect(..) => true, - RasterSelection::Lasso(_) => sel.contains_pixel(cx as i32, cy as i32), + RasterSelection::Lasso(_) | RasterSelection::Mask { .. } => + sel.contains_pixel(cx as i32, cy as i32), }; if inside { let src = ((cy * canvas_w + cx) * 4) as usize; @@ -1976,7 +1999,8 @@ impl EditorApp { let action = RasterStrokeAction::new( float.layer_id, float.time, - float.canvas_before, canvas_after, + std::sync::Arc::try_unwrap(float.canvas_before).unwrap_or_else(|a| (*a).clone()), + canvas_after, w, h, ); if let Err(e) = self.action_executor.execute(Box::new(action)) { @@ -1995,7 +2019,7 @@ impl EditorApp { let document = self.action_executor.document_mut(); let Some(AnyLayer::Raster(rl)) = document.get_layer_mut(&float.layer_id) else { return }; let Some(kf) = rl.keyframe_at_mut(float.time) else { return }; - kf.raw_pixels = float.canvas_before; + kf.raw_pixels = std::sync::Arc::try_unwrap(float.canvas_before).unwrap_or_else(|a| (*a).clone()); } /// Drop (discard) the floating selection keeping the hole punched in the @@ -2015,7 +2039,8 @@ impl EditorApp { let (w, h) = (kf.width, kf.height); let action = RasterStrokeAction::new( float.layer_id, float.time, - float.canvas_before, canvas_after, + std::sync::Arc::try_unwrap(float.canvas_before).unwrap_or_else(|a| (*a).clone()), + canvas_after, w, h, ); if let Err(e) = self.action_executor.execute(Box::new(action)) { @@ -2036,7 +2061,7 @@ impl EditorApp { if matches!(document.get_layer(&layer_id), Some(AnyLayer::Raster(_))) { if let Some(float) = &self.selection.raster_floating { self.clipboard_manager.copy(ClipboardContent::RasterPixels { - pixels: float.pixels.clone(), + pixels: (*float.pixels).clone(), width: float.width, height: float.height, }); @@ -2516,14 +2541,14 @@ impl EditorApp { use lightningbeam_core::selection::{RasterFloatingSelection, RasterSelection}; self.selection.raster_floating = Some(RasterFloatingSelection { - pixels, + pixels: std::sync::Arc::new(pixels), width, height, x: paste_x, y: paste_y, layer_id, time: self.playback_time, - canvas_before, + canvas_before: std::sync::Arc::new(canvas_before), canvas_id: uuid::Uuid::new_v4(), }); // Update the marquee to show the floating selection bounds. @@ -2942,14 +2967,42 @@ impl EditorApp { } MenuAction::Export => { println!("Menu: Export"); - // Open export dialog with calculated timeline endpoint let timeline_endpoint = self.action_executor.document().calculate_timeline_endpoint(); - // Derive project name from the .beam file path, falling back to document name let project_name = self.current_file_path.as_ref() .and_then(|p| p.file_stem()) .map(|s| s.to_string_lossy().into_owned()) .unwrap_or_else(|| self.action_executor.document().name.clone()); - self.export_dialog.open(timeline_endpoint, &project_name); + + // Build document hint for smart export-type defaulting. + let hint = { + use lightningbeam_core::layer::AnyLayer; + use export::dialog::DocumentHint; + fn scan(layers: &[AnyLayer], hint: &mut DocumentHint) { + for l in layers { + match l { + AnyLayer::Video(_) => hint.has_video = true, + AnyLayer::Audio(_) => hint.has_audio = true, + AnyLayer::Raster(_) => hint.has_raster = true, + AnyLayer::Vector(_) | AnyLayer::Effect(_) => hint.has_vector = true, + AnyLayer::Group(g) => scan(&g.children, hint), + } + } + } + let doc = self.action_executor.document(); + let mut h = DocumentHint { + has_video: false, + has_audio: false, + has_raster: false, + has_vector: false, + current_time: doc.current_time, + doc_width: doc.width as u32, + doc_height: doc.height as u32, + }; + scan(&doc.root.children, &mut h); + h + }; + + self.export_dialog.open(timeline_endpoint, &project_name, &hint); } MenuAction::Quit => { println!("Menu: Quit"); @@ -4535,16 +4588,9 @@ impl eframe::App for EditorApp { fn update(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) { let _frame_start = std::time::Instant::now(); - // Disable egui's built-in Ctrl+Plus/Minus zoom behavior - // We handle zoom ourselves for the Stage pane - ctx.options_mut(|o| { - o.zoom_with_keyboard = false; - }); - // Force continuous repaint if we have pending waveform updates // This ensures thumbnails update immediately when waveform data arrives if !self.audio_pools_with_new_waveforms.is_empty() { - println!("🔄 [UPDATE] Pending waveform updates for pools: {:?}", self.audio_pools_with_new_waveforms); ctx.request_repaint(); } @@ -5264,6 +5310,17 @@ impl eframe::App for EditorApp { let export_started = if let Some(orchestrator) = &mut self.export_orchestrator { match export_result { + ExportResult::Image(settings, output_path) => { + println!("🖼 [MAIN] Starting image export: {}", output_path.display()); + let doc = self.action_executor.document(); + orchestrator.start_image_export( + settings, + output_path, + doc.width as u32, + doc.height as u32, + ); + false // image export is silent (no progress dialog) + } ExportResult::AudioOnly(settings, output_path) => { println!("🎵 [MAIN] Starting audio-only export: {}", output_path.display()); @@ -5374,6 +5431,7 @@ impl eframe::App for EditorApp { let mut temp_image_cache = lightningbeam_core::renderer::ImageCache::new(); if let Some(renderer) = &mut temp_renderer { + // Drive incremental video export. if let Ok(has_more) = orchestrator.render_next_video_frame( self.action_executor.document_mut(), device, @@ -5383,10 +5441,24 @@ impl eframe::App for EditorApp { &self.video_manager, ) { if has_more { - // More frames to render - request repaint for next frame ctx.request_repaint(); } } + + // Drive single-frame image export (two-frame async: render then readback). + match orchestrator.render_image_frame( + self.action_executor.document_mut(), + device, + queue, + renderer, + &mut temp_image_cache, + &self.video_manager, + self.selection.raster_floating.as_ref(), + ) { + Ok(false) => { ctx.request_repaint(); } // readback pending + Ok(true) => {} // done or cancelled + Err(e) => { eprintln!("Image export failed: {e}"); } + } } } } @@ -5605,11 +5677,7 @@ impl eframe::App for EditorApp { draw_simplify_mode: &mut self.draw_simplify_mode, rdp_tolerance: &mut self.rdp_tolerance, schneider_max_error: &mut self.schneider_max_error, - brush_radius: &mut self.brush_radius, - brush_opacity: &mut self.brush_opacity, - brush_hardness: &mut self.brush_hardness, - brush_spacing: &mut self.brush_spacing, - brush_use_fg: &mut self.brush_use_fg, + raster_settings: &mut self.raster_settings, audio_controller: self.audio_controller.as_ref(), video_manager: &self.video_manager, playback_time: &mut self.playback_time, @@ -5650,6 +5718,7 @@ impl eframe::App for EditorApp { script_saved: &mut self.script_saved, region_selection: &mut self.region_selection, region_select_mode: &mut self.region_select_mode, + lasso_mode: &mut self.lasso_mode, pending_graph_loads: &self.pending_graph_loads, clipboard_consumed: &mut clipboard_consumed, keymap: &self.keymap, @@ -5660,6 +5729,7 @@ impl eframe::App for EditorApp { test_mode: &mut self.test_mode, #[cfg(debug_assertions)] synthetic_input: &mut synthetic_input_storage, + brush_preview_pixels: &self.brush_preview_pixels, }, pane_instances: &mut self.pane_instances, }; 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 65176ad..55bf6c2 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/infopanel.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/infopanel.rs @@ -11,12 +11,15 @@ /// - Document settings (when nothing is focused) use eframe::egui::{self, DragValue, Ui}; -use lightningbeam_core::actions::{SetDocumentPropertiesAction, SetShapePropertiesAction}; +use lightningbeam_core::brush_settings::{bundled_brushes, BrushSettings}; +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 @@ -25,13 +28,36 @@ pub struct InfopanelPane { tool_section_open: bool, /// Whether the shape properties section is expanded shape_section_open: bool, + /// Index of the selected paint brush preset (None = custom / unset) + selected_brush_preset: Option, + /// Whether the paint brush picker is expanded + brush_picker_expanded: bool, + /// Index of the selected eraser brush preset + selected_eraser_preset: Option, + /// Whether the eraser brush picker is expanded + 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 { pub fn new() -> Self { + let presets = bundled_brushes(); + let default_eraser_idx = presets.iter().position(|p| p.name == "Brush"); Self { tool_section_open: true, shape_section_open: true, + selected_brush_preset: None, + brush_picker_expanded: false, + 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, } } } @@ -47,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, } @@ -58,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, } @@ -120,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; + } } } } @@ -151,7 +191,8 @@ impl InfopanelPane { .and_then(|id| shared.action_executor.document().get_layer(&id)) .map_or(false, |l| matches!(l, AnyLayer::Raster(_))); - let is_raster_paint_tool = active_is_raster && matches!(tool, Tool::Draw | Tool::Erase | Tool::Smudge); + let raster_tool_def = active_is_raster.then(|| crate::tools::raster_tool_def(&tool)).flatten(); + let is_raster_paint_tool = raster_tool_def.is_some(); // Only show tool options for tools that have options let is_vector_tool = !active_is_raster && matches!( @@ -159,23 +200,30 @@ impl InfopanelPane { Tool::Select | Tool::BezierEdit | Tool::Draw | Tool::Rectangle | Tool::Ellipse | Tool::Line | Tool::Polygon ); - let has_options = is_vector_tool || is_raster_paint_tool || matches!( + let is_raster_transform = active_is_raster + && matches!(tool, Tool::Transform) + && shared.selection.raster_floating.is_some(); + + let is_raster_select = active_is_raster && matches!(tool, Tool::Select); + let is_raster_shape = active_is_raster && matches!( tool, - Tool::PaintBucket | Tool::RegionSelect + Tool::Rectangle | Tool::Ellipse | Tool::Line | Tool::Polygon + ); + let has_options = is_vector_tool || is_raster_paint_tool || is_raster_transform + || is_raster_select || is_raster_shape || matches!( + tool, + Tool::PaintBucket | Tool::RegionSelect | Tool::MagicWand | Tool::QuickSelect + | Tool::Warp | Tool::Liquify | Tool::Gradient ); if !has_options { return; } - let header_label = if is_raster_paint_tool { - match tool { - Tool::Erase => "Eraser", - Tool::Smudge => "Smudge", - _ => "Brush", - } + let header_label = if is_raster_transform { + "Raster Transform" } else { - "Tool Options" + raster_tool_def.map(|d| d.header_label()).unwrap_or("Tool Options") }; egui::CollapsingHeader::new(header_label) @@ -190,6 +238,23 @@ impl InfopanelPane { ui.add_space(2.0); } + // Raster transform tool hint. + if is_raster_transform { + ui.label("Drag handles to move, scale, or rotate."); + ui.add_space(4.0); + ui.label("Enter — apply Esc — cancel"); + ui.add_space(4.0); + return; + } + + // Raster paint tool: delegate to per-tool impl. + if let Some(def) = raster_tool_def { + def.render_ui(ui, shared.raster_settings); + if def.show_brush_preset_picker() { + self.render_raster_tool_options(ui, shared, def.is_eraser()); + } + } + match tool { Tool::Draw if !is_raster_paint_tool => { // Stroke width @@ -242,17 +307,192 @@ impl InfopanelPane { } Tool::PaintBucket => { - // Gap tolerance + if active_is_raster { + use crate::tools::FillThresholdMode; + ui.horizontal(|ui| { + ui.label("Threshold:"); + ui.add( + egui::Slider::new( + &mut shared.raster_settings.fill_threshold, + 0.0_f32..=255.0, + ) + .step_by(1.0), + ); + }); + ui.horizontal(|ui| { + ui.label("Softness:"); + ui.add( + egui::Slider::new( + &mut shared.raster_settings.fill_softness, + 0.0_f32..=100.0, + ) + .custom_formatter(|v, _| format!("{:.0}%", v)), + ); + }); + ui.horizontal(|ui| { + ui.label("Mode:"); + ui.selectable_value( + &mut shared.raster_settings.fill_threshold_mode, + FillThresholdMode::Absolute, + "Absolute", + ); + ui.selectable_value( + &mut shared.raster_settings.fill_threshold_mode, + FillThresholdMode::Relative, + "Relative", + ); + }); + } else { + // Vector: gap tolerance + ui.horizontal(|ui| { + ui.label("Gap Tolerance:"); + ui.add( + DragValue::new(shared.paint_bucket_gap_tolerance) + .speed(0.1) + .range(0.0..=50.0), + ); + }); + } + } + + Tool::Select if is_raster_select => { + use crate::tools::SelectionShape; ui.horizontal(|ui| { - ui.label("Gap Tolerance:"); - ui.add( - DragValue::new(shared.paint_bucket_gap_tolerance) - .speed(0.1) - .range(0.0..=50.0), + ui.label("Shape:"); + ui.selectable_value( + &mut shared.raster_settings.select_shape, + SelectionShape::Rect, + "Rectangle", + ); + ui.selectable_value( + &mut shared.raster_settings.select_shape, + SelectionShape::Ellipse, + "Ellipse", ); }); } + Tool::MagicWand => { + use crate::tools::FillThresholdMode; + ui.horizontal(|ui| { + ui.label("Threshold:"); + ui.add( + egui::Slider::new( + &mut shared.raster_settings.wand_threshold, + 0.0_f32..=255.0, + ) + .step_by(1.0), + ); + }); + ui.horizontal(|ui| { + ui.label("Mode:"); + ui.selectable_value( + &mut shared.raster_settings.wand_mode, + FillThresholdMode::Absolute, + "Absolute", + ); + ui.selectable_value( + &mut shared.raster_settings.wand_mode, + FillThresholdMode::Relative, + "Relative", + ); + }); + ui.checkbox(&mut shared.raster_settings.wand_contiguous, "Contiguous"); + } + + Tool::QuickSelect => { + use crate::tools::FillThresholdMode; + ui.horizontal(|ui| { + ui.label("Radius:"); + ui.add( + egui::Slider::new( + &mut shared.raster_settings.quick_select_radius, + 1.0_f32..=200.0, + ) + .step_by(1.0), + ); + }); + ui.horizontal(|ui| { + ui.label("Threshold:"); + ui.add( + egui::Slider::new( + &mut shared.raster_settings.wand_threshold, + 0.0_f32..=255.0, + ) + .step_by(1.0), + ); + }); + ui.horizontal(|ui| { + ui.label("Mode:"); + ui.selectable_value( + &mut shared.raster_settings.wand_mode, + FillThresholdMode::Absolute, + "Absolute", + ); + ui.selectable_value( + &mut shared.raster_settings.wand_mode, + FillThresholdMode::Relative, + "Relative", + ); + }); + } + + 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| { @@ -300,48 +540,20 @@ impl InfopanelPane { }); } - // Raster paint tools - Tool::Draw | Tool::Erase | Tool::Smudge if is_raster_paint_tool => { - // Color source toggle (Draw tool only) - if matches!(tool, Tool::Draw) { - ui.horizontal(|ui| { - ui.label("Color:"); - ui.selectable_value(shared.brush_use_fg, true, "FG"); - ui.selectable_value(shared.brush_use_fg, false, "BG"); - }); - } + Tool::Gradient if active_is_raster => { ui.horizontal(|ui| { - ui.label("Size:"); - ui.add( - egui::Slider::new(shared.brush_radius, 1.0_f32..=200.0) - .logarithmic(true) - .suffix(" px"), - ); - }); - if !matches!(tool, Tool::Smudge) { - ui.horizontal(|ui| { - ui.label("Opacity:"); - ui.add( - egui::Slider::new(shared.brush_opacity, 0.0_f32..=1.0) - .custom_formatter(|v, _| format!("{:.0}%", v * 100.0)), - ); - }); - } - ui.horizontal(|ui| { - ui.label("Hardness:"); - ui.add( - egui::Slider::new(shared.brush_hardness, 0.0_f32..=1.0) - .custom_formatter(|v, _| format!("{:.0}%", v * 100.0)), - ); - }); - ui.horizontal(|ui| { - ui.label("Spacing:"); - ui.add( - egui::Slider::new(shared.brush_spacing, 0.01_f32..=1.0) - .logarithmic(true) - .custom_formatter(|v, _| format!("{:.0}%", v * 100.0)), - ); + 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, + ); } _ => {} @@ -351,6 +563,192 @@ impl InfopanelPane { }); } + /// Render all options for a raster paint tool (brush picker + sliders). + /// `is_eraser` drives which shared state is read/written. + fn render_raster_tool_options( + &mut self, + ui: &mut Ui, + shared: &mut SharedPaneState, + is_eraser: bool, + ) { + self.render_brush_preset_grid(ui, shared, is_eraser); + ui.add_space(2.0); + + let rs = &mut shared.raster_settings; + + if !is_eraser { + ui.horizontal(|ui| { + ui.label("Color:"); + ui.selectable_value(&mut rs.brush_use_fg, true, "FG"); + ui.selectable_value(&mut rs.brush_use_fg, false, "BG"); + }); + } + + macro_rules! field { + ($eraser:ident, $brush:ident) => { + if is_eraser { &mut rs.$eraser } else { &mut rs.$brush } + } + } + + ui.horizontal(|ui| { + ui.label("Size:"); + ui.add(egui::Slider::new(field!(eraser_radius, brush_radius), 1.0_f32..=200.0).logarithmic(true).suffix(" px")); + }); + ui.horizontal(|ui| { + ui.label("Opacity:"); + ui.add(egui::Slider::new(field!(eraser_opacity, brush_opacity), 0.0_f32..=1.0) + .custom_formatter(|v, _| format!("{:.0}%", v * 100.0))); + }); + ui.horizontal(|ui| { + ui.label("Hardness:"); + ui.add(egui::Slider::new(field!(eraser_hardness, brush_hardness), 0.0_f32..=1.0) + .custom_formatter(|v, _| format!("{:.0}%", v * 100.0))); + }); + ui.horizontal(|ui| { + ui.label("Spacing:"); + ui.add(egui::Slider::new(field!(eraser_spacing, brush_spacing), 0.01_f32..=1.0) + .logarithmic(true) + .custom_formatter(|v, _| format!("{:.0}%", v * 100.0))); + }); + } + + /// Render the brush preset thumbnail grid (collapsible). + /// `is_eraser` drives which picker state and which shared settings are updated. + fn render_brush_preset_grid(&mut self, ui: &mut Ui, shared: &mut SharedPaneState, is_eraser: bool) { + let presets = bundled_brushes(); + if presets.is_empty() { return; } + + // Build preview TextureHandles from GPU-rendered pixel data when available. + if self.brush_preview_textures.len() != presets.len() { + if let Ok(previews) = shared.brush_preview_pixels.try_lock() { + if previews.len() == presets.len() { + self.brush_preview_textures.clear(); + for (idx, (w, h, pixels)) in previews.iter().enumerate() { + let image = egui::ColorImage::from_rgba_premultiplied( + [*w as usize, *h as usize], + pixels, + ); + let handle = ui.ctx().load_texture( + format!("brush_preview_{}", presets[idx].name), + image, + egui::TextureOptions::LINEAR, + ); + self.brush_preview_textures.push(handle); + } + } + } + } + + // Read picker state into locals to avoid multiple &mut self borrows. + let mut expanded = if is_eraser { self.eraser_picker_expanded } else { self.brush_picker_expanded }; + let mut selected = if is_eraser { self.selected_eraser_preset } else { self.selected_brush_preset }; + + let gap = 3.0; + let cols = 2usize; + let avail_w = ui.available_width(); + let cell_w = ((avail_w - gap * (cols as f32 - 1.0)) / cols as f32).max(50.0); + let cell_h = 80.0; + + if !expanded { + // Collapsed: show just the currently selected preset as a single wide cell. + let show_idx = selected.unwrap_or(0); + if let Some(preset) = presets.get(show_idx) { + let full_w = avail_w.max(50.0); + let (rect, resp) = ui.allocate_exact_size(egui::vec2(full_w, cell_h), egui::Sense::click()); + let painter = ui.painter(); + let bg = if resp.hovered() { + egui::Color32::from_rgb(50, 56, 70) + } else { + egui::Color32::from_rgb(45, 65, 95) + }; + painter.rect_filled(rect, 4.0, bg); + painter.rect_stroke(rect, 4.0, egui::Stroke::new(1.5, egui::Color32::from_rgb(80, 140, 220)), egui::StrokeKind::Middle); + let preview_rect = egui::Rect::from_min_size( + rect.min + egui::vec2(4.0, 4.0), + egui::vec2(rect.width() - 8.0, cell_h - 22.0), + ); + if let Some(tex) = self.brush_preview_textures.get(show_idx) { + painter.image(tex.id(), preview_rect, + egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)), + egui::Color32::WHITE); + } + painter.text(egui::pos2(rect.center().x, rect.max.y - 9.0), + egui::Align2::CENTER_CENTER, preset.name, + egui::FontId::proportional(9.5), egui::Color32::from_rgb(140, 190, 255)); + if resp.clicked() { expanded = true; } + } + } else { + // Expanded: full grid; clicking a preset selects it and collapses. + for (row_idx, chunk) in presets.chunks(cols).enumerate() { + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = gap; + for (col_idx, preset) in chunk.iter().enumerate() { + let idx = row_idx * cols + col_idx; + let is_sel = selected == Some(idx); + let (rect, resp) = ui.allocate_exact_size(egui::vec2(cell_w, cell_h), egui::Sense::click()); + let painter = ui.painter(); + let bg = if is_sel { + egui::Color32::from_rgb(45, 65, 95) + } else if resp.hovered() { + egui::Color32::from_rgb(45, 50, 62) + } else { + egui::Color32::from_rgb(32, 36, 44) + }; + painter.rect_filled(rect, 4.0, bg); + if is_sel { + painter.rect_stroke(rect, 4.0, egui::Stroke::new(1.5, egui::Color32::from_rgb(80, 140, 220)), egui::StrokeKind::Middle); + } + let preview_rect = egui::Rect::from_min_size( + rect.min + egui::vec2(4.0, 4.0), + egui::vec2(cell_w - 8.0, cell_h - 22.0), + ); + if let Some(tex) = self.brush_preview_textures.get(idx) { + painter.image(tex.id(), preview_rect, + egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)), + egui::Color32::WHITE); + } + painter.text(egui::pos2(rect.center().x, rect.max.y - 9.0), + egui::Align2::CENTER_CENTER, preset.name, + egui::FontId::proportional(9.5), + if is_sel { egui::Color32::from_rgb(140, 190, 255) } else { egui::Color32::from_gray(160) }); + if resp.clicked() { + selected = Some(idx); + expanded = false; + let s = &preset.settings; + let rs = &mut shared.raster_settings; + if is_eraser { + rs.eraser_opacity = s.opaque.clamp(0.0, 1.0); + rs.eraser_hardness = s.hardness.clamp(0.0, 1.0); + rs.eraser_spacing = s.dabs_per_radius; + rs.active_eraser_settings = s.clone(); + } else { + rs.brush_opacity = s.opaque.clamp(0.0, 1.0); + rs.brush_hardness = s.hardness.clamp(0.0, 1.0); + rs.brush_spacing = s.dabs_per_radius; + rs.active_brush_settings = s.clone(); + // If the user was on a preset-backed tool (Pencil/Pen/Airbrush) + // and manually picked a different brush, revert to the generic tool. + if matches!(*shared.selected_tool, Tool::Pencil | Tool::Pen | Tool::Airbrush) { + *shared.selected_tool = Tool::Draw; + } + } + } + } + }); + ui.add_space(gap); + } + } + + // Write back picker state. + if is_eraser { + self.eraser_picker_expanded = expanded; + self.selected_eraser_preset = selected; + } else { + self.brush_picker_expanded = expanded; + self.selected_brush_preset = selected; + } + } + // Transform section: deferred to Phase 2 (DCEL elements don't have instance transforms) /// Render shape properties section (fill/stroke) @@ -377,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| { @@ -864,6 +1306,40 @@ impl InfopanelPane { } } +/// Draw a brush dab preview into `rect` approximating the brush falloff shape. +/// +/// Renders N concentric filled circles from outermost to innermost. Because each +/// inner circle overwrites the pixels of all outer circles beneath it, the visible +/// alpha at distance `d` from the centre equals the alpha of the innermost circle +/// whose radius ≥ `d`. This step-approximates the actual brush falloff formula: +/// `opa = ((1 − r) / (1 − hardness))²` for `r > hardness`, 1 inside the hard core. +fn paint_brush_dab(painter: &egui::Painter, rect: egui::Rect, s: &BrushSettings) { + let center = rect.center(); + let max_r = (rect.width().min(rect.height()) / 2.0 - 2.0).max(1.0); + let h = s.hardness; + let a = s.opaque; + + const N: usize = 12; + for i in 0..N { + // t: normalized radial position of this ring, 1.0 = outermost edge + let t = 1.0 - i as f32 / N as f32; + let r = max_r * t; + + let opa_weight = if h >= 1.0 || t <= h { + 1.0f32 + } else { + let x = (1.0 - t) / (1.0 - h).max(1e-4); + (x * x).min(1.0) + }; + + let alpha = (opa_weight * a * 220.0).min(220.0) as u8; + painter.circle_filled( + center, r, + egui::Color32::from_rgba_unmultiplied(200, 200, 220, alpha), + ); + } +} + /// Convert MIDI note number to note name (e.g. 60 -> "C4") fn midi_note_name(note: u8) -> String { const NAMES: [&str; 12] = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"]; diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/mod.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/mod.rs index f226e72..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; @@ -187,13 +188,8 @@ pub struct SharedPaneState<'a> { pub draw_simplify_mode: &'a mut lightningbeam_core::tool::SimplifyMode, pub rdp_tolerance: &'a mut f64, pub schneider_max_error: &'a mut f64, - /// Raster brush settings - pub brush_radius: &'a mut f32, - pub brush_opacity: &'a mut f32, - pub brush_hardness: &'a mut f32, - pub brush_spacing: &'a mut f32, - /// Whether the brush paints with the foreground (fill) color (true) or background (stroke) color (false) - pub brush_use_fg: &'a mut bool, + /// All per-tool raster paint settings (replaces 20+ individual fields). + pub raster_settings: &'a mut crate::tools::RasterToolSettings, /// Audio engine controller for playback control (wrapped in Arc> for thread safety) pub audio_controller: Option<&'a std::sync::Arc>>, /// Video manager for video decoding and frame caching @@ -269,6 +265,8 @@ pub struct SharedPaneState<'a> { pub region_selection: &'a mut Option, /// Region select mode (Rectangle or Lasso) pub region_select_mode: &'a mut lightningbeam_core::tool::RegionSelectMode, + /// Lasso select sub-mode (Freehand / Polygonal / Magnetic) + pub lasso_mode: &'a mut lightningbeam_core::tool::LassoMode, /// Counter for in-flight graph preset loads — increment when sending a /// GraphLoadPreset command so the repaint loop stays alive until the /// audio thread sends GraphPresetLoaded back @@ -291,6 +289,10 @@ pub struct SharedPaneState<'a> { /// Synthetic input from test mode replay (debug builds only) #[cfg(debug_assertions)] pub synthetic_input: &'a mut Option, + /// GPU-rendered brush preview thumbnails. Populated by `VelloCallback::prepare()` + /// on the first frame; panes (e.g. infopanel) convert the pixel data to egui + /// TextureHandles. Each entry is `(width, height, sRGB-premultiplied RGBA bytes)`. + pub brush_preview_pixels: &'a std::sync::Arc)>>>, } /// Trait for pane rendering diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/shaders/alpha_composite.wgsl b/lightningbeam-ui/lightningbeam-editor/src/panes/shaders/alpha_composite.wgsl new file mode 100644 index 0000000..5012b86 --- /dev/null +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/shaders/alpha_composite.wgsl @@ -0,0 +1,27 @@ +// Alpha composite compute shader. +// +// Composites the accumulated-dab scratch buffer C on top of the source buffer A, +// writing the result into the output buffer B: +// +// B[px] = C[px] + A[px] * (1 − C[px].a) (Porter-Duff src-over, C over A) +// +// All textures are Rgba8Unorm, linear premultiplied RGBA. +// Dispatch: ceil(w/8) × ceil(h/8) × 1. + +@group(0) @binding(0) var tex_a: texture_2d; // source (A) +@group(0) @binding(1) var tex_c: texture_2d; // accumulated dabs (C) +@group(0) @binding(2) var tex_b: texture_storage_2d; // output (B) + +@compute @workgroup_size(8, 8) +fn main(@builtin(global_invocation_id) gid: vec3) { + let dims = textureDimensions(tex_a); + if gid.x >= dims.x || gid.y >= dims.y { return; } + + let coord = vec2(i32(gid.x), i32(gid.y)); + let a = textureLoad(tex_a, coord, 0); + let c = textureLoad(tex_c, coord, 0); + + // Porter-Duff src-over: C is the foreground (dabs), A is the background. + // out = c + a * (1 - c.a) + textureStore(tex_b, coord, c + a * (1.0 - c.a)); +} diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/shaders/brush_dab.wgsl b/lightningbeam-ui/lightningbeam-editor/src/panes/shaders/brush_dab.wgsl index 0d57dc3..134b3dd 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/shaders/brush_dab.wgsl +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/shaders/brush_dab.wgsl @@ -20,7 +20,7 @@ struct GpuDab { x: f32, y: f32, radius: f32, hardness: f32, // bytes 0–15 opacity: f32, color_r: f32, color_g: f32, color_b: f32, // bytes 16–31 color_a: f32, ndx: f32, ndy: f32, smudge_dist: f32, // bytes 32–47 - blend_mode: u32, _pad0: u32, _pad1: u32, _pad2: u32, // bytes 48–63 + blend_mode: u32, elliptical_dab_ratio: f32, elliptical_dab_angle: f32, lock_alpha: f32, // bytes 48–63 } struct Params { @@ -76,7 +76,20 @@ fn bilinear_sample(px: f32, py: f32) -> vec4 { fn apply_dab(current: vec4, dab: GpuDab, px: i32, py: i32) -> vec4 { let dx = f32(px) + 0.5 - dab.x; let dy = f32(py) + 0.5 - dab.y; - let rr = (dx * dx + dy * dy) / (dab.radius * dab.radius); + + // Normalised squared distance — supports circular and elliptical dabs. + var rr: f32; + if dab.elliptical_dab_ratio > 1.001 { + // Rotate into the dab's local frame. + // Major axis is along dab.elliptical_dab_angle; minor axis is compressed by ratio. + let c = cos(dab.elliptical_dab_angle); + let s = sin(dab.elliptical_dab_angle); + let dx_r = dx * c + dy * s; // along major axis + let dy_r = (-dx * s + dy * c) * dab.elliptical_dab_ratio; // minor axis compressed + rr = (dx_r * dx_r + dy_r * dy_r) / (dab.radius * dab.radius); + } else { + rr = (dx * dx + dy * dy) / (dab.radius * dab.radius); + } if rr > 1.0 { return current; } // Quadratic falloff: flat inner core, smooth quadratic outer zone. @@ -94,15 +107,17 @@ fn apply_dab(current: vec4, dab: GpuDab, px: i32, py: i32) -> vec4 { } if dab.blend_mode == 0u { - // Normal: "over" operator + // Normal: "over" operator on premultiplied RGBA. + // If lock_alpha > 0.5, preserve the destination alpha unchanged. let dab_a = opa_weight * dab.opacity * dab.color_a; if dab_a <= 0.0 { return current; } let ba = 1.0 - dab_a; + let out_a = select(dab_a + ba * current.a, current.a, dab.lock_alpha > 0.5); return vec4( dab_a * dab.color_r + ba * current.r, dab_a * dab.color_g + ba * current.g, dab_a * dab.color_b + ba * current.b, - dab_a + ba * current.a, + out_a, ); } else if dab.blend_mode == 1u { // Erase: multiplicative alpha reduction @@ -111,7 +126,7 @@ fn apply_dab(current: vec4, dab: GpuDab, px: i32, py: i32) -> vec4 { let new_a = current.a * (1.0 - dab_a); let scale = select(0.0, new_a / current.a, current.a > 1e-6); return vec4(current.r * scale, current.g * scale, current.b * scale, new_a); - } else { + } else if dab.blend_mode == 2u { // Smudge: directional warp — sample from position behind the stroke direction let alpha = opa_weight * dab.opacity; if alpha <= 0.0 { return current; } @@ -125,6 +140,192 @@ fn apply_dab(current: vec4, dab: GpuDab, px: i32, py: i32) -> vec4 { alpha * src.b + da * current.b, alpha * src.a + da * current.a, ); + } else if dab.blend_mode == 3u { + // Clone stamp: sample from (this_pixel + offset) in the source canvas. + // color_r/color_g store the world-space offset (source_world - drag_start_world) + // computed once when the stroke begins. Each pixel samples its own source texel. + let alpha = opa_weight * dab.opacity; + if alpha <= 0.0 { return current; } + let src_x = f32(px) + 0.5 + dab.color_r; + let src_y = f32(py) + 0.5 + dab.color_g; + let src = bilinear_sample(src_x, src_y); + let ba = 1.0 - alpha; + return vec4( + alpha * src.r + ba * current.r, + alpha * src.g + ba * current.g, + alpha * src.b + ba * current.b, + alpha * src.a + ba * current.a, + ); + } else if dab.blend_mode == 5u { + // Pattern stamp: procedural tiling pattern using brush color. + // ndx = pattern_type (0=Checker, 1=Dots, 2=H-Lines, 3=V-Lines, 4=Diagonal, 5=Crosshatch) + // ndy = pattern_scale (tile size in pixels, >= 1.0) + let scale = max(dab.ndy, 1.0); + let pt = u32(dab.ndx); + + // Fractional position within the tile [0.0, 1.0) + let tx = fract(f32(px) / scale); + let ty = fract(f32(py) / scale); + + var on: bool; + if pt == 0u { // Checkerboard + let cx = u32(floor(f32(px) / scale)); + let cy = u32(floor(f32(py) / scale)); + on = (cx + cy) % 2u == 0u; + } else if pt == 1u { // Polka dots (r ≈ 0.35 of cell radius) + let ddx = tx - 0.5; let ddy = ty - 0.5; + on = ddx * ddx + ddy * ddy < 0.1225; + } else if pt == 2u { // Horizontal lines (50% duty) + on = ty < 0.5; + } else if pt == 3u { // Vertical lines (50% duty) + on = tx < 0.5; + } else if pt == 4u { // Diagonal \ (top-left → bottom-right) + on = fract((f32(px) + f32(py)) / scale) < 0.5; + } else if pt == 5u { // Diagonal / (top-right → bottom-left) + on = fract((f32(px) - f32(py)) / scale) < 0.5; + } else { // Crosshatch (type 6+) + on = tx < 0.4 || ty < 0.4; + } + + if !on { return current; } + + // Paint with brush color — same compositing as Normal blend + let dab_a = opa_weight * dab.opacity * dab.color_a; + if dab_a <= 0.0 { return current; } + let ba = 1.0 - dab_a; + return vec4( + dab_a * dab.color_r + ba * current.r, + dab_a * dab.color_g + ba * current.g, + dab_a * dab.color_b + ba * current.b, + dab_a + ba * current.a, + ); + } else if dab.blend_mode == 4u { + // Healing brush: per-pixel color-corrected clone stamp. + // color_r/color_g = source offset (ox, oy), same as clone stamp. + // For each pixel: result = src_pixel + (local_dest_mean - local_src_mean) + // Means are computed from 4 cardinal neighbors at ±half-radius — per-pixel, no banding. + let alpha = opa_weight * dab.opacity; + if alpha <= 0.0 { return current; } + + let cw = i32(params.canvas_w); + let ch = i32(params.canvas_h); + let ox = dab.color_r; + let oy = dab.color_g; + let hr = max(dab.radius * 0.5, 1.0); + let ihr = i32(hr); + + // Per-pixel DESTINATION mean: 4 cardinal neighbors from canvas_src (pre-batch state) + let d_n = textureLoad(canvas_src, vec2(px, clamp(py - ihr, 0, ch - 1)), 0); + let d_s = textureLoad(canvas_src, vec2(px, clamp(py + ihr, 0, ch - 1)), 0); + let d_w = textureLoad(canvas_src, vec2(clamp(px - ihr, 0, cw - 1), py ), 0); + let d_e = textureLoad(canvas_src, vec2(clamp(px + ihr, 0, cw - 1), py ), 0); + let d_mean = (d_n + d_s + d_w + d_e) * 0.25; + + // Per-pixel SOURCE mean: 4 cardinal neighbors at offset position (bilinear for sub-pixel offsets) + let spx = f32(px) + 0.5 + ox; + let spy = f32(py) + 0.5 + oy; + let s_mean = (bilinear_sample(spx, spy - hr) + + bilinear_sample(spx, spy + hr) + + bilinear_sample(spx - hr, spy ) + + bilinear_sample(spx + hr, spy )) * 0.25; + + // Source pixel + color correction + let s_pixel = bilinear_sample(spx, spy); + let corrected = clamp(s_pixel + (d_mean - s_mean), vec4(0.0), vec4(1.0)); + + let ba = 1.0 - alpha; + return vec4( + alpha * corrected.r + ba * current.r, + alpha * corrected.g + ba * current.g, + alpha * corrected.b + ba * current.b, + alpha * corrected.a + ba * current.a, + ); + } else if dab.blend_mode == 6u { + // Dodge / Burn: power-curve exposure adjustment. + // color_r: 0.0 = dodge, 1.0 = burn + // Uses pow(channel, gamma) which is asymmetric across channels: + // burn (gamma > 1): low channels compressed toward 0 faster than high ones → saturation increases + // dodge (gamma < 1): low channels lifted faster than high ones → saturation decreases + // This matches the behaviour of GIMP / Photoshop dodge-burn tools. + let s = opa_weight * dab.opacity; + if s <= 0.0 { return current; } + + let rgb = max(current.rgb, vec3(0.0)); + var adjusted: vec3; + if dab.color_r < 0.5 { + // Dodge: gamma < 1 → brightens + adjusted = pow(rgb, vec3(max(1.0 - s, 0.001))); + } else { + // Burn: gamma > 1 → darkens and increases saturation + adjusted = pow(rgb, vec3(1.0 + s)); + } + return vec4(clamp(adjusted, vec3(0.0), vec3(1.0)), current.a); + } else if dab.blend_mode == 7u { + // Sponge: saturate or desaturate existing pixels. + // color_r: 0.0 = saturate, 1.0 = desaturate + // Computes luminance, then moves RGB toward (desaturate) or away from (saturate) it. + let s = opa_weight * dab.opacity; + if s <= 0.0 { return current; } + + let luma = dot(current.rgb, vec3(0.2126, 0.7152, 0.0722)); + let luma_vec = vec3(luma); + var adjusted: vec3; + if dab.color_r < 0.5 { + // Saturate: push RGB away from luma (increase chroma) + adjusted = clamp(current.rgb + s * (current.rgb - luma_vec), vec3(0.0), vec3(1.0)); + } else { + // Desaturate: blend RGB toward luma + adjusted = mix(current.rgb, luma_vec, s); + } + return vec4(adjusted, current.a); + } else if dab.blend_mode == 8u { + // Blur / Sharpen: 5×5 separable Gaussian kernel. + // color_r: 0.0 = blur, 1.0 = sharpen + // ndx: kernel radius in canvas pixels (> 0) + // + // Samples are placed on a grid at ±step and ±2*step per axis, where step = kr/2. + // Weights are exp(-x²/2σ²) with σ = step, factored as a separable product. + // This gives a true Gaussian falloff rather than a flat ring, so edges blend + // into a smooth gradient rather than a flat averaged zone. + let s = opa_weight * dab.opacity; + if s <= 0.0 { return current; } + + let kr = max(dab.ndx, 1.0); + let cx2 = f32(px) + 0.5; + let cy2 = f32(py) + 0.5; + let step = kr * 0.5; + + // 1-D Gaussian weights at distances 0, ±step, ±2*step (σ = step): + // exp(0) = 1.0, exp(-0.5) ≈ 0.6065, exp(-2.0) ≈ 0.1353 + var gauss = array(0.1353, 0.6065, 1.0, 0.6065, 0.1353); + + var blur_sum = vec4(0.0); + var blur_w = 0.0; + for (var iy = 0; iy < 5; iy++) { + for (var ix = 0; ix < 5; ix++) { + let w = gauss[ix] * gauss[iy]; + let spx = cx2 + (f32(ix) - 2.0) * step; + let spy = cy2 + (f32(iy) - 2.0) * step; + blur_sum += bilinear_sample(spx, spy) * w; + blur_w += w; + } + } + let blurred = blur_sum / blur_w; + + let c = textureLoad(canvas_src, vec2(px, py), 0); + var result: vec4; + if dab.color_r < 0.5 { + // Blur: blend current toward the Gaussian-weighted local average. + result = mix(current, blurred, s); + } else { + // Sharpen: unsharp mask — push pixel away from the local average. + // sharpened = 2*src - blurred → highlights diverge, shadows diverge. + let sharpened = clamp(c * 2.0 - blurred, vec4(0.0), vec4(1.0)); + result = mix(current, sharpened, s); + } + return result; + } else { + return current; } } diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/shaders/canvas_blit.wgsl b/lightningbeam-ui/lightningbeam-editor/src/panes/shaders/canvas_blit.wgsl index cf1084b..e193544 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/shaders/canvas_blit.wgsl +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/shaders/canvas_blit.wgsl @@ -1,30 +1,31 @@ // Canvas blit shader. // // Renders a GPU raster canvas (at document resolution) into an Rgba16Float HDR -// buffer (at viewport resolution), applying the camera transform (pan + zoom) -// to map document-space pixels to viewport-space pixels. +// buffer (at viewport resolution), applying a general affine transform that maps +// viewport UV [0,1]² directly to canvas UV [0,1]². +// +// The combined inverse transform (viewport UV → canvas UV) is pre-computed on the +// CPU and uploaded as a column-major 3×3 matrix packed into three vec4 uniforms. // // The canvas stores premultiplied linear RGBA. We output it as-is so the HDR // compositor sees the same premultiplied-linear format it always works with, // bypassing the sRGB intermediate used for Vello layers. // -// Any viewport pixel whose corresponding document coordinate falls outside -// [0, canvas_w) × [0, canvas_h) outputs transparent black. +// Any viewport pixel whose corresponding canvas coordinate falls outside [0,1)² +// outputs transparent black. -struct CameraParams { - pan_x: f32, - pan_y: f32, - zoom: f32, - canvas_w: f32, - canvas_h: f32, - viewport_w: f32, - viewport_h: f32, - _pad: f32, +struct BlitTransform { + /// Column 0 of the viewport_uv → canvas_uv affine matrix (+ padding). + col0: vec4, + /// Column 1 (+ padding). + col1: vec4, + /// Column 2: translation column — col2.xy = translation, col2.z = 1 (+ padding). + col2: vec4, } @group(0) @binding(0) var canvas_tex: texture_2d; @group(0) @binding(1) var canvas_sampler: sampler; -@group(0) @binding(2) var camera: CameraParams; +@group(0) @binding(2) var transform: BlitTransform; /// Selection mask: R8Unorm, 255 = inside selection (keep), 0 = outside (discard). /// A 1×1 all-white texture is bound when no selection is active. @group(0) @binding(3) var mask_tex: texture_2d; @@ -48,14 +49,10 @@ fn vs_main(@builtin(vertex_index) vertex_index: u32) -> VertexOutput { @fragment fn fs_main(in: VertexOutput) -> @location(0) vec4 { - // Map viewport UV [0,1] → viewport pixel - let vp = in.uv * vec2(camera.viewport_w, camera.viewport_h); - - // Map viewport pixel → document pixel (inverse camera transform) - let doc = (vp - vec2(camera.pan_x, camera.pan_y)) / camera.zoom; - - // Map document pixel → canvas UV [0,1] - let canvas_uv = doc / vec2(camera.canvas_w, camera.canvas_h); + // Apply the combined inverse transform: viewport UV → canvas UV. + let m = mat3x3(transform.col0.xyz, transform.col1.xyz, transform.col2.xyz); + let canvas_uv_h = m * vec3(in.uv.x, in.uv.y, 1.0); + let canvas_uv = canvas_uv_h.xy; // Out-of-bounds → transparent if canvas_uv.x < 0.0 || canvas_uv.x > 1.0 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/raster_transform.wgsl b/lightningbeam-ui/lightningbeam-editor/src/panes/shaders/raster_transform.wgsl new file mode 100644 index 0000000..154376e --- /dev/null +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/shaders/raster_transform.wgsl @@ -0,0 +1,78 @@ +// GPU affine-transform resample shader. +// +// For each output pixel, computes the corresponding source pixel via an inverse +// 2D affine transform (no perspective) and bilinear-samples from the source texture. +// +// Used by the raster selection transform tool: the source is the immutable "anchor" +// canvas (original float pixels), the destination is the current float canvas. +// +// CPU precomputes the inverse affine matrix components and the output bounding box. +// The shader just does the per-pixel mapping and bilinear interpolation. +// +// Dispatch: ceil(dst_w / 8) × ceil(dst_h / 8) × 1 + +struct Params { + // Inverse affine: src_pixel = A * out_pixel + b + // For output pixel center (ox, oy), source pixel is: + // sx = a00*ox + a01*oy + b0 + // sy = a10*ox + a11*oy + b1 + a00: f32, a01: f32, + a10: f32, a11: f32, + b0: f32, b1: f32, + src_w: u32, src_h: u32, + dst_w: u32, dst_h: u32, + _pad0: u32, _pad1: u32, // pad to 48 bytes (3 × 16) +} + +@group(0) @binding(0) var params: Params; +@group(0) @binding(1) var src: texture_2d; +@group(0) @binding(2) 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); +} + +@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; } + + let ox = f32(gid.x); + let oy = f32(gid.y); + + // Map output pixel index → source pixel position via inverse affine. + // We use pixel centers (ox + 0.5, oy + 0.5) in the forward transform, but the + // b0/b1 precomputation on the CPU already accounts for the +0.5 offset, so ox/oy + // are used directly here (the CPU bakes +0.5 into b). + let sx = params.a00 * ox + params.a01 * oy + params.b0; + let sy = params.a10 * ox + params.a11 * oy + params.b1; + + var color: vec4; + if sx < 0.0 || sy < 0.0 || sx >= f32(params.src_w) || sy >= f32(params.src_h) { + // Outside source bounds → transparent + color = vec4(0.0); + } else { + // Bilinear sample at pixel center + 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/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 c8a293d..e862fda 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs @@ -402,6 +402,16 @@ struct VelloRenderContext { webcam_frame: Option, /// GPU brush dabs to dispatch in this frame's prepare() call. pending_raster_dabs: Option, + /// GPU affine-resample dispatch for the raster transform tool. + 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. @@ -412,6 +422,35 @@ struct VelloRenderContext { /// True while the current stroke targets the float buffer (B) rather than /// the layer canvas (A). Used in prepare() to route the GPU canvas blit. painting_float: bool, + /// Shared pixel buffer for brush preview thumbnails. + /// `prepare()` renders all presets here on the first frame; + /// the infopanel converts the pixel data to egui TextureHandles. + /// Each entry is `(width, height, sRGB-premultiplied RGBA bytes)`. + brush_preview_pixels: std::sync::Arc)>>>, + + // ── New unified raster tool rendering ───────────────────────────────────── + + /// When `Some`, the compositor blits B (the tool output canvas) at the layer + /// or float slot described here, instead of the Vello scene / idle raster texture. + active_tool_render: Option, + /// Canvas UUIDs to remove from `GpuBrushEngine` at the top of the next `prepare()`. + /// Replaced the single `pending_canvas_removal` field. + pending_canvas_removals: Vec, + /// First-frame canvas initialization for the active raster tool workspace. + /// `prepare()` creates A/B/C canvases and uploads source pixels on the same frame + /// the tool starts (mousedown). Cleared after one consume. + pending_workspace_init: Option, + /// GPU work extracted from the active `RasterTool` this frame via + /// `take_pending_gpu_work()`. Executed in `prepare()` before compositing. + pending_tool_gpu_work: Option>, + /// Raster layer keyframe UUIDs whose `raster_layer_cache` entry should be + /// removed at the top of `prepare()` so the fresh `raw_pixels` are re-uploaded. + /// Populated by the pre-callback dirty-keyframe scan (for undo/redo) and by + /// stroke/fill/warp commit handlers. + pending_layer_cache_removals: Vec, + /// When `Some`, readback this B-canvas into `RASTER_READBACK_RESULTS` after + /// dispatching GPU tool work. Set on mouseup by the unified raster tool commit path. + pending_tool_readback_b: Option, } /// Callback for Vello rendering within egui @@ -485,6 +524,10 @@ impl egui_wgpu::CallbackTrait for VelloCallback { camera_transform }; + // Timing instrumentation: track where frame budget is spent. + // Prints to stderr when any section exceeds 2 ms, or total > 8 ms. + let _t_prepare_start = std::time::Instant::now(); + // Choose rendering path based on HDR compositing flag let mut scene = if USE_HDR_COMPOSITING { // HDR Compositing Pipeline: render each layer separately for proper opacity @@ -502,6 +545,75 @@ impl egui_wgpu::CallbackTrait for VelloCallback { gpu_brush.remove_canvas(&kf_id); } } + // Process the bulk-removal list (A/B/C canvases from finished tool ops). + // The Vec was moved into this callback by StagePane via std::mem::take, + // so it is already gone from StagePane; no drain needed. + if !self.ctx.pending_canvas_removals.is_empty() { + if let Ok(mut gpu_brush) = shared.gpu_brush.lock() { + for id in &self.ctx.pending_canvas_removals { + gpu_brush.remove_canvas(id); + } + } + } + // Invalidate raster_layer_cache entries whose raw_pixels changed (undo/redo, + // stroke commit, fill commit, etc.). Removing the entry here causes the + // raster-cache section below to re-upload the fresh pixels on the same frame. + if !self.ctx.pending_layer_cache_removals.is_empty() { + if let Ok(mut gpu_brush) = shared.gpu_brush.lock() { + for id in &self.ctx.pending_layer_cache_removals { + gpu_brush.remove_layer_texture(id); + } + } + } + let _t_after_removals = std::time::Instant::now(); + + // First-frame canvas initialization for the unified raster tool workspace. + // Creates A (source), B (output) and C (scratch) canvases; uploads pixels to A. + // B and C start zero-initialized (transparent). + if let Some(ref init) = self.ctx.pending_workspace_init { + if let Ok(mut gpu_brush) = shared.gpu_brush.lock() { + // A canvas: source pixels. + gpu_brush.ensure_canvas(device, init.a_canvas_id, init.width, init.height); + if let Some(canvas) = gpu_brush.canvases.get(&init.a_canvas_id) { + canvas.upload(queue, &init.a_pixels); + } + // B canvas: output (zero-initialized by GPU allocation). + gpu_brush.ensure_canvas(device, init.b_canvas_id, init.width, init.height); + // C canvas: scratch (zero-initialized by GPU allocation). + gpu_brush.ensure_canvas(device, init.c_canvas_id, init.width, init.height); + } + } + + // Unified raster tool GPU dispatch (dab shaders, composite pass, etc.). + if let Some(ref work) = self.ctx.pending_tool_gpu_work { + if let Ok(mut gpu_brush) = shared.gpu_brush.lock() { + work.execute(device, queue, &mut *gpu_brush); + } + } + + // Unified tool B-canvas readback on mouseup (commit path). + // Triggered when the active RasterTool's finish() returns true. + if let Some(b_id) = self.ctx.pending_tool_readback_b { + if let Ok(mut gpu_brush) = shared.gpu_brush.lock() { + let dims = gpu_brush.canvases.get(&b_id).map(|c| (c.width, c.height)); + if let Some((w, h)) = dims { + if let Some(pixels) = gpu_brush.readback_canvas(device, queue, b_id) { + let results = RASTER_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, RasterReadbackResult { + layer_id: uuid::Uuid::nil(), // unused; routing via pending_undo_before + time: 0.0, + canvas_width: w, + canvas_height: h, + pixels, + }); + } + } + } + } + } // Lazy float GPU canvas initialization. // If a float exists but its GPU canvas hasn't been created yet, upload float.pixels now. @@ -513,7 +625,7 @@ impl egui_wgpu::CallbackTrait for VelloCallback { let pixels = if float_sel.pixels.is_empty() { vec![0u8; (float_sel.width * float_sel.height * 4) as usize] } else { - float_sel.pixels.clone() + (*float_sel.pixels).clone() }; canvas.upload(queue, &pixels); } @@ -581,6 +693,203 @@ impl egui_wgpu::CallbackTrait for VelloCallback { } } + // --- Raster transform dispatch --- + // Runs after dab dispatch; uploads anchor pixels and runs the affine-resample + // shader from anchor → display canvas. + if let Some(ref dispatch) = self.ctx.pending_transform_dispatch { + if let Ok(mut gpu_brush) = shared.gpu_brush.lock() { + // Ensure anchor canvas at original dimensions. + gpu_brush.ensure_canvas(device, dispatch.anchor_canvas_id, dispatch.anchor_w, dispatch.anchor_h); + if let Some(canvas) = gpu_brush.canvases.get(&dispatch.anchor_canvas_id) { + canvas.upload(queue, &dispatch.anchor_pixels); + } + // Ensure display canvas at new (transformed) dimensions. + gpu_brush.ensure_canvas(device, dispatch.display_canvas_id, dispatch.new_w, dispatch.new_h); + // Dispatch the affine-resample shader. + let params = crate::gpu_brush::RasterTransformGpuParams { + a00: dispatch.a00, a01: dispatch.a01, + a10: dispatch.a10, a11: dispatch.a11, + b0: dispatch.b0, b1: dispatch.b1, + src_w: dispatch.anchor_w, src_h: dispatch.anchor_h, + dst_w: dispatch.new_w, dst_h: dispatch.new_h, + _pad0: 0, _pad1: 0, + }; + gpu_brush.render_transform(device, queue, &dispatch.anchor_canvas_id, &dispatch.display_canvas_id, params); + + // Final commit: readback the display canvas so render_content() can swap it in as the new float. + if dispatch.is_final_commit { + if let Some(pixels) = gpu_brush.readback_canvas(device, queue, dispatch.display_canvas_id) { + let results = TRANSFORM_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, TransformReadbackResult { + pixels, + width: dispatch.new_w, + height: dispatch.new_h, + x: dispatch.new_x, + y: dispatch.new_y, + display_canvas_id: dispatch.display_canvas_id, + }); + } + } + } + } + } + + // --- 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() { + if let Ok(mut gpu_brush) = shared.gpu_brush.lock() { + use lightningbeam_core::brush_engine::{BrushEngine, StrokeState}; + use lightningbeam_core::raster_layer::{StrokeRecord, StrokePoint, RasterBlendMode}; + use lightningbeam_core::brush_settings::bundled_brushes; + + const PW: u32 = 120; + const PH: u32 = 56; + + for preset in bundled_brushes() { + let preview_radius = (PH as f32 * 0.22).max(2.5); + let mut scaled = preset.settings.clone(); + scaled.radius_log = preview_radius.ln(); + scaled.slow_tracking = 0.0; + scaled.slow_tracking_per_dab = 0.0; + + let y_lo = PH as f32 * 0.72; + let y_hi = PH as f32 * 0.28; + let x0 = PW as f32 * 0.10; + let x1 = PW as f32 * 0.90; + let mid_x = (x0 + x1) * 0.5; + let mid_y = (y_lo + y_hi) * 0.5; + let stroke = StrokeRecord { + brush_settings: scaled, + color: [0.85f32, 0.88, 1.0, 1.0], + blend_mode: RasterBlendMode::Normal, + tool_params: [0.0; 4], + points: vec![ + StrokePoint { x: x0, y: y_lo, pressure: 1.0, tilt_x: 0.0, tilt_y: 0.0, timestamp: 0.0 }, + StrokePoint { x: mid_x, y: mid_y, pressure: 1.0, tilt_x: 0.0, tilt_y: 0.0, timestamp: 0.0 }, + StrokePoint { x: x1, y: y_hi, pressure: 1.0, tilt_x: 0.0, tilt_y: 0.0, timestamp: 0.0 }, + ], + }; + let mut state = StrokeState::new(); + let (dabs, _) = BrushEngine::compute_dabs(&stroke, &mut state, 0.0); + let pixels = gpu_brush.render_to_image(device, queue, &dabs, PW, PH); + previews.push((PW, PH, pixels)); + } + } + } + } + + let _t_after_gpu_dispatches = std::time::Instant::now(); + let mut image_cache = shared.image_cache.lock().unwrap(); let composite_result = lightningbeam_core::renderer::render_document_for_compositing( @@ -589,8 +898,11 @@ impl egui_wgpu::CallbackTrait for VelloCallback { &mut image_cache, &shared.video_manager, self.ctx.webcam_frame.as_ref(), + self.ctx.selection.raster_floating.as_ref(), + true, // Draw checkerboard for transparent backgrounds in the UI ); drop(image_cache); + let _t_after_scene_build = std::time::Instant::now(); // Get buffer pool for layer rendering let mut buffer_pool = shared.buffer_pool.lock().unwrap(); @@ -727,27 +1039,101 @@ impl egui_wgpu::CallbackTrait for VelloCallback { // Now render and composite each layer incrementally for rendered_layer in &composite_result.layers { - // Check if this raster layer has a live GPU canvas that should be - // blitted every frame, even when no new dabs arrived this frame. - // `painting_canvas` persists for the entire stroke duration. - // When painting into float (B), the GPU canvas is B's canvas — don't - // use it to replace the Vello scene for the layer (A must still render - // via Vello). - let gpu_canvas_kf: Option = if self.ctx.painting_float { - None - } else { - self.ctx.painting_canvas + // Determine which GPU canvas (if any) to blit for this layer. + // + // Priority order: + // 1. Active tool B canvas (new unified tool render). + // 2. Legacy painting_canvas (old per-tool render path, kept during migration). + // 3. Warp/Liquify display canvas. + // 4. Raster layer texture cache (idle raster layers — bypasses Vello). + // 5. None → fall through to Vello scene rendering. + // + // When painting_float is true, the active tool is working on the float, + // so the layer itself should still render normally (via Vello or cache). + let gpu_canvas_kf: Option = { + // 1. New unified tool render: B canvas replaces this layer. + let from_tool = self.ctx.active_tool_render.as_ref() + .filter(|tr| tr.layer_id == Some(rendered_layer.layer_id)) + .map(|tr| tr.b_canvas_id); + + // 2. Legacy painting_canvas (old stroke path). + let from_legacy = if self.ctx.painting_float { + None + } else { + self.ctx.painting_canvas + .filter(|(layer_id, _)| *layer_id == rendered_layer.layer_id) + .map(|(_, kf_id)| kf_id) + }; + + // 3. Warp/Liquify display canvas. + let from_warp = self.ctx.warp_display .filter(|(layer_id, _)| *layer_id == rendered_layer.layer_id) - .map(|(_, kf_id)| kf_id) + .map(|(_, display_id)| display_id); + + from_tool.or(from_legacy).or(from_warp) }; - if !rendered_layer.has_content && gpu_canvas_kf.is_none() { + // 4. Raster layer texture cache: for idle raster layers (no active tool canvas). + // Upload raw_pixels to the cache if texture_dirty; then use the cache entry. + let raster_cache_kf: Option = if gpu_canvas_kf.is_none() { + // Find the active keyframe for this raster layer. + let doc = &self.ctx.document; + let raster_kf_id = doc.get_layer(&rendered_layer.layer_id) + .and_then(|l| match l { + lightningbeam_core::layer::AnyLayer::Raster(rl) => { + rl.keyframe_at(self.ctx.playback_time) + } + _ => None, + }) + .map(|kf| kf.id); + + if let Some(kf_id) = raster_kf_id { + if let Ok(mut gpu_brush) = shared.gpu_brush.lock() { + // Check if we have pixels to upload. + let kf_data = doc.get_layer(&rendered_layer.layer_id) + .and_then(|l| match l { + lightningbeam_core::layer::AnyLayer::Raster(rl) => { + rl.keyframe_at(self.ctx.playback_time) + } + _ => None, + }); + if let Some(kf) = kf_data { + if !kf.raw_pixels.is_empty() { + // Pass dirty=false: the cache entry was already removed + // above via pending_layer_cache_removals when raw_pixels + // changed (undo/redo, stroke commit, etc.). A cache miss + // triggers upload; a cache hit skips the expensive sRGB + // conversion + GPU write that was firing every frame. + gpu_brush.ensure_layer_texture( + device, queue, kf_id, + &kf.raw_pixels, + kf.width, kf.height, + false, + ); + Some(kf_id) + } else { + None + } + } else { + None + } + } else { + None + } + } else { + None + } + } else { + None + }; + + if !rendered_layer.has_content && gpu_canvas_kf.is_none() && raster_cache_kf.is_none() { continue; } match &rendered_layer.layer_type { - RenderedLayerType::Content => { - // Regular content layer - render to sRGB, convert to linear, then composite + RenderedLayerType::Vector => { + // Vector/group layer — render Vello scene → sRGB → linear → composite. let srgb_handle = buffer_pool.acquire(device, layer_spec); let hdr_layer_handle = buffer_pool.acquire(device, hdr_spec); @@ -756,69 +1142,25 @@ impl egui_wgpu::CallbackTrait for VelloCallback { buffer_pool.get_view(hdr_layer_handle), &instance_resources.hdr_texture_view, ) { - // GPU canvas blit path: if a live GPU canvas exists for this - // raster layer, blit it directly into the HDR buffer (premultiplied - // linear → Rgba16Float), bypassing the sRGB intermediate entirely. - // Vello path: render to sRGB buffer → srgb_to_linear → HDR buffer. - let used_gpu_canvas = if let Some(kf_id) = gpu_canvas_kf { - let mut used = false; - if let Ok(gpu_brush) = shared.gpu_brush.lock() { - if let Some(canvas) = gpu_brush.canvases.get(&kf_id) { - let camera = crate::gpu_brush::CameraParams { - pan_x: self.ctx.pan_offset.x, - pan_y: self.ctx.pan_offset.y, - zoom: self.ctx.zoom, - canvas_w: canvas.width as f32, - canvas_h: canvas.height as f32, - viewport_w: width as f32, - viewport_h: height as f32, - _pad: 0.0, - }; - shared.canvas_blit.blit( - device, queue, - canvas.src_view(), - hdr_layer_view, // blit directly to HDR - &camera, - None, // no mask on layer canvas blit - ); - used = true; - } - } - used - } else { - false - }; - - if !used_gpu_canvas { - // Render layer scene to sRGB buffer, then convert to HDR - if let Ok(mut renderer) = shared.renderer.lock() { - renderer.render_to_texture(device, queue, &rendered_layer.scene, srgb_view, &layer_render_params).ok(); - } - let mut convert_encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { - label: Some("layer_srgb_to_linear_encoder"), - }); - shared.srgb_to_linear.convert(device, &mut convert_encoder, srgb_view, hdr_layer_view); - queue.submit(Some(convert_encoder.finish())); + if let Ok(mut renderer) = shared.renderer.lock() { + renderer.render_to_texture(device, queue, &rendered_layer.scene, srgb_view, &layer_render_params).ok(); } + let mut convert_encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { + label: Some("layer_srgb_to_linear_encoder"), + }); + shared.srgb_to_linear.convert(device, &mut convert_encoder, srgb_view, hdr_layer_view); + queue.submit(Some(convert_encoder.finish())); - // Composite this layer onto the HDR accumulator with its opacity let compositor_layer = lightningbeam_core::gpu::CompositorLayer::new( hdr_layer_handle, rendered_layer.opacity, rendered_layer.blend_mode, ); - let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("layer_composite_encoder"), }); shared.compositor.composite( - device, - queue, - &mut encoder, - &[compositor_layer], - &buffer_pool, - hdr_view, - None, // Don't clear - blend onto existing content + device, queue, &mut encoder, &[compositor_layer], &buffer_pool, hdr_view, None, ); queue.submit(Some(encoder.finish())); } @@ -826,6 +1168,154 @@ impl egui_wgpu::CallbackTrait for VelloCallback { buffer_pool.release(srgb_handle); buffer_pool.release(hdr_layer_handle); } + RenderedLayerType::Raster { transform: layer_transform, .. } => { + // Raster layer — GPU canvas blit directly to HDR (bypasses Vello). + // Tool override canvas (gpu_canvas_kf) takes priority over cached texture. + if let Some(use_kf_id) = gpu_canvas_kf.or(raster_cache_kf) { + let hdr_layer_handle = buffer_pool.acquire(device, hdr_spec); + if let (Some(hdr_layer_view), Some(hdr_view)) = ( + buffer_pool.get_view(hdr_layer_handle), + &instance_resources.hdr_texture_view, + ) { + if let Ok(gpu_brush) = shared.gpu_brush.lock() { + let canvas = gpu_brush.canvases.get(&use_kf_id) + .or_else(|| gpu_brush.raster_layer_cache.get(&use_kf_id)); + if let Some(canvas) = canvas { + let bt = crate::gpu_brush::BlitTransform::new( + *layer_transform, + canvas.width, canvas.height, + width, height, + ); + shared.canvas_blit.blit( + device, queue, canvas.src_view(), hdr_layer_view, &bt, None, + ); + } + } + let compositor_layer = lightningbeam_core::gpu::CompositorLayer::new( + hdr_layer_handle, + rendered_layer.opacity, + rendered_layer.blend_mode, + ); + let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { + label: Some("raster_composite_encoder"), + }); + shared.compositor.composite( + device, queue, &mut encoder, &[compositor_layer], &buffer_pool, hdr_view, None, + ); + queue.submit(Some(encoder.finish())); + } + buffer_pool.release(hdr_layer_handle); + } + } + RenderedLayerType::Video { instances } => { + // Video layer — per-instance: upload decoded frame → blit → composite. + for inst in instances { + if inst.rgba_data.is_empty() { continue; } + let hdr_layer_handle = buffer_pool.acquire(device, hdr_spec); + if let (Some(hdr_layer_view), Some(hdr_view)) = ( + buffer_pool.get_view(hdr_layer_handle), + &instance_resources.hdr_texture_view, + ) { + // Convert sRGB straight-alpha → linear premultiplied. + let linear: Vec = inst.rgba_data.chunks_exact(4).flat_map(|p| { + let a = p[3] as f32 / 255.0; + let lin = |c: u8| -> f32 { + let f = c as f32 / 255.0; + if f <= 0.04045 { f / 12.92 } else { ((f + 0.055) / 1.055).powf(2.4) } + }; + let r = (lin(p[0]) * a * 255.0 + 0.5) as u8; + let g = (lin(p[1]) * a * 255.0 + 0.5) as u8; + let b = (lin(p[2]) * a * 255.0 + 0.5) as u8; + [r, g, b, p[3]] + }).collect(); + + let tex = device.create_texture(&wgpu::TextureDescriptor { + label: Some("video_frame_tex"), + size: wgpu::Extent3d { width: inst.width, height: inst.height, depth_or_array_layers: 1 }, + mip_level_count: 1, sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: wgpu::TextureFormat::Rgba8Unorm, + usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST, + view_formats: &[], + }); + queue.write_texture( + wgpu::TexelCopyTextureInfo { texture: &tex, mip_level: 0, origin: wgpu::Origin3d::ZERO, aspect: wgpu::TextureAspect::All }, + &linear, + wgpu::TexelCopyBufferLayout { offset: 0, bytes_per_row: Some(inst.width * 4), rows_per_image: Some(inst.height) }, + wgpu::Extent3d { width: inst.width, height: inst.height, depth_or_array_layers: 1 }, + ); + let tex_view = tex.create_view(&wgpu::TextureViewDescriptor::default()); + + let bt = crate::gpu_brush::BlitTransform::new( + inst.transform, inst.width, inst.height, width, height, + ); + shared.canvas_blit.blit(device, queue, &tex_view, hdr_layer_view, &bt, None); + + let compositor_layer = lightningbeam_core::gpu::CompositorLayer::new( + hdr_layer_handle, + inst.opacity, + lightningbeam_core::gpu::BlendMode::Normal, + ); + let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { + label: Some("video_composite_encoder"), + }); + shared.compositor.composite( + device, queue, &mut encoder, &[compositor_layer], &buffer_pool, hdr_view, None, + ); + queue.submit(Some(encoder.finish())); + } + buffer_pool.release(hdr_layer_handle); + } + } + RenderedLayerType::Float { canvas_id: float_canvas_id, x: float_x, y: float_y, width: fw, height: fh, transform: layer_transform, pixels: _ } => { + // Floating raster selection — now composited at the correct z-position + // (immediately above its parent layer) rather than on top of everything. + // + // Override priority: + // 1. transform_display: transform tool is active on the float. + // 2. active_tool_render (layer_id=None): unified tool on the float. + // 3. float_canvas_id from this entry: normal float display. + let blit_params: Option<(uuid::Uuid, i32, i32, u32, u32)> = + if let Some(ref td) = self.ctx.transform_display { + Some((td.display_canvas_id, td.x, td.y, td.w, td.h)) + } else if let Some(ref tr) = self.ctx.active_tool_render.as_ref().filter(|tr| tr.layer_id.is_none()) { + Some((tr.b_canvas_id, tr.x, tr.y, tr.width, tr.height)) + } else { + Some((*float_canvas_id, *float_x, *float_y, *fw, *fh)) + }; + + if let Some((blit_canvas_id, blit_x, blit_y, blit_w, blit_h)) = blit_params { + if let Ok(gpu_brush) = shared.gpu_brush.lock() { + if let Some(canvas) = gpu_brush.canvases.get(&blit_canvas_id) { + let float_hdr_handle = buffer_pool.acquire(device, hdr_spec); + if let (Some(fhdr_view), Some(hdr_view)) = ( + buffer_pool.get_view(float_hdr_handle), + &instance_resources.hdr_texture_view, + ) { + // float_canvas_px → viewport_px: + // layer_transform maps doc_px → viewport_px + // translate(blit_x, blit_y) maps float_canvas_px → doc_px + let float_to_vp = *layer_transform + * Affine::translate((blit_x as f64, blit_y as f64)); + let bt = crate::gpu_brush::BlitTransform::new( + float_to_vp, blit_w, blit_h, width, height, + ); + shared.canvas_blit.blit( + device, queue, canvas.src_view(), fhdr_view, &bt, + float_mask_view.as_ref(), + ); + let float_layer = lightningbeam_core::gpu::CompositorLayer::normal(float_hdr_handle, 1.0); + let mut enc = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { + label: Some("float_canvas_composite"), + }); + shared.compositor.composite(device, queue, &mut enc, &[float_layer], &buffer_pool, hdr_view, None); + queue.submit(Some(enc.finish())); + } + buffer_pool.release(float_hdr_handle); + } + } + } + } RenderedLayerType::Effect { effect_instances } => { // Effect layer - apply effects to the current HDR accumulator let current_time = self.ctx.document.current_time; @@ -1000,55 +1490,21 @@ impl egui_wgpu::CallbackTrait for VelloCallback { buffer_pool.release(clip_hdr_handle); } - // Blit the float GPU canvas on top of all composited layers. - // The float_mask_view clips to the selection shape (None = full float visible). - if let Some(ref float_sel) = self.ctx.selection.raster_floating { - let float_canvas_id = float_sel.canvas_id; - let float_x = float_sel.x; - let float_y = float_sel.y; - let float_w = float_sel.width; - let float_h = float_sel.height; - if let Ok(gpu_brush) = shared.gpu_brush.lock() { - if let Some(canvas) = gpu_brush.canvases.get(&float_canvas_id) { - let float_hdr_handle = buffer_pool.acquire(device, hdr_spec); - if let (Some(fhdr_view), Some(hdr_view)) = ( - buffer_pool.get_view(float_hdr_handle), - &instance_resources.hdr_texture_view, - ) { - let fcamera = crate::gpu_brush::CameraParams { - pan_x: self.ctx.pan_offset.x + float_x as f32 * self.ctx.zoom, - pan_y: self.ctx.pan_offset.y + float_y as f32 * self.ctx.zoom, - zoom: self.ctx.zoom, - canvas_w: float_w as f32, - canvas_h: float_h as f32, - viewport_w: width as f32, - viewport_h: height as f32, - _pad: 0.0, - }; - // Blit directly to HDR (straight-alpha linear, no sRGB step) - shared.canvas_blit.blit( - device, queue, - canvas.src_view(), - fhdr_view, - &fcamera, - float_mask_view.as_ref(), - ); - let float_layer = lightningbeam_core::gpu::CompositorLayer::normal(float_hdr_handle, 1.0); - let mut enc = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { - label: Some("float_canvas_composite"), - }); - shared.compositor.composite(device, queue, &mut enc, &[float_layer], &buffer_pool, hdr_view, None); - queue.submit(Some(enc.finish())); - } - buffer_pool.release(float_hdr_handle); - } - } - } - // Advance frame counter for buffer cleanup buffer_pool.next_frame(); drop(buffer_pool); + // --- Frame timing report --- + let _t_end = std::time::Instant::now(); + let total_ms = (_t_end - _t_prepare_start).as_secs_f64() * 1000.0; + let removals_ms = (_t_after_removals - _t_prepare_start).as_secs_f64() * 1000.0; + let gpu_dispatches_ms = (_t_after_gpu_dispatches - _t_after_removals).as_secs_f64() * 1000.0; + let scene_build_ms = (_t_after_scene_build - _t_after_gpu_dispatches).as_secs_f64() * 1000.0; + let composite_ms = (_t_end - _t_after_scene_build).as_secs_f64() * 1000.0; + crate::debug_overlay::update_prepare_timing( + total_ms, removals_ms, gpu_dispatches_ms, scene_build_ms, composite_ms, + ); + // For drag preview and other overlays, we still need a scene // Create an empty scene - the composited result is already in hdr_texture vello::Scene::new() @@ -1500,300 +1956,6 @@ impl egui_wgpu::CallbackTrait for VelloCallback { } - // 3. Draw rectangle creation preview - if let lightningbeam_core::tool::ToolState::CreatingRectangle { ref start_point, ref current_point, centered, constrain_square, .. } = self.ctx.tool_state { - use vello::kurbo::Point; - - // Calculate rectangle bounds based on mode (same logic as in handler) - let (width, height, position) = if centered { - let dx = current_point.x - start_point.x; - let dy = current_point.y - start_point.y; - - let (w, h) = if constrain_square { - let size = dx.abs().max(dy.abs()) * 2.0; - (size, size) - } else { - (dx.abs() * 2.0, dy.abs() * 2.0) - }; - - let pos = Point::new(start_point.x - w / 2.0, start_point.y - h / 2.0); - (w, h, pos) - } else { - let mut min_x = start_point.x.min(current_point.x); - let mut min_y = start_point.y.min(current_point.y); - let mut max_x = start_point.x.max(current_point.x); - let mut max_y = start_point.y.max(current_point.y); - - if constrain_square { - let width = max_x - min_x; - let height = max_y - min_y; - let size = width.max(height); - - if current_point.x > start_point.x { - max_x = min_x + size; - } else { - min_x = max_x - size; - } - - if current_point.y > start_point.y { - max_y = min_y + size; - } else { - min_y = max_y - size; - } - } - - (max_x - min_x, max_y - min_y, Point::new(min_x, min_y)) - }; - - if width > 0.0 && height > 0.0 { - let rect = KurboRect::new(0.0, 0.0, width, height); - let preview_transform = overlay_transform * Affine::translate((position.x, position.y)); - - if self.ctx.fill_enabled { - let fill_color = Color::from_rgba8( - self.ctx.fill_color.r(), - self.ctx.fill_color.g(), - self.ctx.fill_color.b(), - self.ctx.fill_color.a(), - ); - scene.fill( - Fill::NonZero, - preview_transform, - fill_color, - None, - &rect, - ); - } - - let stroke_color = Color::from_rgba8( - self.ctx.stroke_color.r(), - self.ctx.stroke_color.g(), - self.ctx.stroke_color.b(), - self.ctx.stroke_color.a(), - ); - scene.stroke( - &Stroke::new(self.ctx.stroke_width), - preview_transform, - stroke_color, - None, - &rect, - ); - } - } - - // 4. Draw ellipse creation preview - if let lightningbeam_core::tool::ToolState::CreatingEllipse { ref start_point, ref current_point, corner_mode, constrain_circle, .. } = self.ctx.tool_state { - use vello::kurbo::{Point, Circle as KurboCircle, Ellipse}; - - // Calculate ellipse parameters based on mode (same logic as in handler) - let (rx, ry, position) = if corner_mode { - let min_x = start_point.x.min(current_point.x); - let min_y = start_point.y.min(current_point.y); - let max_x = start_point.x.max(current_point.x); - let max_y = start_point.y.max(current_point.y); - - let width = max_x - min_x; - let height = max_y - min_y; - - let (rx, ry) = if constrain_circle { - let radius = width.max(height) / 2.0; - (radius, radius) - } else { - (width / 2.0, height / 2.0) - }; - - let position = Point::new(min_x + rx, min_y + ry); - - (rx, ry, position) - } else { - let dx = (current_point.x - start_point.x).abs(); - let dy = (current_point.y - start_point.y).abs(); - - let (rx, ry) = if constrain_circle { - let radius = (dx * dx + dy * dy).sqrt(); - (radius, radius) - } else { - (dx, dy) - }; - - (rx, ry, *start_point) - }; - - if rx > 0.0 && ry > 0.0 { - let preview_transform = overlay_transform * Affine::translate((position.x, position.y)); - - let fill_color = Color::from_rgba8( - self.ctx.fill_color.r(), - self.ctx.fill_color.g(), - self.ctx.fill_color.b(), - self.ctx.fill_color.a(), - ); - let stroke_color = Color::from_rgba8( - self.ctx.stroke_color.r(), - self.ctx.stroke_color.g(), - self.ctx.stroke_color.b(), - self.ctx.stroke_color.a(), - ); - - if rx == ry { - let circle = KurboCircle::new((0.0, 0.0), rx); - if self.ctx.fill_enabled { - scene.fill(Fill::NonZero, preview_transform, fill_color, None, &circle); - } - scene.stroke(&Stroke::new(self.ctx.stroke_width), preview_transform, stroke_color, None, &circle); - } else { - let ellipse = Ellipse::new((0.0, 0.0), (rx, ry), 0.0); - if self.ctx.fill_enabled { - scene.fill(Fill::NonZero, preview_transform, fill_color, None, &ellipse); - } - scene.stroke(&Stroke::new(self.ctx.stroke_width), preview_transform, stroke_color, None, &ellipse); - } - } - } - - // 5. Draw line creation preview - if let lightningbeam_core::tool::ToolState::CreatingLine { ref start_point, ref current_point, .. } = self.ctx.tool_state { - use vello::kurbo::Line; - - // Calculate line length - let dx = current_point.x - start_point.x; - let dy = current_point.y - start_point.y; - let length = (dx * dx + dy * dy).sqrt(); - - if length > 0.0 { - // Use actual stroke color for line preview - let stroke_color = Color::from_rgba8( - self.ctx.stroke_color.r(), - self.ctx.stroke_color.g(), - self.ctx.stroke_color.b(), - self.ctx.stroke_color.a(), - ); - - // Draw the line directly - let line = Line::new(*start_point, *current_point); - scene.stroke( - &Stroke::new(2.0), - overlay_transform, - stroke_color, - None, - &line, - ); - } - } - - // 6. Draw polygon creation preview - if let lightningbeam_core::tool::ToolState::CreatingPolygon { ref center, ref current_point, num_sides, .. } = self.ctx.tool_state { - use vello::kurbo::{BezPath, Point}; - use std::f64::consts::PI; - - // Calculate radius - let dx = current_point.x - center.x; - let dy = current_point.y - center.y; - let radius = (dx * dx + dy * dy).sqrt(); - - if radius > 5.0 && num_sides >= 3 { - let preview_transform = overlay_transform * Affine::translate((center.x, center.y)); - - // Use actual fill color (same as final shape) - let fill_color = Color::from_rgba8( - self.ctx.fill_color.r(), - self.ctx.fill_color.g(), - self.ctx.fill_color.b(), - self.ctx.fill_color.a(), - ); - - // Create the polygon path inline - let mut path = BezPath::new(); - let angle_step = 2.0 * PI / num_sides as f64; - let start_angle = -PI / 2.0; - - // First vertex - let first_x = radius * start_angle.cos(); - let first_y = radius * start_angle.sin(); - path.move_to(Point::new(first_x, first_y)); - - // Add remaining vertices - for i in 1..num_sides { - let angle = start_angle + angle_step * i as f64; - let x = radius * angle.cos(); - let y = radius * angle.sin(); - path.line_to(Point::new(x, y)); - } - - path.close_path(); - - if self.ctx.fill_enabled { - scene.fill( - Fill::NonZero, - preview_transform, - fill_color, - None, - &path, - ); - } - - let stroke_color = Color::from_rgba8( - self.ctx.stroke_color.r(), - self.ctx.stroke_color.g(), - self.ctx.stroke_color.b(), - self.ctx.stroke_color.a(), - ); - scene.stroke( - &Stroke::new(self.ctx.stroke_width), - preview_transform, - stroke_color, - None, - &path, - ); - } - } - - // 7. Draw path drawing preview - if let lightningbeam_core::tool::ToolState::DrawingPath { ref points, .. } = self.ctx.tool_state { - use vello::kurbo::BezPath; - - if points.len() >= 2 { - // Build a simple line path from the raw points for preview - let mut preview_path = BezPath::new(); - preview_path.move_to(points[0]); - for point in &points[1..] { - preview_path.line_to(*point); - } - - // Draw fill if enabled - if self.ctx.fill_enabled { - let fill_color = Color::from_rgba8( - self.ctx.fill_color.r(), - self.ctx.fill_color.g(), - self.ctx.fill_color.b(), - self.ctx.fill_color.a(), - ); - scene.fill( - Fill::NonZero, - overlay_transform, - fill_color, - None, - &preview_path, - ); - } - - let stroke_color = Color::from_rgba8( - self.ctx.stroke_color.r(), - self.ctx.stroke_color.g(), - self.ctx.stroke_color.b(), - self.ctx.stroke_color.a(), - ); - - scene.stroke( - &Stroke::new(self.ctx.stroke_width), - overlay_transform, - stroke_color, - None, - &preview_path, - ); - } - } - // 8. Vector editing preview: DCEL edits are applied live to the document, // so the normal DCEL render path draws the current state. No separate // preview rendering is needed. @@ -2146,6 +2308,145 @@ impl egui_wgpu::CallbackTrait for VelloCallback { } } + // Shape / path creation previews — drawn regardless of layer type so raster layers + // also see the live outline during drag. + { + use vello::peniko::{Color, Fill}; + use vello::kurbo::{Rect as KurboRect, Stroke}; + + // Rectangle preview + if let lightningbeam_core::tool::ToolState::CreatingRectangle { ref start_point, ref current_point, centered, constrain_square, .. } = self.ctx.tool_state { + use vello::kurbo::Point; + let (width, height, position) = if centered { + let dx = current_point.x - start_point.x; + let dy = current_point.y - start_point.y; + let (w, h) = if constrain_square { + let size = dx.abs().max(dy.abs()) * 2.0; + (size, size) + } else { + (dx.abs() * 2.0, dy.abs() * 2.0) + }; + let pos = Point::new(start_point.x - w / 2.0, start_point.y - h / 2.0); + (w, h, pos) + } else { + let mut min_x = start_point.x.min(current_point.x); + let mut min_y = start_point.y.min(current_point.y); + let mut max_x = start_point.x.max(current_point.x); + let mut max_y = start_point.y.max(current_point.y); + if constrain_square { + let size = (max_x - min_x).max(max_y - min_y); + if current_point.x > start_point.x { max_x = min_x + size; } else { min_x = max_x - size; } + if current_point.y > start_point.y { max_y = min_y + size; } else { min_y = max_y - size; } + } + (max_x - min_x, max_y - min_y, Point::new(min_x, min_y)) + }; + if width > 0.0 && height > 0.0 { + let rect = KurboRect::new(0.0, 0.0, width, height); + let preview_transform = overlay_transform * Affine::translate((position.x, position.y)); + if self.ctx.fill_enabled { + let fc = self.ctx.fill_color; + scene.fill(Fill::NonZero, preview_transform, Color::from_rgba8(fc.r(), fc.g(), fc.b(), fc.a()), None, &rect); + } + let sc = self.ctx.stroke_color; + scene.stroke(&Stroke::new(self.ctx.stroke_width), preview_transform, Color::from_rgba8(sc.r(), sc.g(), sc.b(), sc.a()), None, &rect); + } + } + + // Ellipse preview + if let lightningbeam_core::tool::ToolState::CreatingEllipse { ref start_point, ref current_point, corner_mode, constrain_circle, .. } = self.ctx.tool_state { + use vello::kurbo::{Point, Circle as KurboCircle, Ellipse}; + let (rx, ry, position) = if corner_mode { + let min_x = start_point.x.min(current_point.x); + let min_y = start_point.y.min(current_point.y); + let max_x = start_point.x.max(current_point.x); + let max_y = start_point.y.max(current_point.y); + let (rx, ry) = if constrain_circle { + let r = (max_x - min_x).max(max_y - min_y) / 2.0; + (r, r) + } else { ((max_x - min_x) / 2.0, (max_y - min_y) / 2.0) }; + (rx, ry, Point::new(min_x + rx, min_y + ry)) + } else { + let dx = (current_point.x - start_point.x).abs(); + let dy = (current_point.y - start_point.y).abs(); + let (rx, ry) = if constrain_circle { + let r = (dx * dx + dy * dy).sqrt(); (r, r) + } else { (dx, dy) }; + (rx, ry, *start_point) + }; + if rx > 0.0 && ry > 0.0 { + let preview_transform = overlay_transform * Affine::translate((position.x, position.y)); + let fc = self.ctx.fill_color; + let fill_color = Color::from_rgba8(fc.r(), fc.g(), fc.b(), fc.a()); + let sc = self.ctx.stroke_color; + let stroke_color = Color::from_rgba8(sc.r(), sc.g(), sc.b(), sc.a()); + if rx == ry { + let circle = KurboCircle::new((0.0, 0.0), rx); + if self.ctx.fill_enabled { scene.fill(Fill::NonZero, preview_transform, fill_color, None, &circle); } + scene.stroke(&Stroke::new(self.ctx.stroke_width), preview_transform, stroke_color, None, &circle); + } else { + let ellipse = Ellipse::new((0.0, 0.0), (rx, ry), 0.0); + if self.ctx.fill_enabled { scene.fill(Fill::NonZero, preview_transform, fill_color, None, &ellipse); } + scene.stroke(&Stroke::new(self.ctx.stroke_width), preview_transform, stroke_color, None, &ellipse); + } + } + } + + // Line preview + if let lightningbeam_core::tool::ToolState::CreatingLine { ref start_point, ref current_point, .. } = self.ctx.tool_state { + use vello::kurbo::Line; + let dx = current_point.x - start_point.x; + let dy = current_point.y - start_point.y; + if (dx * dx + dy * dy).sqrt() > 0.0 { + let sc = self.ctx.stroke_color; + let line = Line::new(*start_point, *current_point); + scene.stroke(&Stroke::new(2.0), overlay_transform, Color::from_rgba8(sc.r(), sc.g(), sc.b(), sc.a()), None, &line); + } + } + + // Polygon preview + if let lightningbeam_core::tool::ToolState::CreatingPolygon { ref center, ref current_point, num_sides, .. } = self.ctx.tool_state { + use vello::kurbo::{BezPath, Point}; + use std::f64::consts::PI; + let dx = current_point.x - center.x; + let dy = current_point.y - center.y; + let radius = (dx * dx + dy * dy).sqrt(); + if radius > 5.0 && num_sides >= 3 { + let preview_transform = overlay_transform * Affine::translate((center.x, center.y)); + let angle_step = 2.0 * PI / num_sides as f64; + let start_angle = -PI / 2.0; + let mut path = BezPath::new(); + path.move_to(Point::new(radius * start_angle.cos(), radius * start_angle.sin())); + for i in 1..num_sides { + let angle = start_angle + angle_step * i as f64; + path.line_to(Point::new(radius * angle.cos(), radius * angle.sin())); + } + path.close_path(); + if self.ctx.fill_enabled { + let fc = self.ctx.fill_color; + scene.fill(Fill::NonZero, preview_transform, Color::from_rgba8(fc.r(), fc.g(), fc.b(), fc.a()), None, &path); + } + let sc = self.ctx.stroke_color; + scene.stroke(&Stroke::new(self.ctx.stroke_width), preview_transform, Color::from_rgba8(sc.r(), sc.g(), sc.b(), sc.a()), None, &path); + } + } + + // Freehand path preview + if let lightningbeam_core::tool::ToolState::DrawingPath { ref points, .. } = self.ctx.tool_state { + use vello::kurbo::BezPath; + if points.len() >= 2 { + let mut preview_path = BezPath::new(); + preview_path.move_to(points[0]); + for point in &points[1..] { preview_path.line_to(*point); } + if self.ctx.fill_enabled { + let fc = self.ctx.fill_color; + scene.fill(Fill::NonZero, overlay_transform, Color::from_rgba8(fc.r(), fc.g(), fc.b(), fc.a()), None, &preview_path); + } + let sc = self.ctx.stroke_color; + scene.stroke(&Stroke::new(self.ctx.stroke_width), overlay_transform, Color::from_rgba8(sc.r(), sc.g(), sc.b(), sc.a()), None, &preview_path); + } + } + } + // Render scene to texture using shared renderer if let Some(texture_view) = &instance_resources.texture_view { if USE_HDR_COMPOSITING { @@ -2431,6 +2732,49 @@ pub struct StagePane { /// True while the current stroke is being painted onto the float buffer (B) /// rather than the layer canvas (A). painting_float: bool, + /// Timestamp (ui time in seconds) of the last `compute_dabs` call for this stroke. + /// Used to compute `dt` for the unified distance+time dab accumulator. + raster_last_compute_time: f64, + /// Clone stamp: (source_world - drag_start_world) computed at stroke start. + /// Constant for the entire stroke; cleared when the stroke ends. + clone_stroke_offset: Option<(f32, f32)>, + /// Live state for the raster transform tool (scale/rotate/move float). + raster_transform_state: Option, + /// GPU transform work to dispatch in prepare(). + 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, + + // ── New unified raster tool state ───────────────────────────────────────── + /// The active `RasterTool` implementation plus its GPU workspace. + /// Set on mousedown; cleared (and workspace queued for removal) on commit/cancel. + active_raster_tool: Option<(Box, crate::raster_tool::RasterWorkspace)>, + /// Canvas UUIDs to remove from `GpuBrushEngine` at the top of the next `prepare()`. + /// Drains into `VelloRenderContext::pending_canvas_removals` each frame. + pending_canvas_removals: Vec, + /// First-frame canvas init packet for the active raster tool. Forwarded to + /// `VelloRenderContext` on the mousedown frame; cleared after one forwarding. + pending_workspace_init: Option, + /// Keyframe UUIDs whose `raster_layer_cache` entry must be removed so fresh + /// `raw_pixels` are re-uploaded. Drained into `VelloRenderContext` each frame. + pending_layer_cache_removals: Vec, + /// True when the unified raster tool has finished (mouseup) and is waiting for + /// the GPU readback result. Cleared in render_content() after the result arrives. + active_tool_awaiting_readback: bool, + /// B-canvas UUID to readback into RASTER_READBACK_RESULTS on the next prepare(). + /// Set on mouseup when `tool.finish()` returns true; forwarded to VelloRenderContext. + pending_tool_readback_b: Option, + /// Synthetic drag/click override for test mode replay (debug builds only) #[cfg(debug_assertions)] replay_override: Option, @@ -2445,6 +2789,156 @@ pub struct ReplayDragState { pub drag_stopped: bool, } +/// Accumulated state for the Quick Select brush-based selection tool. +struct QuickSelectState { + /// Per-pixel OR'd selection mask (width × height). + mask: Vec, + /// RGBA snapshot of the canvas at drag start (read-only for all fills). + pixels: Vec, + width: u32, + height: u32, + /// Last canvas-pixel position where a fill was run (for debouncing). + 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 { @@ -2489,6 +2983,112 @@ struct PendingRasterDabs { wants_final_readback: bool, } +/// Which transform handle the user is interacting with. +#[derive(Clone, Copy, PartialEq)] +enum RasterTransformHandle { + Move, + Corner { right: bool, bottom: bool }, + EdgeH { bottom: bool }, + EdgeV { right: bool }, + Rotate, + Origin, // the pivot point, draggable +} + +/// Live state for an ongoing raster transform operation. +struct RasterTransformState { + /// canvas_id of the float when this state was created. If different → stale, reinit. + float_canvas_id: uuid::Uuid, + /// Anchor: original pixels, never written during transform. + anchor_canvas_id: uuid::Uuid, + /// sRGB-encoded pixel data for the anchor canvas (re-uploaded each dispatch). + anchor_pixels: Vec, + anchor_w: u32, + anchor_h: u32, + /// Display canvas: compute shader output, shown in place of float during transform. + display_canvas_id: uuid::Uuid, + /// Center of the transformed bounding box in canvas (world) coords. + cx: f32, cy: f32, + scale_x: f32, scale_y: f32, + /// Rotation in radians. + angle: f32, + /// Pivot point for rotate/scale (defaults to center). + origin_x: f32, origin_y: f32, + /// Which handle is being dragged, if any. + active_handle: Option, + /// Which handle the cursor is currently over (for visual feedback). + hovered_handle: Option, + /// World position where the current drag started. + drag_start_world: egui::Vec2, + /// Snapped values captured at drag start. + snap_cx: f32, snap_cy: f32, + snap_sx: f32, snap_sy: f32, + snap_angle: f32, + snap_origin_x: f32, snap_origin_y: f32, + /// True once at least one GPU transform dispatch has been queued. + transform_applied: bool, + /// True after Enter: waiting for the final readback before clearing state. + wants_apply: bool, +} + +/// GPU work queued by `handle_raster_transform_tool` for `prepare()`. +struct PendingTransformDispatch { + anchor_canvas_id: uuid::Uuid, + /// Anchor pixels — re-uploaded each dispatch to keep the anchor immutable. + anchor_pixels: Vec, + anchor_w: u32, + anchor_h: u32, + /// Display canvas: compute shader output (was float_canvas_id). + display_canvas_id: uuid::Uuid, + /// AABB of the transformed output (for readback result positioning). + new_x: i32, new_y: i32, + /// Output canvas dimensions (may differ from anchor if scaled/rotated). + new_w: u32, + new_h: u32, + /// Inverse affine coefficients: src_pixel = A * out_pixel + b. + a00: f32, a01: f32, + a10: f32, a11: f32, + b0: f32, b1: f32, + /// If true, readback the display canvas after dispatch and store in TRANSFORM_READBACK_RESULTS. + 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, + width: u32, + height: u32, + x: i32, + y: i32, + display_canvas_id: uuid::Uuid, +} + +/// Sent from StagePane to VelloCallback to override float blit with display canvas. +struct TransformDisplayInfo { + display_canvas_id: uuid::Uuid, + x: i32, y: i32, + w: u32, h: u32, +} + +static TRANSFORM_READBACK_RESULTS: OnceLock>>> = OnceLock::new(); + /// Result stored by `prepare()` after a stroke-end readback. struct RasterReadbackResult { layer_id: uuid::Uuid, @@ -2554,6 +3154,22 @@ impl StagePane { pending_canvas_removal: None, stroke_clip_selection: None, painting_float: false, + raster_last_compute_time: 0.0, + clone_stroke_offset: None, + 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(), + active_raster_tool: None, + pending_canvas_removals: Vec::new(), + pending_workspace_init: None, + pending_layer_cache_removals: Vec::new(), + active_tool_awaiting_readback: false, + pending_tool_readback_b: None, #[cfg(debug_assertions)] replay_override: None, } @@ -3635,21 +4251,18 @@ impl StagePane { use lightningbeam_core::layer::AnyLayer; use vello::kurbo::Point; - // Check if we have an active vector layer let active_layer_id = match *shared.active_layer_id { Some(id) => id, None => return, }; - let active_layer = match shared.action_executor.document().get_layer(&active_layer_id) { - Some(layer) => layer, - None => return, - }; - - // Only work on VectorLayer - if !matches!(active_layer, AnyLayer::Vector(_)) { - return; - } + let is_raster = shared.action_executor.document() + .get_layer(&active_layer_id) + .map_or(false, |l| matches!(l, AnyLayer::Raster(_))); + let is_vector = shared.action_executor.document() + .get_layer(&active_layer_id) + .map_or(false, |l| matches!(l, AnyLayer::Vector(_))); + if !is_raster && !is_vector { return; } let point = self.snap_point(Point::new(world_pos.x as f64, world_pos.y as f64), shared); @@ -3726,28 +4339,43 @@ impl StagePane { // Only create shape if rectangle has non-zero size if width > 1.0 && height > 1.0 { - use lightningbeam_core::shape::{ShapeColor, StrokeStyle}; - use lightningbeam_core::actions::AddShapeAction; - - let path = Self::create_rectangle_path(min_x, min_y, max_x, max_y); - - let fill_color = if *shared.fill_enabled { - Some(ShapeColor::from_egui(*shared.fill_color)) + if is_raster { + let sc = *shared.stroke_color; + let fc = *shared.fill_color; + let fill_en = *shared.fill_enabled; + let thickness = *shared.stroke_width as f32; + // Subtract 0.5 to align with Vello's pixel-center convention + // (ImageBrush displays pixel (px,py) centered at world (px+0.5, py+0.5)) + let (x0, y0, x1, y1) = (min_x as f32 - 0.5, min_y as f32 - 0.5, max_x as f32 - 0.5, max_y as f32 - 0.5); + let stroke_rgba = [sc.r(), sc.g(), sc.b(), sc.a()]; + let fill_rgba = fill_en.then(|| [fc.r(), fc.g(), fc.b(), fc.a()]); + Self::apply_raster_pixel_edit(shared, active_layer_id, "Draw rectangle", |pixels, w, h| { + lightningbeam_core::raster_draw::draw_rect( + pixels, w, h, x0, y0, x1, y1, + Some(stroke_rgba), fill_rgba, thickness, + ); + }); } else { - None - }; - - let action = AddShapeAction::new( - active_layer_id, - *shared.playback_time, - path, - Some(StrokeStyle { width: *shared.stroke_width, ..Default::default() }), - Some(ShapeColor::from_egui(*shared.stroke_color)), - fill_color, - true, // closed - ).with_description("Add rectangle"); - let _ = shared.action_executor.execute(Box::new(action)); + use lightningbeam_core::shape::{ShapeColor, StrokeStyle}; + use lightningbeam_core::actions::AddShapeAction; + let path = Self::create_rectangle_path(min_x, min_y, max_x, max_y); + let fill_color = if *shared.fill_enabled { + Some(ShapeColor::from_egui(*shared.fill_color)) + } else { + None + }; + let action = AddShapeAction::new( + active_layer_id, + *shared.playback_time, + path, + Some(StrokeStyle { width: *shared.stroke_width, ..Default::default() }), + Some(ShapeColor::from_egui(*shared.stroke_color)), + fill_color, + true, // closed + ).with_description("Add rectangle"); + let _ = shared.action_executor.execute(Box::new(action)); + } // Clear tool state to stop preview rendering *shared.tool_state = ToolState::Idle; } @@ -3768,21 +4396,18 @@ impl StagePane { use lightningbeam_core::layer::AnyLayer; use vello::kurbo::Point; - // Check if we have an active vector layer let active_layer_id = match *shared.active_layer_id { Some(id) => id, None => return, }; - let active_layer = match shared.action_executor.document().get_layer(&active_layer_id) { - Some(layer) => layer, - None => return, - }; - - // Only work on VectorLayer - if !matches!(active_layer, AnyLayer::Vector(_)) { - return; - } + let is_raster = shared.action_executor.document() + .get_layer(&active_layer_id) + .map_or(false, |l| matches!(l, AnyLayer::Raster(_))); + let is_vector = shared.action_executor.document() + .get_layer(&active_layer_id) + .map_or(false, |l| matches!(l, AnyLayer::Vector(_))); + if !is_raster && !is_vector { return; } let point = self.snap_point(Point::new(world_pos.x as f64, world_pos.y as f64), shared); @@ -3850,28 +4475,42 @@ impl StagePane { // Only create shape if ellipse has non-zero size if rx > 1.0 && ry > 1.0 { - use lightningbeam_core::shape::{ShapeColor, StrokeStyle}; - use lightningbeam_core::actions::AddShapeAction; - - let path = Self::create_ellipse_path(position.x, position.y, rx, ry); - - let fill_color = if *shared.fill_enabled { - Some(ShapeColor::from_egui(*shared.fill_color)) + if is_raster { + let sc = *shared.stroke_color; + let fc = *shared.fill_color; + let fill_en = *shared.fill_enabled; + let thickness = *shared.stroke_width as f32; + let (cx, cy) = (position.x as f32 - 0.5, position.y as f32 - 0.5); + let (erx, ery) = (rx as f32, ry as f32); + let stroke_rgba = [sc.r(), sc.g(), sc.b(), sc.a()]; + let fill_rgba = fill_en.then(|| [fc.r(), fc.g(), fc.b(), fc.a()]); + Self::apply_raster_pixel_edit(shared, active_layer_id, "Draw ellipse", |pixels, w, h| { + lightningbeam_core::raster_draw::draw_ellipse( + pixels, w, h, cx, cy, erx, ery, + Some(stroke_rgba), fill_rgba, thickness, + ); + }); } else { - None - }; - - let action = AddShapeAction::new( - active_layer_id, - *shared.playback_time, - path, - Some(StrokeStyle { width: *shared.stroke_width, ..Default::default() }), - Some(ShapeColor::from_egui(*shared.stroke_color)), - fill_color, - true, // closed - ).with_description("Add ellipse"); - let _ = shared.action_executor.execute(Box::new(action)); + use lightningbeam_core::shape::{ShapeColor, StrokeStyle}; + use lightningbeam_core::actions::AddShapeAction; + let path = Self::create_ellipse_path(position.x, position.y, rx, ry); + let fill_color = if *shared.fill_enabled { + Some(ShapeColor::from_egui(*shared.fill_color)) + } else { + None + }; + let action = AddShapeAction::new( + active_layer_id, + *shared.playback_time, + path, + Some(StrokeStyle { width: *shared.stroke_width, ..Default::default() }), + Some(ShapeColor::from_egui(*shared.stroke_color)), + fill_color, + true, // closed + ).with_description("Add ellipse"); + let _ = shared.action_executor.execute(Box::new(action)); + } // Clear tool state to stop preview rendering *shared.tool_state = ToolState::Idle; } @@ -3884,7 +4523,7 @@ impl StagePane { ui: &mut egui::Ui, response: &egui::Response, world_pos: egui::Vec2, - _shift_held: bool, + shift_held: bool, _ctrl_held: bool, shared: &mut SharedPaneState, ) { @@ -3892,24 +4531,37 @@ impl StagePane { use lightningbeam_core::layer::AnyLayer; use vello::kurbo::Point; - // Check if we have an active vector layer let active_layer_id = match *shared.active_layer_id { Some(id) => id, None => return, }; - let active_layer = match shared.action_executor.document().get_layer(&active_layer_id) { - Some(layer) => layer, - None => return, - }; + let is_raster = shared.action_executor.document() + .get_layer(&active_layer_id) + .map_or(false, |l| matches!(l, AnyLayer::Raster(_))); + let is_vector = shared.action_executor.document() + .get_layer(&active_layer_id) + .map_or(false, |l| matches!(l, AnyLayer::Vector(_))); + if !is_raster && !is_vector { return; } - // Only work on VectorLayer - if !matches!(active_layer, AnyLayer::Vector(_)) { - return; + let mut point = self.snap_point(Point::new(world_pos.x as f64, world_pos.y as f64), shared); + + // Shift: snap to 45° angle increments (raster; also applied to vector for consistency). + if shift_held { + if let ToolState::CreatingLine { start_point, .. } = shared.tool_state { + let dx = point.x - start_point.x; + let dy = point.y - start_point.y; + let len = (dx * dx + dy * dy).sqrt(); + let angle = (dy as f32).atan2(dx as f32); + let snapped = (angle / (std::f32::consts::PI / 4.0)).round() + * (std::f32::consts::PI / 4.0); + point = Point::new( + start_point.x + len * snapped.cos() as f64, + start_point.y + len * snapped.sin() as f64, + ); + } } - let point = self.snap_point(Point::new(world_pos.x as f64, world_pos.y as f64), shared); - // Mouse down: start creating line if self.rsp_drag_started(response) || self.rsp_clicked(response) { *shared.tool_state = ToolState::CreatingLine { @@ -3931,30 +4583,38 @@ impl StagePane { // Mouse up: create the line shape if self.rsp_drag_stopped(response) || (self.rsp_any_released(ui) && matches!(shared.tool_state, ToolState::CreatingLine { .. })) { if let ToolState::CreatingLine { start_point, current_point } = shared.tool_state.clone() { - // Calculate line length to ensure it's not too small let dx = current_point.x - start_point.x; let dy = current_point.y - start_point.y; let length = (dx * dx + dy * dy).sqrt(); - // Only create shape if line has reasonable length if length > 1.0 { - use lightningbeam_core::shape::{ShapeColor, StrokeStyle}; - use lightningbeam_core::actions::AddShapeAction; + if is_raster { + let sc = *shared.stroke_color; + let thickness = *shared.stroke_width as f32; + let (ax, ay) = (start_point.x as f32 - 0.5, start_point.y as f32 - 0.5); + let (bx, by) = (current_point.x as f32 - 0.5, current_point.y as f32 - 0.5); + let stroke_rgba = [sc.r(), sc.g(), sc.b(), sc.a()]; + Self::apply_raster_pixel_edit(shared, active_layer_id, "Draw line", |pixels, w, h| { + lightningbeam_core::raster_draw::draw_line( + pixels, w, h, ax, ay, bx, by, stroke_rgba, thickness, + ); + }); + } else { + use lightningbeam_core::shape::{ShapeColor, StrokeStyle}; + use lightningbeam_core::actions::AddShapeAction; - let path = Self::create_line_path(start_point, current_point); - - let action = AddShapeAction::new( - active_layer_id, - *shared.playback_time, - path, - Some(StrokeStyle { width: *shared.stroke_width, ..Default::default() }), - Some(ShapeColor::from_egui(*shared.stroke_color)), - None, // no fill for lines - false, // not closed - ).with_description("Add line"); - let _ = shared.action_executor.execute(Box::new(action)); - - // Clear tool state to stop preview rendering + let path = Self::create_line_path(start_point, current_point); + let action = AddShapeAction::new( + active_layer_id, + *shared.playback_time, + path, + Some(StrokeStyle { width: *shared.stroke_width, ..Default::default() }), + Some(ShapeColor::from_egui(*shared.stroke_color)), + None, // no fill for lines + false, // not closed + ).with_description("Add line"); + let _ = shared.action_executor.execute(Box::new(action)); + } *shared.tool_state = ToolState::Idle; } } @@ -3974,7 +4634,6 @@ impl StagePane { use lightningbeam_core::layer::AnyLayer; use vello::kurbo::Point; - // Check if we have an active vector layer let active_layer_id = match *shared.active_layer_id { Some(id) => id, None => return, @@ -3985,11 +4644,13 @@ impl StagePane { None => return, }; - // Only work on VectorLayer - if !matches!(active_layer, AnyLayer::Vector(_)) { + let is_raster = matches!(active_layer, AnyLayer::Raster(_)); + let is_vector = matches!(active_layer, AnyLayer::Vector(_)); + if !is_raster && !is_vector { return; } + let num_sides = *shared.polygon_sides; let point = self.snap_point(Point::new(world_pos.x as f64, world_pos.y as f64), shared); // Mouse down: start creating polygon (center point) @@ -3997,7 +4658,7 @@ impl StagePane { *shared.tool_state = ToolState::CreatingPolygon { center: point, current_point: point, - num_sides: 5, // Default to 5 sides (pentagon) + num_sides, }; } @@ -4022,27 +4683,55 @@ impl StagePane { // Only create shape if polygon has reasonable size if radius > 5.0 { - use lightningbeam_core::shape::{ShapeColor, StrokeStyle}; - use lightningbeam_core::actions::AddShapeAction; + if is_raster { + use lightningbeam_core::raster_draw; + use std::f64::consts::TAU; - let path = Self::create_polygon_path(center, num_sides, radius); + let cx = center.x as f32 - 0.5; + let cy = center.y as f32 - 0.5; + let r = radius as f32; + let n = num_sides as usize; + let vertices: Vec<(f32, f32)> = (0..n).map(|i| { + let angle = (i as f64 / n as f64) * TAU - std::f64::consts::FRAC_PI_2; + (cx + r * angle.cos() as f32, cy + r * angle.sin() as f32) + }).collect(); - let fill_color = if *shared.fill_enabled { - Some(ShapeColor::from_egui(*shared.fill_color)) + let stroke_color = shared.stroke_color.to_array(); + let stroke_rgba = [stroke_color[0], stroke_color[1], stroke_color[2], stroke_color[3]]; + let fill_rgba = if *shared.fill_enabled { + let fc = shared.fill_color.to_array(); + Some([fc[0], fc[1], fc[2], fc[3]]) + } else { + None + }; + let thickness = *shared.stroke_width as f32; + + let _ = Self::apply_raster_pixel_edit(shared, active_layer_id, "Add polygon", |pixels, w, h| { + raster_draw::draw_polygon(pixels, w, h, &vertices, Some(stroke_rgba), fill_rgba, thickness); + }); } else { - None - }; + use lightningbeam_core::shape::{ShapeColor, StrokeStyle}; + use lightningbeam_core::actions::AddShapeAction; - let action = AddShapeAction::new( - active_layer_id, - *shared.playback_time, - path, - Some(StrokeStyle { width: *shared.stroke_width, ..Default::default() }), - Some(ShapeColor::from_egui(*shared.stroke_color)), - fill_color, - true, // closed - ).with_description("Add polygon"); - let _ = shared.action_executor.execute(Box::new(action)); + let path = Self::create_polygon_path(center, num_sides, radius); + + let fill_color = if *shared.fill_enabled { + Some(ShapeColor::from_egui(*shared.fill_color)) + } else { + None + }; + + let action = AddShapeAction::new( + active_layer_id, + *shared.playback_time, + path, + Some(StrokeStyle { width: *shared.stroke_width, ..Default::default() }), + Some(ShapeColor::from_egui(*shared.stroke_color)), + fill_color, + true, // closed + ).with_description("Add polygon"); + let _ = shared.action_executor.execute(Box::new(action)); + } // Clear tool state to stop preview rendering *shared.tool_state = ToolState::Idle; @@ -4667,7 +5356,8 @@ impl StagePane { let (w, h) = (kf.width, kf.height); let action = RasterStrokeAction::new( float.layer_id, float.time, - float.canvas_before, canvas_after, + std::sync::Arc::try_unwrap(float.canvas_before).unwrap_or_else(|a| (*a).clone()), + canvas_after, w, h, ); if let Err(e) = shared.action_executor.execute(Box::new(action)) { @@ -4727,6 +5417,268 @@ impl StagePane { mask } + /// Allocate the three A/B/C GPU canvases and build a [`crate::raster_tool::RasterWorkspace`] + /// for a new raster tool operation. + /// + /// Called on **mousedown** before any tool-specific code runs. The returned + /// [`crate::raster_tool::WorkspaceInitPacket`] must be stored in `self.pending_workspace_init` + /// so that [`VelloCallback::prepare`] can create the GPU textures on the first frame. + /// + /// - If a floating selection is active, the workspace targets it (Float path). + /// - Otherwise, any lingering float is committed first, then the active raster + /// layer's keyframe becomes the workspace source (Layer path). + /// + /// Returns `None` when there is no raster target (no active layer, or the active + /// layer is not a raster layer). + fn begin_raster_workspace( + shared: &mut SharedPaneState, + ) -> Option<(crate::raster_tool::RasterWorkspace, crate::raster_tool::WorkspaceInitPacket)> { + use crate::raster_tool::{WorkspaceInitPacket, WorkspaceSource, RasterWorkspace}; + use lightningbeam_core::layer::AnyLayer; + + if let Some(ref float) = shared.selection.raster_floating { + // ── Float-active path ───────────────────────────────────────── + // Paint onto the floating selection's existing GPU canvas (A). + // Do NOT commit the float; it remains active. + let pixels = if float.pixels.is_empty() { + vec![0u8; (float.width * float.height * 4) as usize] + } else { + (*float.pixels).clone() + }; + let (w, h, x, y) = (float.width, float.height, float.x, float.y); + + let a_id = uuid::Uuid::new_v4(); + let b_id = uuid::Uuid::new_v4(); + let c_id = uuid::Uuid::new_v4(); + + let ws = RasterWorkspace { + a_canvas_id: a_id, + b_canvas_id: b_id, + c_canvas_id: c_id, + mask_texture: None, + width: w, + height: h, + x, + y, + source: WorkspaceSource::Float, + before_pixels: pixels.clone(), + }; + let init = WorkspaceInitPacket { + a_canvas_id: a_id, + a_pixels: pixels, + b_canvas_id: b_id, + c_canvas_id: c_id, + width: w, + height: h, + }; + Some((ws, init)) + } else { + // ── Layer-active path ───────────────────────────────────────── + // Commit any lingering float so buffer_before reflects the fully-composited canvas. + Self::commit_raster_floating_now(shared); + + let layer_id = (*shared.active_layer_id)?; + let time = *shared.playback_time; + + let (doc_w, doc_h) = { + let doc = shared.action_executor.document(); + (doc.width as u32, doc.height as u32) + }; + + // Ensure the keyframe exists before reading its ID. + { + let doc = shared.action_executor.document_mut(); + if let Some(AnyLayer::Raster(rl)) = doc.get_layer_mut(&layer_id) { + rl.ensure_keyframe_at(time, doc_w, doc_h); + } else { + return None; // not a raster layer + } + } + + // Read keyframe id and pixels. + let (kf_id, w, h, pixels) = { + let doc = shared.action_executor.document(); + let AnyLayer::Raster(rl) = doc.get_layer(&layer_id)? else { return None }; + let kf = rl.keyframe_at(time)?; + let pixels = if kf.raw_pixels.is_empty() { + vec![0u8; (kf.width * kf.height * 4) as usize] + } else { + kf.raw_pixels.clone() + }; + (kf.id, kf.width, kf.height, pixels) + }; + + let a_id = uuid::Uuid::new_v4(); + let b_id = uuid::Uuid::new_v4(); + let c_id = uuid::Uuid::new_v4(); + + let ws = RasterWorkspace { + a_canvas_id: a_id, + b_canvas_id: b_id, + c_canvas_id: c_id, + mask_texture: None, + width: w, + height: h, + x: 0, + y: 0, + source: WorkspaceSource::Layer { + layer_id, + time, + kf_id, + canvas_w: doc_w, + canvas_h: doc_h, + }, + before_pixels: pixels.clone(), + }; + let init = WorkspaceInitPacket { + a_canvas_id: a_id, + a_pixels: pixels, + b_canvas_id: b_id, + c_canvas_id: c_id, + width: w, + height: h, + }; + Some((ws, init)) + } + } + + /// Unified raster stroke handler using the [`crate::raster_tool::RasterTool`] trait. + /// + /// Handles all paint-style brush tools (Paint, Pencil, Airbrush, Eraser, etc.). + /// - **mousedown**: calls `begin_raster_workspace()` + instantiates `BrushRasterTool`. + /// - **drag**: calls `tool.update()` each frame. + /// - **mouseup**: calls `tool.finish()`, schedules GPU B-canvas readback if committed. + fn handle_unified_raster_stroke_tool( + &mut self, + ui: &mut egui::Ui, + response: &egui::Response, + world_pos: egui::Vec2, + def: &'static dyn crate::tools::RasterToolDef, + shared: &mut SharedPaneState, + ) { + use lightningbeam_core::tool::ToolState; + use lightningbeam_core::raster_layer::RasterBlendMode; + use crate::raster_tool::{BrushRasterTool, RasterTool, WorkspaceSource}; + + let active_layer_id = match *shared.active_layer_id { + Some(id) => id, + None => return, + }; + + // Only operate on raster layers + let is_raster = shared.action_executor.document() + .get_layer(&active_layer_id) + .map_or(false, |l| matches!(l, lightningbeam_core::layer::AnyLayer::Raster(_))); + if !is_raster { return; } + + let blend_mode = def.blend_mode(); + + // ---------------------------------------------------------------- + // Mouse down: initialise the workspace and start the tool + // ---------------------------------------------------------------- + let stroke_start = (self.rsp_primary_pressed(ui) && response.hovered() + && self.active_raster_tool.is_none()) + || (self.rsp_clicked(response) && self.active_raster_tool.is_none()); + if stroke_start { + // Build brush settings from the tool definition. + let bp = def.brush_params(shared.raster_settings); + let (mut b, radius, opacity, hardness, spacing) = + (bp.base_settings, bp.radius, bp.opacity, bp.hardness, bp.spacing); + b.radius_log = radius.ln() - b.pressure_radius_gain * 0.5; + b.hardness = hardness; + b.opaque = opacity; + b.dabs_per_radius = spacing; + if matches!(blend_mode, RasterBlendMode::Smudge) { + b.dabs_per_actual_radius = 0.0; + b.smudge_radius_log = shared.raster_settings.smudge_strength; + } + if matches!(blend_mode, RasterBlendMode::BlurSharpen) { + b.dabs_per_actual_radius = 0.0; + } + let color = if matches!(blend_mode, RasterBlendMode::Erase) { + [1.0f32, 1.0, 1.0, 1.0] + } else { + let c = if shared.raster_settings.brush_use_fg { + *shared.stroke_color + } else { + *shared.fill_color + }; + let s2l = |v: u8| -> f32 { + let f = v as f32 / 255.0; + if f <= 0.04045 { f / 12.92 } else { ((f + 0.055) / 1.055).powf(2.4) } + }; + [s2l(c.r()), s2l(c.g()), s2l(c.b()), c.a() as f32 / 255.0] + }; + + if let Some((ws, init)) = Self::begin_raster_workspace(shared) { + let mut tool = Box::new(BrushRasterTool::new(color, b, blend_mode)); + self.raster_last_compute_time = ui.input(|i| i.time); + tool.begin(&ws, world_pos, 0.0, shared.raster_settings); + self.pending_workspace_init = Some(init); + *shared.tool_state = ToolState::DrawingRasterStroke { points: vec![] }; + self.active_raster_tool = Some((tool, ws)); + } + } + + // ---------------------------------------------------------------- + // Per-frame update: fires every frame while stroke is active so + // time-based brushes (airbrush) accumulate dabs even when stationary. + // ---------------------------------------------------------------- + if self.active_raster_tool.is_some() + && matches!(*shared.tool_state, ToolState::DrawingRasterStroke { .. }) + && !stroke_start + { + let current_time = ui.input(|i| i.time); + let dt = (current_time - self.raster_last_compute_time).clamp(0.0, 0.1) as f32; + self.raster_last_compute_time = current_time; + if let Some((ref mut tool, ref ws)) = self.active_raster_tool { + tool.update(ws, world_pos, dt, shared.raster_settings); + } + } + + // Keep egui repainting while a stroke is in progress. + if matches!(*shared.tool_state, ToolState::DrawingRasterStroke { .. }) { + ui.ctx().request_repaint(); + } + + // ---------------------------------------------------------------- + // Mouse up: finish the tool, trigger readback if needed + // ---------------------------------------------------------------- + let stroke_end = self.rsp_drag_stopped(response) + || (self.rsp_any_released(ui) + && self.active_raster_tool.is_some() + && matches!(*shared.tool_state, ToolState::DrawingRasterStroke { .. })); + if stroke_end { + *shared.tool_state = ToolState::Idle; + if self.active_raster_tool.is_some() { + let needs_commit = { + let (ref mut tool, ref ws) = self.active_raster_tool.as_mut().unwrap(); + tool.finish(ws) + }; + if needs_commit { + let ws = &self.active_raster_tool.as_ref().unwrap().1; + self.painting_float = matches!(ws.source, WorkspaceSource::Float); + let (undo_layer_id, undo_time) = match &ws.source { + WorkspaceSource::Layer { layer_id, time, .. } => (*layer_id, *time), + WorkspaceSource::Float => (uuid::Uuid::nil(), 0.0), + }; + self.pending_undo_before = Some(( + undo_layer_id, undo_time, ws.width, ws.height, + ws.before_pixels.clone(), + )); + self.pending_tool_readback_b = Some(ws.b_canvas_id); + self.active_tool_awaiting_readback = true; + // Keep active_raster_tool alive until render_content() consumes the result. + } else { + // No commit (no dabs were placed); discard immediately. + if let Some((_, ws)) = self.active_raster_tool.take() { + self.pending_canvas_removals.extend(ws.canvas_ids()); + } + } + } + } + } + fn lift_selection_to_float(shared: &mut SharedPaneState) { use lightningbeam_core::layer::AnyLayer; use lightningbeam_core::selection::RasterFloatingSelection; @@ -4767,14 +5719,14 @@ impl StagePane { // Re-set selection (commit_raster_floating_now cleared it) and create float. shared.selection.raster_selection = Some(sel); shared.selection.raster_floating = Some(RasterFloatingSelection { - pixels: float_pixels, + pixels: std::sync::Arc::new(float_pixels), width: w, height: h, x: x0, y: y0, layer_id, time, - canvas_before, + canvas_before: std::sync::Arc::new(canvas_before), canvas_id: uuid::Uuid::new_v4(), }); } @@ -4782,6 +5734,27 @@ impl StagePane { /// `self.pending_raster_dabs` for dispatch by `VelloCallback::prepare()`. /// /// The actual pixel rendering happens on the GPU (compute shader). The CPU + /// Build the `tool_params: [f32; 4]` for a StrokeRecord. + /// For clone/healing: [offset_x, offset_y, 0, 0] (computed from clone_stroke_offset). + /// For all other tools: delegates to def.tool_params(). + fn make_tool_params( + &self, + def: &dyn crate::tools::RasterToolDef, + shared: &SharedPaneState, + ) -> [f32; 4] { + use lightningbeam_core::raster_layer::RasterBlendMode; + match def.blend_mode() { + RasterBlendMode::CloneStamp | RasterBlendMode::Healing => { + if let Some((ox, oy)) = self.clone_stroke_offset { + [ox, oy, 0.0, 0.0] + } else { + [0.0; 4] + } + } + _ => def.tool_params(shared.raster_settings), + } + } + /// only does dab placement arithmetic (cheap). On stroke end a readback is /// requested so the undo system can capture the final pixel state. fn handle_raster_stroke_tool( @@ -4789,9 +5762,10 @@ impl StagePane { ui: &mut egui::Ui, response: &egui::Response, world_pos: egui::Vec2, - blend_mode: lightningbeam_core::raster_layer::RasterBlendMode, + def: &'static dyn crate::tools::RasterToolDef, shared: &mut SharedPaneState, ) { + let blend_mode = def.blend_mode(); use lightningbeam_core::tool::ToolState; use lightningbeam_core::layer::AnyLayer; use lightningbeam_core::raster_layer::StrokePoint; @@ -4810,24 +5784,37 @@ impl StagePane { if !is_raster { return; } let brush = { - use lightningbeam_core::brush_settings::BrushSettings; - BrushSettings { - radius_log: shared.brush_radius.ln(), - hardness: *shared.brush_hardness, - opaque: *shared.brush_opacity, - dabs_per_radius: *shared.brush_spacing, - color_h: 0.0, - color_s: 0.0, - color_v: 0.0, - pressure_radius_gain: 0.3, - pressure_opacity_gain: 0.8, + // Delegate brush parameter extraction to the tool definition. + let bp = def.brush_params(shared.raster_settings); + let (base_settings, radius, opacity, hardness, spacing) = + (bp.base_settings, bp.radius, bp.opacity, bp.hardness, bp.spacing); + let mut b = base_settings; + // Compensate for pressure_radius_gain so that the UI-chosen radius is the + // actual rendered radius at our fixed mouse pressure of 1.0. + // radius_at_pressure(1.0) = exp(radius_log + gain × 0.5) + // → radius_log = ln(radius) - gain × 0.5 + b.radius_log = radius.ln() - b.pressure_radius_gain * 0.5; + b.hardness = hardness; + b.opaque = opacity; + b.dabs_per_radius = spacing; + if matches!(blend_mode, lightningbeam_core::raster_layer::RasterBlendMode::Smudge) { + // Zero dabs_per_actual_radius so the spacing slider is the sole density control. + b.dabs_per_actual_radius = 0.0; + // strength controls how far behind the stroke to sample (smudge_dist multiplier). + // smudge_dist = radius * exp(smudge_radius_log), so log(strength) gives the ratio. + b.smudge_radius_log = shared.raster_settings.smudge_strength; // linear [0,1] strength } + if matches!(blend_mode, lightningbeam_core::raster_layer::RasterBlendMode::BlurSharpen) { + // Zero dabs_per_actual_radius so the spacing slider is the sole density control. + b.dabs_per_actual_radius = 0.0; + } + b }; let color = if matches!(blend_mode, lightningbeam_core::raster_layer::RasterBlendMode::Erase) { [1.0f32, 1.0, 1.0, 1.0] } else { - let c = if *shared.brush_use_fg { *shared.stroke_color } else { *shared.fill_color }; + let c = if shared.raster_settings.brush_use_fg { *shared.stroke_color } else { *shared.fill_color }; let s2l = |v: u8| -> f32 { let f = v as f32 / 255.0; if f <= 0.04045 { f / 12.92 } else { ((f + 0.055) / 1.055).powf(2.4) } @@ -4838,7 +5825,27 @@ impl StagePane { // ---------------------------------------------------------------- // Mouse down: capture buffer_before, start stroke, compute first dab // ---------------------------------------------------------------- - if self.rsp_drag_started(response) || self.rsp_clicked(response) { + // Use primary_pressed (fires immediately on mouse-down) so the first dab + // appears before any drag movement. Guard against re-triggering if a stroke + // is already in progress. + // rsp_clicked fires on the release frame of a quick click; the first condition + // already handles the press frame with is_none() guard. The clicked guard is + // only needed when no stroke is active (avoids re-starting mid-stroke). + let stroke_start = (self.rsp_primary_pressed(ui) && response.hovered() + && self.raster_stroke_state.is_none()) + || (self.rsp_clicked(response) && self.raster_stroke_state.is_none()); + if stroke_start { + // Clone stamp / healing brush: compute and store the source offset (source - drag_start). + // This is constant for the entire stroke and used in every StrokeRecord below. + if matches!(blend_mode, lightningbeam_core::raster_layer::RasterBlendMode::CloneStamp + | lightningbeam_core::raster_layer::RasterBlendMode::Healing) { + self.clone_stroke_offset = shared.raster_settings.clone_source.map(|s| ( + s.x - world_pos.x, s.y - world_pos.y, + )); + } else { + self.clone_stroke_offset = None; + } + // Determine if we are painting into the float (B) or the layer (A). let painting_float = shared.selection.raster_floating.is_some(); self.painting_float = painting_float; @@ -4850,14 +5857,13 @@ impl StagePane { let (canvas_id, float_x, float_y, canvas_width, canvas_height, buffer_before, layer_id, time) = { let float = shared.selection.raster_floating.as_ref().unwrap(); - let buf = float.pixels.clone(); + let buf = (*float.pixels).clone(); (float.canvas_id, float.x, float.y, float.width, float.height, buf, float.layer_id, float.time) }; // Compute first dab (same arithmetic as the layer case). let mut stroke_state = StrokeState::new(); - stroke_state.distance_since_last_dab = f32::MAX; // Convert to float-local space: dabs must be in canvas pixel coords. let first_pt = StrokePoint { x: world_pos.x - float_x as f32, @@ -4868,9 +5874,11 @@ impl StagePane { brush_settings: brush.clone(), color, blend_mode, + tool_params: self.make_tool_params(def, shared), points: vec![first_pt.clone()], }; - let (dabs, dab_bbox) = BrushEngine::compute_dabs(&single, &mut stroke_state); + let (dabs, dab_bbox) = BrushEngine::compute_dabs(&single, &mut stroke_state, 0.0); + self.raster_last_compute_time = ui.input(|i| i.time); self.painting_canvas = Some((layer_id, canvas_id)); self.pending_undo_before = Some(( @@ -4946,7 +5954,6 @@ impl StagePane { // Compute the first dab (single-point tap) let mut stroke_state = StrokeState::new(); - stroke_state.distance_since_last_dab = f32::MAX; let first_pt = StrokePoint { x: world_pos.x, y: world_pos.y, @@ -4956,9 +5963,11 @@ impl StagePane { brush_settings: brush.clone(), color, blend_mode, + tool_params: self.make_tool_params(def, shared), points: vec![first_pt.clone()], }; - let (dabs, dab_bbox) = BrushEngine::compute_dabs(&single, &mut stroke_state); + let (dabs, dab_bbox) = BrushEngine::compute_dabs(&single, &mut stroke_state, 0.0); + self.raster_last_compute_time = ui.input(|i| i.time); // Layer strokes apply selection masking at readback time via stroke_clip_selection. @@ -4996,6 +6005,7 @@ impl StagePane { // Mouse drag: compute dabs for this segment // ---------------------------------------------------------------- if self.rsp_dragged(response) { + let tool_params = self.make_tool_params(def, shared); if let Some((layer_id, time, ref mut stroke_state, _)) = self.raster_stroke_state { if let Some(prev_pt) = self.raster_last_point.take() { // Get canvas info and float offset now (used for both distance check @@ -5038,9 +6048,13 @@ impl StagePane { brush_settings: brush.clone(), color, blend_mode, + tool_params, points: vec![prev_pt, curr_local], }; - let (dabs, dab_bbox) = BrushEngine::compute_dabs(&seg, stroke_state); + let current_time = ui.input(|i| i.time); + let dt = (current_time - self.raster_last_compute_time).clamp(0.0, 0.1) as f32; + self.raster_last_compute_time = current_time; + let (dabs, dab_bbox) = BrushEngine::compute_dabs(&seg, stroke_state, dt); self.pending_raster_dabs = Some(PendingRasterDabs { keyframe_id: canvas_id, layer_id, @@ -5059,6 +6073,80 @@ impl StagePane { } } + // ---------------------------------------------------------------- + // Stationary time-based dabs: when the mouse hasn't moved this frame, + // still pass dt to the engine so time-based brushes (airbrush, etc.) + // can accumulate and fire at the cursor position. + // ---------------------------------------------------------------- + if self.pending_raster_dabs.is_none() + && matches!(*shared.tool_state, ToolState::DrawingRasterStroke { .. }) + { + let current_time = ui.input(|i| i.time); + if self.raster_last_compute_time > 0.0 { + let dt = (current_time - self.raster_last_compute_time).clamp(0.0, 0.1) as f32; + self.raster_last_compute_time = current_time; + let tool_params = self.make_tool_params(def, shared); + + if let Some((layer_id, time, ref mut stroke_state, _)) = self.raster_stroke_state { + let canvas_info = if self.painting_float { + shared.selection.raster_floating.as_ref().map(|f| { + (f.canvas_id, f.width, f.height, f.x as f32, f.y as f32) + }) + } else { + let doc = shared.action_executor.document(); + if let Some(AnyLayer::Raster(rl)) = doc.get_layer(&layer_id) { + if let Some(kf) = rl.keyframe_at(time) { + Some((kf.id, kf.width, kf.height, 0.0f32, 0.0f32)) + } else { None } + } else { None } + }; + + if let Some((canvas_id, cw, ch, cx, cy)) = canvas_info { + let pt = StrokePoint { + x: world_pos.x - cx, + y: world_pos.y - cy, + pressure: 1.0, tilt_x: 0.0, tilt_y: 0.0, timestamp: 0.0, + }; + let single = StrokeRecord { + brush_settings: brush.clone(), + color, + blend_mode, + tool_params, + points: vec![pt], + }; + let (dabs, dab_bbox) = BrushEngine::compute_dabs(&single, stroke_state, dt); + if !dabs.is_empty() { + self.pending_raster_dabs = Some(PendingRasterDabs { + keyframe_id: canvas_id, + layer_id, + time, + canvas_width: cw, + canvas_height: ch, + initial_pixels: None, + dabs, + dab_bbox, + wants_final_readback: false, + }); + } + } + } + } + } + + // Reset compute-time tracker when stroke ends so next stroke starts fresh. + if !matches!(*shared.tool_state, ToolState::DrawingRasterStroke { .. }) { + self.raster_last_compute_time = 0.0; + } + + // Keep egui repainting while a stroke is active so that: + // 1. Time-based dabs (dabs_per_second) fire at the correct rate even when the + // mouse is held stationary (no move events → no automatic egui repaint). + // 2. The post-stroke Vello update (consuming the readback result) happens on + // the very next frame rather than waiting for the next user input event. + if matches!(*shared.tool_state, ToolState::DrawingRasterStroke { .. }) { + ui.ctx().request_repaint(); + } + // ---------------------------------------------------------------- // Mouse up: request a full-canvas readback for the undo snapshot // ---------------------------------------------------------------- @@ -5106,6 +6194,88 @@ impl StagePane { } /// Rectangular marquee selection tool for raster layers. + /// Snapshot the active raster keyframe pixels, pass them to `draw_fn` to + /// modify the buffer, then apply the result as an undoable `RasterFillAction`. + /// + /// Returns `false` if the layer or keyframe is not available. + fn apply_raster_pixel_edit( + shared: &mut SharedPaneState, + layer_id: uuid::Uuid, + description: &'static str, + draw_fn: F, + ) -> bool + where + F: FnOnce(&mut [u8], u32, u32), + { + use lightningbeam_core::layer::AnyLayer; + use lightningbeam_core::actions::RasterFillAction; + + let time = *shared.playback_time; + // Canvas dimensions (to create keyframe if needed). + let (doc_w, doc_h) = { + let doc = shared.action_executor.document(); + (doc.width as u32, doc.height as u32) + }; + // Ensure a keyframe exists at the current time. + { + let doc = shared.action_executor.document_mut(); + if let Some(AnyLayer::Raster(rl)) = doc.get_layer_mut(&layer_id) { + rl.ensure_keyframe_at(time, doc_w, doc_h); + } + } + // Snapshot the pixel buffer before drawing. + let (buffer_before, w, h) = { + let doc = shared.action_executor.document(); + match doc.get_layer(&layer_id) { + Some(AnyLayer::Raster(rl)) => match rl.keyframe_at(time) { + Some(kf) => { + let expected = (kf.width * kf.height * 4) as usize; + let buf = if kf.raw_pixels.len() == expected { + kf.raw_pixels.clone() + } else { + vec![0u8; expected] + }; + (buf, kf.width, kf.height) + } + None => return false, + }, + _ => return false, + } + }; + let mut buffer_after = buffer_before.clone(); + draw_fn(&mut buffer_after, w, h); + let action = RasterFillAction::new(layer_id, time, buffer_before, buffer_after, w, h) + .with_description(description); + let _ = shared.action_executor.execute(Box::new(action)); + true + } + + /// Build a per-pixel boolean mask for an ellipse inscribed in the given + /// axis-aligned bounding box. Used by the elliptical marquee mode. + fn make_ellipse_mask(x0: i32, y0: i32, x1: i32, y1: i32) -> lightningbeam_core::selection::RasterSelection { + use lightningbeam_core::selection::RasterSelection; + let w = (x1 - x0) as u32; + let h = (y1 - y0) as u32; + if w == 0 || h == 0 { + return RasterSelection::Mask { data: vec![], width: 0, height: 0, origin_x: x0, origin_y: y0 }; + } + // Center in local pixel space. Add 0.5 to radii so the ellipse + // touches every edge pixel without cutting them off. + let cx = (w as f32 - 1.0) / 2.0; + let cy = (h as f32 - 1.0) / 2.0; + let rx = cx + 0.5; + let ry = cy + 0.5; + let mut data = vec![false; (w * h) as usize]; + for row in 0..h { + for col in 0..w { + let dx = (col as f32 - cx) / rx; + let dy = (row as f32 - cy) / ry; + data[(row * w + col) as usize] = dx * dx + dy * dy <= 1.0; + } + } + RasterSelection::Mask { data, width: w, height: h, origin_x: x0, origin_y: y0 } + } + fn handle_raster_select_tool( &mut self, ui: &mut egui::Ui, @@ -5116,6 +6286,7 @@ impl StagePane { use lightningbeam_core::layer::AnyLayer; use lightningbeam_core::selection::RasterSelection; use lightningbeam_core::tool::ToolState; + use crate::tools::SelectionShape; let Some(layer_id) = *shared.active_layer_id else { return }; let doc = shared.action_executor.document(); @@ -5123,6 +6294,7 @@ impl StagePane { if let AnyLayer::Raster(rl) = l { rl.keyframe_at(*shared.playback_time) } else { None } }) else { return }; let (canvas_w, canvas_h) = (kf.width as i32, kf.height as i32); + let ellipse = shared.raster_settings.select_shape == SelectionShape::Ellipse; if self.rsp_drag_started(response) { let (px, py) = (world_pos.x as i32, world_pos.y as i32); @@ -5154,7 +6326,11 @@ impl StagePane { *current = (px, py); let (x0, x1) = (start.0.min(px).max(0), start.0.max(px).min(canvas_w)); let (y0, y1) = (start.1.min(py).max(0), start.1.max(py).min(canvas_h)); - shared.selection.raster_selection = Some(RasterSelection::Rect(x0, y0, x1, y1)); + shared.selection.raster_selection = Some(if ellipse { + Self::make_ellipse_mask(x0, y0, x1, y1) + } else { + RasterSelection::Rect(x0, y0, x1, y1) + }); } ToolState::MovingRasterSelection { ref mut last } => { let (dx, dy) = (px - last.0, py - last.1); @@ -5166,6 +6342,13 @@ impl StagePane { RasterSelection::Rect(*x0 + dx, *y0 + dy, *x1 + dx, *y1 + dy), RasterSelection::Lasso(pts) => RasterSelection::Lasso(pts.iter().map(|(x, y)| (x + dx, y + dy)).collect()), + RasterSelection::Mask { data, width, height, origin_x, origin_y } => + RasterSelection::Mask { + data: std::mem::take(data), + width: *width, height: *height, + origin_x: *origin_x + dx, + origin_y: *origin_y + dy, + }, }; } // Shift floating pixels if any. @@ -5184,7 +6367,11 @@ impl StagePane { let (x0, x1) = (start.0.min(current.0).max(0), start.0.max(current.0).min(canvas_w)); let (y0, y1) = (start.1.min(current.1).max(0), start.1.max(current.1).min(canvas_h)); if x1 > x0 && y1 > y0 { - shared.selection.raster_selection = Some(RasterSelection::Rect(x0, y0, x1, y1)); + shared.selection.raster_selection = Some(if ellipse { + Self::make_ellipse_mask(x0, y0, x1, y1) + } else { + RasterSelection::Rect(x0, y0, x1, y1) + }); Self::lift_selection_to_float(shared); } else { shared.selection.raster_selection = None; @@ -5334,31 +6521,28 @@ impl StagePane { shared: &mut SharedPaneState, ) { use lightningbeam_core::layer::AnyLayer; - use lightningbeam_core::shape::ShapeColor; - use lightningbeam_core::actions::PaintBucketAction; - use vello::kurbo::Point; - // Check if we have an active vector layer let active_layer_id = match shared.active_layer_id { - Some(id) => id, + Some(id) => *id, None => return, }; - let active_layer = match shared.action_executor.document().get_layer(&active_layer_id) { - Some(layer) => layer, - None => return, - }; + if !self.rsp_clicked(response) { return; } - if !matches!(active_layer, AnyLayer::Vector(_)) { - return; - } + let is_raster = shared.action_executor.document() + .get_layer(&active_layer_id) + .map_or(false, |l| matches!(l, AnyLayer::Raster(_))); - if self.rsp_clicked(response) { + if is_raster { + self.handle_raster_paint_bucket(world_pos, active_layer_id, shared); + } else { + use lightningbeam_core::shape::ShapeColor; + use lightningbeam_core::actions::PaintBucketAction; + use vello::kurbo::Point; let click_point = Point::new(world_pos.x as f64, world_pos.y as f64); let fill_color = ShapeColor::from_egui(*shared.fill_color); - let action = PaintBucketAction::new( - *active_layer_id, + active_layer_id, *shared.playback_time, click_point, fill_color, @@ -5367,6 +6551,416 @@ impl StagePane { } } + fn handle_raster_paint_bucket( + &mut self, + world_pos: egui::Vec2, + layer_id: uuid::Uuid, + shared: &mut SharedPaneState, + ) { + use lightningbeam_core::layer::AnyLayer; + use lightningbeam_core::actions::RasterFillAction; + use lightningbeam_core::flood_fill::{raster_flood_fill, FillThresholdMode}; + use crate::tools::FillThresholdMode as EditorMode; + + let time = *shared.playback_time; + + // Ensure a keyframe exists at the current time. + let (doc_w, doc_h) = { + let doc = shared.action_executor.document(); + (doc.width as u32, doc.height as u32) + }; + { + let doc = shared.action_executor.document_mut(); + if let Some(AnyLayer::Raster(rl)) = doc.get_layer_mut(&layer_id) { + rl.ensure_keyframe_at(time, doc_w, doc_h); + } + } + + // Snapshot current pixels. + let (buffer_before, width, height) = { + let doc = shared.action_executor.document(); + if let Some(AnyLayer::Raster(rl)) = doc.get_layer(&layer_id) { + if let Some(kf) = rl.keyframe_at(time) { + let expected = (kf.width * kf.height * 4) as usize; + let buf = if kf.raw_pixels.len() == expected { + kf.raw_pixels.clone() + } else { + vec![0u8; expected] + }; + (buf, kf.width, kf.height) + } else { return; } + } else { return; } + }; + + let seed_x = world_pos.x as i32; + let seed_y = world_pos.y as i32; + if seed_x < 0 || seed_y < 0 || seed_x >= width as i32 || seed_y >= height as i32 { + return; + } + + let fill_egui = *shared.fill_color; + let fill_color = [fill_egui.r(), fill_egui.g(), fill_egui.b(), fill_egui.a()]; + let threshold = shared.raster_settings.fill_threshold; + let softness = shared.raster_settings.fill_softness; + let core_mode = match shared.raster_settings.fill_threshold_mode { + EditorMode::Absolute => FillThresholdMode::Absolute, + EditorMode::Relative => FillThresholdMode::Relative, + }; + + let mut buffer_after = buffer_before.clone(); + raster_flood_fill( + &mut buffer_after, + width, height, + seed_x, seed_y, + fill_color, + threshold, softness, + core_mode, + true, // paint bucket always fills contiguous region + shared.selection.raster_selection.as_ref(), + ); + + let action = RasterFillAction::new(layer_id, time, buffer_before, buffer_after, width, height); + let _ = shared.action_executor.execute(Box::new(action)); + } + + fn handle_magic_wand_tool( + &mut self, + response: &egui::Response, + world_pos: egui::Vec2, + shared: &mut SharedPaneState, + ) { + use lightningbeam_core::layer::AnyLayer; + use lightningbeam_core::flood_fill::{raster_fill_mask, FillThresholdMode}; + use lightningbeam_core::selection::RasterSelection; + use crate::tools::FillThresholdMode as EditorMode; + + if !self.rsp_clicked(response) { return; } + + 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, AnyLayer::Raster(_))); + if !is_raster { return; } + + let time = *shared.playback_time; + + // Ensure keyframe exists. + let (doc_w, doc_h) = { + let doc = shared.action_executor.document(); + (doc.width as u32, doc.height as u32) + }; + { + let doc = shared.action_executor.document_mut(); + if let Some(AnyLayer::Raster(rl)) = doc.get_layer_mut(&layer_id) { + rl.ensure_keyframe_at(time, doc_w, doc_h); + } + } + + let (pixels, width, height) = { + let doc = shared.action_executor.document(); + if let Some(AnyLayer::Raster(rl)) = doc.get_layer(&layer_id) { + if let Some(kf) = rl.keyframe_at(time) { + let expected = (kf.width * kf.height * 4) as usize; + let buf = if kf.raw_pixels.len() == expected { + kf.raw_pixels.clone() + } else { + vec![0u8; expected] + }; + (buf, kf.width, kf.height) + } else { return; } + } else { return; } + }; + + let seed_x = world_pos.x as i32; + let seed_y = world_pos.y as i32; + if seed_x < 0 || seed_y < 0 || seed_x >= width as i32 || seed_y >= height as i32 { + return; + } + + let threshold = shared.raster_settings.wand_threshold; + let contiguous = shared.raster_settings.wand_contiguous; + let core_mode = match shared.raster_settings.wand_mode { + EditorMode::Absolute => FillThresholdMode::Absolute, + EditorMode::Relative => FillThresholdMode::Relative, + }; + + // Use existing raster_selection as clip if present (so the wand only + // selects inside the current selection — Shift/Intersect not yet supported). + let dist_map = raster_fill_mask( + &pixels, width, height, + seed_x, seed_y, + threshold, core_mode, contiguous, + None, // ignore existing selection for wand — it defines a new one + ); + + let data: Vec = dist_map.iter().map(|d| d.is_some()).collect(); + + shared.selection.raster_selection = Some(RasterSelection::Mask { + data, + width, + height, + origin_x: 0, + origin_y: 0, + }); + Self::lift_selection_to_float(shared); + } + + fn handle_quick_select_tool( + &mut self, + ui: &mut egui::Ui, + response: &egui::Response, + world_pos: egui::Vec2, + shared: &mut SharedPaneState, + ) { + use lightningbeam_core::layer::AnyLayer; + use lightningbeam_core::selection::RasterSelection; + + 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, AnyLayer::Raster(_))); + if !is_raster { return; } + + let time = *shared.playback_time; + let radius = shared.raster_settings.quick_select_radius; + let threshold = shared.raster_settings.wand_threshold; + + if self.rsp_drag_started(response) { + // Commit any existing float selection before starting a new one. + Self::commit_raster_floating_now(shared); + + // Ensure the keyframe exists. + let (doc_w, doc_h) = { + let doc = shared.action_executor.document(); + (doc.width as u32, doc.height as u32) + }; + { + let doc = shared.action_executor.document_mut(); + if let Some(AnyLayer::Raster(rl)) = doc.get_layer_mut(&layer_id) { + rl.ensure_keyframe_at(time, doc_w, doc_h); + } + } + + // Snapshot canvas pixels. + let (pixels, width, height) = { + let doc = shared.action_executor.document(); + if let Some(AnyLayer::Raster(rl)) = doc.get_layer(&layer_id) { + if let Some(kf) = rl.keyframe_at(time) { + let expected = (kf.width * kf.height * 4) as usize; + let buf = if kf.raw_pixels.len() == expected { + kf.raw_pixels.clone() + } else { + vec![0u8; expected] + }; + (buf, kf.width, kf.height) + } else { return; } + } else { return; } + }; + + let seed_x = world_pos.x as i32; + let seed_y = world_pos.y as i32; + let mask = vec![false; (width * height) as usize]; + + let mut qs = QuickSelectState { + mask, + pixels, + width, + height, + last_pos: (seed_x - (radius as i32 * 2), seed_y), // force first fill + }; + + // Run the initial fill at the starting position. + let mode = match shared.raster_settings.wand_mode { + crate::tools::FillThresholdMode::Absolute => + lightningbeam_core::flood_fill::FillThresholdMode::Absolute, + crate::tools::FillThresholdMode::Relative => + lightningbeam_core::flood_fill::FillThresholdMode::Relative, + }; + Self::quick_select_fill_point(&mut qs, seed_x, seed_y, threshold, mode, radius); + + shared.selection.raster_selection = Some(RasterSelection::Mask { + data: qs.mask.clone(), + width: qs.width, + height: qs.height, + origin_x: 0, + origin_y: 0, + }); + + self.quick_select_state = Some(qs); + } + + if self.rsp_dragged(response) { + let mode = match shared.raster_settings.wand_mode { + crate::tools::FillThresholdMode::Absolute => + lightningbeam_core::flood_fill::FillThresholdMode::Absolute, + crate::tools::FillThresholdMode::Relative => + lightningbeam_core::flood_fill::FillThresholdMode::Relative, + }; + + if let Some(ref mut qs) = self.quick_select_state { + let sx = world_pos.x as i32; + let sy = world_pos.y as i32; + let dx = sx - qs.last_pos.0; + let dy = sy - qs.last_pos.1; + let min_move = (radius / 2.0).max(1.0) as i32; + if dx * dx + dy * dy >= min_move * min_move { + Self::quick_select_fill_point(qs, sx, sy, threshold, mode, radius); + } + // Always sync raster_selection from the current mask so the + // marching ants update every frame (same pattern as marquee select). + shared.selection.raster_selection = Some(RasterSelection::Mask { + data: qs.mask.clone(), + width: qs.width, + height: qs.height, + origin_x: 0, + origin_y: 0, + }); + } + } + + if self.rsp_drag_stopped(response) { + if self.quick_select_state.is_some() { + Self::lift_selection_to_float(shared); + self.quick_select_state = None; + } + } + } + + /// Run a single flood-fill from `(seed_x, seed_y)` clipped to a local region + /// and OR the result into `qs.mask`. + fn quick_select_fill_point( + qs: &mut QuickSelectState, + seed_x: i32, seed_y: i32, + threshold: f32, + mode: lightningbeam_core::flood_fill::FillThresholdMode, + radius: f32, + ) { + use lightningbeam_core::flood_fill::raster_fill_mask; + use lightningbeam_core::selection::RasterSelection; + + if seed_x < 0 || seed_y < 0 + || seed_x >= qs.width as i32 + || seed_y >= qs.height as i32 + { + return; + } + + let expand = (radius * 3.0) as i32; + let clip_x0 = (seed_x - expand).max(0); + let clip_y0 = (seed_y - expand).max(0); + let clip_x1 = (seed_x + expand).min(qs.width as i32); + let clip_y1 = (seed_y + expand).min(qs.height as i32); + let clip = RasterSelection::Rect(clip_x0, clip_y0, clip_x1, clip_y1); + + let dist_map = raster_fill_mask( + &qs.pixels, qs.width, qs.height, + seed_x, seed_y, + threshold, mode, true, // contiguous = true + Some(&clip), + ); + + for (i, d) in dist_map.iter().enumerate() { + if d.is_some() { + qs.mask[i] = true; + } + } + qs.last_pos = (seed_x, seed_y); + } + + /// Draw marching ants for a pixel mask selection. + /// + /// Animates horizontal edges leftward and vertical edges downward (position-based), + /// producing a coherent clockwise-like marching effect without contour tracing. + fn draw_marching_ants_mask( + painter: &egui::Painter, + rect_min: egui::Pos2, + data: &[bool], + width: u32, height: u32, + origin_x: i32, origin_y: i32, + zoom: f32, pan: egui::Vec2, + phase: f32, + ) { + let w = width as i32; + let h = height as i32; + + // Phase in screen pixels: 4px on, 4px off cycling every 8 screen pixels. + // One canvas pixel = zoom screen pixels; scale phase accordingly. + let screen_phase = phase; // already in screen pixels (matches draw_marching_ants) + let cycle_canvas = 8.0 / zoom.max(0.01); // canvas-pixel length of a full 8-screen-px cycle + let half_cycle_canvas = cycle_canvas / 2.0; + + let to_screen = |cx: i32, cy: i32| egui::pos2( + rect_min.x + pan.x + cx as f32 * zoom, + rect_min.y + pan.y + cy as f32 * zoom, + ); + + // Pre-scan: compute tight bounding box of set pixels so we don't iterate + // the full canvas every frame (critical perf for large canvases with small masks). + let mut min_row = h; + let mut max_row = -1i32; + let mut min_col = w; + let mut max_col = -1i32; + for row in 0..h { + for col in 0..w { + if data[(row * w + col) as usize] { + if row < min_row { min_row = row; } + if row > max_row { max_row = row; } + if col < min_col { min_col = col; } + if col > max_col { max_col = col; } + } + } + } + if max_row < 0 { return; } // Empty mask — nothing to draw. + let r0 = (min_row - 1).max(0); + let r1 = (max_row + 1).min(h - 1); + let c0 = (min_col - 1).max(0); + let c1 = (max_col + 1).min(w - 1); + + // Horizontal edges: between (row-1) and (row). Animate along x axis. + // Use screen-space phase so the dash pattern looks correct at any zoom. + for row in r0..=(r1 + 1) { + for col in c0..=c1 { + let above = row > 0 && data[((row-1) * w + col) as usize]; + let below = row < h && data[(row * w + col) as usize]; + if above == below { continue; } + let cx = origin_x + col; + let cy = origin_y + row; + // canvas-pixel position along the edge, converted to screen pixels for phase + let cx_screen = cx as f32 * zoom; + let on = (cx_screen - screen_phase).rem_euclid(8.0) < 4.0; + // Also check next pixel to handle partial overlap of the 4-px window + let _ = half_cycle_canvas; // suppress unused warning + if on { + let p0 = to_screen(cx, cy); + let p1 = to_screen(cx + 1, cy); + painter.line_segment([p0, p1], egui::Stroke::new(2.5, egui::Color32::WHITE)); + painter.line_segment([p0, p1], egui::Stroke::new(1.5, egui::Color32::BLACK)); + } + } + } + + // Vertical edges: between (col-1) and (col). Animate along y axis. + for col in c0..=(c1 + 1) { + for row in r0..=r1 { + let left = col > 0 && data[(row * w + col - 1) as usize]; + let right = col < w && data[(row * w + col ) as usize]; + if left == right { continue; } + let cx = origin_x + col; + let cy = origin_y + row; + let cy_screen = cy as f32 * zoom; + let on = (cy_screen - screen_phase).rem_euclid(8.0) < 4.0; + if on { + let p0 = to_screen(cx, cy); + let p1 = to_screen(cx, cy + 1); + painter.line_segment([p0, p1], egui::Stroke::new(2.5, egui::Color32::WHITE)); + painter.line_segment([p0, p1], egui::Stroke::new(1.5, egui::Color32::BLACK)); + } + } + } + } + /// Apply transform preview to objects based on current mouse position fn apply_transform_preview( vector_layer: &mut lightningbeam_core::layer::VectorLayer, @@ -6018,6 +7612,560 @@ impl StagePane { } } + // ------------------------------------------------------------------------- + // Raster transform tool + // ------------------------------------------------------------------------- + + /// CPU computation for raster transform: output AABB and inverse affine matrix. + /// + /// Returns `(new_w, new_h, new_x, new_y, a00, a01, a10, a11, b0, b1)` where + /// the inverse affine maps output pixel (ox, oy) → source pixel (sx, sy): + /// sx = a00*ox + a01*oy + b0 + /// sy = a10*ox + a11*oy + b1 + fn compute_transform_params( + orig_w: u32, orig_h: u32, + cx: f32, cy: f32, + scale_x: f32, scale_y: f32, + angle: f32, + ) -> (u32, u32, i32, i32, f32, f32, f32, f32, f32, f32) { + let hw = scale_x * orig_w as f32 / 2.0; + let hh = scale_y * orig_h as f32 / 2.0; + let cos_a = angle.cos(); + let sin_a = angle.sin(); + + // Rotate corners of scaled rect around (cx, cy) + let local = [(-hw, -hh), (hw, -hh), (-hw, hh), (hw, hh)]; + let rotated: [(f32, f32); 4] = local.map(|(lx, ly)| { + (cx + lx * cos_a - ly * sin_a, cy + lx * sin_a + ly * cos_a) + }); + + // AABB of rotated corners + let min_x = rotated.iter().map(|p| p.0).fold(f32::INFINITY, f32::min).floor(); + let min_y = rotated.iter().map(|p| p.1).fold(f32::INFINITY, f32::min).floor(); + let max_x = rotated.iter().map(|p| p.0).fold(f32::NEG_INFINITY, f32::max).ceil(); + let max_y = rotated.iter().map(|p| p.1).fold(f32::NEG_INFINITY, f32::max).ceil(); + let new_x = min_x as i32; + let new_y = min_y as i32; + let new_w = ((max_x - min_x).max(1.0)) as u32; + let new_h = ((max_y - min_y).max(1.0)) as u32; + + // Inverse affine: R^-1 * S^-1 + // Forward: dst = cx + R * S * (src_center_offset) + // Inverse: src_pixel = (src_w/2, src_h/2) + S^-1 * R^-1 * (out_pixel - cx, out_pixel_y - cy) + // with out_pixel center accounted for by baking +0.5 into b (CPU side). + let a00 = cos_a / scale_x; + let a01 = sin_a / scale_x; + let a10 = -sin_a / scale_y; + let a11 = cos_a / scale_y; + + // b accounts for the center offset and the new AABB origin. + // For output pixel (ox, oy) at its center (ox + 0.5, oy + 0.5) in output canvas coords, + // the source pixel is: + // (src_w/2, src_h/2) + A^-1 * ((new_x + ox + 0.5) - cx, (new_y + oy + 0.5) - cy) + // We bake (new_x + 0.5 - cx, new_y + 0.5 - cy) into b so the shader just uses ox/oy directly. + let off_x = new_x as f32 + 0.5 - cx; + let off_y = new_y as f32 + 0.5 - cy; + let b0 = orig_w as f32 / 2.0 + a00 * off_x + a01 * off_y; + let b1 = orig_h as f32 / 2.0 + a10 * off_x + a11 * off_y; + + (new_w, new_h, new_x, new_y, a00, a01, a10, a11, b0, b1) + } + + fn handle_raster_transform_tool( + &mut self, + ui: &mut egui::Ui, + response: &egui::Response, + world_pos: egui::Vec2, + shared: &mut SharedPaneState, + ) { + // If float was cleared, clear transform state. + if shared.selection.raster_floating.is_none() { + self.raster_transform_state = None; + return; + } + let float_canvas_id = shared.selection.raster_floating.as_ref().unwrap().canvas_id; + + // If the float changed (new selection made), clear and reinit state. + if let Some(ref ts) = self.raster_transform_state { + if ts.float_canvas_id != float_canvas_id { + self.raster_transform_state = None; + } + } + + // --- Lazy init --- + if self.raster_transform_state.is_none() { + let float = shared.selection.raster_floating.as_ref().unwrap(); + let expected_len = (float.width * float.height * 4) as usize; + let anchor_pixels = if float.pixels.len() == expected_len { + (*float.pixels).clone() + } else { + vec![0u8; expected_len] + }; + let cx = float.x as f32 + float.width as f32 / 2.0; + let cy = float.y as f32 + float.height as f32 / 2.0; + self.raster_transform_state = Some(RasterTransformState { + float_canvas_id: float.canvas_id, + anchor_canvas_id: uuid::Uuid::new_v4(), + anchor_pixels, + anchor_w: float.width, + anchor_h: float.height, + display_canvas_id: uuid::Uuid::new_v4(), + cx, cy, + scale_x: 1.0, scale_y: 1.0, angle: 0.0, + origin_x: cx, origin_y: cy, + active_handle: None, hovered_handle: None, + drag_start_world: world_pos, + snap_cx: cx, snap_cy: cy, + snap_sx: 1.0, snap_sy: 1.0, snap_angle: 0.0, + snap_origin_x: cx, snap_origin_y: cy, + transform_applied: true, + wants_apply: false, + }); + // Queue an identity dispatch immediately so the display canvas is populated + // from frame 1. Without this, Move-only drags don't update the image because + // transform_applied would stay false (no scale/rotate → no needs_dispatch). + let init_dispatch = { + let ts = self.raster_transform_state.as_ref().unwrap(); + let (new_w, new_h, new_x, new_y, a00, a01, a10, a11, b0, b1) = + Self::compute_transform_params(ts.anchor_w, ts.anchor_h, ts.cx, ts.cy, 1.0, 1.0, 0.0); + PendingTransformDispatch { + anchor_canvas_id: ts.anchor_canvas_id, + anchor_pixels: ts.anchor_pixels.clone(), + anchor_w: ts.anchor_w, + anchor_h: ts.anchor_h, + display_canvas_id: ts.display_canvas_id, + new_x, new_y, new_w, new_h, + a00, a01, a10, a11, b0, b1, + is_final_commit: false, + } + }; + self.pending_transform_dispatch = Some(init_dispatch); + } + + // Early return while waiting for a final readback (wants_apply set, readback pending). + if self.raster_transform_state.as_ref().map_or(false, |ts| ts.wants_apply) { + return; + } + + // --- Keyboard shortcuts --- + // Enter: queue final dispatch + readback, keep state alive until readback completes. + if ui.input(|i| i.key_pressed(egui::Key::Enter)) { + let dispatch = self.raster_transform_state.as_ref().and_then(|ts| { + if ts.transform_applied { + let (new_w, new_h, new_x, new_y, a00, a01, a10, a11, b0, b1) = + Self::compute_transform_params(ts.anchor_w, ts.anchor_h, ts.cx, ts.cy, ts.scale_x, ts.scale_y, ts.angle); + Some(PendingTransformDispatch { + anchor_canvas_id: ts.anchor_canvas_id, + anchor_pixels: ts.anchor_pixels.clone(), + anchor_w: ts.anchor_w, anchor_h: ts.anchor_h, + display_canvas_id: ts.display_canvas_id, + new_x, new_y, new_w, new_h, + a00, a01, a10, a11, b0, b1, + is_final_commit: true, + }) + } else { + None + } + }); + if let Some(d) = dispatch { + self.pending_transform_dispatch = Some(d); + // Keep state alive (wants_apply = true) until readback completes. + self.raster_transform_state.as_mut().unwrap().wants_apply = true; + } else { + // No transform was applied — just clear state. + self.raster_transform_state = None; + } + return; + } + + // Escape: float canvas is unchanged — just clear state. + // The anchor/display canvases are orphaned; they'll be freed when the GPU engine + // is next queried (the canvases are small and short-lived). + if ui.input(|i| i.key_pressed(egui::Key::Escape)) { + self.raster_transform_state = None; + return; + } + + // Read drag states before the mutable borrow of raster_transform_state. + let drag_started = self.rsp_drag_started(response); + let dragged = self.rsp_dragged(response); + let drag_stopped = self.rsp_drag_stopped(response); + let shift = ui.input(|i| i.modifiers.shift); + + // Collect pending dispatch from the inner block to assign after the borrow ends. + let pending_dispatch; + { + let ts = self.raster_transform_state.as_mut().unwrap(); + + // --- Compute handle positions in world space --- + let hw = ts.scale_x * ts.anchor_w as f32 / 2.0; + let hh = ts.scale_y * ts.anchor_h as f32 / 2.0; + let cos_a = ts.angle.cos(); + let sin_a = ts.angle.sin(); + let zoom = self.zoom; + + // Local offset → world position + let to_world = |lx: f32, ly: f32| -> egui::Vec2 { + egui::vec2(ts.cx + lx * cos_a - ly * sin_a, ts.cy + lx * sin_a + ly * cos_a) + }; + + // Rotate handle: above top-center, 24 screen-pixels outside bbox. + let rotate_offset = 24.0 / zoom; + let rotate_handle = to_world(0.0, -hh - rotate_offset); + + let handles: [(RasterTransformHandle, egui::Vec2); 10] = [ + (RasterTransformHandle::Corner { right: false, bottom: false }, to_world(-hw, -hh)), + (RasterTransformHandle::Corner { right: true, bottom: false }, to_world( hw, -hh)), + (RasterTransformHandle::Corner { right: false, bottom: true }, to_world(-hw, hh)), + (RasterTransformHandle::Corner { right: true, bottom: true }, to_world( hw, hh)), + (RasterTransformHandle::EdgeH { bottom: false }, to_world(0.0, -hh)), + (RasterTransformHandle::EdgeH { bottom: true }, to_world(0.0, hh)), + (RasterTransformHandle::EdgeV { right: false }, to_world(-hw, 0.0)), + (RasterTransformHandle::EdgeV { right: true }, to_world( hw, 0.0)), + (RasterTransformHandle::Rotate, rotate_handle), + (RasterTransformHandle::Origin, egui::vec2(ts.origin_x, ts.origin_y)), + ]; + + let hit_r_world = 8.0 / zoom; + let hovered = handles.iter() + .find(|(_, wp)| (world_pos - *wp).length() <= hit_r_world) + .map(|(h, _)| *h); + + // Inside bbox → Move handle (if no specific handle hit) + let in_bbox = { + let dx = world_pos.x - ts.cx; + let dy = world_pos.y - ts.cy; + let local_x = dx * cos_a + dy * sin_a; + let local_y = -dx * sin_a + dy * cos_a; + local_x.abs() <= hw && local_y.abs() <= hh + }; + let hovered = hovered.or_else(|| if in_bbox { Some(RasterTransformHandle::Move) } else { None }); + + // Store hovered handle for visual feedback in the draw function. + ts.hovered_handle = if ts.active_handle.is_none() { hovered } else { ts.active_handle }; + + // Set cursor icon based on hovered/active handle. + if let Some(h) = ts.active_handle.or(ts.hovered_handle) { + let cursor = match h { + RasterTransformHandle::Move => egui::CursorIcon::Grab, + RasterTransformHandle::Origin => egui::CursorIcon::Crosshair, + RasterTransformHandle::Corner { right, bottom } => { + if right == bottom { egui::CursorIcon::ResizeNwSe } + else { egui::CursorIcon::ResizeNeSw } + } + RasterTransformHandle::EdgeH { .. } => egui::CursorIcon::ResizeVertical, + RasterTransformHandle::EdgeV { .. } => egui::CursorIcon::ResizeHorizontal, + RasterTransformHandle::Rotate => egui::CursorIcon::AllScroll, + }; + ui.ctx().set_cursor_icon(cursor); + } + + // --- Drag start: use press_origin for hit testing (drag fires after threshold) --- + if drag_started { + let click_world = ui.input(|i| i.pointer.press_origin()) + .map(|sp| { + let canvas_pos = sp - response.rect.min.to_vec2(); + egui::vec2( + (canvas_pos.x - self.pan_offset.x) / self.zoom, + (canvas_pos.y - self.pan_offset.y) / self.zoom, + ) + }) + .unwrap_or(world_pos); + + // Recompute hovered handle at click_world position. + let click_hovered = handles.iter() + .find(|(_, wp)| (click_world - *wp).length() <= hit_r_world) + .map(|(h, _)| *h); + let click_in_bbox = { + let dx = click_world.x - ts.cx; + let dy = click_world.y - ts.cy; + let local_x = dx * cos_a + dy * sin_a; + let local_y = -dx * sin_a + dy * cos_a; + local_x.abs() <= hw && local_y.abs() <= hh + }; + let click_handle = click_hovered.or_else(|| if click_in_bbox { Some(RasterTransformHandle::Move) } else { None }); + + ts.active_handle = click_handle; + ts.drag_start_world = click_world; + ts.snap_cx = ts.cx; + ts.snap_cy = ts.cy; + ts.snap_sx = ts.scale_x; + ts.snap_sy = ts.scale_y; + ts.snap_angle = ts.angle; + ts.snap_origin_x = ts.origin_x; + ts.snap_origin_y = ts.origin_y; + } + + // --- Drag --- + let mut needs_dispatch = false; + if dragged { + if let Some(handle) = ts.active_handle { + let delta = world_pos - ts.drag_start_world; + let snap_hw = ts.snap_sx * ts.anchor_w as f32 / 2.0; + let snap_hh = ts.snap_sy * ts.anchor_h as f32 / 2.0; + let local_dx = delta.x * cos_a + delta.y * sin_a; + let local_dy = -delta.x * sin_a + delta.y * cos_a; + + match handle { + RasterTransformHandle::Move => { + ts.cx = ts.snap_cx + delta.x; + ts.cy = ts.snap_cy + delta.y; + ts.origin_x = ts.snap_origin_x + delta.x; + ts.origin_y = ts.snap_origin_y + delta.y; + // Pure move: display canvas keeps same pixels, position updated via compute_transform_params. + } + RasterTransformHandle::Origin => { + ts.origin_x = ts.snap_origin_x + delta.x; + ts.origin_y = ts.snap_origin_y + delta.y; + // No GPU dispatch needed for origin move alone. + } + RasterTransformHandle::Corner { right, bottom } => { + let sign_x = if right { 1.0_f32 } else { -1.0 }; + let sign_y = if bottom { 1.0_f32 } else { -1.0 }; + // Divide by 2: dragged corner = new_cx ± new_hw, and + // new_cx = wfx ± new_hw, so corner = wfx ± 2*new_hw. + // To make the corner move 1:1 with mouse, new_hw grows by delta/2. + // Signed clamp: allow negative scale (flip) but prevent exactly 0 + // which would make the inverse affine matrix singular. + let raw_hw = snap_hw + sign_x * local_dx / 2.0; + let new_hw = if raw_hw.abs() < 0.001 { if raw_hw <= 0.0 { -0.001 } else { 0.001 } } else { raw_hw }; + let new_hh = if shift { + // Preserve aspect ratio; sign follows new_hw. + new_hw * (ts.anchor_h as f32 / ts.anchor_w as f32).max(0.001) + } else { + let raw_hh = snap_hh + sign_y * local_dy / 2.0; + if raw_hh.abs() < 0.001 { if raw_hh <= 0.0 { -0.001 } else { 0.001 } } else { raw_hh } + }; + ts.scale_x = new_hw / (ts.anchor_w as f32 / 2.0).max(0.001); + ts.scale_y = new_hh / (ts.anchor_h as f32 / 2.0).max(0.001); + // Fixed corner world pos (opposite corner, from snap state). + let wfx = ts.snap_cx - sign_x * snap_hw * cos_a + sign_y * snap_hh * sin_a; + let wfy = ts.snap_cy - sign_x * snap_hw * sin_a - sign_y * snap_hh * cos_a; + // New center: fixed corner + rotated new half-extents. + ts.cx = wfx + sign_x * new_hw * cos_a - sign_y * new_hh * sin_a; + ts.cy = wfy + sign_x * new_hw * sin_a + sign_y * new_hh * cos_a; + // Maintain origin's relative position within the scaled bbox. + let o_dx = ts.snap_origin_x - ts.snap_cx; + let o_dy = ts.snap_origin_y - ts.snap_cy; + let o_local_x = o_dx * cos_a + o_dy * sin_a; + let o_local_y = -o_dx * sin_a + o_dy * cos_a; + let o_norm_x = if snap_hw > 0.0 { o_local_x / snap_hw } else { 0.0 }; + let o_norm_y = if snap_hh > 0.0 { o_local_y / snap_hh } else { 0.0 }; + let no_x = o_norm_x * new_hw; + let no_y = o_norm_y * new_hh; + ts.origin_x = ts.cx + no_x * cos_a - no_y * sin_a; + ts.origin_y = ts.cy + no_x * sin_a + no_y * cos_a; + needs_dispatch = true; + } + RasterTransformHandle::EdgeH { bottom } => { + let sign_y = if bottom { 1.0_f32 } else { -1.0 }; + let raw_hh = snap_hh + sign_y * local_dy / 2.0; + let new_hh = if raw_hh.abs() < 0.001 { if raw_hh <= 0.0 { -0.001 } else { 0.001 } } else { raw_hh }; + ts.scale_y = new_hh / (ts.anchor_h as f32 / 2.0).max(0.001); + // Fixed edge world position (opposite edge center). + let wfx = ts.snap_cx + sign_y * snap_hh * sin_a; + let wfy = ts.snap_cy - sign_y * snap_hh * cos_a; + ts.cx = wfx - sign_y * new_hh * sin_a; + ts.cy = wfy + sign_y * new_hh * cos_a; + // Maintain origin's relative Y position within the scaled bbox. + let o_dx = ts.snap_origin_x - ts.snap_cx; + let o_dy = ts.snap_origin_y - ts.snap_cy; + let o_local_x = o_dx * cos_a + o_dy * sin_a; + let o_local_y = -o_dx * sin_a + o_dy * cos_a; + let o_norm_y = if snap_hh > 0.0 { o_local_y / snap_hh } else { 0.0 }; + let no_x = o_local_x; // X local coord unchanged by EdgeH + let no_y = o_norm_y * new_hh; + ts.origin_x = ts.cx + no_x * cos_a - no_y * sin_a; + ts.origin_y = ts.cy + no_x * sin_a + no_y * cos_a; + needs_dispatch = true; + } + RasterTransformHandle::EdgeV { right } => { + let sign_x = if right { 1.0_f32 } else { -1.0 }; + let raw_hw = snap_hw + sign_x * local_dx / 2.0; + let new_hw = if raw_hw.abs() < 0.001 { if raw_hw <= 0.0 { -0.001 } else { 0.001 } } else { raw_hw }; + ts.scale_x = new_hw / (ts.anchor_w as f32 / 2.0).max(0.001); + // Fixed edge world position (opposite edge center). + let wfx = ts.snap_cx - sign_x * snap_hw * cos_a; + let wfy = ts.snap_cy - sign_x * snap_hw * sin_a; + ts.cx = wfx + sign_x * new_hw * cos_a; + ts.cy = wfy + sign_x * new_hw * sin_a; + // Maintain origin's relative X position within the scaled bbox. + let o_dx = ts.snap_origin_x - ts.snap_cx; + let o_dy = ts.snap_origin_y - ts.snap_cy; + let o_local_x = o_dx * cos_a + o_dy * sin_a; + let o_local_y = -o_dx * sin_a + o_dy * cos_a; + let o_norm_x = if snap_hw > 0.0 { o_local_x / snap_hw } else { 0.0 }; + let no_x = o_norm_x * new_hw; + let no_y = o_local_y; // Y local coord unchanged by EdgeV + ts.origin_x = ts.cx + no_x * cos_a - no_y * sin_a; + ts.origin_y = ts.cy + no_x * sin_a + no_y * cos_a; + needs_dispatch = true; + } + RasterTransformHandle::Rotate => { + // Rotate around origin (not center). + let v_start = ts.drag_start_world - egui::vec2(ts.origin_x, ts.origin_y); + let v_now = world_pos - egui::vec2(ts.origin_x, ts.origin_y); + let a_start = v_start.y.atan2(v_start.x); + let a_now = v_now.y.atan2(v_now.x); + let d_angle = a_now - a_start; + ts.angle = ts.snap_angle + d_angle; + // Also rotate cx/cy around the origin. + let ox = ts.snap_origin_x; + let oy = ts.snap_origin_y; + let dcx = ts.snap_cx - ox; + let dcy = ts.snap_cy - oy; + let (cos_d, sin_d) = (d_angle.cos(), d_angle.sin()); + ts.cx = ox + dcx * cos_d - dcy * sin_d; + ts.cy = oy + dcx * sin_d + dcy * cos_d; + needs_dispatch = true; + } + } + } + } + + // Build pending dispatch before the borrow ends (avoid partial move issues). + if needs_dispatch && dragged { + let (new_w, new_h, new_x, new_y, a00, a01, a10, a11, b0, b1) = + Self::compute_transform_params(ts.anchor_w, ts.anchor_h, ts.cx, ts.cy, ts.scale_x, ts.scale_y, ts.angle); + ts.transform_applied = true; + let anchor_canvas_id = ts.anchor_canvas_id; + let anchor_pixels = ts.anchor_pixels.clone(); + let anchor_w = ts.anchor_w; + let anchor_h = ts.anchor_h; + let display_canvas_id = ts.display_canvas_id; + pending_dispatch = Some(PendingTransformDispatch { + anchor_canvas_id, anchor_pixels, anchor_w, anchor_h, + display_canvas_id, new_x, new_y, new_w, new_h, + a00, a01, a10, a11, b0, b1, + is_final_commit: false, + }); + } else { + pending_dispatch = None; + } + + // --- Drag stop --- + if drag_stopped { + ts.active_handle = None; + } + } + + if let Some(p) = pending_dispatch { + self.pending_transform_dispatch = Some(p); + } + + // Handle drawing is deferred to render_raster_transform_overlays(), called + // from render_content() AFTER the VelloCallback is registered, so the handles + // appear on top of the Vello scene rather than underneath it. + } + + fn draw_raster_transform_handles_static( + ui: &mut egui::Ui, + rect: egui::Rect, + ts: &RasterTransformState, + zoom: f32, + pan: egui::Vec2, + ) { + let painter = ui.painter_at(rect); + + // World → screen + let w2s = |wx: f32, wy: f32| -> egui::Pos2 { + egui::pos2( + wx * zoom + pan.x + rect.min.x, + wy * zoom + pan.y + rect.min.y, + ) + }; + + let hw = ts.scale_x * ts.anchor_w as f32 / 2.0; + let hh = ts.scale_y * ts.anchor_h as f32 / 2.0; + let cos_a = ts.angle.cos(); + let sin_a = ts.angle.sin(); + let to_world = |lx: f32, ly: f32| -> (f32, f32) { + (ts.cx + lx * cos_a - ly * sin_a, ts.cy + lx * sin_a + ly * cos_a) + }; + + // Draw bounding box outline (4 edges between corners) + let corners_local = [(-hw, -hh), (hw, -hh), (hw, hh), (-hw, hh)]; + let corners_screen: Vec = corners_local.iter() + .map(|&(lx, ly)| { let (wx, wy) = to_world(lx, ly); w2s(wx, wy) }) + .collect(); + let outline_color = egui::Color32::from_rgba_unmultiplied(255, 255, 255, 200); + let shadow_color = egui::Color32::from_rgba_unmultiplied(0, 0, 0, 120); + for i in 0..4 { + let a = corners_screen[i]; + let b = corners_screen[(i + 1) % 4]; + painter.line_segment([a, b], egui::Stroke::new(2.0, shadow_color)); + painter.line_segment([a, b], egui::Stroke::new(1.0, outline_color)); + } + + // Colors + let handle_normal = egui::Color32::WHITE; + let handle_hovered = egui::Color32::from_rgb(100, 180, 255); // light blue + let handle_active = egui::Color32::from_rgb(30, 120, 255); // bright blue + + let handle_color = |h: RasterTransformHandle| -> egui::Color32 { + if ts.active_handle == Some(h) { handle_active } + else if ts.hovered_handle == Some(h) { handle_hovered } + else { handle_normal } + }; + + // Draw corner + edge handles (paired with their handle enum variant) + let handle_pairs: [(RasterTransformHandle, (f32, f32)); 8] = [ + (RasterTransformHandle::Corner { right: false, bottom: false }, to_world(-hw, -hh)), + (RasterTransformHandle::Corner { right: true, bottom: false }, to_world( hw, -hh)), + (RasterTransformHandle::Corner { right: false, bottom: true }, to_world(-hw, hh)), + (RasterTransformHandle::Corner { right: true, bottom: true }, to_world( hw, hh)), + (RasterTransformHandle::EdgeH { bottom: false }, to_world(0.0, -hh)), + (RasterTransformHandle::EdgeH { bottom: true }, to_world(0.0, hh)), + (RasterTransformHandle::EdgeV { right: false }, to_world(-hw, 0.0)), + (RasterTransformHandle::EdgeV { right: true }, to_world( hw, 0.0)), + ]; + for (handle, (wx, wy)) in handle_pairs { + let sp = w2s(wx, wy); + let is_hover = ts.hovered_handle == Some(handle) || ts.active_handle == Some(handle); + let size = if is_hover { 10.0 } else { 8.0 }; + let inner = if is_hover { 8.0 } else { 6.0 }; + painter.rect_filled( + egui::Rect::from_center_size(sp, egui::vec2(size, size)), + 0.0, + shadow_color, + ); + painter.rect_filled( + egui::Rect::from_center_size(sp, egui::vec2(inner, inner)), + 0.0, + handle_color(handle), + ); + } + + // Draw rotate handle (circle above top-center) + let rotate_offset = 24.0 / zoom; + let (rwx, rwy) = to_world(0.0, -hh - rotate_offset); + let rsp = w2s(rwx, rwy); + // Line from top-center to rotate handle + let (tcx, tcy) = to_world(0.0, -hh); + painter.line_segment([w2s(tcx, tcy), rsp], egui::Stroke::new(1.0, outline_color)); + let rot_hov = ts.hovered_handle == Some(RasterTransformHandle::Rotate) + || ts.active_handle == Some(RasterTransformHandle::Rotate); + let rot_color = if ts.active_handle == Some(RasterTransformHandle::Rotate) { handle_active } + else if ts.hovered_handle == Some(RasterTransformHandle::Rotate) { handle_hovered } + else { handle_normal }; + let rot_r = if rot_hov { 7.0 } else { 5.0 }; + painter.circle_filled(rsp, rot_r, rot_color); + painter.circle_stroke(rsp, rot_r, egui::Stroke::new(1.5, shadow_color)); + + // Draw origin handle (pivot point for rotate/scale) — a small crosshair circle. + // origin_x/origin_y are already in world coords, use w2s directly. + let origin_sp = w2s(ts.origin_x, ts.origin_y); + let orig_color = if ts.hovered_handle == Some(RasterTransformHandle::Origin) + || ts.active_handle == Some(RasterTransformHandle::Origin) { handle_hovered } else { handle_normal }; + painter.circle_filled(origin_sp, 5.0, orig_color); + painter.circle_stroke(origin_sp, 5.0, egui::Stroke::new(1.5, shadow_color)); + let arm = 6.0; + painter.line_segment([origin_sp - egui::vec2(arm, 0.0), origin_sp + egui::vec2(arm, 0.0)], + egui::Stroke::new(1.0, shadow_color)); + painter.line_segment([origin_sp - egui::vec2(0.0, arm), origin_sp + egui::vec2(0.0, arm)], + egui::Stroke::new(1.0, shadow_color)); + } + /// Apply an affine transform to selected DCEL vertices and their connected edge control points. /// Reads original positions from `original_dcel` and writes transformed positions to `dcel`. fn apply_dcel_transform( @@ -6044,6 +8192,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 = std::sync::Arc::new(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, @@ -6055,6 +9087,15 @@ impl StagePane { use lightningbeam_core::layer::AnyLayer; use vello::kurbo::Point; + // Raster floating selection on a raster layer → raster transform path. + if let Some(active_id) = *shared.active_layer_id { + let is_raster = shared.action_executor.document().get_layer(&active_id) + .map_or(false, |l| matches!(l, AnyLayer::Raster(_))); + if is_raster && shared.selection.raster_floating.is_some() { + return self.handle_raster_transform_tool(ui, response, world_pos, shared); + } + } + // Check if we have an active layer let active_layer_id = match *shared.active_layer_id { Some(id) => id, @@ -7321,6 +10362,21 @@ impl StagePane { }); } + // Alt+click: set source point for clone/healing tools. + { + use lightningbeam_core::tool::Tool; + let tool_uses_alt = crate::tools::raster_tool_def(shared.selected_tool) + .map_or(false, |d| d.uses_alt_click()); + if tool_uses_alt + && alt_held + && self.rsp_primary_pressed(ui) + && response.hovered() + { + eprintln!("[clone/healing] set clone source to ({:.1}, {:.1})", world_pos.x, world_pos.y); + shared.raster_settings.clone_source = Some(world_pos); + } + } + // Handle tool input (only if not using Alt modifier for panning) if !alt_held { use lightningbeam_core::tool::Tool; @@ -7351,20 +10407,24 @@ impl StagePane { shared.action_executor.document().get_layer(&id) }).map_or(false, |l| matches!(l, lightningbeam_core::layer::AnyLayer::Raster(_))); if is_raster { - self.handle_raster_stroke_tool(ui, &response, world_pos, lightningbeam_core::raster_layer::RasterBlendMode::Normal, shared); + self.handle_unified_raster_stroke_tool(ui, &response, world_pos, &crate::tools::paint::PAINT, shared); } else { self.handle_draw_tool(ui, &response, world_pos, shared); } } - Tool::Erase => { - self.handle_raster_stroke_tool(ui, &response, world_pos, lightningbeam_core::raster_layer::RasterBlendMode::Erase, shared); - } - Tool::Smudge => { - self.handle_raster_stroke_tool(ui, &response, world_pos, lightningbeam_core::raster_layer::RasterBlendMode::Smudge, shared); + tool if crate::tools::raster_tool_def(&tool).is_some() => { + let def = crate::tools::raster_tool_def(&tool).unwrap(); + self.handle_raster_stroke_tool(ui, &response, world_pos, def, shared); } Tool::SelectLasso => { self.handle_raster_lasso_tool(ui, &response, world_pos, shared); } + Tool::MagicWand => { + self.handle_magic_wand_tool(&response, world_pos, shared); + } + Tool::QuickSelect => { + self.handle_quick_select_tool(ui, &response, world_pos, shared); + } Tool::Transform => { self.handle_transform_tool(ui, &response, world_pos, shared); } @@ -7383,6 +10443,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 } @@ -7695,6 +10764,9 @@ impl StagePane { ) { use lightningbeam_core::selection::RasterSelection; + // Don't show marching ants during raster transform — the handles show the bbox outline. + if self.raster_transform_state.is_some() { return; } + let has_sel = shared.selection.raster_selection.is_some(); if !has_sel { return; } @@ -7718,6 +10790,13 @@ impl StagePane { RasterSelection::Lasso(pts) => { Self::draw_marching_ants_lasso(&painter, rect.min, pts, zoom, pan, phase); } + RasterSelection::Mask { data, width, height, origin_x, origin_y } => { + Self::draw_marching_ants_mask( + &painter, rect.min, + data, *width, *height, *origin_x, *origin_y, + zoom, pan, phase, + ); + } } } @@ -7821,6 +10900,83 @@ impl StagePane { } } } + + /// Draw the brush-size outline cursor for raster paint tools. + /// + /// Renders an alternating black/white dashed ellipse (marching-ants style) centred on + /// `pos` (screen space). The ellipse shape reflects the brush's `elliptical_dab_ratio` + /// and angle; for brushes with position jitter (`offset_by_random`) the radius is + /// expanded so the outline marks the full extent where paint can land. + fn draw_brush_cursor( + &self, + ui: &mut egui::Ui, + rect: egui::Rect, + pos: egui::Pos2, + shared: &SharedPaneState, + ) { + use lightningbeam_core::tool::Tool; + + // Compute semi-axes (world pixels) and dab rotation angle. + let (a_world, b_world, dab_angle_rad) = if matches!(*shared.selected_tool, Tool::QuickSelect) { + let r = shared.raster_settings.quick_select_radius; + (r, r, 0.0_f32) + } else if let Some(def) = crate::tools::raster_tool_def(shared.selected_tool) { + let r = def.cursor_radius(shared.raster_settings); + // For the standard paint brush, also account for elliptical shape. + if matches!(*shared.selected_tool, + Tool::Draw | Tool::Pencil | Tool::Pen | Tool::Airbrush) + { + let bs = &shared.raster_settings.active_brush_settings; + let ratio = bs.elliptical_dab_ratio.max(1.0); + let expand = 1.0 + bs.offset_by_random; + (r * expand, r * expand / ratio, bs.elliptical_dab_angle.to_radians()) + } else { + (r, r, 0.0_f32) + } + } else { + let bs = &shared.raster_settings.active_brush_settings; + let r = shared.raster_settings.brush_radius; + let ratio = bs.elliptical_dab_ratio.max(1.0); + let expand = 1.0 + bs.offset_by_random; + (r * expand, r * expand / ratio, bs.elliptical_dab_angle.to_radians()) + }; + + let a = a_world * self.zoom; // major semi-axis in screen pixels + let b = b_world * self.zoom; // minor semi-axis in screen pixels + if a < 1.0 { return; } + + let painter = ui.painter_at(rect); + let cos_a = dab_angle_rad.cos(); + let sin_a = dab_angle_rad.sin(); + + // Approximate ellipse perimeter (Ramanujan) to decide how many dashes to draw. + let h = ((a - b) / (a + b)).powi(2); + let perimeter = std::f32::consts::PI * (a + b) + * (1.0 + 3.0 * h / (10.0 + (4.0 - 3.0 * h).sqrt())); + let dash_px = 4.0_f32; + let n = ((perimeter / dash_px).ceil() as usize).max(8); + + let pt = |i: usize| -> egui::Pos2 { + let t = i as f32 / n as f32 * std::f32::consts::TAU; + let ex = a * t.cos(); + let ey = b * t.sin(); + pos + egui::vec2(ex * cos_a - ey * sin_a, ex * sin_a + ey * cos_a) + }; + + // Alternating black/white 1-px segments. + for i in 0..n { + let color = if i % 2 == 0 { egui::Color32::BLACK } else { egui::Color32::WHITE }; + painter.line_segment([pt(i), pt(i + 1)], egui::Stroke::new(1.0, color)); + } + + // Small crosshair at centre. + let arm = 3.0_f32.min(a * 0.3).max(1.0); + for (color, width) in [(egui::Color32::BLACK, 2.0_f32), (egui::Color32::WHITE, 1.0_f32)] { + let s = egui::Stroke::new(width, color); + painter.line_segment([pos - egui::vec2(arm, 0.0), pos + egui::vec2(arm, 0.0)], s); + painter.line_segment([pos - egui::vec2(0.0, arm), pos + egui::vec2(0.0, arm)], s); + } + } } @@ -7887,7 +11043,10 @@ impl PaneRenderer for StagePane { } } } - float.pixels = pixels; + float.pixels = std::sync::Arc::new(pixels); + // Invalidate the float's GPU canvas so the lazy-init + // in prepare() re-uploads the fresh pixels next frame. + self.pending_canvas_removals.push(float.canvas_id); } } self.stroke_clip_selection = None; @@ -7936,6 +11095,14 @@ impl PaneRenderer for StagePane { self.pending_canvas_removal = Some(kf_id); } } + // Unified tool cleanup: clear active_raster_tool and queue A/B/C for removal. + // Runs after both the float and layer branches. + if self.active_tool_awaiting_readback { + self.active_tool_awaiting_readback = false; + if let Some((_, ws)) = self.active_raster_tool.take() { + self.pending_canvas_removals.extend(ws.canvas_ids()); + } + } } } @@ -7958,6 +11125,163 @@ impl PaneRenderer for StagePane { } } + // Consume transform readback results: swap display canvas in as the new float canvas. + if let Ok(mut results) = TRANSFORM_READBACK_RESULTS + .get_or_init(|| Arc::new(Mutex::new(std::collections::HashMap::new()))) + .lock() + { + if let Some(rb) = results.remove(&self.instance_id) { + if let Some(ref mut float) = shared.selection.raster_floating { + self.pending_canvas_removal = Some(float.canvas_id); + float.canvas_id = rb.display_canvas_id; + float.pixels = std::sync::Arc::new(rb.pixels); + float.width = rb.width; + float.height = rb.height; + float.x = rb.x; + float.y = rb.y; + } + // Update the selection border to match the new (transformed) float bounds, + // so marching ants appear around the result after switching tools / Enter. + // This also replaces the stale pre-transform rect so commit masking is correct. + shared.selection.raster_selection = Some( + lightningbeam_core::selection::RasterSelection::Rect( + rb.x, rb.y, + rb.x + rb.width as i32, + rb.y + rb.height as i32, + ) + ); + // Readback complete — clear transform state. + self.raster_transform_state = None; + } + } + + // 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 = std::sync::Arc::new(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. + { + use lightningbeam_core::tool::Tool; + let float_gone = shared.selection.raster_floating.is_none(); + let not_transform = !matches!(*shared.selected_tool, Tool::Transform); + if (float_gone || not_transform) && self.raster_transform_state.is_some() { + // If a transform was applied but not yet committed, queue the final dispatch now. + let needs_dispatch = self.raster_transform_state.as_ref() + .map_or(false, |ts| ts.transform_applied && !ts.wants_apply); + if needs_dispatch { + let dispatch = { + let ts = self.raster_transform_state.as_ref().unwrap(); + let (new_w, new_h, new_x, new_y, a00, a01, a10, a11, b0, b1) = + Self::compute_transform_params(ts.anchor_w, ts.anchor_h, ts.cx, ts.cy, ts.scale_x, ts.scale_y, ts.angle); + PendingTransformDispatch { + anchor_canvas_id: ts.anchor_canvas_id, + anchor_pixels: ts.anchor_pixels.clone(), + anchor_w: ts.anchor_w, anchor_h: ts.anchor_h, + display_canvas_id: ts.display_canvas_id, + new_x, new_y, new_w, new_h, + a00, a01, a10, a11, b0, b1, + is_final_commit: true, + } + }; + self.pending_transform_dispatch = Some(dispatch); + self.raster_transform_state.as_mut().unwrap().wants_apply = true; + // Don't clear state yet — wait for readback (handles stay visible 1 frame). + } else if !self.raster_transform_state.as_ref().map_or(false, |ts| ts.wants_apply) { + // No pending dispatch — just clear. + self.raster_transform_state = None; + } + } + } + + // 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); @@ -8258,6 +11582,65 @@ impl PaneRenderer for StagePane { vello::kurbo::Point::new(local.x as f64, local.y as f64) }); + // Compute transform_display for the VelloCallback. + // Only override the float blit once the display canvas has actual content + // (transform_applied = true). Before the first drag, show the regular float canvas. + let transform_display = self.raster_transform_state.as_ref() + .filter(|ts| ts.transform_applied) + .map(|ts| { + let (new_w, new_h, new_x, new_y, ..) = Self::compute_transform_params( + ts.anchor_w, ts.anchor_h, ts.cx, ts.cy, ts.scale_x, ts.scale_y, ts.angle, + ); + TransformDisplayInfo { + display_canvas_id: ts.display_canvas_id, + x: new_x, y: new_y, w: new_w, h: new_h, + } + }); + + // 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, + })) + }); + + // Scan for raster keyframes whose texture_dirty flag was set since last frame + // (e.g. by undo/redo or a stroke action execute/rollback). Must run BEFORE + // document_arc() is called below so that Arc::make_mut does not clone the document. + { + let doc = shared.action_executor.document_mut(); + fn collect_dirty(layers: &mut [lightningbeam_core::layer::AnyLayer], out: &mut Vec) { + for layer in layers.iter_mut() { + if let lightningbeam_core::layer::AnyLayer::Raster(rl) = layer { + for kf in &mut rl.keyframes { + if kf.texture_dirty { + out.push(kf.id); + kf.texture_dirty = false; + } + } + } + } + } + collect_dirty(&mut doc.root.children, &mut self.pending_layer_cache_removals); + } + // Use egui's custom painting callback for Vello // document_arc() returns Arc - cheap pointer copy, not deep clone let callback = VelloCallback { ctx: VelloRenderContext { @@ -8286,10 +11669,33 @@ impl PaneRenderer for StagePane { mouse_world_pos, webcam_frame: shared.webcam_frame.clone(), 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(), painting_float: self.painting_float, + brush_preview_pixels: shared.brush_preview_pixels.clone(), + active_tool_render: self.active_raster_tool.as_ref().map(|(_, ws)| { + crate::raster_tool::ActiveToolRender { + b_canvas_id: ws.b_canvas_id, + x: ws.x, y: ws.y, + width: ws.width, height: ws.height, + layer_id: match &ws.source { + crate::raster_tool::WorkspaceSource::Layer { layer_id, .. } => Some(*layer_id), + crate::raster_tool::WorkspaceSource::Float => None, + }, + } + }), + pending_canvas_removals: std::mem::take(&mut self.pending_canvas_removals), + pending_workspace_init: self.pending_workspace_init.take(), + pending_tool_gpu_work: self.active_raster_tool.as_mut() + .and_then(|(tool, _)| tool.take_pending_gpu_work()), + pending_layer_cache_removals: std::mem::take(&mut self.pending_layer_cache_removals), + pending_tool_readback_b: self.pending_tool_readback_b.take(), }}; let cb = egui_wgpu::Callback::new_paint_callback( @@ -8373,6 +11779,13 @@ impl PaneRenderer for StagePane { // Raster selection overlays: marching ants + floating selection texture self.render_raster_selection_overlays(ui, rect, shared); + // Raster transform handles (drawn after Vello scene so they appear on top) + if let Some(ref ts) = self.raster_transform_state { + let zoom = self.zoom; + let pan = self.pan_offset; + Self::draw_raster_transform_handles_static(ui, rect, ts, zoom, pan); + } + // Render snap indicator (works for all tools, not just Select/BezierEdit) self.render_snap_indicator(ui, rect, shared); @@ -8401,14 +11814,67 @@ impl PaneRenderer for StagePane { ); } - // Set custom tool cursor when pointer is over the stage canvas - // (system cursors from transform handles take priority via render_overlay check) + // Draw clone source indicator when clone stamp or healing brush tool is selected. + let tool_uses_alt = crate::tools::raster_tool_def(shared.selected_tool) + .map_or(false, |d| d.uses_alt_click()); + if tool_uses_alt { + if let Some(src_world) = shared.raster_settings.clone_source { + let src_canvas = egui::vec2( + src_world.x * self.zoom + self.pan_offset.x, + src_world.y * self.zoom + self.pan_offset.y, + ); + let src_screen = rect.min + src_canvas; + let painter = ui.painter_at(rect); + let r = 8.0_f32; // circle radius + let arm = 14.0_f32; // arm half-length (extends past the circle) + let gap = r + 2.0; // gap between circle edge and arm start + for (width, color) in [ + (3.0_f32, egui::Color32::BLACK), + (1.5_f32, egui::Color32::WHITE), + ] { + let s = egui::Stroke::new(width, color); + painter.circle_stroke(src_screen, r, s); + painter.line_segment([src_screen - egui::vec2(arm, 0.0), src_screen - egui::vec2(gap, 0.0)], s); + painter.line_segment([src_screen + egui::vec2(gap, 0.0), src_screen + egui::vec2(arm, 0.0)], s); + painter.line_segment([src_screen - egui::vec2(0.0, arm), src_screen - egui::vec2(0.0, gap)], s); + painter.line_segment([src_screen + egui::vec2(0.0, gap), src_screen + egui::vec2(0.0, arm)], s); + } + } + } + + // Set custom tool cursor when pointer is over the stage canvas. + // Raster paint tools get a brush-size outline; everything else uses the SVG cursor. if let Some(pos) = ui.input(|i| i.pointer.hover_pos()) { if rect.contains(pos) { - crate::custom_cursor::set( - ui.ctx(), - crate::custom_cursor::CustomCursor::from_tool(*shared.selected_tool), - ); + use lightningbeam_core::tool::Tool; + let is_raster_paint = matches!( + *shared.selected_tool, + Tool::Draw | Tool::Pencil | Tool::Pen | Tool::Airbrush + | Tool::Erase | Tool::Smudge + | Tool::CloneStamp | Tool::HealingBrush | Tool::PatternStamp + | Tool::DodgeBurn | Tool::Sponge | Tool::BlurSharpen + | Tool::QuickSelect + ) && shared.active_layer_id.and_then(|id| { + shared.action_executor.document().get_layer(&id) + }).map_or(false, |l| matches!(l, lightningbeam_core::layer::AnyLayer::Raster(_))); + + // Only override the cursor when no higher-order layer (e.g. a modal dialog) + // is covering the canvas at this position. + let canvas_is_topmost = ui.ctx() + .layer_id_at(pos) + .map_or(true, |l| l == ui.layer_id()); + + if is_raster_paint && canvas_is_topmost { + ui.ctx().set_cursor_icon(egui::CursorIcon::None); + self.draw_brush_cursor(ui, rect, pos, shared); + } else if is_raster_paint { + // A modal is covering the canvas — let the system cursor show normally. + } else { + crate::custom_cursor::set( + ui.ctx(), + crate::custom_cursor::CustomCursor::from_tool(*shared.selected_tool), + ); + } } } } diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/toolbar.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/toolbar.rs index daf606b..019df99 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/toolbar.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/toolbar.rs @@ -5,7 +5,8 @@ use eframe::egui; use lightningbeam_core::layer::{AnyLayer, LayerType}; -use lightningbeam_core::tool::{Tool, RegionSelectMode}; +use lightningbeam_core::tool::{Tool, RegionSelectMode, LassoMode}; +use lightningbeam_core::brush_settings::bundled_brushes; use crate::keymap::tool_app_action; use super::{NodePath, PaneRenderer, SharedPaneState}; @@ -101,7 +102,7 @@ impl PaneRenderer for ToolbarPane { } // Draw sub-tool arrow indicator for tools with modes - let has_sub_tools = matches!(tool, Tool::RegionSelect); + let has_sub_tools = matches!(tool, Tool::RegionSelect | Tool::SelectLasso); if has_sub_tools { let arrow_size = 6.0; let margin = 4.0; @@ -125,6 +126,22 @@ impl PaneRenderer for ToolbarPane { // Check for click first if response.clicked() { *shared.selected_tool = *tool; + // Preset-backed tools: auto-select the matching bundled brush. + let preset_name = match tool { + Tool::Pencil => Some("Pencil"), + Tool::Pen => Some("Pen"), + Tool::Airbrush => Some("Airbrush"), + _ => None, + }; + if let Some(name) = preset_name { + if let Some(preset) = bundled_brushes().iter().find(|p| p.name == name) { + let s = &preset.settings; + shared.raster_settings.brush_opacity = s.opaque.clamp(0.0, 1.0); + shared.raster_settings.brush_hardness = s.hardness.clamp(0.0, 1.0); + shared.raster_settings.brush_spacing = s.dabs_per_radius; + shared.raster_settings.active_brush_settings = s.clone(); + } + } } // Right-click context menu for tools with sub-options @@ -150,6 +167,33 @@ impl PaneRenderer for ToolbarPane { ui.close(); } } + Tool::SelectLasso => { + ui.set_min_width(130.0); + if ui.selectable_label( + *shared.lasso_mode == LassoMode::Freehand, + "Freehand", + ).clicked() { + *shared.lasso_mode = LassoMode::Freehand; + *shared.selected_tool = Tool::SelectLasso; + ui.close(); + } + if ui.selectable_label( + *shared.lasso_mode == LassoMode::Polygonal, + "Polygonal", + ).clicked() { + *shared.lasso_mode = LassoMode::Polygonal; + *shared.selected_tool = Tool::SelectLasso; + ui.close(); + } + if ui.selectable_label( + *shared.lasso_mode == LassoMode::Magnetic, + "Magnetic", + ).clicked() { + *shared.lasso_mode = LassoMode::Magnetic; + *shared.selected_tool = Tool::SelectLasso; + ui.close(); + } + } _ => {} } }); @@ -176,6 +220,13 @@ impl PaneRenderer for ToolbarPane { RegionSelectMode::Lasso => "Lasso", }; format!("{} - {}{}\nRight-click for options", tool.display_name(), mode, hint) + } else if *tool == Tool::SelectLasso { + let mode = match *shared.lasso_mode { + LassoMode::Freehand => "Freehand", + LassoMode::Polygonal => "Polygonal", + LassoMode::Magnetic => "Magnetic", + }; + format!("{} - {}{}\nRight-click for options", tool.display_name(), mode, hint) } else { format!("{}{}", tool.display_name(), hint) }; diff --git a/lightningbeam-ui/lightningbeam-editor/src/raster_tool.rs b/lightningbeam-ui/lightningbeam-editor/src/raster_tool.rs new file mode 100644 index 0000000..f99dac3 --- /dev/null +++ b/lightningbeam-ui/lightningbeam-editor/src/raster_tool.rs @@ -0,0 +1,758 @@ +//! Unified raster tool interface. +//! +//! Every raster tool operates on three GPU textures of identical dimensions: +//! +//! | Buffer | Access | Purpose | +//! |--------|--------|---------| +//! | **A** | Read-only | Source pixels, uploaded from layer/float at mousedown. | +//! | **B** | Write-only | Output / display. Compositor shows B while the tool is active. | +//! | **C** | Read+Write | Scratch. Dabs accumulate here across the stroke; composite A+C→B each frame. | +//! +//! All three are `Rgba8Unorm` with the same pixel dimensions. The framework +//! allocates and validates them in [`begin_raster_workspace`]; tools only +//! dispatch shaders. + +use std::sync::Arc; +use uuid::Uuid; +use eframe::egui; + +// ── WorkspaceSource ────────────────────────────────────────────────────────── + +/// Describes whether the tool is operating on a raster layer or a floating selection. +#[derive(Clone, Debug)] +pub enum WorkspaceSource { + /// Operating on the full raster layer. + Layer { + layer_id: Uuid, + time: f64, + /// The keyframe's own UUID (the A-canvas key in `GpuBrushEngine`). + kf_id: Uuid, + /// Full canvas dimensions (may differ from workspace dims for floating selections). + canvas_w: u32, + canvas_h: u32, + }, + /// Operating on the floating selection. + Float, +} + +// ── RasterWorkspace ─────────────────────────────────────────────────────────── + +/// GPU buffer IDs and metadata for a single tool operation. +/// +/// Created by [`begin_raster_workspace`] on mousedown. All three canvas UUIDs +/// index into `GpuBrushEngine::canvases` and are valid for the lifetime of the +/// active tool. They are queued for removal in `pending_canvas_removals` after +/// commit or cancel. +#[derive(Debug)] +pub struct RasterWorkspace { + /// A canvas (Rgba8Unorm) — source pixels, uploaded at mousedown, read-only for tools. + pub a_canvas_id: Uuid, + /// B canvas (Rgba8Unorm) — output / display; compositor shows this while active. + pub b_canvas_id: Uuid, + /// C canvas (Rgba8Unorm) — scratch; tools accumulate dabs here across the stroke. + pub c_canvas_id: Uuid, + /// Optional R8Unorm selection mask (same pixel dimensions as A/B/C). + /// `None` means the entire workspace is selected. + pub mask_texture: Option>, + /// Pixel dimensions. A, B, C, and mask are all guaranteed to be this size. + pub width: u32, + pub height: u32, + /// Top-left position in document-pixel space. + /// `(0, 0)` for a layer workspace; `(float.x, float.y)` for a float workspace. + pub x: i32, + pub y: i32, + /// Where the workspace came from — drives commit behaviour. + pub source: WorkspaceSource, + /// CPU snapshot taken at mousedown for undo / cancel. + /// Length is always `width * height * 4` (sRGB premultiplied RGBA). + pub before_pixels: Vec, +} + +impl RasterWorkspace { + /// Panic-safe bounds check. Asserts that every GPU canvas exists and has + /// the dimensions declared by this workspace. Called by the framework + /// before `begin()` and before each `update()`. + pub fn validate(&self, gpu: &crate::gpu_brush::GpuBrushEngine) { + for (name, id) in [ + ("A", self.a_canvas_id), + ("B", self.b_canvas_id), + ("C", self.c_canvas_id), + ] { + let canvas = gpu.canvases.get(&id).unwrap_or_else(|| { + panic!( + "RasterWorkspace::validate: buffer '{}' (id={}) not found in GpuBrushEngine", + name, id + ) + }); + assert_eq!( + canvas.width, self.width, + "RasterWorkspace::validate: buffer '{}' width {} != workspace width {}", + name, canvas.width, self.width + ); + assert_eq!( + canvas.height, self.height, + "RasterWorkspace::validate: buffer '{}' height {} != workspace height {}", + name, canvas.height, self.height + ); + } + let expected = (self.width * self.height * 4) as usize; + assert_eq!( + self.before_pixels.len(), expected, + "RasterWorkspace::validate: before_pixels.len()={} != expected {}", + self.before_pixels.len(), expected + ); + } + + /// Returns the three canvas UUIDs as an array (convenient for bulk removal). + pub fn canvas_ids(&self) -> [Uuid; 3] { + [self.a_canvas_id, self.b_canvas_id, self.c_canvas_id] + } +} + +// ── WorkspaceInitPacket ─────────────────────────────────────────────────────── + +/// Data sent to `prepare()` on the first frame to create and upload the A/B/C canvases. +/// +/// The canvas UUIDs are pre-allocated in `begin_raster_workspace()` (UI thread). +/// The actual `wgpu::Texture` creation and pixel upload happens in `prepare()`. +pub struct WorkspaceInitPacket { + /// A canvas UUID (already in `RasterWorkspace::a_canvas_id`). + pub a_canvas_id: Uuid, + /// Pixel data to upload to A. Length must equal `width * height * 4`. + pub a_pixels: Vec, + /// B canvas UUID. + pub b_canvas_id: Uuid, + /// C canvas UUID. + pub c_canvas_id: Uuid, + pub width: u32, + pub height: u32, +} + +// ── ActiveToolRender ────────────────────────────────────────────────────────── + +/// Passed to `VelloRenderContext` so the compositor can blit the tool's B output +/// in the correct position in the layer stack. +/// +/// While an `ActiveToolRender` is set: +/// - If `layer_id == Some(id)`: blit B at that layer's compositor slot. +/// - If `layer_id == None`: blit B at the float's compositor slot. +#[derive(Clone, Debug)] +pub struct ActiveToolRender { + /// B canvas to blit. + pub b_canvas_id: Uuid, + /// Position of the B canvas in document space. + pub x: i32, + pub y: i32, + /// Pixel dimensions of the B canvas. + pub width: u32, + pub height: u32, + /// `Some(layer_id)` → B replaces this layer's render slot. + /// `None` → B replaces the float render slot. + pub layer_id: Option, +} + +// ── PendingGpuWork ──────────────────────────────────────────────────────────── + +/// GPU work to execute in `VelloCallback::prepare()`. +/// +/// Tools compute dab lists and other CPU-side data in `update()` (UI thread), +/// store them as a `Box`, and return that work through +/// `RasterTool::take_pending_gpu_work()` each frame. `prepare()` then calls +/// `execute()` with the render-thread `device`/`queue`/`gpu`. +/// +/// `execute()` takes `&self` so the work object need not be consumed; it lives +/// in the `VelloRenderContext` (which is immutable in `prepare()`). +pub trait PendingGpuWork: Send + Sync { + fn execute( + &self, + device: &wgpu::Device, + queue: &wgpu::Queue, + gpu: &mut crate::gpu_brush::GpuBrushEngine, + ); +} + +// ── RasterTool trait ────────────────────────────────────────────────────────── + +/// Unified interface for all raster tools. +/// +/// All methods run on the UI thread. They update the tool's internal state +/// and store pending GPU op descriptors in fields that `StagePane` forwards +/// to `VelloRenderContext` for execution by `VelloCallback::prepare()`. +pub trait RasterTool: Send + Sync { + /// Called on **mousedown** after [`begin_raster_workspace`] has allocated and + /// validated A, B, and C. The tool should initialise its internal state and + /// optionally queue an initial GPU dispatch (e.g. identity composite for + /// transform so the handle frame appears immediately). + fn begin( + &mut self, + ws: &RasterWorkspace, + pos: egui::Vec2, + dt: f32, + settings: &crate::tools::RasterToolSettings, + ); + + /// Called every frame while the pointer is held (including the first drag frame). + /// The tool should accumulate new work into C and queue a composite A+C→B pass. + /// `dt` is the elapsed time in seconds since the previous call; used by time-based + /// brushes (airbrush, etc.) to fire dabs at the correct rate when stationary. + fn update( + &mut self, + ws: &RasterWorkspace, + pos: egui::Vec2, + dt: f32, + settings: &crate::tools::RasterToolSettings, + ); + + /// Called on **pointer release**. Returns `true` if a GPU readback of B should + /// be performed and the result committed to the document. Returns `false` if + /// the operation was a no-op (e.g. the pointer never moved). + fn finish(&mut self, ws: &RasterWorkspace) -> bool; + + /// Called on **Escape** or tool switch mid-stroke. The caller restores the + /// source pixels from `ws.before_pixels` without creating an undo entry; the + /// tool just cleans up internal state. + fn cancel(&mut self, ws: &RasterWorkspace); + + /// Called once per frame (in the VelloCallback construction, UI thread) to + /// extract pending GPU work accumulated by `begin()` / `update()`. + /// + /// The tool clears its internal pending work and returns it. `prepare()` on + /// the render thread then calls `work.execute()`. Default: no GPU work. + fn take_pending_gpu_work(&mut self) -> Option> { + None + } +} + +// ── BrushRasterTool ─────────────────────────────────────────────────────────── + +use lightningbeam_core::brush_engine::{BrushEngine, GpuDab, StrokeState}; +use lightningbeam_core::brush_settings::BrushSettings; +use lightningbeam_core::raster_layer::{RasterBlendMode, StrokePoint, StrokeRecord}; + +/// GPU work for one frame of a brush stroke: dispatch dabs into C, then composite A+C→B. +struct PendingBrushWork { + dabs: Vec, + bbox: (i32, i32, i32, i32), + a_id: Uuid, + b_id: Uuid, + c_id: Uuid, + canvas_w: u32, + canvas_h: u32, +} + +impl PendingGpuWork for PendingBrushWork { + fn execute( + &self, + device: &wgpu::Device, + queue: &wgpu::Queue, + gpu: &mut crate::gpu_brush::GpuBrushEngine, + ) { + // 1. Accumulate this frame's dabs into C (if any). + if !self.dabs.is_empty() { + gpu.render_dabs(device, queue, self.c_id, &self.dabs, self.bbox, self.canvas_w, self.canvas_h); + } + // 2. Always composite A + C → B so B shows A's content even with no dabs this frame. + // On begin() with empty C this initialises B = A, avoiding a transparent flash. + gpu.composite_a_c_to_b(device, queue, self.a_id, self.c_id, self.b_id, self.canvas_w, self.canvas_h); + } +} + +/// Raster tool for paint brushes (Normal blend mode). +/// +/// Each `update()` call computes new dabs for that frame and stores them as +/// `PendingBrushWork`. `take_pending_gpu_work()` hands the work to `prepare()` +/// which dispatches the dab and composite shaders on the render thread. +pub struct BrushRasterTool { + color: [f32; 4], + brush: BrushSettings, + blend_mode: RasterBlendMode, + stroke_state: StrokeState, + last_point: Option, + pending: Option>, + /// True after at least one non-empty frame (so finish() knows a commit is needed). + has_dabs: bool, + /// Offset to convert world coordinates to canvas-local coordinates. + canvas_offset_x: i32, + canvas_offset_y: i32, +} + +impl BrushRasterTool { + /// Create a new brush tool. + /// + /// `color` — linear premultiplied RGBA, matches the format expected by `GpuDab`. + pub fn new( + color: [f32; 4], + brush: BrushSettings, + blend_mode: RasterBlendMode, + ) -> Self { + Self { + color, + brush, + blend_mode, + stroke_state: StrokeState::new(), + last_point: None, + pending: None, + has_dabs: false, + canvas_offset_x: 0, + canvas_offset_y: 0, + } + } + + fn make_stroke_point(pos: egui::Vec2, off_x: i32, off_y: i32) -> StrokePoint { + StrokePoint { + x: pos.x - off_x as f32, + y: pos.y - off_y as f32, + pressure: 1.0, + tilt_x: 0.0, + tilt_y: 0.0, + timestamp: 0.0, + } + } + + fn dispatch_dabs( + &mut self, + ws: &RasterWorkspace, + pt: StrokePoint, + dt: f32, + ) { + // Use a 2-point segment when we have a previous point so the engine + // interpolates dabs along the path. First mousedown uses a single point. + let points = match self.last_point.take() { + Some(prev) => vec![prev, pt.clone()], + None => vec![pt.clone()], + }; + let record = StrokeRecord { + brush_settings: self.brush.clone(), + color: self.color, + blend_mode: self.blend_mode, + tool_params: [0.0; 4], + points, + }; + let (dabs, bbox) = BrushEngine::compute_dabs(&record, &mut self.stroke_state, dt); + if !dabs.is_empty() { + self.has_dabs = true; + self.pending = Some(Box::new(PendingBrushWork { + dabs, + bbox, + a_id: ws.a_canvas_id, + b_id: ws.b_canvas_id, + c_id: ws.c_canvas_id, + canvas_w: ws.width, + canvas_h: ws.height, + })); + } + self.last_point = Some(pt); + } +} + +impl RasterTool for BrushRasterTool { + fn begin(&mut self, ws: &RasterWorkspace, pos: egui::Vec2, _dt: f32, _settings: &crate::tools::RasterToolSettings) { + self.canvas_offset_x = ws.x; + self.canvas_offset_y = ws.y; + let pt = Self::make_stroke_point(pos, ws.x, ws.y); + self.dispatch_dabs(ws, pt, 0.0); + // Always ensure a composite is queued on begin() so B is initialised from A + // on the first frame even if no dabs fired (large spacing, etc.). + if self.pending.is_none() { + self.pending = Some(Box::new(PendingBrushWork { + dabs: vec![], + bbox: (0, 0, ws.width as i32, ws.height as i32), + a_id: ws.a_canvas_id, + b_id: ws.b_canvas_id, + c_id: ws.c_canvas_id, + canvas_w: ws.width, + canvas_h: ws.height, + })); + } + } + + fn update(&mut self, ws: &RasterWorkspace, pos: egui::Vec2, dt: f32, _settings: &crate::tools::RasterToolSettings) { + let pt = Self::make_stroke_point(pos, ws.x, ws.y); + self.dispatch_dabs(ws, pt, dt); + } + + fn finish(&mut self, _ws: &RasterWorkspace) -> bool { + self.has_dabs + } + + fn cancel(&mut self, _ws: &RasterWorkspace) { + self.pending = None; + self.has_dabs = false; + } + + fn take_pending_gpu_work(&mut self) -> Option> { + self.pending.take().map(|w| w as Box) + } +} + +// ── EffectBrushTool ─────────────────────────────────────────────────────────── + +/// Raster tool for effect brushes (Blur, Sharpen, Dodge, Burn, Sponge, Desaturate). +/// +/// C accumulates a per-pixel influence weight (R channel, 0–255). +/// The composite pass applies the effect to A, scaled by C.r, writing to B: +/// `B = lerp(A, effect(A), C.r)` +/// +/// Using C as an influence map (rather than accumulating modified pixels) prevents +/// overlapping dabs from compounding the effect beyond the C.r cap (255). +/// +/// # GPU implementation (TODO) +/// Requires a dedicated `effect_brush_composite.wgsl` shader that reads A and C, +/// applies the blend-mode-specific filter to A, and blends by C.r → B. +pub struct EffectBrushTool { + brush: BrushSettings, + blend_mode: RasterBlendMode, + has_dabs: bool, +} + +impl EffectBrushTool { + pub fn new(brush: BrushSettings, blend_mode: RasterBlendMode) -> Self { + Self { brush, blend_mode, has_dabs: false } + } +} + +impl RasterTool for EffectBrushTool { + fn begin(&mut self, _ws: &RasterWorkspace, _pos: egui::Vec2, _dt: f32, _settings: &crate::tools::RasterToolSettings) {} + fn update(&mut self, _ws: &RasterWorkspace, _pos: egui::Vec2, _dt: f32, _settings: &crate::tools::RasterToolSettings) { + self.has_dabs = true; // placeholder + } + fn finish(&mut self, _ws: &RasterWorkspace) -> bool { self.has_dabs } + fn cancel(&mut self, _ws: &RasterWorkspace) { self.has_dabs = false; } + // GPU shaders not yet implemented; take_pending_gpu_work returns None (default). +} + +// ── SmudgeTool ──────────────────────────────────────────────────────────────── + +/// Raster tool for the smudge brush. +/// +/// `begin()`: copy A → C so C starts with the source pixels for color pickup. +/// `update()`: dispatch smudge dabs using `blend_mode=2` (reads C as source, +/// writes smear to C); then composite C over A → B. +/// Because the smudge shader reads from `canvas_src` (C.src) and writes to +/// `canvas_dst` (C.dst), existing dabs are preserved in the smear history. +/// +/// # GPU implementation (TODO) +/// Requires an initial A → C copy in `begin()` (via GPU copy command). +/// The smudge dab dispatch then uses `render_dabs(c_id, smudge_dabs, ...)`. +/// The composite pass is `composite_a_c_to_b` (same as BrushRasterTool). +pub struct SmudgeTool { + brush: BrushSettings, + has_dabs: bool, +} + +impl SmudgeTool { + pub fn new(brush: BrushSettings) -> Self { + Self { brush, has_dabs: false } + } +} + +impl RasterTool for SmudgeTool { + fn begin(&mut self, _ws: &RasterWorkspace, _pos: egui::Vec2, _dt: f32, _settings: &crate::tools::RasterToolSettings) {} + fn update(&mut self, _ws: &RasterWorkspace, _pos: egui::Vec2, _dt: f32, _settings: &crate::tools::RasterToolSettings) { + self.has_dabs = true; // placeholder + } + fn finish(&mut self, _ws: &RasterWorkspace) -> bool { self.has_dabs } + fn cancel(&mut self, _ws: &RasterWorkspace) { self.has_dabs = false; } + // GPU shaders not yet implemented; take_pending_gpu_work returns None (default). +} + +// ── GradientRasterTool ──────────────────────────────────────────────────────── + +use crate::gpu_brush::GpuGradientStop; +use lightningbeam_core::gradient::{GradientExtend, GradientType, ShapeGradient}; + +fn gradient_stops_to_gpu(gradient: &ShapeGradient) -> Vec { + gradient.stops.iter().map(|s| { + GpuGradientStop::from_srgb_u8(s.position, s.color.r, s.color.g, s.color.b, s.color.a) + }).collect() +} + +fn gradient_extend_to_u32(extend: GradientExtend) -> u32 { + match extend { + GradientExtend::Pad => 0, + GradientExtend::Reflect => 1, + GradientExtend::Repeat => 2, + } +} + +fn gradient_kind_to_u32(kind: GradientType) -> u32 { + match kind { + GradientType::Linear => 0, + GradientType::Radial => 1, + } +} + +struct PendingGradientWork { + a_id: Uuid, + b_id: Uuid, + stops: Vec, + start: (f32, f32), + end: (f32, f32), + opacity: f32, + extend_mode: u32, + kind: u32, +} + +impl PendingGpuWork for PendingGradientWork { + fn execute(&self, device: &wgpu::Device, queue: &wgpu::Queue, gpu: &mut crate::gpu_brush::GpuBrushEngine) { + gpu.apply_gradient_fill( + device, queue, + &self.a_id, &self.b_id, + &self.stops, + self.start, self.end, + self.opacity, self.extend_mode, self.kind, + ); + } +} + +/// Raster tool for gradient fills. +/// +/// `begin()` records the canvas-local start position. +/// `update()` recomputes gradient parameters from settings and queues a +/// `PendingGradientWork` that calls `apply_gradient_fill` in `prepare()`. +/// `finish()` returns whether any gradient was dispatched. +pub struct GradientRasterTool { + start_canvas: egui::Vec2, + end_canvas: egui::Vec2, + pending: Option>, + has_dispatched: bool, +} + +impl GradientRasterTool { + pub fn new() -> Self { + Self { + start_canvas: egui::Vec2::ZERO, + end_canvas: egui::Vec2::ZERO, + pending: None, + has_dispatched: false, + } + } +} + +impl RasterTool for GradientRasterTool { + fn begin(&mut self, ws: &RasterWorkspace, pos: egui::Vec2, _dt: f32, _settings: &crate::tools::RasterToolSettings) { + let canvas_pos = pos - egui::vec2(ws.x as f32, ws.y as f32); + self.start_canvas = canvas_pos; + self.end_canvas = canvas_pos; + } + + fn update(&mut self, ws: &RasterWorkspace, pos: egui::Vec2, _dt: f32, settings: &crate::tools::RasterToolSettings) { + self.end_canvas = pos - egui::vec2(ws.x as f32, ws.y as f32); + let gradient = &settings.gradient; + self.pending = Some(Box::new(PendingGradientWork { + a_id: ws.a_canvas_id, + b_id: ws.b_canvas_id, + stops: gradient_stops_to_gpu(gradient), + start: (self.start_canvas.x, self.start_canvas.y), + end: (self.end_canvas.x, self.end_canvas.y), + opacity: settings.gradient_opacity, + extend_mode: gradient_extend_to_u32(gradient.extend), + kind: gradient_kind_to_u32(gradient.kind), + })); + self.has_dispatched = true; + } + + fn finish(&mut self, _ws: &RasterWorkspace) -> bool { self.has_dispatched } + + fn cancel(&mut self, _ws: &RasterWorkspace) { + self.pending = None; + self.has_dispatched = false; + } + + fn take_pending_gpu_work(&mut self) -> Option> { + self.pending.take().map(|w| w as Box) + } +} + +// ── TransformRasterTool ─────────────────────────────────────────────────────── + +use crate::gpu_brush::RasterTransformGpuParams; + +struct PendingTransformWork { + a_id: Uuid, + b_id: Uuid, + params: RasterTransformGpuParams, +} + +impl PendingGpuWork for PendingTransformWork { + fn execute(&self, device: &wgpu::Device, queue: &wgpu::Queue, gpu: &mut crate::gpu_brush::GpuBrushEngine) { + gpu.render_transform(device, queue, &self.a_id, &self.b_id, self.params); + } +} + +/// Raster tool for affine transforms (move, scale, rotate, shear). +/// +/// `begin()` stores the initial canvas dimensions and queues an identity +/// transform so B is initialised on the first frame. +/// `update()` recomputes the inverse affine matrix from the current handle +/// positions and queues a new `PendingTransformWork`. +/// +/// The inverse matrix maps output pixel coordinates back to source pixel +/// coordinates: `src = M_inv * dst + b` +/// where `M_inv = [[a00, a01], [a10, a11]]` and `b = [b0, b1]`. +/// +/// # GPU implementation +/// Fully wired — uses `GpuBrushEngine::render_transform`. Handle interaction +/// logic (drag, rotate, scale) is handled by the tool's `update()` caller in +/// `stage.rs` which computes and passes in the `RasterTransformGpuParams`. +pub struct TransformRasterTool { + pending: Option>, + has_dispatched: bool, + canvas_w: u32, + canvas_h: u32, +} + +impl TransformRasterTool { + pub fn new() -> Self { + Self { + pending: None, + has_dispatched: false, + canvas_w: 0, + canvas_h: 0, + } + } + + /// Queue a transform with the given inverse-affine matrix. + /// Called by the stage handler after computing handle positions. + pub fn set_transform( + &mut self, + ws: &RasterWorkspace, + params: RasterTransformGpuParams, + ) { + self.pending = Some(Box::new(PendingTransformWork { + a_id: ws.a_canvas_id, + b_id: ws.b_canvas_id, + params, + })); + self.has_dispatched = true; + } +} + +impl RasterTool for TransformRasterTool { + fn begin(&mut self, ws: &RasterWorkspace, _pos: egui::Vec2, _dt: f32, _settings: &crate::tools::RasterToolSettings) { + self.canvas_w = ws.width; + self.canvas_h = ws.height; + // Queue identity transform so B shows the source immediately. + let identity = RasterTransformGpuParams { + a00: 1.0, a01: 0.0, + a10: 0.0, a11: 1.0, + b0: 0.0, b1: 0.0, + src_w: ws.width, src_h: ws.height, + dst_w: ws.width, dst_h: ws.height, + _pad0: 0, _pad1: 0, + }; + self.set_transform(ws, identity); + } + + fn update(&mut self, _ws: &RasterWorkspace, _pos: egui::Vec2, _dt: f32, _settings: &crate::tools::RasterToolSettings) { + // Handle interaction and matrix updates are driven from stage.rs via set_transform(). + } + + fn finish(&mut self, _ws: &RasterWorkspace) -> bool { self.has_dispatched } + + fn cancel(&mut self, _ws: &RasterWorkspace) { + self.pending = None; + self.has_dispatched = false; + } + + fn take_pending_gpu_work(&mut self) -> Option> { + self.pending.take().map(|w| w as Box) + } +} + +// ── WarpRasterTool ──────────────────────────────────────────────────────────── + +/// Raster tool for warp / mesh deformation. +/// +/// Uses a displacement buffer (managed by `GpuBrushEngine`) that maps each +/// output pixel to a source offset. The displacement grid is updated by +/// dragging control points; the warp shader reads anchor pixels + displacement +/// → B each frame. +/// +/// # GPU implementation (TODO) +/// Requires: `create_displacement_buf`, `apply_warp` already exist in +/// `GpuBrushEngine`. Wire brush-drag interaction to update displacement +/// entries and call `apply_warp`. +pub struct WarpRasterTool { + has_dispatched: bool, +} + +impl WarpRasterTool { + pub fn new() -> Self { Self { has_dispatched: false } } +} + +impl RasterTool for WarpRasterTool { + fn begin(&mut self, _ws: &RasterWorkspace, _pos: egui::Vec2, _dt: f32, _settings: &crate::tools::RasterToolSettings) {} + fn update(&mut self, _ws: &RasterWorkspace, _pos: egui::Vec2, _dt: f32, _settings: &crate::tools::RasterToolSettings) { + self.has_dispatched = true; // placeholder + } + fn finish(&mut self, _ws: &RasterWorkspace) -> bool { self.has_dispatched } + fn cancel(&mut self, _ws: &RasterWorkspace) { self.has_dispatched = false; } + // take_pending_gpu_work: default (None) — full GPU wiring is TODO. +} + +// ── LiquifyRasterTool ───────────────────────────────────────────────────────── + +/// Raster tool for liquify (per-pixel displacement painting). +/// +/// Similar to `WarpRasterTool` but uses a full per-pixel displacement map +/// (grid_cols = grid_rows = 0 in `apply_warp`) painted by brush strokes. +/// Each dab accumulates displacement in the push/pull/swirl direction. +/// +/// # GPU implementation (TODO) +/// Requires: a dab-to-displacement shader that accumulates per-pixel offsets +/// into the displacement buffer, then `apply_warp` reads it → B. +pub struct LiquifyRasterTool { + has_dispatched: bool, +} + +impl LiquifyRasterTool { + pub fn new() -> Self { Self { has_dispatched: false } } +} + +impl RasterTool for LiquifyRasterTool { + fn begin(&mut self, _ws: &RasterWorkspace, _pos: egui::Vec2, _dt: f32, _settings: &crate::tools::RasterToolSettings) {} + fn update(&mut self, _ws: &RasterWorkspace, _pos: egui::Vec2, _dt: f32, _settings: &crate::tools::RasterToolSettings) { + self.has_dispatched = true; // placeholder + } + fn finish(&mut self, _ws: &RasterWorkspace) -> bool { self.has_dispatched } + fn cancel(&mut self, _ws: &RasterWorkspace) { self.has_dispatched = false; } + // take_pending_gpu_work: default (None) — full GPU wiring is TODO. +} + +// ── SelectionTool ───────────────────────────────────────────────────────────── + +/// Raster selection tool (Magic Wand / Quick Select). +/// +/// C (RGBA8) acts as the growing selection; C.r = mask value (0 or 255). +/// Each `update()` frame a flood-fill / region-grow shader extends C.r. +/// The composite pass draws A + a tinted overlay from C.r → B so the user +/// sees the growing selection boundary. +/// +/// `finish()` returns false (commit does not write pixels back to the layer; +/// instead the caller extracts C.r into the standalone `R8Unorm` selection +/// texture via `shared.raster_selection`). +/// +/// # GPU implementation (TODO) +/// Requires: a flood-fill compute shader seeded by the click position that +/// grows the selection in C.r; and a composite shader that tints selected +/// pixels blue/cyan for preview. +pub struct SelectionTool { + has_selection: bool, +} + +impl SelectionTool { + pub fn new() -> Self { Self { has_selection: false } } +} + +impl RasterTool for SelectionTool { + fn begin(&mut self, _ws: &RasterWorkspace, _pos: egui::Vec2, _dt: f32, _settings: &crate::tools::RasterToolSettings) {} + fn update(&mut self, _ws: &RasterWorkspace, _pos: egui::Vec2, _dt: f32, _settings: &crate::tools::RasterToolSettings) { + self.has_selection = true; // placeholder + } + /// Selection tools never trigger a pixel readback/commit on mouseup. + /// The caller reads C.r directly into the selection mask texture. + fn finish(&mut self, _ws: &RasterWorkspace) -> bool { false } + fn cancel(&mut self, _ws: &RasterWorkspace) { self.has_selection = false; } + // take_pending_gpu_work: default (None) — full GPU wiring is TODO. +} diff --git a/lightningbeam-ui/lightningbeam-editor/src/tools/blur_sharpen.rs b/lightningbeam-ui/lightningbeam-editor/src/tools/blur_sharpen.rs new file mode 100644 index 0000000..876b5d6 --- /dev/null +++ b/lightningbeam-ui/lightningbeam-editor/src/tools/blur_sharpen.rs @@ -0,0 +1,60 @@ +use super::{BrushParams, RasterToolDef, RasterToolSettings}; +use eframe::egui; +use lightningbeam_core::{brush_settings::BrushSettings, raster_layer::RasterBlendMode}; + +pub struct BlurSharpenTool; +pub static BLUR_SHARPEN: BlurSharpenTool = BlurSharpenTool; + +impl RasterToolDef for BlurSharpenTool { + fn blend_mode(&self) -> RasterBlendMode { RasterBlendMode::BlurSharpen } + fn header_label(&self) -> &'static str { "Blur / Sharpen" } + fn brush_params(&self, s: &RasterToolSettings) -> BrushParams { + BrushParams { + base_settings: BrushSettings::default(), + radius: s.blur_sharpen_radius, + opacity: s.blur_sharpen_strength, + hardness: s.blur_sharpen_hardness, + spacing: s.blur_sharpen_spacing, + } + } + fn tool_params(&self, s: &RasterToolSettings) -> [f32; 4] { + [s.blur_sharpen_mode as f32, s.blur_sharpen_kernel, 0.0, 0.0] + } + fn show_brush_preset_picker(&self) -> bool { false } + fn render_ui(&self, ui: &mut egui::Ui, s: &mut RasterToolSettings) { + ui.horizontal(|ui| { + if ui.selectable_label(s.blur_sharpen_mode == 0, "Blur").clicked() { + s.blur_sharpen_mode = 0; + } + if ui.selectable_label(s.blur_sharpen_mode == 1, "Sharpen").clicked() { + s.blur_sharpen_mode = 1; + } + }); + ui.horizontal(|ui| { + ui.label("Size:"); + ui.add(egui::Slider::new(&mut s.blur_sharpen_radius, 1.0_f32..=500.0).logarithmic(true).suffix(" px")); + }); + ui.horizontal(|ui| { + ui.label("Strength:"); + ui.add(egui::Slider::new(&mut s.blur_sharpen_strength, 0.0_f32..=1.0) + .custom_formatter(|v, _| format!("{:.0}%", v * 100.0))); + }); + ui.horizontal(|ui| { + ui.label("Hardness:"); + ui.add(egui::Slider::new(&mut s.blur_sharpen_hardness, 0.0_f32..=1.0) + .custom_formatter(|v, _| format!("{:.0}%", v * 100.0))); + }); + ui.horizontal(|ui| { + ui.label("Kernel:"); + ui.add(egui::Slider::new(&mut s.blur_sharpen_kernel, 1.0_f32..=20.0) + .logarithmic(true) + .custom_formatter(|v, _| format!("{:.1} px", v))); + }); + ui.horizontal(|ui| { + ui.label("Spacing:"); + ui.add(egui::Slider::new(&mut s.blur_sharpen_spacing, 0.5_f32..=20.0) + .logarithmic(true) + .custom_formatter(|v, _| format!("{:.1}", v))); + }); + } +} diff --git a/lightningbeam-ui/lightningbeam-editor/src/tools/clone_stamp.rs b/lightningbeam-ui/lightningbeam-editor/src/tools/clone_stamp.rs new file mode 100644 index 0000000..bbfb9f2 --- /dev/null +++ b/lightningbeam-ui/lightningbeam-editor/src/tools/clone_stamp.rs @@ -0,0 +1,29 @@ +use super::{BrushParams, RasterToolDef, RasterToolSettings}; +use eframe::egui; +use lightningbeam_core::raster_layer::RasterBlendMode; + +pub struct CloneStampTool; +pub static CLONE_STAMP: CloneStampTool = CloneStampTool; + +impl RasterToolDef for CloneStampTool { + fn blend_mode(&self) -> RasterBlendMode { RasterBlendMode::CloneStamp } + fn header_label(&self) -> &'static str { "Clone Stamp" } + fn brush_params(&self, s: &RasterToolSettings) -> BrushParams { + BrushParams { + base_settings: s.active_brush_settings.clone(), + radius: s.brush_radius, + opacity: s.brush_opacity, + hardness: s.brush_hardness, + spacing: s.brush_spacing, + } + } + /// For Clone Stamp, tool_params are filled by stage.rs at stroke-start time + /// (offset = clone_source - stroke_start), not from settings directly. + fn tool_params(&self, _s: &RasterToolSettings) -> [f32; 4] { [0.0; 4] } + fn uses_alt_click(&self) -> bool { true } + fn render_ui(&self, ui: &mut egui::Ui, s: &mut RasterToolSettings) { + if s.clone_source.is_none() { + ui.label("Alt+click to set source point."); + } + } +} diff --git a/lightningbeam-ui/lightningbeam-editor/src/tools/dodge_burn.rs b/lightningbeam-ui/lightningbeam-editor/src/tools/dodge_burn.rs new file mode 100644 index 0000000..7ab1968 --- /dev/null +++ b/lightningbeam-ui/lightningbeam-editor/src/tools/dodge_burn.rs @@ -0,0 +1,54 @@ +use super::{BrushParams, RasterToolDef, RasterToolSettings}; +use eframe::egui; +use lightningbeam_core::{brush_settings::BrushSettings, raster_layer::RasterBlendMode}; + +pub struct DodgeBurnTool; +pub static DODGE_BURN: DodgeBurnTool = DodgeBurnTool; + +impl RasterToolDef for DodgeBurnTool { + fn blend_mode(&self) -> RasterBlendMode { RasterBlendMode::DodgeBurn } + fn header_label(&self) -> &'static str { "Dodge / Burn" } + fn brush_params(&self, s: &RasterToolSettings) -> BrushParams { + BrushParams { + base_settings: BrushSettings::default(), + radius: s.dodge_burn_radius, + opacity: s.dodge_burn_exposure, + hardness: s.dodge_burn_hardness, + spacing: s.dodge_burn_spacing, + } + } + fn tool_params(&self, s: &RasterToolSettings) -> [f32; 4] { + [s.dodge_burn_mode as f32, 0.0, 0.0, 0.0] + } + fn show_brush_preset_picker(&self) -> bool { false } + fn render_ui(&self, ui: &mut egui::Ui, s: &mut RasterToolSettings) { + ui.horizontal(|ui| { + if ui.selectable_label(s.dodge_burn_mode == 0, "Dodge").clicked() { + s.dodge_burn_mode = 0; + } + if ui.selectable_label(s.dodge_burn_mode == 1, "Burn").clicked() { + s.dodge_burn_mode = 1; + } + }); + ui.horizontal(|ui| { + ui.label("Size:"); + ui.add(egui::Slider::new(&mut s.dodge_burn_radius, 1.0_f32..=500.0).logarithmic(true).suffix(" px")); + }); + ui.horizontal(|ui| { + ui.label("Exposure:"); + ui.add(egui::Slider::new(&mut s.dodge_burn_exposure, 0.0_f32..=1.0) + .custom_formatter(|v, _| format!("{:.0}%", v * 100.0))); + }); + ui.horizontal(|ui| { + ui.label("Hardness:"); + ui.add(egui::Slider::new(&mut s.dodge_burn_hardness, 0.0_f32..=1.0) + .custom_formatter(|v, _| format!("{:.0}%", v * 100.0))); + }); + ui.horizontal(|ui| { + ui.label("Spacing:"); + ui.add(egui::Slider::new(&mut s.dodge_burn_spacing, 0.5_f32..=20.0) + .logarithmic(true) + .custom_formatter(|v, _| format!("{:.1}", v))); + }); + } +} diff --git a/lightningbeam-ui/lightningbeam-editor/src/tools/erase.rs b/lightningbeam-ui/lightningbeam-editor/src/tools/erase.rs new file mode 100644 index 0000000..361858a --- /dev/null +++ b/lightningbeam-ui/lightningbeam-editor/src/tools/erase.rs @@ -0,0 +1,23 @@ +use super::{BrushParams, RasterToolDef, RasterToolSettings}; +use eframe::egui; +use lightningbeam_core::raster_layer::RasterBlendMode; + +pub struct EraseTool; +pub static ERASE: EraseTool = EraseTool; + +impl RasterToolDef for EraseTool { + fn blend_mode(&self) -> RasterBlendMode { RasterBlendMode::Erase } + fn header_label(&self) -> &'static str { "Eraser" } + fn brush_params(&self, s: &RasterToolSettings) -> BrushParams { + BrushParams { + base_settings: s.active_eraser_settings.clone(), + radius: s.eraser_radius, + opacity: s.eraser_opacity, + hardness: s.eraser_hardness, + spacing: s.eraser_spacing, + } + } + fn tool_params(&self, _s: &RasterToolSettings) -> [f32; 4] { [0.0; 4] } + fn is_eraser(&self) -> bool { true } + fn render_ui(&self, _ui: &mut egui::Ui, _s: &mut RasterToolSettings) {} +} diff --git a/lightningbeam-ui/lightningbeam-editor/src/tools/healing_brush.rs b/lightningbeam-ui/lightningbeam-editor/src/tools/healing_brush.rs new file mode 100644 index 0000000..c6ae87a --- /dev/null +++ b/lightningbeam-ui/lightningbeam-editor/src/tools/healing_brush.rs @@ -0,0 +1,28 @@ +use super::{BrushParams, RasterToolDef, RasterToolSettings}; +use eframe::egui; +use lightningbeam_core::raster_layer::RasterBlendMode; + +pub struct HealingBrushTool; +pub static HEALING_BRUSH: HealingBrushTool = HealingBrushTool; + +impl RasterToolDef for HealingBrushTool { + fn blend_mode(&self) -> RasterBlendMode { RasterBlendMode::Healing } + fn header_label(&self) -> &'static str { "Healing Brush" } + fn brush_params(&self, s: &RasterToolSettings) -> BrushParams { + BrushParams { + base_settings: s.active_brush_settings.clone(), + radius: s.brush_radius, + opacity: s.brush_opacity, + hardness: s.brush_hardness, + spacing: s.brush_spacing, + } + } + /// tool_params are filled by stage.rs at stroke-start time (clone offset). + fn tool_params(&self, _s: &RasterToolSettings) -> [f32; 4] { [0.0; 4] } + fn uses_alt_click(&self) -> bool { true } + fn render_ui(&self, ui: &mut egui::Ui, s: &mut RasterToolSettings) { + if s.clone_source.is_none() { + ui.label("Alt+click to set source point."); + } + } +} diff --git a/lightningbeam-ui/lightningbeam-editor/src/tools/mod.rs b/lightningbeam-ui/lightningbeam-editor/src/tools/mod.rs new file mode 100644 index 0000000..de4c0c9 --- /dev/null +++ b/lightningbeam-ui/lightningbeam-editor/src/tools/mod.rs @@ -0,0 +1,268 @@ +/// Per-tool module for raster painting tools. +/// +/// Each tool implements `RasterToolDef`. Adding a new tool requires: +/// 1. A new file in this directory implementing `RasterToolDef`. +/// 2. One entry in `raster_tool_def()` below. +/// 3. Core changes: `RasterBlendMode` variant, `brush_engine.rs` constant, WGSL branch. + +use eframe::egui; +use lightningbeam_core::{ + brush_settings::BrushSettings, + raster_layer::RasterBlendMode, + tool::Tool, +}; + +pub mod paint; +pub mod erase; +pub mod smudge; +pub mod clone_stamp; +pub mod healing_brush; +pub mod pattern_stamp; +pub mod dodge_burn; +pub mod sponge; +pub mod blur_sharpen; + +// --------------------------------------------------------------------------- +// Shared settings struct (replaces 20+ individual SharedPaneState / EditorApp fields) +// --------------------------------------------------------------------------- + +/// All per-tool settings for raster painting. Owned by `EditorApp`; borrowed +/// by `SharedPaneState` as a single `&'a mut RasterToolSettings`. +pub struct RasterToolSettings { + // --- Paint brush --- + pub brush_radius: f32, + pub brush_opacity: f32, + pub brush_hardness: f32, + pub brush_spacing: f32, + /// true = paint with FG (stroke) color, false = BG (fill) color + pub brush_use_fg: bool, + pub active_brush_settings: BrushSettings, + // --- Eraser --- + pub eraser_radius: f32, + pub eraser_opacity: f32, + pub eraser_hardness: f32, + pub eraser_spacing: f32, + pub active_eraser_settings: BrushSettings, + // --- Smudge --- + pub smudge_radius: f32, + pub smudge_hardness: f32, + pub smudge_spacing: f32, + pub smudge_strength: f32, + // --- Clone / Healing --- + /// World-space source point set by Alt+click. + pub clone_source: Option, + // --- Pattern stamp --- + pub pattern_type: u32, + pub pattern_scale: f32, + // --- Dodge / Burn --- + pub dodge_burn_radius: f32, + pub dodge_burn_hardness: f32, + pub dodge_burn_spacing: f32, + pub dodge_burn_exposure: f32, + /// 0 = dodge (lighten), 1 = burn (darken) + pub dodge_burn_mode: u32, + // --- Sponge --- + pub sponge_radius: f32, + pub sponge_hardness: f32, + pub sponge_spacing: f32, + pub sponge_flow: f32, + /// 0 = saturate, 1 = desaturate + pub sponge_mode: u32, + // --- Blur / Sharpen --- + pub blur_sharpen_radius: f32, + pub blur_sharpen_hardness: f32, + pub blur_sharpen_spacing: f32, + pub blur_sharpen_strength: f32, + /// Neighborhood kernel radius in canvas pixels (1–20) + pub blur_sharpen_kernel: f32, + /// 0 = blur, 1 = sharpen + pub blur_sharpen_mode: u32, + // --- Magic wand (raster) --- + /// Color-distance threshold for magic wand selection (same scale as fill_threshold). + pub wand_threshold: f32, + /// Absolute = compare to seed pixel; Relative = compare to BFS parent. + pub wand_mode: FillThresholdMode, + /// true = BFS from click (contiguous region only); false = global color scan. + pub wand_contiguous: bool, + // --- Quick Select --- + /// Brush radius in canvas pixels for the quick-select tool. + pub quick_select_radius: f32, + // --- Flood fill (Paint Bucket, raster) --- + /// Color-distance threshold (Euclidean RGBA, 0–510). Pixels within this + /// distance of the comparison color are included in the fill. + pub fill_threshold: f32, + /// Soft-edge width as a percentage of the threshold (0 = hard, 100 = full fade). + pub fill_softness: f32, + /// Whether to compare each pixel to the seed pixel (Absolute) or to its BFS + /// parent pixel (Relative, spreads across gradients). + pub fill_threshold_mode: FillThresholdMode, + // --- Marquee select shape --- + /// Whether the rectangular select tool draws a rect or an ellipse. + pub select_shape: SelectionShape, + // --- 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. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum SelectionShape { + #[default] + Rect, + Ellipse, +} + +/// Threshold comparison mode for the raster flood fill. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum FillThresholdMode { + /// Compare each candidate pixel to the original seed pixel (default). + #[default] + Absolute, + /// Compare each candidate pixel to the pixel it was reached from. + Relative, +} + +impl Default for RasterToolSettings { + fn default() -> Self { + Self { + brush_radius: 10.0, + brush_opacity: 1.0, + brush_hardness: 0.5, + brush_spacing: 0.1, + brush_use_fg: true, + active_brush_settings: BrushSettings::default(), + eraser_radius: 10.0, + eraser_opacity: 1.0, + eraser_hardness: 0.5, + eraser_spacing: 0.1, + active_eraser_settings: lightningbeam_core::brush_settings::bundled_brushes() + .iter() + .find(|p| p.name == "Brush") + .map(|p| p.settings.clone()) + .unwrap_or_default(), + smudge_radius: 15.0, + smudge_hardness: 0.8, + smudge_spacing: 8.0, + smudge_strength: 1.0, + clone_source: None, + pattern_type: 0, + pattern_scale: 32.0, + dodge_burn_radius: 30.0, + dodge_burn_hardness: 0.5, + dodge_burn_spacing: 3.0, + dodge_burn_exposure: 0.5, + dodge_burn_mode: 0, + sponge_radius: 30.0, + sponge_hardness: 0.5, + sponge_spacing: 3.0, + sponge_flow: 0.5, + sponge_mode: 0, + blur_sharpen_radius: 30.0, + blur_sharpen_hardness: 0.5, + blur_sharpen_spacing: 3.0, + blur_sharpen_strength: 0.5, + blur_sharpen_kernel: 5.0, + blur_sharpen_mode: 0, + wand_threshold: 15.0, + wand_mode: FillThresholdMode::Absolute, + wand_contiguous: true, + fill_threshold: 15.0, + fill_softness: 0.0, + 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, + } + } +} + +// --------------------------------------------------------------------------- +// Brush parameters extracted per-tool +// --------------------------------------------------------------------------- + +pub struct BrushParams { + pub base_settings: BrushSettings, + pub radius: f32, + pub opacity: f32, + pub hardness: f32, + pub spacing: f32, +} + +// --------------------------------------------------------------------------- +// RasterToolDef trait +// --------------------------------------------------------------------------- + +pub trait RasterToolDef: Send + Sync { + fn blend_mode(&self) -> RasterBlendMode; + fn header_label(&self) -> &'static str; + fn brush_params(&self, s: &RasterToolSettings) -> BrushParams; + /// Encode tool-specific state into the 4-float `StrokeRecord::tool_params`. + fn tool_params(&self, s: &RasterToolSettings) -> [f32; 4]; + /// Cursor display radius (world pixels). + fn cursor_radius(&self, s: &RasterToolSettings) -> f32 { + self.brush_params(s).radius + } + /// Render tool-specific controls in the infopanel (called before preset picker if any). + fn render_ui(&self, ui: &mut egui::Ui, s: &mut RasterToolSettings); + /// Whether to show the brush preset picker after `render_ui`. + fn show_brush_preset_picker(&self) -> bool { true } + /// Whether this tool is the eraser (drives preset picker + color UI visibility). + fn is_eraser(&self) -> bool { false } + /// Whether Alt+click sets a source point for this tool. + fn uses_alt_click(&self) -> bool { false } +} + +// --------------------------------------------------------------------------- +// Lookup: Tool → &'static dyn RasterToolDef +// --------------------------------------------------------------------------- + +pub fn raster_tool_def(tool: &Tool) -> Option<&'static dyn RasterToolDef> { + match tool { + Tool::Draw | Tool::Pencil | Tool::Pen | Tool::Airbrush => Some(&paint::PAINT), + Tool::Erase => Some(&erase::ERASE), + Tool::Smudge => Some(&smudge::SMUDGE), + Tool::CloneStamp => Some(&clone_stamp::CLONE_STAMP), + Tool::HealingBrush => Some(&healing_brush::HEALING_BRUSH), + Tool::PatternStamp => Some(&pattern_stamp::PATTERN_STAMP), + Tool::DodgeBurn => Some(&dodge_burn::DODGE_BURN), + Tool::Sponge => Some(&sponge::SPONGE), + Tool::BlurSharpen => Some(&blur_sharpen::BLUR_SHARPEN), + _ => None, + } +} diff --git a/lightningbeam-ui/lightningbeam-editor/src/tools/paint.rs b/lightningbeam-ui/lightningbeam-editor/src/tools/paint.rs new file mode 100644 index 0000000..75c134e --- /dev/null +++ b/lightningbeam-ui/lightningbeam-editor/src/tools/paint.rs @@ -0,0 +1,22 @@ +use super::{BrushParams, RasterToolDef, RasterToolSettings}; +use eframe::egui; +use lightningbeam_core::raster_layer::RasterBlendMode; + +pub struct PaintTool; +pub static PAINT: PaintTool = PaintTool; + +impl RasterToolDef for PaintTool { + fn blend_mode(&self) -> RasterBlendMode { RasterBlendMode::Normal } + fn header_label(&self) -> &'static str { "Brush" } + fn brush_params(&self, s: &RasterToolSettings) -> BrushParams { + BrushParams { + base_settings: s.active_brush_settings.clone(), + radius: s.brush_radius, + opacity: s.brush_opacity, + hardness: s.brush_hardness, + spacing: s.brush_spacing, + } + } + fn tool_params(&self, _s: &RasterToolSettings) -> [f32; 4] { [0.0; 4] } + fn render_ui(&self, _ui: &mut egui::Ui, _s: &mut RasterToolSettings) {} +} diff --git a/lightningbeam-ui/lightningbeam-editor/src/tools/pattern_stamp.rs b/lightningbeam-ui/lightningbeam-editor/src/tools/pattern_stamp.rs new file mode 100644 index 0000000..acd83d3 --- /dev/null +++ b/lightningbeam-ui/lightningbeam-editor/src/tools/pattern_stamp.rs @@ -0,0 +1,49 @@ +use super::{BrushParams, RasterToolDef, RasterToolSettings}; +use eframe::egui; +use lightningbeam_core::raster_layer::RasterBlendMode; + +pub struct PatternStampTool; +pub static PATTERN_STAMP: PatternStampTool = PatternStampTool; + +const PATTERN_NAMES: &[&str] = &[ + "Checkerboard", "Dots", "H-Lines", "V-Lines", "Diagonal \\", "Diagonal /", "Crosshatch", +]; + +impl RasterToolDef for PatternStampTool { + fn blend_mode(&self) -> RasterBlendMode { RasterBlendMode::PatternStamp } + fn header_label(&self) -> &'static str { "Pattern Stamp" } + fn brush_params(&self, s: &RasterToolSettings) -> BrushParams { + BrushParams { + base_settings: s.active_brush_settings.clone(), + radius: s.brush_radius, + opacity: s.brush_opacity, + hardness: s.brush_hardness, + spacing: s.brush_spacing, + } + } + fn tool_params(&self, s: &RasterToolSettings) -> [f32; 4] { + [s.pattern_type as f32, s.pattern_scale, 0.0, 0.0] + } + fn render_ui(&self, ui: &mut egui::Ui, s: &mut RasterToolSettings) { + let selected_name = PATTERN_NAMES + .get(s.pattern_type as usize) + .copied() + .unwrap_or("Checkerboard"); + ui.horizontal(|ui| { + ui.label("Pattern:"); + egui::ComboBox::from_id_salt("pattern_type") + .selected_text(selected_name) + .show_ui(ui, |ui| { + for (i, name) in PATTERN_NAMES.iter().enumerate() { + ui.selectable_value(&mut s.pattern_type, i as u32, *name); + } + }); + }); + ui.horizontal(|ui| { + ui.label("Scale:"); + ui.add(egui::Slider::new(&mut s.pattern_scale, 4.0_f32..=256.0) + .logarithmic(true).suffix(" px")); + }); + ui.add_space(4.0); + } +} diff --git a/lightningbeam-ui/lightningbeam-editor/src/tools/smudge.rs b/lightningbeam-ui/lightningbeam-editor/src/tools/smudge.rs new file mode 100644 index 0000000..b79eeb8 --- /dev/null +++ b/lightningbeam-ui/lightningbeam-editor/src/tools/smudge.rs @@ -0,0 +1,44 @@ +use super::{BrushParams, RasterToolDef, RasterToolSettings}; +use eframe::egui; +use lightningbeam_core::{brush_settings::BrushSettings, raster_layer::RasterBlendMode}; + +pub struct SmudgeTool; +pub static SMUDGE: SmudgeTool = SmudgeTool; + +impl RasterToolDef for SmudgeTool { + fn blend_mode(&self) -> RasterBlendMode { RasterBlendMode::Smudge } + fn header_label(&self) -> &'static str { "Smudge" } + fn brush_params(&self, s: &RasterToolSettings) -> BrushParams { + BrushParams { + base_settings: BrushSettings::default(), + radius: s.smudge_radius, + opacity: 1.0, // strength is a separate smudge_dist multiplier + hardness: s.smudge_hardness, + spacing: s.smudge_spacing, + } + } + fn tool_params(&self, _s: &RasterToolSettings) -> [f32; 4] { [0.0; 4] } + fn show_brush_preset_picker(&self) -> bool { false } + fn render_ui(&self, ui: &mut egui::Ui, s: &mut RasterToolSettings) { + ui.horizontal(|ui| { + ui.label("Size:"); + ui.add(egui::Slider::new(&mut s.smudge_radius, 1.0_f32..=200.0).logarithmic(true).suffix(" px")); + }); + ui.horizontal(|ui| { + ui.label("Strength:"); + ui.add(egui::Slider::new(&mut s.smudge_strength, 0.0_f32..=1.0) + .custom_formatter(|v, _| format!("{:.0}%", v * 100.0))); + }); + ui.horizontal(|ui| { + ui.label("Hardness:"); + ui.add(egui::Slider::new(&mut s.smudge_hardness, 0.0_f32..=1.0) + .custom_formatter(|v, _| format!("{:.0}%", v * 100.0))); + }); + ui.horizontal(|ui| { + ui.label("Spacing:"); + ui.add(egui::Slider::new(&mut s.smudge_spacing, 0.5_f32..=20.0) + .logarithmic(true) + .custom_formatter(|v, _| format!("{:.1}", v))); + }); + } +} diff --git a/lightningbeam-ui/lightningbeam-editor/src/tools/sponge.rs b/lightningbeam-ui/lightningbeam-editor/src/tools/sponge.rs new file mode 100644 index 0000000..a410810 --- /dev/null +++ b/lightningbeam-ui/lightningbeam-editor/src/tools/sponge.rs @@ -0,0 +1,54 @@ +use super::{BrushParams, RasterToolDef, RasterToolSettings}; +use eframe::egui; +use lightningbeam_core::{brush_settings::BrushSettings, raster_layer::RasterBlendMode}; + +pub struct SpongeTool; +pub static SPONGE: SpongeTool = SpongeTool; + +impl RasterToolDef for SpongeTool { + fn blend_mode(&self) -> RasterBlendMode { RasterBlendMode::Sponge } + fn header_label(&self) -> &'static str { "Sponge" } + fn brush_params(&self, s: &RasterToolSettings) -> BrushParams { + BrushParams { + base_settings: BrushSettings::default(), + radius: s.sponge_radius, + opacity: s.sponge_flow, + hardness: s.sponge_hardness, + spacing: s.sponge_spacing, + } + } + fn tool_params(&self, s: &RasterToolSettings) -> [f32; 4] { + [s.sponge_mode as f32, 0.0, 0.0, 0.0] + } + fn show_brush_preset_picker(&self) -> bool { false } + fn render_ui(&self, ui: &mut egui::Ui, s: &mut RasterToolSettings) { + ui.horizontal(|ui| { + if ui.selectable_label(s.sponge_mode == 0, "Saturate").clicked() { + s.sponge_mode = 0; + } + if ui.selectable_label(s.sponge_mode == 1, "Desaturate").clicked() { + s.sponge_mode = 1; + } + }); + ui.horizontal(|ui| { + ui.label("Size:"); + ui.add(egui::Slider::new(&mut s.sponge_radius, 1.0_f32..=500.0).logarithmic(true).suffix(" px")); + }); + ui.horizontal(|ui| { + ui.label("Flow:"); + ui.add(egui::Slider::new(&mut s.sponge_flow, 0.0_f32..=1.0) + .custom_formatter(|v, _| format!("{:.0}%", v * 100.0))); + }); + ui.horizontal(|ui| { + ui.label("Hardness:"); + ui.add(egui::Slider::new(&mut s.sponge_hardness, 0.0_f32..=1.0) + .custom_formatter(|v, _| format!("{:.0}%", v * 100.0))); + }); + ui.horizontal(|ui| { + ui.label("Spacing:"); + ui.add(egui::Slider::new(&mut s.sponge_spacing, 0.5_f32..=20.0) + .logarithmic(true) + .custom_formatter(|v, _| format!("{:.1}", v))); + }); + } +} diff --git a/nam-ffi/build.rs b/nam-ffi/build.rs index 778ca6a..a3703b1 100644 --- a/nam-ffi/build.rs +++ b/nam-ffi/build.rs @@ -13,6 +13,19 @@ fn main() { let wrapper_dir = Path::new(&manifest_dir).join("cmake"); let neural_audio_dir = Path::new(&manifest_dir).join("../vendor/NeuralAudio"); + // Copy our patched CAPI files over the submodule versions before building. + // The upstream submodule uses `wchar_t*` on all platforms; our patch makes + // Linux/macOS use `const char*` instead, matching what the Rust FFI sends. + let capi_dir = neural_audio_dir.join("NeuralAudioCAPI"); + let override_dir = Path::new(&manifest_dir).join("capi-override"); + for filename in &["NeuralAudioCApi.h", "NeuralAudioCApi.cpp"] { + let src = override_dir.join(filename); + let dst = capi_dir.join(filename); + std::fs::copy(&src, &dst) + .unwrap_or_else(|e| panic!("Failed to copy {} override: {}", filename, e)); + println!("cargo:rerun-if-changed=capi-override/{}", filename); + } + let mut cfg = cmake::Config::new(&wrapper_dir); // Force single-config generator on Unix to avoid libraries landing in Release/ subdirs if !cfg!(target_os = "windows") { @@ -50,6 +63,4 @@ fn main() { _ => {} } - println!("cargo:rerun-if-changed=../vendor/NeuralAudio/NeuralAudioCAPI/NeuralAudioCApi.h"); - println!("cargo:rerun-if-changed=../vendor/NeuralAudio/NeuralAudioCAPI/NeuralAudioCApi.cpp"); } diff --git a/nam-ffi/capi-override/NeuralAudioCApi.cpp b/nam-ffi/capi-override/NeuralAudioCApi.cpp new file mode 100644 index 0000000..c385586 --- /dev/null +++ b/nam-ffi/capi-override/NeuralAudioCApi.cpp @@ -0,0 +1,83 @@ +#include "NeuralAudioCApi.h" +#include "NeuralModel.h" + +struct NeuralModel +{ + NeuralAudio::NeuralModel* model; +}; + +#ifdef _WIN32 +NeuralModel* CreateModelFromFile(const wchar_t* modelPath) +#else +NeuralModel* CreateModelFromFile(const char* modelPath) +#endif +{ + NeuralModel* model = new NeuralModel(); + + model->model = NeuralAudio::NeuralModel::CreateFromFile(modelPath); + + return model; +} + +void DeleteModel(NeuralModel* model) +{ + delete model->model; + delete model; +} + +void SetLSTMLoadMode(int loadMode) +{ + NeuralAudio::NeuralModel::SetLSTMLoadMode((NeuralAudio::EModelLoadMode)loadMode); +} + +void SetWaveNetLoadMode(int loadMode) +{ + NeuralAudio::NeuralModel::SetWaveNetLoadMode((NeuralAudio::EModelLoadMode)loadMode); +} + +void SetAudioInputLevelDBu(float audioDBu) +{ + NeuralAudio::NeuralModel::SetAudioInputLevelDBu(audioDBu); +} + +void SetDefaultMaxAudioBufferSize(int maxSize) +{ + NeuralAudio::NeuralModel::SetDefaultMaxAudioBufferSize(maxSize); +} + +int GetLoadMode(NeuralModel* model) +{ + return model->model->GetLoadMode(); +} + +bool IsStatic(NeuralModel* model) +{ + return model->model->IsStatic(); +} + +void SetMaxAudioBufferSize(NeuralModel* model, int maxSize) +{ + model->model->SetMaxAudioBufferSize(maxSize); +} + +float GetRecommendedInputDBAdjustment(NeuralModel* model) +{ + return model->model->GetRecommendedInputDBAdjustment(); +} + +float GetRecommendedOutputDBAdjustment(NeuralModel* model) +{ + return model->model->GetRecommendedOutputDBAdjustment(); +} + +float GetSampleRate(NeuralModel* model) +{ + return model->model->GetSampleRate(); +} + +void Process(NeuralModel* model, float* input, float* output, size_t numSamples) +{ + model->model->Process(input, output, numSamples); +} + + diff --git a/nam-ffi/capi-override/NeuralAudioCApi.h b/nam-ffi/capi-override/NeuralAudioCApi.h new file mode 100644 index 0000000..4e62f08 --- /dev/null +++ b/nam-ffi/capi-override/NeuralAudioCApi.h @@ -0,0 +1,50 @@ +#pragma once + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +#ifdef _MSC_VER +#define NA_EXTERN extern __declspec(dllexport) +#else +#define NA_EXTERN extern +#endif + +struct NeuralModel; + + +#ifdef _WIN32 +NA_EXTERN NeuralModel* CreateModelFromFile(const wchar_t* modelPath); +#else +NA_EXTERN NeuralModel* CreateModelFromFile(const char* modelPath); +#endif + +NA_EXTERN void DeleteModel(NeuralModel* model); + +NA_EXTERN void SetLSTMLoadMode(int loadMode); + +NA_EXTERN void SetWaveNetLoadMode(int loadMode); + +NA_EXTERN void SetAudioInputLevelDBu(float audioDBu); + +NA_EXTERN void SetDefaultMaxAudioBufferSize(int maxSize); + +NA_EXTERN int GetLoadMode(NeuralModel* model); + +NA_EXTERN bool IsStatic(NeuralModel* model); + +NA_EXTERN void SetMaxAudioBufferSize(NeuralModel* model, int maxSize); + +NA_EXTERN float GetRecommendedInputDBAdjustment(NeuralModel* model); + +NA_EXTERN float GetRecommendedOutputDBAdjustment(NeuralModel* model); + +NA_EXTERN float GetSampleRate(NeuralModel* model); + +NA_EXTERN void Process(NeuralModel* model, float* input, float* output, size_t numSamples); + +#ifdef __cplusplus +} +#endif \ No newline at end of file diff --git a/src/assets/brushes/CREDITS b/src/assets/brushes/CREDITS new file mode 100644 index 0000000..6ae8533 --- /dev/null +++ b/src/assets/brushes/CREDITS @@ -0,0 +1,9 @@ +Brush presets sourced from the mypaint/mypaint-brushes repository. +https://github.com/mypaint/mypaint-brushes + +License: CC0 1.0 Universal (Public Domain Dedication) +https://creativecommons.org/publicdomain/zero/1.0/ + +Contributors: + classic/ brushes — original MyPaint contributors + deevad/ brushes — David Revoy (deevad), http://www.davidrevoy.com diff --git a/src/assets/brushes/airbrush.myb b/src/assets/brushes/airbrush.myb new file mode 100644 index 0000000..9babe67 --- /dev/null +++ b/src/assets/brushes/airbrush.myb @@ -0,0 +1,204 @@ +{ + "comment": "MyPaint brush file", + "group": "", + "description": "An airbrush", + "notes": "A brush preset part of the Brushkit v0.6 \n created in october 2012 by David Revoy ( aka Deevad ) \n source: http://www.davidrevoy.com/article142/ressource-mypaint-brushes \n license: CC-Zero/Public-Domain", + "parent_brush_name": "", + "settings": { + "anti_aliasing": { + "base_value": 1.0, + "inputs": {} + }, + "change_color_h": { + "base_value": 0.0, + "inputs": {} + }, + "change_color_hsl_s": { + "base_value": 0.0, + "inputs": {} + }, + "change_color_hsv_s": { + "base_value": 0.0, + "inputs": {} + }, + "change_color_l": { + "base_value": 0.0, + "inputs": {} + }, + "change_color_v": { + "base_value": 0.0, + "inputs": {} + }, + "color_h": { + "base_value": 0.08902229845626071, + "inputs": {} + }, + "color_s": { + "base_value": 1.0, + "inputs": {} + }, + "color_v": { + "base_value": 0.0, + "inputs": {} + }, + "colorize": { + "base_value": 0.0, + "inputs": {} + }, + "custom_input": { + "base_value": 0.0, + "inputs": { + "pressure": [ + [0.0, 0.0], + [1.0, 1.0] + ] + } + }, + "custom_input_slowness": { + "base_value": 0.71, + "inputs": {} + }, + "dabs_per_actual_radius": { + "base_value": 5.75, + "inputs": {} + }, + "dabs_per_basic_radius": { + "base_value": 0.0, + "inputs": {} + }, + "dabs_per_second": { + "base_value": 30.0, + "inputs": {} + }, + "direction_filter": { + "base_value": 2.99, + "inputs": {} + }, + "elliptical_dab_angle": { + "base_value": 90.0, + "inputs": { + "direction": [ + [0.0, 0.0], + [180.0, 180.0] + ] + } + }, + "elliptical_dab_ratio": { + "base_value": 1.0, + "inputs": {} + }, + "eraser": { + "base_value": 0.0, + "inputs": {} + }, + "hardness": { + "base_value": 0.48, + "inputs": {} + }, + "lock_alpha": { + "base_value": 0.0, + "inputs": {} + }, + "offset_by_random": { + "base_value": 0.0, + "inputs": {} + }, + "offset_by_speed": { + "base_value": 0.0, + "inputs": {} + }, + "offset_by_speed_slowness": { + "base_value": 1.0, + "inputs": {} + }, + "opaque": { + "base_value": 0.52, + "inputs": {} + }, + "opaque_linearize": { + "base_value": 2.0, + "inputs": {} + }, + "opaque_multiply": { + "base_value": 0.0, + "inputs": { + "pressure": [ + [0.0, 0.0], + [0.111111, 0.5], + [0.308642, 0.833333], + [1.0, 1.0] + ] + } + }, + "radius_by_random": { + "base_value": 0.0, + "inputs": {} + }, + "radius_logarithmic": { + "base_value": 4.7, + "inputs": { + "custom": [ + [-2.0, 0.45], + [2.0, -0.45] + ] + } + }, + "restore_color": { + "base_value": 0.0, + "inputs": {} + }, + "slow_tracking": { + "base_value": 0.0, + "inputs": {} + }, + "slow_tracking_per_dab": { + "base_value": 0.0, + "inputs": {} + }, + "smudge": { + "base_value": 0.0, + "inputs": {} + }, + "smudge_length": { + "base_value": 0.5, + "inputs": {} + }, + "smudge_radius_log": { + "base_value": 0.0, + "inputs": {} + }, + "speed1_gamma": { + "base_value": 4.0, + "inputs": {} + }, + "speed1_slowness": { + "base_value": 0.04, + "inputs": {} + }, + "speed2_gamma": { + "base_value": 4.0, + "inputs": {} + }, + "speed2_slowness": { + "base_value": 0.8, + "inputs": {} + }, + "stroke_duration_logarithmic": { + "base_value": 4.0, + "inputs": {} + }, + "stroke_holdtime": { + "base_value": 0.0, + "inputs": {} + }, + "stroke_threshold": { + "base_value": 0.0, + "inputs": {} + }, + "tracking_noise": { + "base_value": 0.0, + "inputs": {} + } + }, + "version": 3 +} diff --git a/src/assets/brushes/brush.myb b/src/assets/brushes/brush.myb new file mode 100644 index 0000000..dfcbef1 --- /dev/null +++ b/src/assets/brushes/brush.myb @@ -0,0 +1,203 @@ +{ + "comment": "MyPaint brush file", + "group": "", + "parent_brush_name": "classic/brush", + "settings": { + "anti_aliasing": { + "base_value": 1.0, + "inputs": {} + }, + "change_color_h": { + "base_value": 0.0, + "inputs": {} + }, + "change_color_hsl_s": { + "base_value": 0.0, + "inputs": {} + }, + "change_color_hsv_s": { + "base_value": 0.0, + "inputs": {} + }, + "change_color_l": { + "base_value": 0.0, + "inputs": {} + }, + "change_color_v": { + "base_value": 0.0, + "inputs": {} + }, + "color_h": { + "base_value": 0.0, + "inputs": {} + }, + "color_s": { + "base_value": 0.0, + "inputs": {} + }, + "color_v": { + "base_value": 0.0, + "inputs": {} + }, + "colorize": { + "base_value": 0.0, + "inputs": {} + }, + "custom_input": { + "base_value": 0.0, + "inputs": {} + }, + "custom_input_slowness": { + "base_value": 0.0, + "inputs": {} + }, + "dabs_per_actual_radius": { + "base_value": 5.82, + "inputs": {} + }, + "dabs_per_basic_radius": { + "base_value": 0.51, + "inputs": {} + }, + "dabs_per_second": { + "base_value": 70.0, + "inputs": {} + }, + "direction_filter": { + "base_value": 2.0, + "inputs": {} + }, + "elliptical_dab_angle": { + "base_value": 90.0, + "inputs": {} + }, + "elliptical_dab_ratio": { + "base_value": 1.0, + "inputs": {} + }, + "eraser": { + "base_value": 0.0, + "inputs": {} + }, + "hardness": { + "base_value": 0.89, + "inputs": {} + }, + "lock_alpha": { + "base_value": 0.0, + "inputs": {} + }, + "offset_by_random": { + "base_value": 0.0, + "inputs": {} + }, + "offset_by_speed": { + "base_value": 0.0, + "inputs": {} + }, + "offset_by_speed_slowness": { + "base_value": 1.0, + "inputs": {} + }, + "opaque": { + "base_value": 1.0, + "inputs": { + "pressure": [ + [0.0, -0.989583], + [0.38253, -0.59375], + [0.656627, 0.041667], + [1.0, 1.0] + ] + } + }, + "opaque_linearize": { + "base_value": 0.44, + "inputs": {} + }, + "opaque_multiply": { + "base_value": 0.0, + "inputs": { + "pressure": [ + [0.0, 0.0], + [0.015, 0.0], + [0.069277, 0.9375], + [0.25, 1.0], + [1.0, 1.0] + ] + } + }, + "radius_by_random": { + "base_value": 0.0, + "inputs": {} + }, + "radius_logarithmic": { + "base_value": 1.01, + "inputs": { + "pressure": [ + [0.0, -1.86375], + [0.237952, -1.42], + [0.5, -0.355], + [0.76506, 1.42], + [1.0, 2.13] + ] + } + }, + "restore_color": { + "base_value": 0.0, + "inputs": {} + }, + "slow_tracking": { + "base_value": 4.47, + "inputs": {} + }, + "slow_tracking_per_dab": { + "base_value": 2.48, + "inputs": {} + }, + "smudge": { + "base_value": 0.0, + "inputs": {} + }, + "smudge_length": { + "base_value": 0.5, + "inputs": {} + }, + "smudge_radius_log": { + "base_value": 0.0, + "inputs": {} + }, + "speed1_gamma": { + "base_value": 2.87, + "inputs": {} + }, + "speed1_slowness": { + "base_value": 0.04, + "inputs": {} + }, + "speed2_gamma": { + "base_value": 4.0, + "inputs": {} + }, + "speed2_slowness": { + "base_value": 0.8, + "inputs": {} + }, + "stroke_duration_logarithmic": { + "base_value": 4.0, + "inputs": {} + }, + "stroke_holdtime": { + "base_value": 0.0, + "inputs": {} + }, + "stroke_threshold": { + "base_value": 0.0, + "inputs": {} + }, + "tracking_noise": { + "base_value": 0.0, + "inputs": {} + } + }, + "version": 3 +} diff --git a/src/assets/brushes/calligraphy.myb b/src/assets/brushes/calligraphy.myb new file mode 100644 index 0000000..9355080 --- /dev/null +++ b/src/assets/brushes/calligraphy.myb @@ -0,0 +1,215 @@ +{ + "comment": "MyPaint brush file", + "description": "", + "group": "", + "notes": "", + "parent_brush_name": "classic/calligraphy", + "settings": { + "anti_aliasing": { + "base_value": 3.53, + "inputs": {} + }, + "change_color_h": { + "base_value": 0.0, + "inputs": {} + }, + "change_color_hsl_s": { + "base_value": 0.0, + "inputs": {} + }, + "change_color_hsv_s": { + "base_value": 0.0, + "inputs": {} + }, + "change_color_l": { + "base_value": 0.0, + "inputs": {} + }, + "change_color_v": { + "base_value": 0.0, + "inputs": {} + }, + "color_h": { + "base_value": 0.0, + "inputs": {} + }, + "color_s": { + "base_value": 0.0, + "inputs": {} + }, + "color_v": { + "base_value": 0.0, + "inputs": {} + }, + "colorize": { + "base_value": 0.0, + "inputs": {} + }, + "custom_input": { + "base_value": 0.0, + "inputs": {} + }, + "custom_input_slowness": { + "base_value": 0.0, + "inputs": {} + }, + "dabs_per_actual_radius": { + "base_value": 2.2, + "inputs": {} + }, + "dabs_per_basic_radius": { + "base_value": 0.0, + "inputs": {} + }, + "dabs_per_second": { + "base_value": 0.0, + "inputs": {} + }, + "direction_filter": { + "base_value": 2.0, + "inputs": {} + }, + "elliptical_dab_angle": { + "base_value": 45.92, + "inputs": {} + }, + "elliptical_dab_ratio": { + "base_value": 5.46, + "inputs": {} + }, + "eraser": { + "base_value": 0.0, + "inputs": {} + }, + "hardness": { + "base_value": 0.74, + "inputs": { + "pressure": [ + [0.0, 0.0], + [1.0, 0.05] + ], + "speed1": [ + [0.0, -0.0], + [1.0, -0.04] + ] + } + }, + "lock_alpha": { + "base_value": 0.0, + "inputs": {} + }, + "offset_by_random": { + "base_value": 0.0, + "inputs": {} + }, + "offset_by_speed": { + "base_value": 0.0, + "inputs": {} + }, + "offset_by_speed_slowness": { + "base_value": 1.0, + "inputs": {} + }, + "opaque": { + "base_value": 1.0, + "inputs": {} + }, + "opaque_linearize": { + "base_value": 0.0, + "inputs": {} + }, + "opaque_multiply": { + "base_value": 0.0, + "inputs": { + "pressure": [ + [0.0, 0.0], + [0.015, 0.0], + [0.015, 1.0], + [1.0, 1.0] + ] + } + }, + "pressure_gain_log": { + "base_value": 0.0, + "inputs": {} + }, + "radius_by_random": { + "base_value": 0.0, + "inputs": {} + }, + "radius_logarithmic": { + "base_value": 2.02, + "inputs": { + "pressure": [ + [0.0, 0.0], + [1.0, 0.5] + ], + "speed1": [ + [0.0, -0.0], + [1.0, -0.12] + ] + } + }, + "restore_color": { + "base_value": 0.0, + "inputs": {} + }, + "slow_tracking": { + "base_value": 0.65, + "inputs": {} + }, + "slow_tracking_per_dab": { + "base_value": 0.8, + "inputs": {} + }, + "smudge": { + "base_value": 0.0, + "inputs": {} + }, + "smudge_length": { + "base_value": 0.5, + "inputs": {} + }, + "smudge_radius_log": { + "base_value": 0.0, + "inputs": {} + }, + "snap_to_pixel": { + "base_value": 0.0, + "inputs": {} + }, + "speed1_gamma": { + "base_value": 2.87, + "inputs": {} + }, + "speed1_slowness": { + "base_value": 0.04, + "inputs": {} + }, + "speed2_gamma": { + "base_value": 4.0, + "inputs": {} + }, + "speed2_slowness": { + "base_value": 0.8, + "inputs": {} + }, + "stroke_duration_logarithmic": { + "base_value": 4.0, + "inputs": {} + }, + "stroke_holdtime": { + "base_value": 0.0, + "inputs": {} + }, + "stroke_threshold": { + "base_value": 0.0, + "inputs": {} + }, + "tracking_noise": { + "base_value": 0.0, + "inputs": {} + } + }, + "version": 3 +} diff --git a/src/assets/brushes/chalk.myb b/src/assets/brushes/chalk.myb new file mode 100644 index 0000000..e7d32c9 --- /dev/null +++ b/src/assets/brushes/chalk.myb @@ -0,0 +1,219 @@ +{ + "comment": "MyPaint brush file", + "description": "A chalk brush attempt, using many tiny particles on canvas to simulate grain", + "group": "", + "notes": "A brush preset part of the Brushkit v0.6 \n created in october 2012 by David Revoy ( aka Deevad ) \n source: http://www.davidrevoy.com/article142/ressource-mypaint-brushes \n license: CC-Zero/Public-Domain", + "parent_brush_name": "deevad/chalk", + "settings": { + "anti_aliasing": { + "base_value": 1.0, + "inputs": {} + }, + "change_color_h": { + "base_value": 0.0, + "inputs": {} + }, + "change_color_hsl_s": { + "base_value": 0.0, + "inputs": {} + }, + "change_color_hsv_s": { + "base_value": 0.0, + "inputs": {} + }, + "change_color_l": { + "base_value": 0.0, + "inputs": {} + }, + "change_color_v": { + "base_value": 0.0, + "inputs": {} + }, + "color_h": { + "base_value": 0.0, + "inputs": {} + }, + "color_s": { + "base_value": 0.0, + "inputs": {} + }, + "color_v": { + "base_value": 0.0, + "inputs": {} + }, + "colorize": { + "base_value": 0.0, + "inputs": {} + }, + "custom_input": { + "base_value": 0.0, + "inputs": {} + }, + "custom_input_slowness": { + "base_value": 0.69, + "inputs": {} + }, + "dabs_per_actual_radius": { + "base_value": 3.93, + "inputs": {} + }, + "dabs_per_basic_radius": { + "base_value": 5.07, + "inputs": {} + }, + "dabs_per_second": { + "base_value": 0.0, + "inputs": {} + }, + "direction_filter": { + "base_value": 2.0, + "inputs": {} + }, + "elliptical_dab_angle": { + "base_value": 90.0, + "inputs": {} + }, + "elliptical_dab_ratio": { + "base_value": 1.0, + "inputs": {} + }, + "eraser": { + "base_value": 0.0, + "inputs": {} + }, + "hardness": { + "base_value": 0.67, + "inputs": { + "pressure": [ + [0.0, -0.4], + [0.667722, -0.0625], + [1.0, 0.6] + ] + } + }, + "lock_alpha": { + "base_value": 0.0, + "inputs": {} + }, + "offset_by_random": { + "base_value": 2.0, + "inputs": { + "pressure": [ + [0.0, 0.0], + [1.0, -2.0] + ], + "speed1": [ + [0.0, -0.2142857142857142], + [4.0, 1.5] + ], + "speed2": [ + [0.0, -0.2142857142857142], + [4.0, 1.5] + ] + } + }, + "offset_by_speed": { + "base_value": 0.0, + "inputs": {} + }, + "offset_by_speed_slowness": { + "base_value": 1.0, + "inputs": {} + }, + "opaque": { + "base_value": 0.2, + "inputs": { + "pressure": [ + [0.0, 0.0], + [1.0, 0.4] + ] + } + }, + "opaque_linearize": { + "base_value": 0.0, + "inputs": {} + }, + "opaque_multiply": { + "base_value": 0.0, + "inputs": { + "pressure": [ + [0.0, 0.0], + [1.0, 1.0] + ] + } + }, + "pressure_gain_log": { + "base_value": 0.0, + "inputs": {} + }, + "radius_by_random": { + "base_value": 0.0, + "inputs": {} + }, + "radius_logarithmic": { + "base_value": 0.58, + "inputs": {} + }, + "restore_color": { + "base_value": 0.0, + "inputs": {} + }, + "slow_tracking": { + "base_value": 2.0, + "inputs": {} + }, + "slow_tracking_per_dab": { + "base_value": 0.0, + "inputs": {} + }, + "smudge": { + "base_value": 0.0, + "inputs": {} + }, + "smudge_length": { + "base_value": 0.5, + "inputs": {} + }, + "smudge_radius_log": { + "base_value": 0.0, + "inputs": {} + }, + "snap_to_pixel": { + "base_value": 0.0, + "inputs": {} + }, + "speed1_gamma": { + "base_value": 4.0, + "inputs": {} + }, + "speed1_slowness": { + "base_value": 0.04, + "inputs": {} + }, + "speed2_gamma": { + "base_value": 4.0, + "inputs": {} + }, + "speed2_slowness": { + "base_value": 0.8, + "inputs": {} + }, + "stroke_duration_logarithmic": { + "base_value": 4.0, + "inputs": {} + }, + "stroke_holdtime": { + "base_value": 0.0, + "inputs": {} + }, + "stroke_threshold": { + "base_value": 0.0, + "inputs": {} + }, + "tracking_noise": { + "base_value": 0.0, + "inputs": {} + } + }, + "version": 3 +} diff --git a/src/assets/brushes/charcoal.myb b/src/assets/brushes/charcoal.myb new file mode 100644 index 0000000..50e7d0a --- /dev/null +++ b/src/assets/brushes/charcoal.myb @@ -0,0 +1,195 @@ +{ + "comment": "MyPaint brush file", + "group": "", + "parent_brush_name": "", + "settings": { + "anti_aliasing": { + "base_value": 0.0, + "inputs": {} + }, + "change_color_h": { + "base_value": 0.0, + "inputs": {} + }, + "change_color_hsl_s": { + "base_value": 0.0, + "inputs": {} + }, + "change_color_hsv_s": { + "base_value": 0.0, + "inputs": {} + }, + "change_color_l": { + "base_value": 0.0, + "inputs": {} + }, + "change_color_v": { + "base_value": 0.0, + "inputs": {} + }, + "color_h": { + "base_value": 0.6354166666666666, + "inputs": {} + }, + "color_s": { + "base_value": 0.8807339449541285, + "inputs": {} + }, + "color_v": { + "base_value": 0.42745098039215684, + "inputs": {} + }, + "colorize": { + "base_value": 0.0, + "inputs": {} + }, + "custom_input": { + "base_value": 0.0, + "inputs": {} + }, + "custom_input_slowness": { + "base_value": 0.0, + "inputs": {} + }, + "dabs_per_actual_radius": { + "base_value": 5.0, + "inputs": {} + }, + "dabs_per_basic_radius": { + "base_value": 0.0, + "inputs": {} + }, + "dabs_per_second": { + "base_value": 0.0, + "inputs": {} + }, + "direction_filter": { + "base_value": 2.0, + "inputs": {} + }, + "elliptical_dab_angle": { + "base_value": 90.0, + "inputs": {} + }, + "elliptical_dab_ratio": { + "base_value": 1.0, + "inputs": {} + }, + "eraser": { + "base_value": 0.0, + "inputs": {} + }, + "hardness": { + "base_value": 0.2, + "inputs": {} + }, + "lock_alpha": { + "base_value": 0.0, + "inputs": {} + }, + "offset_by_random": { + "base_value": 1.6, + "inputs": { + "pressure": [ + [0, 0], + [1.0, -1.4] + ] + } + }, + "offset_by_speed": { + "base_value": 0.0, + "inputs": {} + }, + "offset_by_speed_slowness": { + "base_value": 1.0, + "inputs": {} + }, + "opaque": { + "base_value": 0.4, + "inputs": { + "pressure": [ + [0, 0], + [1.0, 0.4] + ] + } + }, + "opaque_linearize": { + "base_value": 0.0, + "inputs": {} + }, + "opaque_multiply": { + "base_value": 0.0, + "inputs": { + "pressure": [ + [0, 0], + [1.0, 1.0] + ] + } + }, + "radius_by_random": { + "base_value": 0.0, + "inputs": {} + }, + "radius_logarithmic": { + "base_value": 0.7, + "inputs": {} + }, + "restore_color": { + "base_value": 0.0, + "inputs": {} + }, + "slow_tracking": { + "base_value": 2.0, + "inputs": {} + }, + "slow_tracking_per_dab": { + "base_value": 0.0, + "inputs": {} + }, + "smudge": { + "base_value": 0.0, + "inputs": {} + }, + "smudge_length": { + "base_value": 0.5, + "inputs": {} + }, + "smudge_radius_log": { + "base_value": 0.0, + "inputs": {} + }, + "speed1_gamma": { + "base_value": 4.0, + "inputs": {} + }, + "speed1_slowness": { + "base_value": 0.04, + "inputs": {} + }, + "speed2_gamma": { + "base_value": 4.0, + "inputs": {} + }, + "speed2_slowness": { + "base_value": 0.8, + "inputs": {} + }, + "stroke_duration_logarithmic": { + "base_value": 4.0, + "inputs": {} + }, + "stroke_holdtime": { + "base_value": 0.0, + "inputs": {} + }, + "stroke_threshold": { + "base_value": 0.0, + "inputs": {} + }, + "tracking_noise": { + "base_value": 0.0, + "inputs": {} + } + }, + "version": 3 +} diff --git a/src/assets/brushes/dry_brush.myb b/src/assets/brushes/dry_brush.myb new file mode 100644 index 0000000..263c315 --- /dev/null +++ b/src/assets/brushes/dry_brush.myb @@ -0,0 +1,210 @@ +{ + "comment": "MyPaint brush file", + "description": "", + "group": "", + "notes": "", + "parent_brush_name": "classic/dry_brush", + "settings": { + "anti_aliasing": { + "base_value": 0.0, + "inputs": {} + }, + "change_color_h": { + "base_value": 0.0, + "inputs": {} + }, + "change_color_hsl_s": { + "base_value": 0.0, + "inputs": {} + }, + "change_color_hsv_s": { + "base_value": 0.0, + "inputs": {} + }, + "change_color_l": { + "base_value": 0.0, + "inputs": {} + }, + "change_color_v": { + "base_value": 0.0, + "inputs": {} + }, + "color_h": { + "base_value": 0.0, + "inputs": {} + }, + "color_s": { + "base_value": 0.0, + "inputs": {} + }, + "color_v": { + "base_value": 0.0, + "inputs": {} + }, + "colorize": { + "base_value": 0.0, + "inputs": {} + }, + "custom_input": { + "base_value": 0.0, + "inputs": {} + }, + "custom_input_slowness": { + "base_value": 0.0, + "inputs": {} + }, + "dabs_per_actual_radius": { + "base_value": 6.0, + "inputs": {} + }, + "dabs_per_basic_radius": { + "base_value": 6.0, + "inputs": {} + }, + "dabs_per_second": { + "base_value": 0.0, + "inputs": {} + }, + "direction_filter": { + "base_value": 2.0, + "inputs": {} + }, + "elliptical_dab_angle": { + "base_value": 90.0, + "inputs": {} + }, + "elliptical_dab_ratio": { + "base_value": 1.0, + "inputs": {} + }, + "eraser": { + "base_value": 0.0, + "inputs": {} + }, + "hardness": { + "base_value": 0.2, + "inputs": {} + }, + "lock_alpha": { + "base_value": 0.0, + "inputs": {} + }, + "offset_by_random": { + "base_value": 0.0, + "inputs": { + "pressure": [ + [0.0, 0.0], + [1.0, 1.4] + ] + } + }, + "offset_by_speed": { + "base_value": 0.0, + "inputs": {} + }, + "offset_by_speed_slowness": { + "base_value": 1.0, + "inputs": {} + }, + "opaque": { + "base_value": 0.8, + "inputs": { + "pressure": [ + [0.0, 0.0], + [1.0, 0.2] + ] + } + }, + "opaque_linearize": { + "base_value": 0.0, + "inputs": {} + }, + "opaque_multiply": { + "base_value": 0.0, + "inputs": { + "pressure": [ + [0.0, 0.0], + [1.0, 1.0] + ] + } + }, + "pressure_gain_log": { + "base_value": 0.0, + "inputs": {} + }, + "radius_by_random": { + "base_value": 0.1, + "inputs": {} + }, + "radius_logarithmic": { + "base_value": 0.6, + "inputs": { + "speed2": [ + [0.0, 0.042857], + [4.0, -0.3] + ] + } + }, + "restore_color": { + "base_value": 0.0, + "inputs": {} + }, + "slow_tracking": { + "base_value": 2.0, + "inputs": {} + }, + "slow_tracking_per_dab": { + "base_value": 0.0, + "inputs": {} + }, + "smudge": { + "base_value": 0.0, + "inputs": {} + }, + "smudge_length": { + "base_value": 0.5, + "inputs": {} + }, + "smudge_radius_log": { + "base_value": 0.0, + "inputs": {} + }, + "snap_to_pixel": { + "base_value": 0.0, + "inputs": {} + }, + "speed1_gamma": { + "base_value": 4.0, + "inputs": {} + }, + "speed1_slowness": { + "base_value": 0.04, + "inputs": {} + }, + "speed2_gamma": { + "base_value": 4.0, + "inputs": {} + }, + "speed2_slowness": { + "base_value": 0.8, + "inputs": {} + }, + "stroke_duration_logarithmic": { + "base_value": 4.0, + "inputs": {} + }, + "stroke_holdtime": { + "base_value": 0.0, + "inputs": {} + }, + "stroke_threshold": { + "base_value": 0.0, + "inputs": {} + }, + "tracking_noise": { + "base_value": 0.0, + "inputs": {} + } + }, + "version": 3 +} diff --git a/src/assets/brushes/ink_blot.myb b/src/assets/brushes/ink_blot.myb new file mode 100644 index 0000000..d08d153 --- /dev/null +++ b/src/assets/brushes/ink_blot.myb @@ -0,0 +1,200 @@ +{ + "comment": "MyPaint brush file", + "description": "", + "group": "", + "notes": "", + "parent_brush_name": "classic/ink_blot", + "settings": { + "anti_aliasing": { + "base_value": 1.0, + "inputs": {} + }, + "change_color_h": { + "base_value": 0.0, + "inputs": {} + }, + "change_color_hsl_s": { + "base_value": 0.0, + "inputs": {} + }, + "change_color_hsv_s": { + "base_value": 0.0, + "inputs": {} + }, + "change_color_l": { + "base_value": 0.0, + "inputs": {} + }, + "change_color_v": { + "base_value": 0.0, + "inputs": {} + }, + "color_h": { + "base_value": 0.0, + "inputs": {} + }, + "color_s": { + "base_value": 0.0, + "inputs": {} + }, + "color_v": { + "base_value": 0.0, + "inputs": {} + }, + "colorize": { + "base_value": 0.0, + "inputs": {} + }, + "custom_input": { + "base_value": 0.0, + "inputs": {} + }, + "custom_input_slowness": { + "base_value": 0.0, + "inputs": {} + }, + "dabs_per_actual_radius": { + "base_value": 3.32, + "inputs": {} + }, + "dabs_per_basic_radius": { + "base_value": 0.0, + "inputs": {} + }, + "dabs_per_second": { + "base_value": 15.0, + "inputs": {} + }, + "direction_filter": { + "base_value": 2.0, + "inputs": {} + }, + "elliptical_dab_angle": { + "base_value": 90.0, + "inputs": {} + }, + "elliptical_dab_ratio": { + "base_value": 1.0, + "inputs": {} + }, + "eraser": { + "base_value": 0.0, + "inputs": {} + }, + "hardness": { + "base_value": 0.28, + "inputs": {} + }, + "lock_alpha": { + "base_value": 0.0, + "inputs": {} + }, + "offset_by_random": { + "base_value": 0.17, + "inputs": {} + }, + "offset_by_speed": { + "base_value": 0.02, + "inputs": { + "custom": [ + [-2.0, 0.0], + [2.0, 0.0] + ] + } + }, + "offset_by_speed_slowness": { + "base_value": 1.0, + "inputs": {} + }, + "opaque": { + "base_value": 1.0, + "inputs": {} + }, + "opaque_linearize": { + "base_value": 0.9, + "inputs": {} + }, + "opaque_multiply": { + "base_value": 0.0, + "inputs": { + "pressure": [ + [0.0, 0.0], + [1.0, 1.0] + ] + } + }, + "pressure_gain_log": { + "base_value": 0.0, + "inputs": {} + }, + "radius_by_random": { + "base_value": 0.63, + "inputs": {} + }, + "radius_logarithmic": { + "base_value": 2.5, + "inputs": {} + }, + "restore_color": { + "base_value": 0.0, + "inputs": {} + }, + "slow_tracking": { + "base_value": 0.0, + "inputs": {} + }, + "slow_tracking_per_dab": { + "base_value": 0.0, + "inputs": {} + }, + "smudge": { + "base_value": 0.0, + "inputs": {} + }, + "smudge_length": { + "base_value": 0.5, + "inputs": {} + }, + "smudge_radius_log": { + "base_value": 0.0, + "inputs": {} + }, + "snap_to_pixel": { + "base_value": 0.0, + "inputs": {} + }, + "speed1_gamma": { + "base_value": 4.0, + "inputs": {} + }, + "speed1_slowness": { + "base_value": 0.04, + "inputs": {} + }, + "speed2_gamma": { + "base_value": 4.0, + "inputs": {} + }, + "speed2_slowness": { + "base_value": 0.8, + "inputs": {} + }, + "stroke_duration_logarithmic": { + "base_value": 4.0, + "inputs": {} + }, + "stroke_holdtime": { + "base_value": 0.0, + "inputs": {} + }, + "stroke_threshold": { + "base_value": 0.0, + "inputs": {} + }, + "tracking_noise": { + "base_value": 0.0, + "inputs": {} + } + }, + "version": 3 +} diff --git a/src/assets/brushes/liner.myb b/src/assets/brushes/liner.myb new file mode 100644 index 0000000..985a68c --- /dev/null +++ b/src/assets/brushes/liner.myb @@ -0,0 +1,189 @@ +{ + "comment": "MyPaint brush file", + "group": "", + "description": "A small brush to trace regular lines", + "notes": "A brush preset part of the Brushkit v0.6 \n created in october 2012 by David Revoy ( aka Deevad ) \n source: http://www.davidrevoy.com/article142/ressource-mypaint-brushes \n license: CC-Zero/Public-Domain", + "parent_brush_name": "", + "settings": { + "anti_aliasing": { + "base_value": 2.0, + "inputs": {} + }, + "change_color_h": { + "base_value": 0.0, + "inputs": {} + }, + "change_color_hsl_s": { + "base_value": 0.0, + "inputs": {} + }, + "change_color_hsv_s": { + "base_value": 0.0, + "inputs": {} + }, + "change_color_l": { + "base_value": 0.0, + "inputs": {} + }, + "change_color_v": { + "base_value": 0.0, + "inputs": {} + }, + "color_h": { + "base_value": 0.1289192800566187, + "inputs": {} + }, + "color_s": { + "base_value": 1.0, + "inputs": {} + }, + "color_v": { + "base_value": 0.0, + "inputs": {} + }, + "colorize": { + "base_value": 0.0, + "inputs": {} + }, + "custom_input": { + "base_value": 0.0, + "inputs": {} + }, + "custom_input_slowness": { + "base_value": 0.0, + "inputs": {} + }, + "dabs_per_actual_radius": { + "base_value": 4.43, + "inputs": {} + }, + "dabs_per_basic_radius": { + "base_value": 0.0, + "inputs": {} + }, + "dabs_per_second": { + "base_value": 0.0, + "inputs": {} + }, + "direction_filter": { + "base_value": 2.0, + "inputs": {} + }, + "elliptical_dab_angle": { + "base_value": 90.0, + "inputs": {} + }, + "elliptical_dab_ratio": { + "base_value": 1.0, + "inputs": {} + }, + "eraser": { + "base_value": 0.0, + "inputs": {} + }, + "hardness": { + "base_value": 0.8, + "inputs": {} + }, + "lock_alpha": { + "base_value": 0.0, + "inputs": {} + }, + "offset_by_random": { + "base_value": 0.0, + "inputs": {} + }, + "offset_by_speed": { + "base_value": 0.0, + "inputs": {} + }, + "offset_by_speed_slowness": { + "base_value": 1.0, + "inputs": {} + }, + "opaque": { + "base_value": 1.0, + "inputs": {} + }, + "opaque_linearize": { + "base_value": 0.0, + "inputs": {} + }, + "opaque_multiply": { + "base_value": 0.0, + "inputs": { + "pressure": [ + [0.0, 0.0], + [0.015, 0.0], + [0.015, 1.0], + [1.0, 1.0] + ] + } + }, + "radius_by_random": { + "base_value": 0.0, + "inputs": {} + }, + "radius_logarithmic": { + "base_value": 0.7999999999999998, + "inputs": {} + }, + "restore_color": { + "base_value": 0.0, + "inputs": {} + }, + "slow_tracking": { + "base_value": 0.0, + "inputs": {} + }, + "slow_tracking_per_dab": { + "base_value": 0.0, + "inputs": {} + }, + "smudge": { + "base_value": 0.0, + "inputs": {} + }, + "smudge_length": { + "base_value": 0.5, + "inputs": {} + }, + "smudge_radius_log": { + "base_value": 0.0, + "inputs": {} + }, + "speed1_gamma": { + "base_value": 2.87, + "inputs": {} + }, + "speed1_slowness": { + "base_value": 0.04, + "inputs": {} + }, + "speed2_gamma": { + "base_value": 4.0, + "inputs": {} + }, + "speed2_slowness": { + "base_value": 0.8, + "inputs": {} + }, + "stroke_duration_logarithmic": { + "base_value": 1.18, + "inputs": {} + }, + "stroke_holdtime": { + "base_value": 10.0, + "inputs": {} + }, + "stroke_threshold": { + "base_value": 0.0, + "inputs": {} + }, + "tracking_noise": { + "base_value": 0.0, + "inputs": {} + } + }, + "version": 3 +} diff --git a/src/assets/brushes/pen.myb b/src/assets/brushes/pen.myb new file mode 100644 index 0000000..f695e9d --- /dev/null +++ b/src/assets/brushes/pen.myb @@ -0,0 +1,215 @@ +{ + "comment": "MyPaint brush file", + "description": "", + "group": "", + "notes": "", + "parent_brush_name": "classic/pen", + "settings": { + "anti_aliasing": { + "base_value": 1.0, + "inputs": {} + }, + "change_color_h": { + "base_value": 0.0, + "inputs": {} + }, + "change_color_hsl_s": { + "base_value": 0.0, + "inputs": {} + }, + "change_color_hsv_s": { + "base_value": 0.0, + "inputs": {} + }, + "change_color_l": { + "base_value": 0.0, + "inputs": {} + }, + "change_color_v": { + "base_value": 0.0, + "inputs": {} + }, + "color_h": { + "base_value": 0.0, + "inputs": {} + }, + "color_s": { + "base_value": 0.0, + "inputs": {} + }, + "color_v": { + "base_value": 0.0, + "inputs": {} + }, + "colorize": { + "base_value": 0.0, + "inputs": {} + }, + "custom_input": { + "base_value": 0.0, + "inputs": {} + }, + "custom_input_slowness": { + "base_value": 0.0, + "inputs": {} + }, + "dabs_per_actual_radius": { + "base_value": 2.2, + "inputs": {} + }, + "dabs_per_basic_radius": { + "base_value": 0.0, + "inputs": {} + }, + "dabs_per_second": { + "base_value": 0.0, + "inputs": {} + }, + "direction_filter": { + "base_value": 2.0, + "inputs": {} + }, + "elliptical_dab_angle": { + "base_value": 90.0, + "inputs": {} + }, + "elliptical_dab_ratio": { + "base_value": 1.0, + "inputs": {} + }, + "eraser": { + "base_value": 0.0, + "inputs": {} + }, + "hardness": { + "base_value": 0.9, + "inputs": { + "pressure": [ + [0.0, 0.0], + [1.0, 0.05] + ], + "speed1": [ + [0.0, -0.0], + [1.0, -0.09] + ] + } + }, + "lock_alpha": { + "base_value": 0.0, + "inputs": {} + }, + "offset_by_random": { + "base_value": 0.0, + "inputs": {} + }, + "offset_by_speed": { + "base_value": 0.0, + "inputs": {} + }, + "offset_by_speed_slowness": { + "base_value": 1.0, + "inputs": {} + }, + "opaque": { + "base_value": 1.0, + "inputs": {} + }, + "opaque_linearize": { + "base_value": 0.9, + "inputs": {} + }, + "opaque_multiply": { + "base_value": 0.0, + "inputs": { + "pressure": [ + [0.0, 0.0], + [0.015, 0.0], + [0.015, 1.0], + [1.0, 1.0] + ] + } + }, + "pressure_gain_log": { + "base_value": 0.0, + "inputs": {} + }, + "radius_by_random": { + "base_value": 0.0, + "inputs": {} + }, + "radius_logarithmic": { + "base_value": 0.96, + "inputs": { + "pressure": [ + [0.0, 0.0], + [1.0, 0.5] + ], + "speed1": [ + [0.0, -0.0], + [1.0, -0.15] + ] + } + }, + "restore_color": { + "base_value": 0.0, + "inputs": {} + }, + "slow_tracking": { + "base_value": 0.65, + "inputs": {} + }, + "slow_tracking_per_dab": { + "base_value": 0.8, + "inputs": {} + }, + "smudge": { + "base_value": 0.0, + "inputs": {} + }, + "smudge_length": { + "base_value": 0.5, + "inputs": {} + }, + "smudge_radius_log": { + "base_value": 0.0, + "inputs": {} + }, + "snap_to_pixel": { + "base_value": 0.0, + "inputs": {} + }, + "speed1_gamma": { + "base_value": 2.87, + "inputs": {} + }, + "speed1_slowness": { + "base_value": 0.04, + "inputs": {} + }, + "speed2_gamma": { + "base_value": 4.0, + "inputs": {} + }, + "speed2_slowness": { + "base_value": 0.8, + "inputs": {} + }, + "stroke_duration_logarithmic": { + "base_value": 4.0, + "inputs": {} + }, + "stroke_holdtime": { + "base_value": 0.0, + "inputs": {} + }, + "stroke_threshold": { + "base_value": 0.0, + "inputs": {} + }, + "tracking_noise": { + "base_value": 0.0, + "inputs": {} + } + }, + "version": 3 +} diff --git a/src/assets/brushes/pencil.myb b/src/assets/brushes/pencil.myb new file mode 100644 index 0000000..f8860ca --- /dev/null +++ b/src/assets/brushes/pencil.myb @@ -0,0 +1,195 @@ +{ + "comment": "MyPaint brush file", + "group": "", + "parent_brush_name": "classic/pencil", + "settings": { + "anti_aliasing": { + "base_value": 0.0, + "inputs": {} + }, + "change_color_h": { + "base_value": 0.0, + "inputs": {} + }, + "change_color_hsl_s": { + "base_value": 0.0, + "inputs": {} + }, + "change_color_hsv_s": { + "base_value": 0.0, + "inputs": {} + }, + "change_color_l": { + "base_value": 0.0, + "inputs": {} + }, + "change_color_v": { + "base_value": 0.0, + "inputs": {} + }, + "color_h": { + "base_value": 0.0, + "inputs": {} + }, + "color_s": { + "base_value": 0.0, + "inputs": {} + }, + "color_v": { + "base_value": 0.0, + "inputs": {} + }, + "colorize": { + "base_value": 0.0, + "inputs": {} + }, + "custom_input": { + "base_value": 0.0, + "inputs": {} + }, + "custom_input_slowness": { + "base_value": 0.0, + "inputs": {} + }, + "dabs_per_actual_radius": { + "base_value": 4.0, + "inputs": {} + }, + "dabs_per_basic_radius": { + "base_value": 0.0, + "inputs": {} + }, + "dabs_per_second": { + "base_value": 0.0, + "inputs": {} + }, + "direction_filter": { + "base_value": 2.0, + "inputs": {} + }, + "elliptical_dab_angle": { + "base_value": 90.0, + "inputs": {} + }, + "elliptical_dab_ratio": { + "base_value": 1.0, + "inputs": {} + }, + "eraser": { + "base_value": 0.0, + "inputs": {} + }, + "hardness": { + "base_value": 0.1, + "inputs": { + "pressure": [ + [0.0, 0.0], + [1.0, 0.3] + ] + } + }, + "lock_alpha": { + "base_value": 0.0, + "inputs": {} + }, + "offset_by_random": { + "base_value": 0.5, + "inputs": { + "pressure": [ + [0.0, 0.0], + [1.0, -0.3] + ] + } + }, + "offset_by_speed": { + "base_value": 0.0, + "inputs": {} + }, + "offset_by_speed_slowness": { + "base_value": 1.0, + "inputs": {} + }, + "opaque": { + "base_value": 0.7, + "inputs": {} + }, + "opaque_linearize": { + "base_value": 0.9, + "inputs": {} + }, + "opaque_multiply": { + "base_value": 0.0, + "inputs": { + "pressure": [ + [0.0, 0.0], + [1.0, 1.0] + ] + } + }, + "radius_by_random": { + "base_value": 0.0, + "inputs": {} + }, + "radius_logarithmic": { + "base_value": 0.2, + "inputs": {} + }, + "restore_color": { + "base_value": 0.0, + "inputs": {} + }, + "slow_tracking": { + "base_value": 1.0, + "inputs": {} + }, + "slow_tracking_per_dab": { + "base_value": 0.0, + "inputs": {} + }, + "smudge": { + "base_value": 0.0, + "inputs": {} + }, + "smudge_length": { + "base_value": 0.5, + "inputs": {} + }, + "smudge_radius_log": { + "base_value": 0.0, + "inputs": {} + }, + "speed1_gamma": { + "base_value": 4.0, + "inputs": {} + }, + "speed1_slowness": { + "base_value": 0.04, + "inputs": {} + }, + "speed2_gamma": { + "base_value": 4.0, + "inputs": {} + }, + "speed2_slowness": { + "base_value": 0.8, + "inputs": {} + }, + "stroke_duration_logarithmic": { + "base_value": 4.0, + "inputs": {} + }, + "stroke_holdtime": { + "base_value": 0.0, + "inputs": {} + }, + "stroke_threshold": { + "base_value": 0.0, + "inputs": {} + }, + "tracking_noise": { + "base_value": 0.0, + "inputs": {} + } + }, + "version": 3 +} diff --git a/src/assets/todo.svg b/src/assets/todo.svg new file mode 100644 index 0000000..cdd226f --- /dev/null +++ b/src/assets/todo.svg @@ -0,0 +1,5 @@ + + + + +