Merge branch 'rust-ui' of https://git.skyler.io/skyler/Lightningbeam into rust-ui

This commit is contained in:
Skyler Lehmkuhl 2026-03-04 14:50:53 -05:00
commit e500914fa0
8 changed files with 803 additions and 721 deletions

View File

@ -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<GpuDab>,
let push_dab = |dabs: &mut Vec<GpuDab>,
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<RgbaImage, String> {
#[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() {

View File

@ -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<u8>,
/// 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.

View File

@ -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::<DabParams>() as u64,
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
queue.write_buffer(&params_buf, 0, bytemuck::bytes_of(&params));
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::<DabParams>() as u64,
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
queue.write_buffer(&params_buf, 0, bytemuck::bytes_of(&params));
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<u8>` (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),
},
],
});

View File

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

View File

@ -79,15 +79,19 @@ fn apply_dab(current: vec4<f32>, dab: GpuDab, px: i32, py: i32) -> vec4<f32> {
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

View File

@ -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<f32>;
@group(0) @binding(1) var canvas_sampler: sampler;
@group(0) @binding(2) var<uniform> 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<f32>;
@group(0) @binding(4) var mask_sampler: sampler;
struct VertexOutput {
@builtin(position) position: vec4<f32>,
@ -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<f32> {
// Map viewport UV [0,1] viewport pixel
@ -67,21 +64,11 @@ fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> {
}
// 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<f32>(
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<f32>(c.r * inv_a, c.g * inv_a, c.b * inv_a, masked_a);
}

View File

@ -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<uuid::Uuid>,
/// 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<wgpu::Texture> =
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<wgpu::TextureView> =
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<uuid::Uuid> = 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<uuid::Uuid> = 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<lightningbeam_core::selection::RasterSelection>,
/// 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<ReplayDragState>,
@ -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<u8> {
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<u8> {
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(

View File

@ -1,13 +1,74 @@
<svg xmlns="http://www.w3.org/2000/svg" width="80" height="80" viewBox="0 0 100 100">
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
width="80"
height="80"
viewBox="0 0 100 100"
version="1.1"
id="svg3"
sodipodi:docname="focus-painting.svg"
inkscape:version="1.4.3 (0d15f75042, 2025-12-25)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<defs
id="defs3">
<inkscape:path-effect
effect="taper_stroke"
start_shape="center | center"
end_shape="center | center"
id="path-effect4"
is_visible="true"
lpeversion="1"
stroke_width="5.6125002"
subpath="1"
attach_start="0.10963619"
end_offset="2.2"
start_smoothing="0.5"
end_smoothing="0.5"
jointype="extrapolated"
miter_limit="100" />
</defs>
<sodipodi:namedview
id="namedview3"
pagecolor="#505050"
bordercolor="#ffffff"
borderopacity="1"
inkscape:showpageshadow="0"
inkscape:pageopacity="0"
inkscape:pagecheckerboard="1"
inkscape:deskcolor="#505050"
inkscape:zoom="14.7625"
inkscape:cx="39.96613"
inkscape:cy="40"
inkscape:window-width="2256"
inkscape:window-height="1432"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="svg3" />
<!-- Brush handle -->
<rect x="48" y="10" width="8" height="40" rx="3" fill="currentColor" opacity="0.7"/>
<!-- Brush ferrule -->
<rect x="46" y="46" width="12" height="6" rx="1" fill="currentColor"/>
<!-- Brush tip -->
<ellipse cx="52" cy="58" rx="6" ry="10" fill="currentColor"/>
<rect
x="81.272194"
y="-36.096752"
width="11.958412"
height="59.792061"
rx="4.4844046"
fill="currentColor"
id="rect1"
transform="rotate(30)" />
<!-- Paint strokes -->
<path d="M15 72 Q25 62 38 68 Q50 74 60 65" stroke="currentColor" stroke-width="5"
stroke-linecap="round" fill="none" opacity="0.9"/>
<path d="M12 85 Q30 78 50 83 Q65 87 80 80" stroke="currentColor" stroke-width="4"
stroke-linecap="round" fill="none" opacity="0.6"/>
<path
fill="currentColor"
d="m 66.299746,58.763759 c -7.62066,-5.673158 -17.74792,1.05602 -19.475021,9.568162 -2.370872,11.685014 3.629614,18.355507 -8.806098,22.015242 -3.172111,0.933528 23.285353,2.624894 32.260797,-14.902625 6.333382,-12.368021 -3.979678,-16.680779 -3.979678,-16.680779 z"
id="path3"
sodipodi:nodetypes="csssc" />
<path
fill="currentColor"
fill-rule="nonzero"
d="m 33.813416,93.341948 c 3.786453,0.273778 7.592172,-2.402067 7.592172,-2.402067 0,0 -3.590846,-2.935629 -7.18731,-3.195812 -5.228251,-0.378129 -9.762439,-0.934698 -13.051756,-1.560403 -2.192878,-0.417137 -3.867634,-0.900044 -4.607842,-1.225905 -0.246736,-0.108621 -0.38281,-0.201357 -0.355456,-0.180906 0.0022,0.0016 0.0054,0.0041 0.0097,0.0075 0.0039,0.0031 0.0086,0.0069 0.0142,0.01158 0.005,0.0042 0.01078,0.0091 0.01723,0.01482 0.0058,0.0051 0.01217,0.01087 0.01911,0.0173 0.0062,0.0058 0.01294,0.01214 0.02008,0.01909 0.06432,0.06258 0.169505,0.174207 0.284048,0.373635 0.916341,1.595426 -0.196372,2.547062 -0.426706,2.713677 -0.0128,0.0093 -0.01712,0.01167 -0.01332,0.0093 0.0042,-0.0026 0.01845,-0.01109 0.04197,-0.02353 0.02614,-0.01382 0.06375,-0.03252 0.111905,-0.05439 0.05351,-0.0243 0.120022,-0.05252 0.198527,-0.08321 0.08723,-0.03409 0.189254,-0.07124 0.305056,-0.110227 2.565369,-0.894934 5.565859,-1.136655 8.73304,-1.508027 3.205326,-0.336708 6.457931,-1.031908 9.561136,-2.347605 2.159805,-0.821077 4.480948,-2.255287 6.157316,-4.677186 0,0 6e-6,-9e-6 6e-6,-9e-6 0.654807,-0.986306 1.053246,-2.205234 1.088284,-3.558681 -0.03505,-1.42684 -0.460926,-2.674323 -1.124653,-3.658412 -2.02781,-2.904054 -4.881022,-4.446693 -7.363423,-5.409479 -3.797402,-1.653345 -7.235314,-3.220765 -10.08975,-5.483149 -1.052382,-0.868697 -1.961075,-1.659161 -2.408247,-2.565594 0,0 -2e-6,-4e-6 -2e-6,-4e-6 -0.30318,-0.587823 -0.441836,-1.301051 -0.218785,-1.76016 0.251179,-0.929295 1.355962,-1.74296 2.512237,-2.571731 2.157881,-1.423245 4.733062,-2.387225 7.159739,-3.21808 8.413892,-2.856629 17.372351,-4.416313 22.339998,-5.248581 5.517396,-0.924372 8.68008,-1.381256 8.68008,-1.381256 0,0 -3.191898,0.107112 -8.784136,0.510369 -5.097074,0.36755 -14.091507,1.125272 -23.035558,3.433462 -2.560557,0.653766 -5.411841,1.514808 -8.102357,3.009424 -1.486784,0.76195 -3.118328,1.985189 -4.147427,3.99712 -0.687787,1.63464 -0.624481,3.422233 0.09692,4.935688 0,0 4e-6,8e-6 4e-6,8e-6 0.82151,1.695416 2.02396,3.00905 3.255783,4.042946 3.235014,2.809503 6.953183,4.795794 10.775008,6.614646 2.174575,1.159484 4.287272,2.221643 5.044095,3.772438 0.190302,0.3826 0.327622,0.723299 0.273143,0.918609 0.01659,0.181774 -0.128716,0.467185 -0.317084,0.774431 0,0 -2e-6,3e-6 -2e-6,3e-6 -0.594382,1.061879 -2.132563,1.75573 -3.860557,2.553569 -2.461171,1.001334 -5.204568,1.506386 -8.062238,1.796138 -3.112491,0.277459 -6.513856,0.583452 -9.953224,1.709918 -1.229365,0.413854 -3.462781,1.330519 -3.729057,3.579039 -0.288308,2.434558 1.908918,3.667813 3.031985,4.162219 3.497002,1.539481 11.237715,2.648688 19.516091,3.247415 z"
id="path4"
sodipodi:nodetypes="cssc"
inkscape:path-effect="#path-effect4"
inkscape:original-d="M 41.405588,90.939881 C 17.95089,90.093141 2.3853409,85.764617 25.232854,83.403895 38.982538,81.983208 45.797946,74.569678 32.853514,68.670617 16.874547,61.388652 3.3869602,50.211686 61.812023,44.284506" />
</svg>

Before

Width:  |  Height:  |  Size: 698 B

After

Width:  |  Height:  |  Size: 4.9 KiB