diff --git a/lightningbeam-ui/lightningbeam-core/src/brush_engine.rs b/lightningbeam-ui/lightningbeam-core/src/brush_engine.rs index eb2389e..17901c2 100644 --- a/lightningbeam-ui/lightningbeam-core/src/brush_engine.rs +++ b/lightningbeam-ui/lightningbeam-core/src/brush_engine.rs @@ -5,27 +5,19 @@ //! Based on the libmypaint brush engine (ISC license, Martin Renold et al.). //! //! ### Dab shape -//! For each pixel at normalised squared distance `rr = (dist / radius)²` from the -//! dab centre, the opacity weight is calculated using two linear segments: +//! For each pixel at normalised distance `r = dist / radius` from the dab centre, +//! the opacity weight uses a flat inner core and smooth quadratic outer falloff: //! -//! ```text -//! opa -//! ^ -//! * . -//! | * -//! | . -//! +-----------*> rr -//! 0 hardness 1 -//! ``` +//! - `r > 1`: opa = 0 (outside dab) +//! - `r ≤ hardness` (or hardness = 1): opa = 1 (fully opaque core) +//! - `hardness < r ≤ 1`: `opa = ((1 - r) / (1 - hardness))²` (smooth falloff) //! -//! - segment 1 (rr ≤ hardness): `opa = 1 + rr * (-(1/hardness - 1))` -//! - segment 2 (hardness < rr ≤ 1): `opa = hardness/(1-hardness) - rr * hardness/(1-hardness)` -//! - rr > 1: opa = 0 +//! 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 `apply_stroke` calls via `StrokeState`. +//! consecutive calls via `StrokeState`. //! //! ### Blending //! Normal mode uses the standard "over" operator on premultiplied RGBA: @@ -120,7 +112,7 @@ impl BrushEngine { RasterBlendMode::Smudge => 2u32, }; - let mut push_dab = |dabs: &mut Vec, + let push_dab = |dabs: &mut Vec, bbox: &mut (i32, i32, i32, i32), x: f32, y: f32, radius: f32, opacity: f32, @@ -205,312 +197,9 @@ impl BrushEngine { (dabs, bbox) } - - /// Apply a complete stroke to a pixel buffer. - /// - /// A fresh [`StrokeState`] is created for each stroke (starts with full dab - /// placement spacing so the first dab lands at the very first point). - pub fn apply_stroke(buffer: &mut RgbaImage, stroke: &StrokeRecord) { - let mut state = StrokeState::new(); - // Ensure the very first point always gets a dab - state.distance_since_last_dab = f32::MAX; - Self::apply_stroke_with_state(buffer, stroke, &mut state); - } - - /// Apply a stroke segment to a buffer while preserving dab-placement state. - /// - /// Use this when building up a stroke incrementally (e.g. live drawing) so - /// that dab spacing is consistent across motion events. - pub fn apply_stroke_with_state( - buffer: &mut RgbaImage, - stroke: &StrokeRecord, - state: &mut StrokeState, - ) { - if stroke.points.len() < 2 { - // Single-point "tap": draw one dab at the given pressure - if let Some(pt) = stroke.points.first() { - let r = stroke.brush_settings.radius_at_pressure(pt.pressure); - let o = stroke.brush_settings.opacity_at_pressure(pt.pressure); - // Smudge has no drag direction on a single tap — skip painting - if !matches!(stroke.blend_mode, RasterBlendMode::Smudge) { - Self::render_dab(buffer, pt.x, pt.y, r, stroke.brush_settings.hardness, - o, stroke.color, stroke.blend_mode); - } - state.distance_since_last_dab = 0.0; - } - return; - } - - for window in stroke.points.windows(2) { - let p0 = &window[0]; - let p1 = &window[1]; - - let dx = p1.x - p0.x; - let dy = p1.y - p0.y; - let seg_len = (dx * dx + dy * dy).sqrt(); - if seg_len < 1e-4 { - continue; - } - - // Interpolate across this segment - 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; - let spacing = spacing.max(0.5); // at least half a pixel - - let dist_to_next = spacing - state.distance_since_last_dab; - let seg_t_to_next = (dist_to_next / seg_len).max(0.0); - - if seg_t_to_next > 1.0 - t { - // Not enough distance left in this segment for another dab - state.distance_since_last_dab += seg_len * (1.0 - t); - 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 opacity2 = stroke.brush_settings.opacity_at_pressure(pressure2); - - if matches!(stroke.blend_mode, RasterBlendMode::Smudge) { - // Directional warp smudge: each pixel in the dab footprint - // samples from a position offset backwards along the stroke, - // preserving lateral color structure. - let ndx = dx / seg_len; - let ndy = dy / seg_len; - let smudge_dist = (radius2 * stroke.brush_settings.dabs_per_radius).max(1.0); - Self::render_smudge_dab(buffer, x2, y2, radius2, - stroke.brush_settings.hardness, - opacity2, ndx, ndy, smudge_dist); - } else { - Self::render_dab(buffer, x2, y2, radius2, - stroke.brush_settings.hardness, - opacity2, stroke.color, stroke.blend_mode); - } - - state.distance_since_last_dab = 0.0; - } - } - } - - /// Render a single Gaussian dab at pixel position (x, y). - /// - /// Uses the two-segment linear falloff from MyPaint/libmypaint for the - /// opacity mask, then blends using the requested `blend_mode`. - pub fn render_dab( - buffer: &mut RgbaImage, - x: f32, - y: f32, - radius: f32, - hardness: f32, - opacity: f32, - color: [f32; 4], - blend_mode: RasterBlendMode, - ) { - if radius < 0.5 || opacity <= 0.0 { - return; - } - - let hardness = hardness.clamp(1e-3, 1.0); - - // Pre-compute the two linear-segment coefficients (from libmypaint render_dab_mask) - let seg1_offset = 1.0f32; - let seg1_slope = -(1.0 / hardness - 1.0); - let seg2_offset = hardness / (1.0 - hardness); - let seg2_slope = -hardness / (1.0 - hardness); - - let r_fringe = radius + 1.0; - let x0 = ((x - r_fringe).floor() as i32).max(0) as u32; - let y0 = ((y - r_fringe).floor() as i32).max(0) as u32; - let x1 = ((x + r_fringe).ceil() as i32).min(buffer.width() as i32 - 1).max(0) as u32; - let y1 = ((y + r_fringe).ceil() as i32).min(buffer.height() as i32 - 1).max(0) as u32; - - let one_over_r2 = 1.0 / (radius * radius); - - for py in y0..=y1 { - for px in x0..=x1 { - let dx = px as f32 + 0.5 - x; - let dy = py as f32 + 0.5 - y; - let rr = (dx * dx + dy * dy) * one_over_r2; - - if rr > 1.0 { - continue; - } - - // Two-segment opacity (identical to libmypaint calculate_opa) - let opa_weight = if rr <= hardness { - seg1_offset + rr * seg1_slope - } else { - seg2_offset + rr * seg2_slope - } - .clamp(0.0, 1.0); - - let dab_alpha = opa_weight * opacity * color[3]; - if dab_alpha <= 0.0 { - continue; - } - - let pixel = buffer.get_pixel_mut(px, py); - let dst = [ - pixel[0] as f32 / 255.0, - pixel[1] as f32 / 255.0, - pixel[2] as f32 / 255.0, - pixel[3] as f32 / 255.0, - ]; - - let (out_r, out_g, out_b, out_a) = match blend_mode { - RasterBlendMode::Normal | RasterBlendMode::Smudge => { - // Standard "over" operator (smudge pre-computes its color upstream) - let oa = dab_alpha; - let ba = 1.0 - oa; - let out_a = oa + ba * dst[3]; - let out_r = oa * color[0] + ba * dst[0]; - let out_g = oa * color[1] + ba * dst[1]; - let out_b = oa * color[2] + ba * dst[2]; - (out_r, out_g, out_b, out_a) - } - RasterBlendMode::Erase => { - // Multiplicative erase: each dab removes dab_alpha *fraction* of remaining - // alpha. This prevents dense overlapping dabs from summing past 1.0 and - // fully erasing at low opacity — opacity now controls the per-dab fraction - // removed rather than an absolute amount. - let new_a = dst[3] * (1.0 - dab_alpha); - let scale = if dst[3] > 1e-6 { new_a / dst[3] } else { 0.0 }; - (dst[0] * scale, dst[1] * scale, dst[2] * scale, new_a) - } - }; - - pixel[0] = (out_r.clamp(0.0, 1.0) * 255.0) as u8; - pixel[1] = (out_g.clamp(0.0, 1.0) * 255.0) as u8; - pixel[2] = (out_b.clamp(0.0, 1.0) * 255.0) as u8; - pixel[3] = (out_a.clamp(0.0, 1.0) * 255.0) as u8; - } - } - } - - /// Render a smudge dab using directional per-pixel warp. - /// - /// Each pixel in the dab footprint samples from the canvas at a position offset - /// backwards along `(ndx, ndy)` by `smudge_dist` pixels, then blends that - /// sampled color over the current pixel weighted by the dab opacity. - /// - /// Because each pixel samples its own source position, lateral color structure - /// is preserved: dragging over a 1-pixel dot with a 20-pixel brush produces a - /// narrow streak rather than a uniform smear. - /// - /// Updates are collected before any writes to avoid read/write aliasing. - fn render_smudge_dab( - buffer: &mut RgbaImage, - x: f32, - y: f32, - radius: f32, - hardness: f32, - opacity: f32, - ndx: f32, // normalized stroke direction x - ndy: f32, // normalized stroke direction y - smudge_dist: f32, - ) { - if radius < 0.5 || opacity <= 0.0 { - return; - } - - let hardness = hardness.clamp(1e-3, 1.0); - let seg1_offset = 1.0f32; - let seg1_slope = -(1.0 / hardness - 1.0); - let seg2_offset = hardness / (1.0 - hardness); - let seg2_slope = -hardness / (1.0 - hardness); - - let r_fringe = radius + 1.0; - let x0 = ((x - r_fringe).floor() as i32).max(0) as u32; - let y0 = ((y - r_fringe).floor() as i32).max(0) as u32; - let x1 = ((x + r_fringe).ceil() as i32).min(buffer.width() as i32 - 1).max(0) as u32; - let y1 = ((y + r_fringe).ceil() as i32).min(buffer.height() as i32 - 1).max(0) as u32; - - let one_over_r2 = 1.0 / (radius * radius); - - // Collect updates before writing to avoid aliasing between source and dest reads - let mut updates: Vec<(u32, u32, [u8; 4])> = Vec::new(); - - for py in y0..=y1 { - for px in x0..=x1 { - let fdx = px as f32 + 0.5 - x; - let fdy = py as f32 + 0.5 - y; - let rr = (fdx * fdx + fdy * fdy) * one_over_r2; - - if rr > 1.0 { - continue; - } - - let opa_weight = if rr <= hardness { - seg1_offset + rr * seg1_slope - } else { - seg2_offset + rr * seg2_slope - } - .clamp(0.0, 1.0); - - let alpha = opa_weight * opacity; - if alpha <= 0.0 { - continue; - } - - // Sample from one dab-spacing behind the current position along stroke - let src_x = px as f32 + 0.5 - ndx * smudge_dist; - let src_y = py as f32 + 0.5 - ndy * smudge_dist; - let src = Self::sample_bilinear(buffer, src_x, src_y); - - let dst = buffer.get_pixel(px, py); - let da = 1.0 - alpha; - let out = [ - ((alpha * src[0] + da * dst[0] as f32 / 255.0).clamp(0.0, 1.0) * 255.0) as u8, - ((alpha * src[1] + da * dst[1] as f32 / 255.0).clamp(0.0, 1.0) * 255.0) as u8, - ((alpha * src[2] + da * dst[2] as f32 / 255.0).clamp(0.0, 1.0) * 255.0) as u8, - ((alpha * src[3] + da * dst[3] as f32 / 255.0).clamp(0.0, 1.0) * 255.0) as u8, - ]; - updates.push((px, py, out)); - } - } - - for (px, py, rgba) in updates { - let p = buffer.get_pixel_mut(px, py); - p[0] = rgba[0]; - p[1] = rgba[1]; - p[2] = rgba[2]; - p[3] = rgba[3]; - } - } - - /// Bilinearly sample a floating-point position from the buffer, clamped to bounds. - fn sample_bilinear(buffer: &RgbaImage, x: f32, y: f32) -> [f32; 4] { - let w = buffer.width() as i32; - let h = buffer.height() as i32; - let x0 = (x.floor() as i32).clamp(0, w - 1); - let y0 = (y.floor() as i32).clamp(0, h - 1); - let x1 = (x0 + 1).min(w - 1); - let y1 = (y0 + 1).min(h - 1); - let fx = (x - x0 as f32).clamp(0.0, 1.0); - let fy = (y - y0 as f32).clamp(0.0, 1.0); - - let p00 = buffer.get_pixel(x0 as u32, y0 as u32); - let p10 = buffer.get_pixel(x1 as u32, y0 as u32); - let p01 = buffer.get_pixel(x0 as u32, y1 as u32); - let p11 = buffer.get_pixel(x1 as u32, y1 as u32); - - let mut out = [0.0f32; 4]; - for i in 0..4 { - let top = p00[i] as f32 * (1.0 - fx) + p10[i] as f32 * fx; - let bot = p01[i] as f32 * (1.0 - fx) + p11[i] as f32 * fx; - out[i] = (top * (1.0 - fy) + bot * fy) / 255.0; - } - out - } } + /// Create an `RgbaImage` from a raw RGBA pixel buffer. /// /// If `raw` is empty a blank (transparent) image of the given dimensions is returned. @@ -542,46 +231,6 @@ pub fn decode_png(data: &[u8]) -> Result { #[cfg(test)] mod tests { use super::*; - use crate::raster_layer::{StrokePoint, StrokeRecord, RasterBlendMode}; - use crate::brush_settings::BrushSettings; - - fn make_stroke(color: [f32; 4]) -> StrokeRecord { - StrokeRecord { - brush_settings: BrushSettings::default_round_hard(), - color, - blend_mode: RasterBlendMode::Normal, - points: vec![ - StrokePoint { x: 10.0, y: 10.0, pressure: 0.8, tilt_x: 0.0, tilt_y: 0.0, timestamp: 0.0 }, - StrokePoint { x: 50.0, y: 10.0, pressure: 0.8, tilt_x: 0.0, tilt_y: 0.0, timestamp: 0.1 }, - ], - } - } - - #[test] - fn test_stroke_modifies_buffer() { - let mut img = RgbaImage::new(100, 100); - let stroke = make_stroke([1.0, 0.0, 0.0, 1.0]); // red - BrushEngine::apply_stroke(&mut img, &stroke); - // The center pixel should have some red - let px = img.get_pixel(30, 10); - assert!(px[0] > 0, "expected red paint"); - } - - #[test] - fn test_erase_reduces_alpha() { - let mut img = RgbaImage::from_pixel(100, 100, image::Rgba([200, 100, 50, 255])); - let stroke = StrokeRecord { - brush_settings: BrushSettings::default_round_hard(), - color: [0.0, 0.0, 0.0, 1.0], - blend_mode: RasterBlendMode::Erase, - points: vec![ - StrokePoint { x: 50.0, y: 50.0, pressure: 1.0, tilt_x: 0.0, tilt_y: 0.0, timestamp: 0.0 }, - ], - }; - BrushEngine::apply_stroke(&mut img, &stroke); - let px = img.get_pixel(50, 50); - assert!(px[3] < 255, "alpha should be reduced by erase"); - } #[test] fn test_png_roundtrip() { diff --git a/lightningbeam-ui/lightningbeam-core/src/selection.rs b/lightningbeam-ui/lightningbeam-core/src/selection.rs index 2783e71..6facb2a 100644 --- a/lightningbeam-ui/lightningbeam-core/src/selection.rs +++ b/lightningbeam-ui/lightningbeam-core/src/selection.rs @@ -82,6 +82,10 @@ pub struct RasterFloatingSelection { /// 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, + /// 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). + pub canvas_id: Uuid, } /// Tracks the most recently selected thing(s) across the entire document. diff --git a/lightningbeam-ui/lightningbeam-editor/src/gpu_brush.rs b/lightningbeam-ui/lightningbeam-editor/src/gpu_brush.rs index b4092ef..406cb40 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/gpu_brush.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/gpu_brush.rs @@ -269,146 +269,150 @@ impl GpuBrushEngine { .map_or(true, |c| c.width != width || c.height != height); if needs_new { self.canvases.insert(keyframe_id, CanvasPair::new(device, width, height)); + } else { } self.canvases.get_mut(&keyframe_id).unwrap() } /// Dispatch the brush compute shader for `dabs` onto the canvas of `keyframe_id`. /// - /// * Pre-fills `dst` from `src` so untouched pixels are preserved. - /// * Dispatches the compute shader. - /// * Swaps src/dst so the just-written texture becomes the new source. + /// Each dab is dispatched as a separate copy+compute+swap so that every dab + /// reads the result of the previous one. This is required for the smudge tool: + /// if all dabs were batched into one dispatch they would all read the pre-batch + /// canvas state, breaking the carry-forward that makes smudge drag pixels along. /// - /// `dab_bbox` is `(x0, y0, x1, y1)` — the union bounding box of all dabs. - /// If `dabs` is empty or the bbox is invalid, does nothing. + /// `dab_bbox` is the union bounding box (unused here; kept for API compat). + /// If `dabs` is empty, does nothing. pub fn render_dabs( &mut self, device: &wgpu::Device, 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() || bbox.0 == i32::MAX { return; } + if dabs.is_empty() { return; } - let canvas = match self.canvases.get_mut(&keyframe_id) { - Some(c) => c, - None => return, - }; + if !self.canvases.contains_key(&keyframe_id) { return; } - // Clamp bbox to canvas bounds - let x0 = bbox.0.max(0) as u32; - let y0 = bbox.1.max(0) as u32; - let x1 = (bbox.2.min(canvas_w as i32 - 1)).max(0) as u32; - let y1 = (bbox.3.min(canvas_h as i32 - 1)).max(0) as u32; - if x1 < x0 || y1 < y0 { return; } - - let bbox_w = x1 - x0 + 1; - let bbox_h = y1 - y0 + 1; - - // --- Pre-fill dst from src: copy the ENTIRE canvas so every pixel outside - // the dab bounding box is preserved across the ping-pong swap. - // Copying only the bbox would leave dst with data from two frames ago - // in all other regions, causing missing dabs on alternating frames. --- - let mut copy_encoder = device.create_command_encoder( - &wgpu::CommandEncoderDescriptor { label: Some("canvas_copy_encoder") }, - ); let full_extent = wgpu::Extent3d { - width: canvas.width, - height: canvas.height, + width: self.canvases[&keyframe_id].width, + height: self.canvases[&keyframe_id].height, depth_or_array_layers: 1, }; - copy_encoder.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, - }, - full_extent, - ); - queue.submit(Some(copy_encoder.finish())); - // --- Upload dab data and params --- - 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); + for dab in dabs { + // Per-dab bounding box + let r_fringe = dab.radius + 1.0; + let dx0 = (dab.x - r_fringe).floor() as i32; + let dy0 = (dab.y - r_fringe).floor() as i32; + let dx1 = (dab.x + r_fringe).ceil() as i32; + let dy1 = (dab.y + r_fringe).ceil() as i32; - 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 x0 = dx0.max(0) as u32; + let y0 = dy0.max(0) as u32; + let x1 = (dx1.min(canvas_w as i32 - 1)).max(0) as u32; + let y1 = (dy1.min(canvas_h as i32 - 1)).max(0) as u32; + if x1 < x0 || y1 < y0 { continue; } - 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 bbox_w = x1 - x0 + 1; + let bbox_h = y1 - y0 + 1; - // --- Dispatch --- - let mut compute_encoder = device.create_command_encoder( - &wgpu::CommandEncoderDescriptor { label: Some("brush_dab_encoder") }, - ); - { - let mut pass = compute_encoder.begin_compute_pass( - &wgpu::ComputePassDescriptor { - label: Some("brush_dab_pass"), - timestamp_writes: None, - }, + let canvas = self.canvases.get_mut(&keyframe_id).unwrap(); + + // Pre-fill dst from src so pixels outside this dab's bbox are preserved. + let mut copy_enc = device.create_command_encoder( + &wgpu::CommandEncoderDescriptor { label: Some("canvas_copy_encoder") }, ); - pass.set_pipeline(&self.compute_pipeline); - pass.set_bind_group(0, &bg, &[]); - let wg_x = bbox_w.div_ceil(8); - let wg_y = bbox_h.div_ceil(8); - pass.dispatch_workgroups(wg_x, wg_y, 1); - } - queue.submit(Some(compute_encoder.finish())); + 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, + }, + full_extent, + ); + queue.submit(Some(copy_enc.finish())); - // Swap: dst is now the authoritative source - canvas.swap(); + // Upload single-dab buffer and params + 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, + }, + ); + 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())); + + // Swap: the just-written dst becomes src for the next dab. + canvas.swap(); + } } /// Read the current canvas back to a CPU `Vec` (raw RGBA, row-major). @@ -512,6 +516,8 @@ pub struct CanvasBlitPipeline { pub pipeline: wgpu::RenderPipeline, pub bg_layout: wgpu::BindGroupLayout, pub sampler: wgpu::Sampler, + /// Nearest-neighbour sampler used for the selection mask texture. + pub mask_sampler: wgpu::Sampler, } /// Camera parameters uniform for canvas_blit.wgsl. @@ -567,6 +573,24 @@ impl CanvasBlitPipeline { }, count: None, }, + // Binding 3: selection mask texture (R8Unorm; 1×1 white = no mask) + wgpu::BindGroupLayoutEntry { + binding: 3, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Float { filterable: true }, + view_dimension: wgpu::TextureViewDimension::D2, + multisampled: false, + }, + count: None, + }, + // Binding 4: nearest sampler for mask (sharp selection edges) + wgpu::BindGroupLayoutEntry { + binding: 4, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), + count: None, + }, ], }, ); @@ -593,7 +617,7 @@ impl CanvasBlitPipeline { module: &shader, entry_point: Some("fs_main"), targets: &[Some(wgpu::ColorTargetState { - format: wgpu::TextureFormat::Rgba8Unorm, + format: wgpu::TextureFormat::Rgba16Float, blend: None, // canvas already stores premultiplied alpha write_mask: wgpu::ColorWrites::ALL, })], @@ -621,12 +645,25 @@ impl CanvasBlitPipeline { ..Default::default() }); - Self { pipeline, bg_layout, sampler } + let mask_sampler = device.create_sampler(&wgpu::SamplerDescriptor { + label: Some("canvas_mask_sampler"), + address_mode_u: wgpu::AddressMode::ClampToEdge, + address_mode_v: wgpu::AddressMode::ClampToEdge, + address_mode_w: wgpu::AddressMode::ClampToEdge, + mag_filter: wgpu::FilterMode::Nearest, + min_filter: wgpu::FilterMode::Nearest, + mipmap_filter: wgpu::FilterMode::Nearest, + ..Default::default() + }); + + Self { pipeline, bg_layout, sampler, mask_sampler } } - /// Render the canvas texture into `target_view` (Rgba8Unorm) with the given camera. + /// Render the canvas texture into `target_view` (Rgba16Float) with the given camera. /// /// `target_view` is cleared to transparent before writing. + /// `mask_view` is an R8Unorm texture in canvas-pixel space: 255 = keep, 0 = discard. + /// Pass `None` to use the built-in 1×1 all-white default (no masking). pub fn blit( &self, device: &wgpu::Device, @@ -634,7 +671,40 @@ impl CanvasBlitPipeline { canvas_view: &wgpu::TextureView, target_view: &wgpu::TextureView, camera: &CameraParams, + mask_view: Option<&wgpu::TextureView>, ) { + // When no mask is provided, create a temporary 1×1 all-white texture. + // (queue is already available here, unlike in new()) + let tmp_mask_tex; + let tmp_mask_view; + let mask_view: &wgpu::TextureView = match mask_view { + Some(v) => v, + None => { + tmp_mask_tex = device.create_texture(&wgpu::TextureDescriptor { + label: Some("canvas_default_mask"), + size: wgpu::Extent3d { width: 1, height: 1, depth_or_array_layers: 1 }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: wgpu::TextureFormat::R8Unorm, + usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST, + view_formats: &[], + }); + queue.write_texture( + wgpu::TexelCopyTextureInfo { + texture: &tmp_mask_tex, + mip_level: 0, + origin: wgpu::Origin3d::ZERO, + aspect: wgpu::TextureAspect::All, + }, + &[255u8], + wgpu::TexelCopyBufferLayout { offset: 0, bytes_per_row: Some(1), rows_per_image: Some(1) }, + wgpu::Extent3d { width: 1, height: 1, depth_or_array_layers: 1 }, + ); + tmp_mask_view = tmp_mask_tex.create_view(&Default::default()); + &tmp_mask_view + } + }; // Upload camera params let cam_buf = device.create_buffer(&wgpu::BufferDescriptor { label: Some("canvas_blit_cam_buf"), @@ -660,6 +730,14 @@ impl CanvasBlitPipeline { binding: 2, resource: cam_buf.as_entire_binding(), }, + wgpu::BindGroupEntry { + binding: 3, + resource: wgpu::BindingResource::TextureView(mask_view), + }, + wgpu::BindGroupEntry { + binding: 4, + resource: wgpu::BindingResource::Sampler(&self.mask_sampler), + }, ], }); diff --git a/lightningbeam-ui/lightningbeam-editor/src/main.rs b/lightningbeam-ui/lightningbeam-editor/src/main.rs index f459cd3..0ec8123 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/main.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/main.rs @@ -419,7 +419,7 @@ impl FocusIconCache { } } - fn get_or_load(&mut self, icon: FocusIcon, icon_color: egui::Color32, ctx: &egui::Context) -> Option<&egui::TextureHandle> { + fn get_or_load(&mut self, icon: FocusIcon, icon_color: egui::Color32, display_size: f32, ctx: &egui::Context) -> Option<&egui::TextureHandle> { if !self.icons.contains_key(&icon) { let (svg_bytes, svg_filename) = match icon { FocusIcon::Animation => (focus_icons::ANIMATION, "focus-animation.svg"), @@ -436,7 +436,8 @@ impl FocusIconCache { ); let svg_with_color = svg_data.replace("currentColor", &color_hex); - if let Some(texture) = rasterize_svg(svg_with_color.as_bytes(), svg_filename, 120, ctx) { + let render_size = (display_size * ctx.pixels_per_point()).ceil() as u32; + if let Some(texture) = rasterize_svg(svg_with_color.as_bytes(), svg_filename, render_size, ctx) { self.icons.insert(icon, texture); } } @@ -1311,12 +1312,13 @@ impl EditorApp { // Icon area - render SVG texture let icon_color = egui::Color32::from_gray(200); - let icon_center = rect.center_top() + egui::vec2(0.0, 50.0); - let icon_display_size = 60.0; + let title_area_height = 40.0; + let icon_display_size = rect.width() - 16.0; + let icon_center = egui::pos2(rect.center().x, rect.min.y + (rect.height() - title_area_height) * 0.5); // Get or load the SVG icon texture let ctx = ui.ctx().clone(); - if let Some(texture) = self.focus_icon_cache.get_or_load(icon, icon_color, &ctx) { + if let Some(texture) = self.focus_icon_cache.get_or_load(icon, icon_color, icon_display_size, &ctx) { let texture_size = texture.size_vec2(); let scale = icon_display_size / texture_size.x.max(texture_size.y); let scaled_size = texture_size * scale; @@ -1920,7 +1922,7 @@ impl EditorApp { use lightningbeam_core::actions::RasterStrokeAction; let Some(float) = self.selection.raster_floating.take() else { return }; - self.selection.raster_selection = None; + let sel = self.selection.raster_selection.take(); let document = self.action_executor.document_mut(); let Some(AnyLayer::Raster(rl)) = document.get_layer_mut(&float.layer_id) else { return }; @@ -1931,11 +1933,36 @@ impl EditorApp { if kf.raw_pixels.len() != expected { kf.raw_pixels.resize(expected, 0); } - Self::composite_over( - &mut kf.raw_pixels, kf.width, kf.height, - &float.pixels, float.width, float.height, - float.x, float.y, - ); + + // Porter-Duff "src over dst" for sRGB-encoded premultiplied pixels, + // masked by the selection C when present. + for row in 0..float.height { + let dy = float.y + row as i32; + if dy < 0 || dy >= kf.height as i32 { continue; } + for col in 0..float.width { + let dx = float.x + col as i32; + if dx < 0 || dx >= kf.width as i32 { continue; } + // Apply selection mask C (if selection exists, only composite where inside) + if let Some(ref s) = sel { + if !s.contains_pixel(dx, dy) { continue; } + } + let si = ((row * float.width + col) * 4) as usize; + let di = ((dy as u32 * kf.width + dx as u32) * 4) as usize; + let sa = float.pixels[si + 3] as u32; + if sa == 0 { continue; } + let da = kf.raw_pixels[di + 3] as u32; + let out_a = sa + da * (255 - sa) / 255; + kf.raw_pixels[di + 3] = out_a as u8; + if out_a > 0 { + for c in 0..3 { + let v = float.pixels[si + c] as u32 * 255 + + kf.raw_pixels[di + c] as u32 * (255 - sa); + kf.raw_pixels[di + c] = (v / 255).min(255) as u8; + } + } + } + } + let canvas_after = kf.raw_pixels.clone(); let w = kf.width; let h = kf.height; @@ -2394,6 +2421,7 @@ impl EditorApp { layer_id, time: self.playback_time, canvas_before, + canvas_id: uuid::Uuid::new_v4(), }); // Update the marquee to show the floating selection bounds. self.selection.raster_selection = Some(RasterSelection::Rect( 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 f4a500e..0d57dc3 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/shaders/brush_dab.wgsl +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/shaders/brush_dab.wgsl @@ -79,15 +79,19 @@ fn apply_dab(current: vec4, dab: GpuDab, px: i32, py: i32) -> vec4 { let rr = (dx * dx + dy * dy) / (dab.radius * dab.radius); if rr > 1.0 { return current; } - // Two-segment linear falloff (identical to libmypaint calculate_opa) - let h = clamp(dab.hardness, 0.001, 1.0); + // Quadratic falloff: flat inner core, smooth quadratic outer zone. + // r is the actual normalised distance [0,1]; h controls the hard-core radius. + // Inner zone (r ≤ h): fully opaque. + // Outer zone (r > h): opa = ((1-r)/(1-h))^2, giving a smooth bell-shaped dab. + let h = clamp(dab.hardness, 0.0, 1.0); + let r = sqrt(rr); var opa_weight: f32; - if rr <= h { - opa_weight = 1.0 + rr * (-(1.0 / h - 1.0)); + if h >= 1.0 || r <= h { + opa_weight = 1.0; } else { - opa_weight = h / (1.0 - h) + rr * (-h / (1.0 - h)); + let t = (1.0 - r) / (1.0 - h); + opa_weight = t * t; } - opa_weight = clamp(opa_weight, 0.0, 1.0); if dab.blend_mode == 0u { // Normal: "over" operator 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 31337c3..cf1084b 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/shaders/canvas_blit.wgsl +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/shaders/canvas_blit.wgsl @@ -1,8 +1,12 @@ // Canvas blit shader. // -// Renders a GPU raster canvas (at document resolution) into the layer's sRGB -// render buffer (at viewport resolution), applying the camera transform -// (pan + zoom) to map document-space pixels to viewport-space pixels. +// 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. +// +// 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. @@ -21,6 +25,10 @@ struct CameraParams { @group(0) @binding(0) var canvas_tex: texture_2d; @group(0) @binding(1) var canvas_sampler: sampler; @group(0) @binding(2) var camera: CameraParams; +/// 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; +@group(0) @binding(4) var mask_sampler: sampler; struct VertexOutput { @builtin(position) position: vec4, @@ -38,17 +46,6 @@ fn vs_main(@builtin(vertex_index) vertex_index: u32) -> VertexOutput { return out; } -// Linear → sRGB encoding for a single channel. -// Applied to premultiplied linear values so the downstream srgb_to_linear -// pass round-trips correctly without darkening semi-transparent edges. -fn linear_to_srgb(c: f32) -> f32 { - return select( - 1.055 * pow(max(c, 0.0), 1.0 / 2.4) - 0.055, - c * 12.92, - c <= 0.0031308, - ); -} - @fragment fn fs_main(in: VertexOutput) -> @location(0) vec4 { // Map viewport UV [0,1] → viewport pixel @@ -67,21 +64,11 @@ fn fs_main(in: VertexOutput) -> @location(0) vec4 { } // The canvas stores premultiplied linear RGBA. - // The downstream pipeline (srgb_to_linear → compositor) expects the sRGB - // buffer to contain straight-alpha sRGB, i.e. the same format Vello outputs: - // sRGB buffer: srgb(r_straight), srgb(g_straight), srgb(b_straight), a - // srgb_to_linear: r_straight, g_straight, b_straight, a (linear straight) - // compositor: r_straight * a * opacity (premultiplied, correct) - // - // Without unpremultiplying, the compositor would double-premultiply: - // src = (premul_r, premul_g, premul_b, a) → output = premul_r * a = r * a² - // which produces a dark halo over transparent regions. + // The compositor expects straight-alpha linear (it premultiplies by src_alpha itself), + // so unpremultiply here. No sRGB conversion — the HDR buffer is linear throughout. let c = textureSample(canvas_tex, canvas_sampler, canvas_uv); + let mask = textureSample(mask_tex, mask_sampler, canvas_uv).r; + let masked_a = c.a * mask; let inv_a = select(0.0, 1.0 / c.a, c.a > 1e-6); - return vec4( - linear_to_srgb(c.r * inv_a), - linear_to_srgb(c.g * inv_a), - linear_to_srgb(c.b * inv_a), - c.a, - ); + return vec4(c.r * inv_a, c.g * inv_a, c.b * inv_a, masked_a); } diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs index be88f89..4222df5 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs @@ -409,6 +409,9 @@ struct VelloRenderContext { painting_canvas: Option<(uuid::Uuid, uuid::Uuid)>, /// GPU canvas keyframe to remove at the top of this prepare() call. pending_canvas_removal: Option, + /// 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, } /// Callback for Vello rendering within egui @@ -500,6 +503,24 @@ impl egui_wgpu::CallbackTrait for VelloCallback { } } + // Lazy float GPU canvas initialization. + // If a float exists but its GPU canvas hasn't been created yet, upload float.pixels now. + if let Some(ref float_sel) = self.ctx.selection.raster_floating { + if let Ok(mut gpu_brush) = shared.gpu_brush.lock() { + if !gpu_brush.canvases.contains_key(&float_sel.canvas_id) { + gpu_brush.ensure_canvas(device, float_sel.canvas_id, float_sel.width, float_sel.height); + if let Some(canvas) = gpu_brush.canvases.get(&float_sel.canvas_id) { + let pixels = if float_sel.pixels.is_empty() { + vec![0u8; (float_sel.width * float_sel.height * 4) as usize] + } else { + float_sel.pixels.clone() + }; + canvas.upload(queue, &pixels); + } + } + } + } + // --- GPU brush dispatch --- // Dispatch the compute shader for any pending raster dabs from this frame's // input event. Must happen before compositing so the updated canvas texture @@ -643,6 +664,64 @@ impl egui_wgpu::CallbackTrait for VelloCallback { buffer_pool.release(bg_srgb_handle); buffer_pool.release(bg_hdr_handle); + // Build a float-local R8 selection mask for the float canvas blit. + // Computed every frame from raster_selection so it is always correct + // (during strokes and during idle move/drag). + let float_mask_texture: Option = + if let Some(ref float_sel) = self.ctx.selection.raster_floating { + if let Some(ref sel) = self.ctx.selection.raster_selection { + let fw = float_sel.width; + let fh = float_sel.height; + let fx = float_sel.x; + let fy = float_sel.y; + let mut pixels = vec![0u8; (fw * fh) as usize]; + let (x0, y0, x1, y1) = sel.bounding_rect(); + let bx0 = (x0 - fx).max(0) as u32; + let by0 = (y0 - fy).max(0) as u32; + let bx1 = ((x1 - fx) as u32).min(fw); + let by1 = ((y1 - fy) as u32).min(fh); + for py in by0..by1 { + for px in bx0..bx1 { + if sel.contains_pixel(fx + px as i32, fy + py as i32) { + pixels[(py * fw + px) as usize] = 255; + } + } + } + let tex = device.create_texture(&wgpu::TextureDescriptor { + label: Some("float_mask_tex"), + size: wgpu::Extent3d { width: fw, height: fh, depth_or_array_layers: 1 }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: wgpu::TextureFormat::R8Unorm, + 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(fw), + rows_per_image: Some(fh), + }, + wgpu::Extent3d { width: fw, height: fh, depth_or_array_layers: 1 }, + ); + Some(tex) + } else { + None + } + } else { + None + }; + let float_mask_view: Option = + float_mask_texture.as_ref().map(|t| t.create_view(&Default::default())); + // Lock effect processor let mut effect_processor = shared.effect_processor.lock().unwrap(); @@ -651,9 +730,16 @@ impl egui_wgpu::CallbackTrait for VelloCallback { // 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. - let gpu_canvas_kf: Option = self.ctx.painting_canvas - .filter(|(layer_id, _)| *layer_id == rendered_layer.layer_id) - .map(|(_, kf_id)| kf_id); + // 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 + .filter(|(layer_id, _)| *layer_id == rendered_layer.layer_id) + .map(|(_, kf_id)| kf_id) + }; if !rendered_layer.has_content && gpu_canvas_kf.is_none() { continue; @@ -671,8 +757,9 @@ impl egui_wgpu::CallbackTrait for VelloCallback { &instance_resources.hdr_texture_view, ) { // GPU canvas blit path: if a live GPU canvas exists for this - // raster layer, sample it directly instead of rendering the Vello - // scene (which lags until raw_pixels is updated after readback). + // 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() { @@ -690,8 +777,9 @@ impl egui_wgpu::CallbackTrait for VelloCallback { shared.canvas_blit.blit( device, queue, canvas.src_view(), - srgb_view, + hdr_layer_view, // blit directly to HDR &camera, + None, // no mask on layer canvas blit ); used = true; } @@ -702,19 +790,17 @@ impl egui_wgpu::CallbackTrait for VelloCallback { }; if !used_gpu_canvas { - // Render layer scene to sRGB buffer + // 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())); } - // Convert sRGB to linear HDR - 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, @@ -914,6 +1000,51 @@ 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); @@ -2288,6 +2419,9 @@ pub struct StagePane { /// Pixels outside the selection are restored from `buffer_before` so strokes /// only affect the area inside the selection outline. stroke_clip_selection: Option, + /// True while the current stroke is being painted onto the float buffer (B) + /// rather than the layer canvas (A). + painting_float: bool, /// Synthetic drag/click override for test mode replay (debug builds only) #[cfg(debug_assertions)] replay_override: Option, @@ -2410,6 +2544,7 @@ impl StagePane { painting_canvas: None, pending_canvas_removal: None, stroke_clip_selection: None, + painting_float: false, #[cfg(debug_assertions)] replay_override: None, } @@ -4395,7 +4530,7 @@ impl StagePane { else { return; }; - shared.selection.raster_selection = None; + let sel = shared.selection.raster_selection.take(); let document = shared.action_executor.document_mut(); let Some(AnyLayer::Raster(rl)) = document.get_layer_mut(&float.layer_id) else { @@ -4403,13 +4538,24 @@ impl StagePane { }; let Some(kf) = rl.keyframe_at_mut(float.time) else { return }; - // Porter-Duff "src over dst" for sRGB-encoded premultiplied pixels. + // Ensure the canvas buffer is allocated (empty Vec = blank transparent canvas). + let expected = (kf.width * kf.height * 4) as usize; + if kf.raw_pixels.len() != expected { + kf.raw_pixels.resize(expected, 0); + } + + // Porter-Duff "src over dst" for sRGB-encoded premultiplied pixels, + // masked by the selection C when present. for row in 0..float.height { let dy = float.y + row as i32; if dy < 0 || dy >= kf.height as i32 { continue; } for col in 0..float.width { let dx = float.x + col as i32; if dx < 0 || dx >= kf.width as i32 { continue; } + // Apply selection mask C (if selection exists, only composite where inside) + if let Some(ref s) = sel { + if !s.contains_pixel(dx, dy) { continue; } + } let si = ((row * float.width + col) * 4) as usize; let di = ((dy as u32 * kf.width + dx as u32) * 4) as usize; let sa = float.pixels[si + 3] as u32; @@ -4445,6 +4591,52 @@ impl StagePane { /// Call this immediately after a marquee / lasso selection is finalized so /// that all downstream operations (drag-move, copy, cut, stroke-masking) /// see a consistent `raster_floating` whenever a selection is active. + /// Build an R8 mask buffer (0 = outside, 255 = inside) from a selection. + fn build_selection_mask( + sel: &lightningbeam_core::selection::RasterSelection, + width: u32, + height: u32, + ) -> Vec { + let mut mask = vec![0u8; (width * height) as usize]; + let (x0, y0, x1, y1) = sel.bounding_rect(); + let bx0 = x0.max(0) as u32; + let by0 = y0.max(0) as u32; + let bx1 = (x1 as u32).min(width); + let by1 = (y1 as u32).min(height); + for y in by0..by1 { + for x in bx0..bx1 { + if sel.contains_pixel(x as i32, y as i32) { + mask[(y * width + x) as usize] = 255; + } + } + } + mask + } + + /// Build an R8 mask buffer for the float canvas (0 = outside selection, 255 = inside). + /// Coordinates are in float-local space: pixel (fx, fy) corresponds to document pixel + /// (float_x+fx, float_y+fy). + fn build_float_mask( + sel: &lightningbeam_core::selection::RasterSelection, + float_x: i32, float_y: i32, + float_w: u32, float_h: u32, + ) -> Vec { + let mut mask = vec![0u8; (float_w * float_h) as usize]; + let (x0, y0, x1, y1) = sel.bounding_rect(); + let bx0 = (x0 - float_x).max(0) as u32; + let by0 = (y0 - float_y).max(0) as u32; + let bx1 = ((x1 - float_x) as u32).min(float_w); + let by1 = ((y1 - float_y) as u32).min(float_h); + for fy in by0..by1 { + for fx in bx0..bx1 { + if sel.contains_pixel(float_x + fx as i32, float_y + fy as i32) { + mask[(fy * float_w + fx) as usize] = 255; + } + } + } + mask + } + fn lift_selection_to_float(shared: &mut SharedPaneState) { use lightningbeam_core::layer::AnyLayer; use lightningbeam_core::selection::RasterFloatingSelection; @@ -4493,6 +4685,7 @@ impl StagePane { layer_id, time, canvas_before, + canvas_id: uuid::Uuid::new_v4(), }); } @@ -4545,102 +4738,168 @@ impl StagePane { [1.0f32, 1.0, 1.0, 1.0] } else { let c = if *shared.brush_use_fg { *shared.stroke_color } else { *shared.fill_color }; - [c.r() as f32 / 255.0, c.g() as f32 / 255.0, c.b() as f32 / 255.0, c.a() as f32 / 255.0] + 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] }; // ---------------------------------------------------------------- // Mouse down: capture buffer_before, start stroke, compute first dab // ---------------------------------------------------------------- if self.rsp_drag_started(response) || self.rsp_clicked(response) { - // Save selection BEFORE commit clears it — used after readback to - // mask the stroke result so only pixels inside the outline change. + // 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; self.stroke_clip_selection = shared.selection.raster_selection.clone(); - // Commit any floating selection synchronously so buffer_before and - // the GPU canvas initial upload see the fully-composited canvas. - Self::commit_raster_floating_now(shared); + if painting_float { + // ---- Paint onto float buffer B ---- + // Do NOT commit the float. Use the float's own GPU canvas. + 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(); + (float.canvas_id, float.x, float.y, float.width, float.height, + buf, float.layer_id, float.time) + }; - let (doc_width, doc_height) = { - let doc = shared.action_executor.document(); - (doc.width as u32, doc.height as u32) - }; + // 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, + y: world_pos.y - float_y as f32, + pressure: 1.0, tilt_x: 0.0, tilt_y: 0.0, timestamp: 0.0, + }; + let single = StrokeRecord { + brush_settings: brush.clone(), + color, + blend_mode, + points: vec![first_pt.clone()], + }; + let (dabs, dab_bbox) = BrushEngine::compute_dabs(&single, &mut stroke_state); - // Ensure the keyframe exists BEFORE reading its ID, so we always get - // the real UUID. Previously we read the ID first and fell back to a - // randomly-generated UUID when no keyframe existed; that fake UUID was - // stored in painting_canvas but subsequent drag frames used the real UUID - // from keyframe_at(), causing the GPU canvas to be a different object from - // the one being composited. - { - let doc = shared.action_executor.document_mut(); - if let Some(AnyLayer::Raster(rl)) = doc.get_layer_mut(&active_layer_id) { - rl.ensure_keyframe_at(*shared.playback_time, doc_width, doc_height); - } - } + self.painting_canvas = Some((layer_id, canvas_id)); + self.pending_undo_before = Some(( + layer_id, + time, + canvas_width, + canvas_height, + buffer_before, + )); + self.pending_raster_dabs = Some(PendingRasterDabs { + keyframe_id: canvas_id, + layer_id, + time, + canvas_width, + canvas_height, + initial_pixels: None, // canvas already initialized via lazy GPU init + dabs, + dab_bbox, + wants_final_readback: false, + }); + self.raster_stroke_state = Some(( + layer_id, + time, + stroke_state, + Vec::new(), + )); + self.raster_last_point = Some(first_pt); + *shared.tool_state = ToolState::DrawingRasterStroke { points: vec![] }; - // Now read the guaranteed-to-exist keyframe to get the real UUID. - let (keyframe_id, canvas_width, canvas_height, buffer_before, initial_pixels) = { - let doc = shared.action_executor.document(); - if let Some(AnyLayer::Raster(rl)) = doc.get_layer(&active_layer_id) { - if let Some(kf) = rl.keyframe_at(*shared.playback_time) { - let raw = kf.raw_pixels.clone(); - let init = if raw.is_empty() { - vec![0u8; (kf.width * kf.height * 4) as usize] - } else { - raw.clone() - }; - (kf.id, kf.width, kf.height, raw, init) - } else { - return; // shouldn't happen after ensure_keyframe_at + } else { + // ---- Paint onto layer canvas A (existing behavior) ---- + // Commit any floating selection synchronously so buffer_before and + // the GPU canvas initial upload see the fully-composited canvas. + Self::commit_raster_floating_now(shared); + + let (doc_width, doc_height) = { + let doc = shared.action_executor.document(); + (doc.width as u32, doc.height as u32) + }; + + // Ensure the keyframe exists BEFORE reading its ID, so we always get + // the real UUID. Previously we read the ID first and fell back to a + // randomly-generated UUID when no keyframe existed; that fake UUID was + // stored in painting_canvas but subsequent drag frames used the real UUID + // from keyframe_at(), causing the GPU canvas to be a different object from + // the one being composited. + { + let doc = shared.action_executor.document_mut(); + if let Some(AnyLayer::Raster(rl)) = doc.get_layer_mut(&active_layer_id) { + rl.ensure_keyframe_at(*shared.playback_time, doc_width, doc_height); } - } else { - return; } - }; - // Compute the first dab (single-point tap) - let mut stroke_state = StrokeState::new(); - stroke_state.distance_since_last_dab = f32::MAX; + // Now read the guaranteed-to-exist keyframe to get the real UUID. + let (keyframe_id, canvas_width, canvas_height, buffer_before, initial_pixels) = { + let doc = shared.action_executor.document(); + if let Some(AnyLayer::Raster(rl)) = doc.get_layer(&active_layer_id) { + if let Some(kf) = rl.keyframe_at(*shared.playback_time) { + let raw = kf.raw_pixels.clone(); + let init = if raw.is_empty() { + vec![0u8; (kf.width * kf.height * 4) as usize] + } else { + raw.clone() + }; + (kf.id, kf.width, kf.height, raw, init) + } else { + return; // shouldn't happen after ensure_keyframe_at + } + } else { + return; + } + }; - let first_pt = StrokePoint { - x: world_pos.x, y: world_pos.y, - pressure: 1.0, tilt_x: 0.0, tilt_y: 0.0, timestamp: 0.0, - }; - let single = StrokeRecord { - brush_settings: brush.clone(), - color, - blend_mode, - points: vec![first_pt.clone()], - }; - let (dabs, dab_bbox) = BrushEngine::compute_dabs(&single, &mut stroke_state); + // Compute the first dab (single-point tap) + let mut stroke_state = StrokeState::new(); + stroke_state.distance_since_last_dab = f32::MAX; - self.painting_canvas = Some((active_layer_id, keyframe_id)); - self.pending_undo_before = Some(( - active_layer_id, - *shared.playback_time, - canvas_width, - canvas_height, - buffer_before, - )); - self.pending_raster_dabs = Some(PendingRasterDabs { - keyframe_id, - layer_id: active_layer_id, - time: *shared.playback_time, - canvas_width, - canvas_height, - initial_pixels: Some(initial_pixels), - dabs, - dab_bbox, - wants_final_readback: false, - }); - self.raster_stroke_state = Some(( - active_layer_id, - *shared.playback_time, - stroke_state, - Vec::new(), // buffer_before now lives in pending_undo_before - )); - self.raster_last_point = Some(first_pt); - *shared.tool_state = ToolState::DrawingRasterStroke { points: vec![] }; + let first_pt = StrokePoint { + x: world_pos.x, y: world_pos.y, + pressure: 1.0, tilt_x: 0.0, tilt_y: 0.0, timestamp: 0.0, + }; + let single = StrokeRecord { + brush_settings: brush.clone(), + color, + blend_mode, + points: vec![first_pt.clone()], + }; + let (dabs, dab_bbox) = BrushEngine::compute_dabs(&single, &mut stroke_state); + + // Layer strokes apply selection masking at readback time via stroke_clip_selection. + + self.painting_canvas = Some((active_layer_id, keyframe_id)); + self.pending_undo_before = Some(( + active_layer_id, + *shared.playback_time, + canvas_width, + canvas_height, + buffer_before, + )); + self.pending_raster_dabs = Some(PendingRasterDabs { + keyframe_id, + layer_id: active_layer_id, + time: *shared.playback_time, + canvas_width, + canvas_height, + initial_pixels: Some(initial_pixels), + dabs, + dab_bbox, + wants_final_readback: false, + }); + self.raster_stroke_state = Some(( + active_layer_id, + *shared.playback_time, + stroke_state, + Vec::new(), // buffer_before now lives in pending_undo_before + )); + self.raster_last_point = Some(first_pt); + *shared.tool_state = ToolState::DrawingRasterStroke { points: vec![] }; + } } // ---------------------------------------------------------------- @@ -4649,45 +4908,55 @@ impl StagePane { if self.rsp_dragged(response) { if let Some((layer_id, time, ref mut stroke_state, _)) = self.raster_stroke_state { if let Some(prev_pt) = self.raster_last_point.take() { - let curr_pt = StrokePoint { - x: world_pos.x, y: world_pos.y, + // Get canvas info and float offset now (used for both distance check + // and dab dispatch). prev_pt is already in canvas-local space. + 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 } + }; + + let Some((canvas_id, cw, ch, cx, cy)) = canvas_info else { + self.raster_last_point = Some(prev_pt); + return; + }; + + // Convert current world position to canvas-local space. + let curr_local = 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, }; const MIN_DIST_SQ: f32 = 1.5 * 1.5; - let dx = curr_pt.x - prev_pt.x; - let dy = curr_pt.y - prev_pt.y; + let dx = curr_local.x - prev_pt.x; + let dy = curr_local.y - prev_pt.y; let moved_pt = if dx * dx + dy * dy >= MIN_DIST_SQ { - curr_pt.clone() + curr_local.clone() } else { prev_pt.clone() }; if dx * dx + dy * dy >= MIN_DIST_SQ { - // Get keyframe info (needed for canvas dimensions) - let (kf_id, kw, kh) = { - 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) { - (kf.id, kf.width, kf.height) - } else { self.raster_last_point = Some(moved_pt); return; } - } else { self.raster_last_point = Some(moved_pt); return; } - }; - let seg = StrokeRecord { brush_settings: brush.clone(), color, blend_mode, - points: vec![prev_pt, curr_pt], + points: vec![prev_pt, curr_local], }; let (dabs, dab_bbox) = BrushEngine::compute_dabs(&seg, stroke_state); - self.pending_raster_dabs = Some(PendingRasterDabs { - keyframe_id: kf_id, + keyframe_id: canvas_id, layer_id, time, - canvas_width: kw, - canvas_height: kh, + canvas_width: cw, + canvas_height: ch, initial_pixels: None, dabs, dab_bbox, @@ -4718,12 +4987,17 @@ impl StagePane { self.pending_undo_before.as_ref() { let (ub_layer, ub_time, ub_cw, ub_ch) = (*ub_layer, *ub_time, *ub_cw, *ub_ch); - // Get keyframe_id for the canvas texture lookup - let kf_id = shared.action_executor.document() - .get_layer(&ub_layer) - .and_then(|l| if let AnyLayer::Raster(rl) = l { - rl.keyframe_at(ub_time).map(|kf| kf.id) - } else { None }); + // Get canvas_id for the canvas texture lookup. + // When painting into the float, use float.canvas_id; otherwise the keyframe id. + let kf_id = if self.painting_float { + self.painting_canvas.map(|(_, cid)| cid) + } else { + shared.action_executor.document() + .get_layer(&ub_layer) + .and_then(|l| if let AnyLayer::Raster(rl) = l { + rl.keyframe_at(ub_time).map(|kf| kf.id) + } else { None }) + }; if let Some(kf_id) = kf_id { self.pending_raster_dabs = Some(PendingRasterDabs { keyframe_id: kf_id, @@ -7322,7 +7596,7 @@ impl StagePane { /// Render raster selection overlays: /// - Animated "marching ants" around the active raster selection (marquee or lasso) - /// - Floating selection pixels as an egui texture composited at the float position + /// - (Float pixels are rendered through the Vello HDR pipeline in prepare(), not here) fn render_raster_selection_overlays( &mut self, ui: &mut egui::Ui, @@ -7332,8 +7606,7 @@ impl StagePane { use lightningbeam_core::selection::RasterSelection; let has_sel = shared.selection.raster_selection.is_some(); - let has_float = shared.selection.raster_floating.is_some(); - if !has_sel && !has_float { return; } + if !has_sel { return; } let time = ui.input(|i| i.time) as f32; // 8px/s scroll rate → repeating every 1 s @@ -7358,37 +7631,6 @@ impl StagePane { } } - // ── Floating selection texture overlay ──────────────────────────────── - if let Some(float) = &shared.selection.raster_floating { - let tex_id = format!("raster_float_{}_{}", float.layer_id, float.time.to_bits()); - - // Upload pixels as an egui texture (re-uploaded every frame the float exists; - // egui caches by name so this is a no-op when the pixels haven't changed). - let color_image = egui::ColorImage::from_rgba_premultiplied( - [float.width as usize, float.height as usize], - &float.pixels, - ); - let texture = ui.ctx().load_texture( - &tex_id, - color_image, - egui::TextureOptions::NEAREST, - ); - - // Position in screen space - let sx = rect.min.x + pan.x + float.x as f32 * zoom; - let sy = rect.min.y + pan.y + float.y as f32 * zoom; - let sw = float.width as f32 * zoom; - let sh = float.height as f32 * zoom; - let float_rect = egui::Rect::from_min_size(egui::pos2(sx, sy), egui::vec2(sw, sh)); - - painter.image( - texture.id(), - float_rect, - egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)), - egui::Color32::WHITE, - ); - } - // Keep animating while a selection is visible ui.ctx().request_repaint_after(std::time::Duration::from_millis(80)); } @@ -7538,43 +7780,71 @@ impl PaneRenderer for StagePane { .get_or_init(|| Arc::new(Mutex::new(std::collections::HashMap::new()))) .lock() { if let Some(readback) = results.remove(&self.instance_id) { - if let Some((layer_id, time, w, h, buffer_before)) = self.pending_undo_before.take() { - use lightningbeam_core::actions::RasterStrokeAction; - // If a selection was active at stroke-start, restore any pixels - // outside the selection outline to their pre-stroke values. - let canvas_after = match self.stroke_clip_selection.take() { - None => readback.pixels, - Some(sel) => { - let mut masked = readback.pixels; - for y in 0..h { - for x in 0..w { - if !sel.contains_pixel(x as i32, y as i32) { - let i = ((y * w + x) * 4) as usize; - masked[i..i + 4].copy_from_slice(&buffer_before[i..i + 4]); + if self.painting_float { + // Float stroke: update float.pixels, don't create a layer RasterStrokeAction. + if let Some((_, _, w, h, buffer_before)) = self.pending_undo_before.take() { + if let Some(ref mut float) = shared.selection.raster_floating { + // Apply float-local selection mask: restore pixels outside C to + // pre-stroke values so the stroke only affects the selected area. + let mut pixels = readback.pixels; + if let Some(ref sel) = self.stroke_clip_selection { + for fy in 0..h { + for fx in 0..w { + if !sel.contains_pixel(float.x + fx as i32, float.y + fy as i32) { + let i = ((fy * w + fx) * 4) as usize; + pixels[i..i + 4].copy_from_slice(&buffer_before[i..i + 4]); + } } } } - masked + float.pixels = pixels; } - }; - let action = RasterStrokeAction::new( - layer_id, - time, - buffer_before, - canvas_after, - w, - h, - ); - // execute() sets raw_pixels = buffer_after so future Vello renders - // and file saves see the completed stroke. - let _ = shared.action_executor.execute(Box::new(action)); - } - // raw_pixels is now up to date; switch compositing back to the Vello - // scene. Schedule the GPU canvas for removal at the start of the next - // prepare() — keeping it alive for this frame's composite avoids a - // one-frame flash of the stale Vello scene. - if let Some((_, kf_id)) = self.painting_canvas.take() { - self.pending_canvas_removal = Some(kf_id); + } + self.stroke_clip_selection = None; + self.painting_float = false; + // Keep float GPU canvas alive for the next stroke on the float. + // Don't schedule canvas_removal — just clear painting_canvas. + self.painting_canvas = None; + } else { + // Layer stroke: existing behavior — create RasterStrokeAction on raw_pixels. + if let Some((layer_id, time, w, h, buffer_before)) = self.pending_undo_before.take() { + use lightningbeam_core::actions::RasterStrokeAction; + // If a selection was active at stroke-start, restore any pixels + // outside the selection outline to their pre-stroke values. + let canvas_after = match self.stroke_clip_selection.take() { + None => readback.pixels, + Some(sel) => { + let mut masked = readback.pixels; + for y in 0..h { + for x in 0..w { + if !sel.contains_pixel(x as i32, y as i32) { + let i = ((y * w + x) * 4) as usize; + masked[i..i + 4].copy_from_slice(&buffer_before[i..i + 4]); + } + } + } + masked + } + }; + let action = RasterStrokeAction::new( + layer_id, + time, + buffer_before, + canvas_after, + w, + h, + ); + // execute() sets raw_pixels = buffer_after so future Vello renders + // and file saves see the completed stroke. + let _ = shared.action_executor.execute(Box::new(action)); + } + // raw_pixels is now up to date; switch compositing back to the Vello + // scene. Schedule the GPU canvas for removal at the start of the next + // prepare() — keeping it alive for this frame's composite avoids a + // one-frame flash of the stale Vello scene. + if let Some((_, kf_id)) = self.painting_canvas.take() { + self.pending_canvas_removal = Some(kf_id); + } } } } @@ -7929,6 +8199,7 @@ impl PaneRenderer for StagePane { 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, }}; let cb = egui_wgpu::Callback::new_paint_callback( diff --git a/src/assets/focus-painting.svg b/src/assets/focus-painting.svg index 97574e1..059a4fe 100644 --- a/src/assets/focus-painting.svg +++ b/src/assets/focus-painting.svg @@ -1,13 +1,74 @@ - + + + + + + - - - - - + - - + +