work on raster tools

This commit is contained in:
Skyler Lehmkuhl 2026-03-07 16:55:38 -05:00
parent a628d8af37
commit 0d2609c064
16 changed files with 3142 additions and 17 deletions

View File

@ -34,6 +34,7 @@ pub mod group_layers;
pub mod raster_stroke;
pub mod raster_fill;
pub mod move_layer;
pub mod set_fill_paint;
pub use add_clip_instance::AddClipInstanceAction;
pub use add_effect::AddEffectAction;
@ -66,3 +67,4 @@ pub use group_layers::GroupLayersAction;
pub use raster_stroke::RasterStrokeAction;
pub use raster_fill::RasterFillAction;
pub use move_layer::MoveLayerAction;
pub use set_fill_paint::SetFillPaintAction;

View File

@ -0,0 +1,127 @@
//! Action that changes the fill of one or more DCEL faces.
//!
//! Handles both solid-colour and gradient fills, clearing the other type so they
//! don't coexist on a face.
use crate::action::Action;
use crate::dcel::FaceId;
use crate::document::Document;
use crate::gradient::ShapeGradient;
use crate::layer::AnyLayer;
use crate::shape::ShapeColor;
use uuid::Uuid;
/// Snapshot of one face's fill state (both types) for undo.
#[derive(Clone)]
struct OldFill {
face_id: FaceId,
color: Option<ShapeColor>,
gradient: Option<ShapeGradient>,
}
/// Action that sets a solid-colour *or* gradient fill on a set of faces,
/// clearing the other fill type.
pub struct SetFillPaintAction {
layer_id: Uuid,
time: f64,
face_ids: Vec<FaceId>,
new_color: Option<ShapeColor>,
new_gradient: Option<ShapeGradient>,
old_fills: Vec<OldFill>,
description: &'static str,
}
impl SetFillPaintAction {
/// Set a solid fill (clears any gradient on the same faces).
pub fn solid(
layer_id: Uuid,
time: f64,
face_ids: Vec<FaceId>,
color: Option<ShapeColor>,
) -> Self {
Self {
layer_id,
time,
face_ids,
new_color: color,
new_gradient: None,
old_fills: Vec::new(),
description: "Set fill colour",
}
}
/// Set a gradient fill (clears any solid colour on the same faces).
pub fn gradient(
layer_id: Uuid,
time: f64,
face_ids: Vec<FaceId>,
gradient: Option<ShapeGradient>,
) -> Self {
Self {
layer_id,
time,
face_ids,
new_color: None,
new_gradient: gradient,
old_fills: Vec::new(),
description: "Set gradient fill",
}
}
fn get_dcel_mut<'a>(
document: &'a mut Document,
layer_id: &Uuid,
time: f64,
) -> Result<&'a mut crate::dcel::Dcel, String> {
let layer = document
.get_layer_mut(layer_id)
.ok_or_else(|| format!("Layer {} not found", layer_id))?;
match layer {
AnyLayer::Vector(vl) => vl
.dcel_at_time_mut(time)
.ok_or_else(|| format!("No keyframe at time {}", time)),
_ => Err("Not a vector layer".to_string()),
}
}
}
impl Action for SetFillPaintAction {
fn execute(&mut self, document: &mut Document) -> Result<(), String> {
let dcel = Self::get_dcel_mut(document, &self.layer_id, self.time)?;
self.old_fills.clear();
for &fid in &self.face_ids {
let face = dcel.face(fid);
self.old_fills.push(OldFill {
face_id: fid,
color: face.fill_color,
gradient: face.gradient_fill.clone(),
});
let face_mut = dcel.face_mut(fid);
// Setting a gradient clears solid colour and vice-versa.
if self.new_gradient.is_some() || self.new_color.is_none() {
face_mut.fill_color = self.new_color;
face_mut.gradient_fill = self.new_gradient.clone();
} else {
face_mut.fill_color = self.new_color;
face_mut.gradient_fill = None;
}
}
Ok(())
}
fn rollback(&mut self, document: &mut Document) -> Result<(), String> {
let dcel = Self::get_dcel_mut(document, &self.layer_id, self.time)?;
for old in &self.old_fills {
let face = dcel.face_mut(old.face_id);
face.fill_color = old.color;
face.gradient_fill = old.gradient.clone();
}
Ok(())
}
fn description(&self) -> String {
self.description.to_string()
}
}

View File

@ -114,6 +114,8 @@ pub struct Face {
pub image_fill: Option<uuid::Uuid>,
pub fill_rule: FillRule,
#[serde(default)]
pub gradient_fill: Option<crate::gradient::ShapeGradient>,
#[serde(default)]
pub deleted: bool,
}
@ -241,6 +243,7 @@ impl Dcel {
fill_color: None,
image_fill: None,
fill_rule: FillRule::NonZero,
gradient_fill: None,
deleted: false,
};
let debug_recorder = if std::env::var("DAW_DCEL_RECORD").is_ok() {
@ -372,6 +375,7 @@ impl Dcel {
fill_color: None,
image_fill: None,
fill_rule: FillRule::NonZero,
gradient_fill: None,
deleted: false,
};
if let Some(idx) = self.free_faces.pop() {

View File

@ -0,0 +1,178 @@
//! Gradient types for vector and raster fills.
use crate::shape::ShapeColor;
use kurbo::Point;
use serde::{Deserialize, Serialize};
use vello::peniko::{self, Brush, Extend, Gradient};
// ── Stop ────────────────────────────────────────────────────────────────────
/// One colour stop in a gradient.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
pub struct GradientStop {
/// Normalised position in [0.0, 1.0].
pub position: f32,
pub color: ShapeColor,
}
// ── Kind / Extend ────────────────────────────────────────────────────────────
/// Whether the gradient transitions along a line or radiates from a point.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
pub enum GradientType {
#[default]
Linear,
Radial,
}
/// Behaviour outside the gradient's natural [0, 1] range.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
pub enum GradientExtend {
/// Clamp to edge colour (default).
#[default]
Pad,
/// Mirror the gradient.
Reflect,
/// Repeat the gradient.
Repeat,
}
impl From<GradientExtend> for Extend {
fn from(e: GradientExtend) -> Self {
match e {
GradientExtend::Pad => Extend::Pad,
GradientExtend::Reflect => Extend::Reflect,
GradientExtend::Repeat => Extend::Repeat,
}
}
}
// ── ShapeGradient ────────────────────────────────────────────────────────────
/// A serialisable gradient description.
///
/// Stops are kept sorted by position (ascending). There are always ≥ 2 stops.
///
/// *Rendering*: call [`to_peniko_brush`](ShapeGradient::to_peniko_brush) with
/// explicit start/end canvas-space points. For vector faces the caller derives
/// the points from the bounding box + `angle`; for the raster tool the caller
/// uses the drag start/end directly.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct ShapeGradient {
pub kind: GradientType,
/// Colour stops, sorted by position.
pub stops: Vec<GradientStop>,
/// Angle in degrees for Linear (0 = left→right, 90 = top→bottom).
/// Ignored for Radial.
pub angle: f32,
pub extend: GradientExtend,
}
impl Default for ShapeGradient {
fn default() -> Self {
Self {
kind: GradientType::Linear,
stops: vec![
GradientStop { position: 0.0, color: ShapeColor::rgba(0, 0, 0, 255) },
GradientStop { position: 1.0, color: ShapeColor::rgba(0, 0, 0, 0) },
],
angle: 0.0,
extend: GradientExtend::Pad,
}
}
}
impl ShapeGradient {
// ── CPU evaluation ───────────────────────────────────────────────────────
/// Sample RGBA at `t ∈ [0,1]` by linear interpolation between adjacent stops.
/// Stops must be sorted ascending by position.
pub fn eval(&self, t: f32) -> [u8; 4] {
let t = t.clamp(0.0, 1.0);
if self.stops.is_empty() {
return [0, 0, 0, 0];
}
if self.stops.len() == 1 {
let c = self.stops[0].color;
return [c.r, c.g, c.b, c.a];
}
// Find first stop with position > t
let i = self.stops.partition_point(|s| s.position <= t);
if i == 0 {
let c = self.stops[0].color;
return [c.r, c.g, c.b, c.a];
}
if i >= self.stops.len() {
let c = self.stops.last().unwrap().color;
return [c.r, c.g, c.b, c.a];
}
let s0 = self.stops[i - 1];
let s1 = self.stops[i];
let span = s1.position - s0.position;
let f = if span <= 0.0 { 0.0 } else { (t - s0.position) / span };
fn lerp(a: u8, b: u8, f: f32) -> u8 {
(a as f32 + (b as f32 - a as f32) * f).round().clamp(0.0, 255.0) as u8
}
[
lerp(s0.color.r, s1.color.r, f),
lerp(s0.color.g, s1.color.g, f),
lerp(s0.color.b, s1.color.b, f),
lerp(s0.color.a, s1.color.a, f),
]
}
/// Apply `extend` mode to a raw t value, returning t ∈ [0,1].
pub fn apply_extend(&self, t_raw: f32) -> f32 {
match self.extend {
GradientExtend::Pad => t_raw.clamp(0.0, 1.0),
GradientExtend::Repeat => {
let t = t_raw.rem_euclid(1.0);
if t < 0.0 { t + 1.0 } else { t }
}
GradientExtend::Reflect => {
let t = t_raw.rem_euclid(2.0).abs();
if t > 1.0 { 2.0 - t } else { t }
}
}
}
// ── GPU / peniko rendering ───────────────────────────────────────────────
/// Build a `peniko::Brush` from explicit start/end canvas-coordinate points.
///
/// `opacity` in [0,1] is multiplied into all stop alphas.
pub fn to_peniko_brush(&self, start: Point, end: Point, opacity: f32) -> Brush {
// Convert stops to peniko tuples.
let peniko_stops: Vec<(f32, peniko::Color)> = self.stops.iter().map(|s| {
let a_scaled = (s.color.a as f32 * opacity).round().clamp(0.0, 255.0) as u8;
let col = peniko::Color::from_rgba8(s.color.r, s.color.g, s.color.b, a_scaled);
(s.position, col)
}).collect();
let extend: Extend = self.extend.into();
match self.kind {
GradientType::Linear => {
Brush::Gradient(
Gradient::new_linear(start, end)
.with_extend(extend)
.with_stops(peniko_stops.as_slice()),
)
}
GradientType::Radial => {
let mid = Point::new(
(start.x + end.x) * 0.5,
(start.y + end.y) * 0.5,
);
let dx = end.x - start.x;
let dy = end.y - start.y;
let radius = ((dx * dx + dy * dy).sqrt() * 0.5) as f32;
Brush::Gradient(
Gradient::new_radial(mid, radius)
.with_extend(extend)
.with_stops(peniko_stops.as_slice()),
)
}
}
}
}

View File

@ -53,6 +53,7 @@ pub mod raster_layer;
pub mod brush_settings;
pub mod brush_engine;
pub mod raster_draw;
pub mod gradient;
#[cfg(debug_assertions)]
pub mod test_mode;

View File

@ -113,8 +113,14 @@ pub struct RasterKeyframe {
/// and encoded back to PNG on save. An empty Vec means the canvas is blank (transparent).
#[serde(skip)]
pub raw_pixels: Vec<u8>,
/// Set to `true` whenever `raw_pixels` changes so the GPU texture cache can re-upload.
/// Always `true` after load; cleared by the renderer after uploading.
#[serde(skip, default = "default_true")]
pub texture_dirty: bool,
}
fn default_true() -> bool { true }
impl RasterKeyframe {
/// Returns true when the pixel buffer has been initialised (non-blank).
pub fn has_pixels(&self) -> bool {

View File

@ -923,7 +923,25 @@ fn render_video_layer(
}
}
/// Render a vector layer with all its clip instances and shape instances
/// Compute start/end canvas points for a linear gradient across a bounding box.
///
/// The axis is centred on the bbox midpoint and oriented at `angle_deg` degrees
/// (0 = left→right, 90 = top→bottom). The axis extends ± half the bbox diagonal
/// so the gradient covers the entire shape regardless of angle.
fn gradient_bbox_endpoints(angle_deg: f32, bbox: kurbo::Rect) -> (kurbo::Point, kurbo::Point) {
let cx = bbox.center().x;
let cy = bbox.center().y;
let dx = bbox.width();
let dy = bbox.height();
// Use half the diagonal so the full gradient fits at any angle.
let half_len = (dx * dx + dy * dy).sqrt() * 0.5;
let rad = (angle_deg as f64).to_radians();
let (sin, cos) = (rad.sin(), rad.cos());
let start = kurbo::Point::new(cx - cos * half_len, cy - sin * half_len);
let end = kurbo::Point::new(cx + cos * half_len, cy + sin * half_len);
(start, end)
}
/// Render a DCEL to a Vello scene.
///
/// Walks faces for fills and edges for strokes.
@ -942,7 +960,7 @@ pub fn render_dcel(
if face.deleted || i == 0 {
continue; // Skip unbounded face and deleted faces
}
if face.fill_color.is_none() && face.image_fill.is_none() {
if face.fill_color.is_none() && face.image_fill.is_none() && face.gradient_fill.is_none() {
continue; // No fill to render
}
@ -963,7 +981,19 @@ pub fn render_dcel(
}
}
// Color fill
// Gradient fill (takes priority over solid colour fill)
if !filled {
if let Some(ref grad) = face.gradient_fill {
use kurbo::{Point, Rect};
let bbox: Rect = vello::kurbo::Shape::bounding_box(&path);
let (start, end) = gradient_bbox_endpoints(grad.angle, bbox);
let brush = grad.to_peniko_brush(start, end, opacity_f32);
scene.fill(fill_rule, base_transform, &brush, None, &path);
filled = true;
}
}
// Solid colour fill
if !filled {
if let Some(fill_color) = &face.fill_color {
let alpha = ((fill_color.a as f32 / 255.0) * opacity_f32 * 255.0) as u8;

View File

@ -290,6 +290,491 @@ impl RasterTransformPipeline {
}
// ---------------------------------------------------------------------------
// Displacement buffer (Warp / Liquify)
// ---------------------------------------------------------------------------
/// Per-pixel displacement map stored as a GPU buffer of `vec2f` values.
///
/// Each entry `disp[y * width + x]` stores `(dx, dy)` in canvas pixels.
/// Used by both the Warp tool (bilinear grid warp) and the Liquify tool
/// (brush-based freeform displacement).
pub struct DisplacementBuffer {
pub buf: wgpu::Buffer,
pub width: u32,
pub height: u32,
}
// ---------------------------------------------------------------------------
// Warp-apply pipeline
// ---------------------------------------------------------------------------
/// CPU-side parameters uniform for `warp_apply.wgsl`.
/// Must match the `Params` struct in the shader (32 bytes, 16-byte aligned).
/// grid_cols == 0 → per-pixel displacement buffer mode (Liquify).
/// grid_cols > 0 → control-point grid mode (Warp); disp[] has grid_cols*grid_rows entries.
#[repr(C)]
#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
pub struct WarpApplyParams {
pub src_w: u32,
pub src_h: u32,
pub dst_w: u32,
pub dst_h: u32,
pub grid_cols: u32,
pub grid_rows: u32,
pub _pad0: u32,
pub _pad1: u32,
}
/// Compute pipeline that reads a displacement buffer + source texture → warped output.
/// Shared by the Warp tool and the Liquify tool's preview/commit pass.
struct WarpApplyPipeline {
pipeline: wgpu::ComputePipeline,
bg_layout: wgpu::BindGroupLayout,
}
impl WarpApplyPipeline {
fn new(device: &wgpu::Device) -> Self {
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("warp_apply_shader"),
source: wgpu::ShaderSource::Wgsl(
include_str!("panes/shaders/warp_apply.wgsl").into(),
),
});
let bg_layout = device.create_bind_group_layout(
&wgpu::BindGroupLayoutDescriptor {
label: Some("warp_apply_bgl"),
entries: &[
// 0: params uniform
wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::COMPUTE,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Uniform,
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
},
// 1: source texture (anchor canvas, sampled)
wgpu::BindGroupLayoutEntry {
binding: 1,
visibility: wgpu::ShaderStages::COMPUTE,
ty: wgpu::BindingType::Texture {
sample_type: wgpu::TextureSampleType::Float { filterable: true },
view_dimension: wgpu::TextureViewDimension::D2,
multisampled: false,
},
count: None,
},
// 2: displacement buffer (read-only storage)
wgpu::BindGroupLayoutEntry {
binding: 2,
visibility: wgpu::ShaderStages::COMPUTE,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Storage { read_only: true },
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
},
// 3: destination texture (display canvas, write-only storage)
wgpu::BindGroupLayoutEntry {
binding: 3,
visibility: wgpu::ShaderStages::COMPUTE,
ty: wgpu::BindingType::StorageTexture {
access: wgpu::StorageTextureAccess::WriteOnly,
format: wgpu::TextureFormat::Rgba8Unorm,
view_dimension: wgpu::TextureViewDimension::D2,
},
count: None,
},
],
},
);
let pipeline_layout = device.create_pipeline_layout(
&wgpu::PipelineLayoutDescriptor {
label: Some("warp_apply_pl"),
bind_group_layouts: &[&bg_layout],
push_constant_ranges: &[],
},
);
let pipeline = device.create_compute_pipeline(
&wgpu::ComputePipelineDescriptor {
label: Some("warp_apply_pipeline"),
layout: Some(&pipeline_layout),
module: &shader,
entry_point: Some("main"),
compilation_options: Default::default(),
cache: None,
},
);
Self { pipeline, bg_layout }
}
fn apply(
&self,
device: &wgpu::Device,
queue: &wgpu::Queue,
src_view: &wgpu::TextureView,
disp_buf: &wgpu::Buffer,
dst_view: &wgpu::TextureView,
params: WarpApplyParams,
) {
use wgpu::util::DeviceExt;
let uniform_buf = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("warp_apply_params"),
contents: bytemuck::bytes_of(&params),
usage: wgpu::BufferUsages::UNIFORM,
});
let bg = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("warp_apply_bg"),
layout: &self.bg_layout,
entries: &[
wgpu::BindGroupEntry { binding: 0, resource: uniform_buf.as_entire_binding() },
wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::TextureView(src_view) },
wgpu::BindGroupEntry { binding: 2, resource: disp_buf.as_entire_binding() },
wgpu::BindGroupEntry { binding: 3, resource: wgpu::BindingResource::TextureView(dst_view) },
],
});
let mut encoder = device.create_command_encoder(
&wgpu::CommandEncoderDescriptor { label: Some("warp_apply_enc") },
);
{
let mut pass = encoder.begin_compute_pass(
&wgpu::ComputePassDescriptor { label: Some("warp_apply_pass"), timestamp_writes: None },
);
pass.set_pipeline(&self.pipeline);
pass.set_bind_group(0, &bg, &[]);
pass.dispatch_workgroups(params.dst_w.div_ceil(8), params.dst_h.div_ceil(8), 1);
}
queue.submit(Some(encoder.finish()));
}
}
// ---------------------------------------------------------------------------
// Liquify-brush pipeline
// ---------------------------------------------------------------------------
/// CPU-side parameters uniform for `liquify_brush.wgsl`.
/// Must match the `Params` struct in the shader (48 bytes, 16-byte aligned).
#[repr(C)]
#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
pub struct LiquifyBrushParams {
pub cx: f32,
pub cy: f32,
pub radius: f32,
pub strength: f32,
pub dx: f32,
pub dy: f32,
pub mode: u32,
pub map_w: u32,
pub map_h: u32,
pub _pad0: u32,
pub _pad1: u32,
pub _pad2: u32,
}
/// Compute pipeline that updates a displacement map from a single brush step.
struct LiquifyBrushPipeline {
pipeline: wgpu::ComputePipeline,
bg_layout: wgpu::BindGroupLayout,
}
impl LiquifyBrushPipeline {
fn new(device: &wgpu::Device) -> Self {
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("liquify_brush_shader"),
source: wgpu::ShaderSource::Wgsl(
include_str!("panes/shaders/liquify_brush.wgsl").into(),
),
});
let bg_layout = device.create_bind_group_layout(
&wgpu::BindGroupLayoutDescriptor {
label: Some("liquify_brush_bgl"),
entries: &[
// 0: params uniform
wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::COMPUTE,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Uniform,
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
},
// 1: displacement buffer (read-write storage)
wgpu::BindGroupLayoutEntry {
binding: 1,
visibility: wgpu::ShaderStages::COMPUTE,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Storage { read_only: false },
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
},
],
},
);
let pipeline_layout = device.create_pipeline_layout(
&wgpu::PipelineLayoutDescriptor {
label: Some("liquify_brush_pl"),
bind_group_layouts: &[&bg_layout],
push_constant_ranges: &[],
},
);
let pipeline = device.create_compute_pipeline(
&wgpu::ComputePipelineDescriptor {
label: Some("liquify_brush_pipeline"),
layout: Some(&pipeline_layout),
module: &shader,
entry_point: Some("main"),
compilation_options: Default::default(),
cache: None,
},
);
Self { pipeline, bg_layout }
}
fn update_displacement(
&self,
device: &wgpu::Device,
queue: &wgpu::Queue,
disp_buf: &wgpu::Buffer,
params: LiquifyBrushParams,
) {
use wgpu::util::DeviceExt;
let uniform_buf = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("liquify_brush_params"),
contents: bytemuck::bytes_of(&params),
usage: wgpu::BufferUsages::UNIFORM,
});
let bg = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("liquify_brush_bg"),
layout: &self.bg_layout,
entries: &[
wgpu::BindGroupEntry { binding: 0, resource: uniform_buf.as_entire_binding() },
wgpu::BindGroupEntry { binding: 1, resource: disp_buf.as_entire_binding() },
],
});
let r = params.radius.ceil() as u32;
let wg_x = (2 * r + 1).div_ceil(8).max(1);
let wg_y = (2 * r + 1).div_ceil(8).max(1);
let mut encoder = device.create_command_encoder(
&wgpu::CommandEncoderDescriptor { label: Some("liquify_brush_enc") },
);
{
let mut pass = encoder.begin_compute_pass(
&wgpu::ComputePassDescriptor { label: Some("liquify_brush_pass"), timestamp_writes: None },
);
pass.set_pipeline(&self.pipeline);
pass.set_bind_group(0, &bg, &[]);
pass.dispatch_workgroups(wg_x, wg_y, 1);
}
queue.submit(Some(encoder.finish()));
}
}
// ---------------------------------------------------------------------------
// ---------------------------------------------------------------------------
// Gradient-fill pipeline
// ---------------------------------------------------------------------------
/// One gradient stop on the GPU side. Colors are linear straight-alpha [0..1].
/// Must be 32 bytes (8 × f32) to match `GradientStop` in `gradient_fill.wgsl`.
#[repr(C)]
#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
pub struct GpuGradientStop {
pub position: f32,
pub r: f32,
pub g: f32,
pub b: f32,
pub a: f32,
pub _pad: [f32; 3],
}
impl GpuGradientStop {
/// Construct from sRGB u8 bytes (as stored in `ShapeColor`).
/// RGB is converted to linear; alpha is kept linear (not gamma-encoded).
pub fn from_srgb_u8(position: f32, r: u8, g: u8, b: u8, a: u8) -> Self {
Self {
position,
r: srgb_to_linear(r as f32 / 255.0),
g: srgb_to_linear(g as f32 / 255.0),
b: srgb_to_linear(b as f32 / 255.0),
a: a as f32 / 255.0,
_pad: [0.0; 3],
}
}
}
/// CPU-side parameters uniform for `gradient_fill.wgsl`.
/// Must be 48 bytes (12 × u32/f32), 16-byte aligned.
#[repr(C)]
#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
struct GradientFillParams {
canvas_w: u32,
canvas_h: u32,
start_x: f32,
start_y: f32,
end_x: f32,
end_y: f32,
opacity: f32,
extend_mode: u32, // 0 = Pad, 1 = Reflect, 2 = Repeat
num_stops: u32,
kind: u32, // 0 = Linear, 1 = Radial
_pad1: u32,
_pad2: u32,
}
/// Compute pipeline: composites a gradient over an anchor canvas → display canvas.
struct GradientFillPipeline {
pipeline: wgpu::ComputePipeline,
bg_layout: wgpu::BindGroupLayout,
}
impl GradientFillPipeline {
fn new(device: &wgpu::Device) -> Self {
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("gradient_fill_shader"),
source: wgpu::ShaderSource::Wgsl(
include_str!("panes/shaders/gradient_fill.wgsl").into(),
),
});
let bg_layout = device.create_bind_group_layout(
&wgpu::BindGroupLayoutDescriptor {
label: Some("gradient_fill_bgl"),
entries: &[
// 0: params uniform
wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::COMPUTE,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Uniform,
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
},
// 1: anchor (source) canvas
wgpu::BindGroupLayoutEntry {
binding: 1,
visibility: wgpu::ShaderStages::COMPUTE,
ty: wgpu::BindingType::Texture {
sample_type: wgpu::TextureSampleType::Float { filterable: false },
view_dimension: wgpu::TextureViewDimension::D2,
multisampled: false,
},
count: None,
},
// 2: gradient stops (read-only storage buffer)
wgpu::BindGroupLayoutEntry {
binding: 2,
visibility: wgpu::ShaderStages::COMPUTE,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Storage { read_only: true },
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
},
// 3: display (destination) canvas — write-only storage texture
wgpu::BindGroupLayoutEntry {
binding: 3,
visibility: wgpu::ShaderStages::COMPUTE,
ty: wgpu::BindingType::StorageTexture {
access: wgpu::StorageTextureAccess::WriteOnly,
format: wgpu::TextureFormat::Rgba8Unorm,
view_dimension: wgpu::TextureViewDimension::D2,
},
count: None,
},
],
},
);
let pipeline_layout = device.create_pipeline_layout(
&wgpu::PipelineLayoutDescriptor {
label: Some("gradient_fill_pl"),
bind_group_layouts: &[&bg_layout],
push_constant_ranges: &[],
},
);
let pipeline = device.create_compute_pipeline(
&wgpu::ComputePipelineDescriptor {
label: Some("gradient_fill_pipeline"),
layout: Some(&pipeline_layout),
module: &shader,
entry_point: Some("main"),
compilation_options: Default::default(),
cache: None,
},
);
Self { pipeline, bg_layout }
}
fn apply(
&self,
device: &wgpu::Device,
queue: &wgpu::Queue,
src_view: &wgpu::TextureView,
stops_buf: &wgpu::Buffer,
dst_view: &wgpu::TextureView,
params: GradientFillParams,
) {
use wgpu::util::DeviceExt;
let uniform_buf = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("gradient_fill_params"),
contents: bytemuck::bytes_of(&params),
usage: wgpu::BufferUsages::UNIFORM,
});
let bg = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("gradient_fill_bg"),
layout: &self.bg_layout,
entries: &[
wgpu::BindGroupEntry { binding: 0, resource: uniform_buf.as_entire_binding() },
wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::TextureView(src_view) },
wgpu::BindGroupEntry { binding: 2, resource: stops_buf.as_entire_binding() },
wgpu::BindGroupEntry { binding: 3, resource: wgpu::BindingResource::TextureView(dst_view) },
],
});
let mut encoder = device.create_command_encoder(
&wgpu::CommandEncoderDescriptor { label: Some("gradient_fill_enc") },
);
{
let mut pass = encoder.begin_compute_pass(
&wgpu::ComputePassDescriptor { label: Some("gradient_fill_pass"), timestamp_writes: None },
);
pass.set_pipeline(&self.pipeline);
pass.set_bind_group(0, &bg, &[]);
pass.dispatch_workgroups(params.canvas_w.div_ceil(8), params.canvas_h.div_ceil(8), 1);
}
queue.submit(Some(encoder.finish()));
}
}
// GpuBrushEngine
// ---------------------------------------------------------------------------
@ -301,8 +786,18 @@ pub struct GpuBrushEngine {
/// Lazily created on first raster transform use.
transform_pipeline: Option<RasterTransformPipeline>,
/// Lazily created on first warp/liquify use.
warp_apply_pipeline: Option<WarpApplyPipeline>,
/// Lazily created on first liquify brush use.
liquify_brush_pipeline: Option<LiquifyBrushPipeline>,
/// Lazily created on first gradient fill use.
gradient_fill_pipeline: Option<GradientFillPipeline>,
/// Canvas texture pairs keyed by keyframe UUID.
pub canvases: HashMap<Uuid, CanvasPair>,
/// Displacement map buffers keyed by a caller-supplied UUID.
pub displacement_bufs: HashMap<Uuid, DisplacementBuffer>,
}
/// CPU-side parameters uniform for the compute shader.
@ -405,7 +900,11 @@ impl GpuBrushEngine {
compute_pipeline,
compute_bg_layout,
transform_pipeline: None,
warp_apply_pipeline: None,
liquify_brush_pipeline: None,
gradient_fill_pipeline: None,
canvases: HashMap::new(),
displacement_bufs: HashMap::new(),
}
}
@ -803,6 +1302,211 @@ impl GpuBrushEngine {
}
}
}
// -----------------------------------------------------------------------
// Displacement buffer management
// -----------------------------------------------------------------------
/// Create a zero-initialised displacement buffer of `width × height` vec2f entries.
/// Returns the UUID under which it is stored.
pub fn create_displacement_buf(
&mut self,
device: &wgpu::Device,
id: Uuid,
width: u32,
height: u32,
) {
let byte_len = (width * height * 8) as u64; // 2 × f32 per pixel
let buf = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("displacement_buf"),
size: byte_len,
usage: wgpu::BufferUsages::STORAGE
| wgpu::BufferUsages::COPY_DST
| wgpu::BufferUsages::COPY_SRC,
mapped_at_creation: false,
});
self.displacement_bufs.insert(id, DisplacementBuffer { buf, width, height });
}
/// Overwrite the displacement buffer contents with the provided data.
pub fn upload_displacement_buf(
&self,
queue: &wgpu::Queue,
id: &Uuid,
data: &[[f32; 2]],
) {
if let Some(db) = self.displacement_bufs.get(id) {
queue.write_buffer(&db.buf, 0, bytemuck::cast_slice(data));
}
}
/// Zero out a displacement buffer (reset all displacements to (0,0)).
pub fn clear_displacement_buf(&self, queue: &wgpu::Queue, id: &Uuid) {
if let Some(db) = self.displacement_bufs.get(id) {
let zeros = vec![0u8; (db.width * db.height * 8) as usize];
queue.write_buffer(&db.buf, 0, &zeros);
}
}
/// Remove a displacement buffer (e.g. when the warp/liquify operation ends).
pub fn remove_displacement_buf(&mut self, id: &Uuid) {
self.displacement_bufs.remove(id);
}
// -----------------------------------------------------------------------
// Warp apply (shared by Warp and Liquify tools)
// -----------------------------------------------------------------------
/// Upload `disp_data` to the displacement buffer and then run the warp-apply
/// shader from `anchor_id` → `display_id`. The display canvas is swapped after.
///
/// If `disp_data` is `None` the buffer is not re-uploaded (used by Liquify which
/// updates the buffer in-place via `liquify_brush_step`).
/// Apply warp displacement to produce the display canvas.
///
/// `disp_data`: if `Some`, upload this data to the displacement buffer before running.
/// `grid_cols/grid_rows`: if > 0, the disp buffer contains only that many vec2f entries
/// (control-point grid mode). The shader does bilinear interpolation per pixel.
/// If 0, the buffer is a full per-pixel map (Liquify mode).
pub fn apply_warp(
&mut self,
device: &wgpu::Device,
queue: &wgpu::Queue,
anchor_id: &Uuid,
disp_id: &Uuid,
display_id: &Uuid,
disp_data: Option<&[[f32; 2]]>,
grid_cols: u32,
grid_rows: u32,
) {
// Upload new displacement data if provided.
if let Some(data) = disp_data {
if let Some(db) = self.displacement_bufs.get(disp_id) {
queue.write_buffer(&db.buf, 0, bytemuck::cast_slice(data));
}
}
let pipeline = self.warp_apply_pipeline
.get_or_insert_with(|| WarpApplyPipeline::new(device));
let dispatched = {
let anchor = self.canvases.get(anchor_id);
let display = self.canvases.get(display_id);
let disp_b = self.displacement_bufs.get(disp_id);
if let (Some(anchor), Some(display), Some(db)) = (anchor, display, disp_b) {
let params = WarpApplyParams {
src_w: anchor.width,
src_h: anchor.height,
dst_w: display.width,
dst_h: display.height,
grid_cols,
grid_rows,
_pad0: 0,
_pad1: 0,
};
pipeline.apply(device, queue, anchor.src_view(), &db.buf, display.dst_view(), params);
true
} else {
false
}
};
if dispatched {
if let Some(display) = self.canvases.get_mut(display_id) {
display.swap();
}
}
}
// -----------------------------------------------------------------------
// Liquify brush step
// -----------------------------------------------------------------------
// -----------------------------------------------------------------------
// Gradient fill
// -----------------------------------------------------------------------
/// Composite a gradient over the anchor canvas into the display canvas.
///
/// - `anchor_id`: canvas holding the original pixels (read-only each frame).
/// - `display_id`: canvas to write the gradient result into.
/// - `stops`: gradient stops (linear straight-alpha, converted from sRGB by caller).
/// - `start`, `end`: gradient axis endpoints in canvas pixels.
/// - `opacity`: overall tool opacity [0..1].
/// - `extend_mode`: 0 = Pad, 1 = Reflect, 2 = Repeat.
pub fn apply_gradient_fill(
&mut self,
device: &wgpu::Device,
queue: &wgpu::Queue,
anchor_id: &Uuid,
display_id: &Uuid,
stops: &[GpuGradientStop],
start: (f32, f32),
end: (f32, f32),
opacity: f32,
extend_mode: u32,
kind: u32,
) {
use wgpu::util::DeviceExt;
let pipeline = self.gradient_fill_pipeline
.get_or_insert_with(|| GradientFillPipeline::new(device));
// Build the stops storage buffer.
let stops_buf = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("gradient_stops_buf"),
contents: bytemuck::cast_slice(stops),
usage: wgpu::BufferUsages::STORAGE,
});
let dispatched = {
let anchor = self.canvases.get(anchor_id);
let display = self.canvases.get(display_id);
if let (Some(anchor), Some(display)) = (anchor, display) {
let params = GradientFillParams {
canvas_w: anchor.width,
canvas_h: anchor.height,
start_x: start.0,
start_y: start.1,
end_x: end.0,
end_y: end.1,
opacity,
extend_mode,
num_stops: stops.len() as u32,
kind,
_pad1: 0, _pad2: 0,
};
pipeline.apply(device, queue, anchor.src_view(), &stops_buf, display.dst_view(), params);
true
} else {
false
}
};
if dispatched {
if let Some(display) = self.canvases.get_mut(display_id) {
display.swap();
}
}
}
/// Dispatch the liquify-brush compute shader to update the displacement map.
pub fn liquify_brush_step(
&mut self,
device: &wgpu::Device,
queue: &wgpu::Queue,
disp_id: &Uuid,
params: LiquifyBrushParams,
) {
if !self.displacement_bufs.contains_key(disp_id) { return; }
let pipeline = self.liquify_brush_pipeline
.get_or_insert_with(|| LiquifyBrushPipeline::new(device));
if let Some(db) = self.displacement_bufs.get(disp_id) {
pipeline.update_displacement(device, queue, &db.buf, params);
}
}
}
// ---------------------------------------------------------------------------

View File

@ -0,0 +1,261 @@
//! Gradient stop editor widget.
//!
//! Call [`gradient_stop_editor`] inside any egui layout; it returns `true` when
//! `gradient` was modified.
use eframe::egui::{self, Color32, DragValue, Painter, Rect, Sense, Stroke, Vec2};
use lightningbeam_core::gradient::{GradientExtend, GradientStop, GradientType, ShapeGradient};
use lightningbeam_core::shape::ShapeColor;
// ── Public entry point ───────────────────────────────────────────────────────
/// Render an inline gradient editor.
///
/// * `gradient` the gradient being edited (mutated in place).
/// * `selected_stop` index of the currently selected stop (persisted by caller).
///
/// Returns `true` if anything changed.
pub fn gradient_stop_editor(
ui: &mut egui::Ui,
gradient: &mut ShapeGradient,
selected_stop: &mut Option<usize>,
) -> bool {
let mut changed = false;
// ── Row 1: Kind + angle ───────────────────────────────────────────────
ui.horizontal(|ui| {
let was_linear = gradient.kind == GradientType::Linear;
if ui.selectable_label(was_linear, "Linear").clicked() && !was_linear {
gradient.kind = GradientType::Linear;
changed = true;
}
if ui.selectable_label(!was_linear, "Radial").clicked() && was_linear {
gradient.kind = GradientType::Radial;
changed = true;
}
if gradient.kind == GradientType::Linear {
ui.add_space(8.0);
ui.label("Angle:");
if ui.add(
DragValue::new(&mut gradient.angle)
.speed(1.0)
.range(-360.0..=360.0)
.suffix("°"),
).changed() {
changed = true;
}
}
});
// ── Gradient bar + handles ────────────────────────────────────────────
let bar_height = 22.0_f32;
let handle_h = 14.0_f32;
let total_height = bar_height + handle_h + 4.0;
let avail_w = ui.available_width();
let (bar_rect, bar_resp) = ui.allocate_exact_size(
Vec2::new(avail_w, total_height),
Sense::click(),
);
let painter = ui.painter_at(bar_rect);
let bar = Rect::from_min_size(bar_rect.min, Vec2::new(avail_w, bar_height));
let track = Rect::from_min_size(
egui::pos2(bar_rect.min.x, bar_rect.min.y + bar_height + 2.0),
Vec2::new(avail_w, handle_h),
);
// Draw checkerboard background (transparent indicator).
draw_checker(&painter, bar);
// Draw gradient bar as N segments.
let seg = 128_usize;
for i in 0..seg {
let t0 = i as f32 / seg as f32;
let t1 = (i + 1) as f32 / seg as f32;
let t = (t0 + t1) * 0.5;
let [r, g, b, a] = gradient.eval(t);
let col = Color32::from_rgba_unmultiplied(r, g, b, a);
let x0 = bar.min.x + t0 * bar.width();
let x1 = bar.min.x + t1 * bar.width();
let seg_rect = Rect::from_min_max(
egui::pos2(x0, bar.min.y),
egui::pos2(x1, bar.max.y),
);
painter.rect_filled(seg_rect, 0.0, col);
}
// Outline.
painter.rect_stroke(bar, 2.0, Stroke::new(1.0, Color32::from_gray(60)), eframe::egui::StrokeKind::Middle);
// Click on bar → add stop.
if bar_resp.clicked() {
if let Some(pos) = bar_resp.interact_pointer_pos() {
if bar.contains(pos) {
let t = ((pos.x - bar.min.x) / bar.width()).clamp(0.0, 1.0);
let [r, g, b, a] = gradient.eval(t);
gradient.stops.push(GradientStop {
position: t,
color: ShapeColor::rgba(r, g, b, a),
});
gradient.stops.sort_by(|a, b| a.position.partial_cmp(&b.position).unwrap());
*selected_stop = gradient.stops.iter().position(|s| s.position == t);
changed = true;
}
}
}
// Draw stop handles.
// We need to detect drags per-handle, so allocate individual rects with the
// regular egui input model. To avoid borrow conflicts we collect interactions
// before mutating.
let handle_w = 10.0_f32;
let n_stops = gradient.stops.len();
let mut drag_idx: Option<usize> = None;
let mut drag_delta: f32 = 0.0;
let mut click_idx: Option<usize> = None;
// To render handles after collecting, remember their rects.
let handle_rects: Vec<Rect> = (0..n_stops).map(|i| {
let cx = track.min.x + gradient.stops[i].position * track.width();
Rect::from_center_size(
egui::pos2(cx, track.center().y),
Vec2::new(handle_w, handle_h),
)
}).collect();
for (i, &h_rect) in handle_rects.iter().enumerate() {
let resp = ui.interact(h_rect, ui.id().with(("grad_handle", i)), Sense::click_and_drag());
if resp.dragged() {
drag_idx = Some(i);
drag_delta = resp.drag_delta().x / track.width();
}
if resp.clicked() {
click_idx = Some(i);
}
}
// Apply drag.
if let (Some(i), delta) = (drag_idx, drag_delta) {
if delta != 0.0 {
let new_pos = (gradient.stops[i].position + delta).clamp(0.0, 1.0);
gradient.stops[i].position = new_pos;
// Re-sort and track the moved stop.
gradient.stops.sort_by(|a, b| a.position.partial_cmp(&b.position).unwrap());
// Find new index of the moved stop (closest position match).
if let Some(ref mut sel) = *selected_stop {
// Re-find by position proximity.
*sel = gradient.stops.iter().enumerate()
.min_by(|(_, a), (_, b)| {
let pa = (a.position - (gradient.stops.get(i).map_or(0.0, |s| s.position))).abs();
let pb = (b.position - (gradient.stops.get(i).map_or(0.0, |s| s.position))).abs();
pa.partial_cmp(&pb).unwrap()
})
.map(|(idx, _)| idx)
.unwrap_or(0);
}
changed = true;
}
}
if let Some(i) = click_idx {
*selected_stop = Some(i);
}
// Paint handles on top (after interaction so they visually react).
for (i, h_rect) in handle_rects.iter().enumerate() {
let col = ShapeColor_to_Color32(gradient.stops[i].color);
let is_selected = *selected_stop == Some(i);
// Draw a downward-pointing triangle.
let cx = h_rect.center().x;
let top = h_rect.min.y;
let bot = h_rect.max.y;
let hw = h_rect.width() * 0.5;
let tri = vec![
egui::pos2(cx, bot),
egui::pos2(cx - hw, top),
egui::pos2(cx + hw, top),
];
painter.add(egui::Shape::convex_polygon(
tri,
col,
Stroke::new(if is_selected { 2.0 } else { 1.0 },
if is_selected { Color32::WHITE } else { Color32::from_gray(100) }),
));
}
// ── Selected stop detail ──────────────────────────────────────────────
if let Some(i) = *selected_stop {
if i < gradient.stops.len() {
ui.separator();
ui.horizontal(|ui| {
let stop = &mut gradient.stops[i];
let mut rgba = [stop.color.r, stop.color.g, stop.color.b, stop.color.a];
if ui.color_edit_button_srgba_unmultiplied(&mut rgba).changed() {
stop.color = ShapeColor::rgba(rgba[0], rgba[1], rgba[2], rgba[3]);
changed = true;
}
ui.label("Position:");
if ui.add(
DragValue::new(&mut stop.position)
.speed(0.005)
.range(0.0..=1.0),
).changed() {
gradient.stops.sort_by(|a, b| a.position.partial_cmp(&b.position).unwrap());
changed = true;
}
let can_remove = gradient.stops.len() > 2;
if ui.add_enabled(can_remove, egui::Button::new(" Remove")).clicked() {
gradient.stops.remove(i);
*selected_stop = None;
changed = true;
}
});
} else {
*selected_stop = None;
}
}
// ── Extend mode ───────────────────────────────────────────────────────
ui.horizontal(|ui| {
ui.label("Extend:");
if ui.selectable_label(gradient.extend == GradientExtend::Pad, "Pad").clicked() {
gradient.extend = GradientExtend::Pad; changed = true;
}
if ui.selectable_label(gradient.extend == GradientExtend::Reflect, "Reflect").clicked() {
gradient.extend = GradientExtend::Reflect; changed = true;
}
if ui.selectable_label(gradient.extend == GradientExtend::Repeat, "Repeat").clicked() {
gradient.extend = GradientExtend::Repeat; changed = true;
}
});
changed
}
// ── Helpers ──────────────────────────────────────────────────────────────────
fn ShapeColor_to_Color32(c: ShapeColor) -> Color32 {
Color32::from_rgba_unmultiplied(c.r, c.g, c.b, c.a)
}
/// Draw a small grey/white checkerboard inside `rect`.
fn draw_checker(painter: &Painter, rect: Rect) {
let cell = 6.0_f32;
let cols = ((rect.width() / cell).ceil() as u32).max(1);
let rows = ((rect.height() / cell).ceil() as u32).max(1);
for row in 0..rows {
for col in 0..cols {
let light = (row + col) % 2 == 0;
let col32 = if light { Color32::from_gray(200) } else { Color32::from_gray(140) };
let x = rect.min.x + col as f32 * cell;
let y = rect.min.y + row as f32 * cell;
let r = Rect::from_min_size(
egui::pos2(x, y),
Vec2::splat(cell),
).intersect(rect);
painter.rect_filled(r, 0.0, col32);
}
}
}

View File

@ -12,12 +12,14 @@
use eframe::egui::{self, DragValue, Ui};
use lightningbeam_core::brush_settings::{bundled_brushes, BrushSettings};
use lightningbeam_core::actions::{SetDocumentPropertiesAction, SetShapePropertiesAction};
use lightningbeam_core::actions::{SetDocumentPropertiesAction, SetShapePropertiesAction, SetFillPaintAction};
use lightningbeam_core::gradient::ShapeGradient;
use lightningbeam_core::layer::{AnyLayer, LayerTrait};
use lightningbeam_core::selection::FocusSelection;
use lightningbeam_core::shape::ShapeColor;
use lightningbeam_core::tool::{SimplifyMode, Tool};
use super::{NodePath, PaneRenderer, SharedPaneState};
use super::gradient_editor::gradient_stop_editor;
use uuid::Uuid;
/// Info panel pane state
@ -36,6 +38,10 @@ pub struct InfopanelPane {
eraser_picker_expanded: bool,
/// Cached preview textures, one per preset (populated lazily).
brush_preview_textures: Vec<egui::TextureHandle>,
/// Selected stop index for gradient editor in shape section.
selected_shape_gradient_stop: Option<usize>,
/// Selected stop index for gradient editor in tool section (gradient tool).
selected_tool_gradient_stop: Option<usize>,
}
impl InfopanelPane {
@ -50,6 +56,8 @@ impl InfopanelPane {
selected_eraser_preset: default_eraser_idx,
eraser_picker_expanded: false,
brush_preview_textures: Vec::new(),
selected_shape_gradient_stop: None,
selected_tool_gradient_stop: None,
}
}
}
@ -65,6 +73,8 @@ struct SelectionInfo {
// Shape property values (None = mixed)
fill_color: Option<Option<ShapeColor>>,
/// None = mixed across selection; Some(None) = no gradient; Some(Some(g)) = all same gradient
fill_gradient: Option<Option<ShapeGradient>>,
stroke_color: Option<Option<ShapeColor>>,
stroke_width: Option<f64>,
}
@ -76,6 +86,7 @@ impl Default for SelectionInfo {
dcel_count: 0,
layer_id: None,
fill_color: None,
fill_gradient: None,
stroke_color: None,
stroke_width: None,
}
@ -138,21 +149,32 @@ impl InfopanelPane {
// Gather fill properties from selected faces
let mut first_fill_color: Option<Option<ShapeColor>> = None;
let mut fill_color_mixed = false;
let mut first_fill_gradient: Option<Option<ShapeGradient>> = None;
let mut fill_gradient_mixed = false;
for &fid in shared.selection.selected_faces() {
let face = dcel.face(fid);
let fc = face.fill_color;
let fg = face.gradient_fill.clone();
match first_fill_color {
None => first_fill_color = Some(fc),
Some(prev) if prev != fc => fill_color_mixed = true,
_ => {}
}
match &first_fill_gradient {
None => first_fill_gradient = Some(fg),
Some(prev) if *prev != fg => fill_gradient_mixed = true,
_ => {}
}
}
if !fill_color_mixed {
info.fill_color = first_fill_color;
}
if !fill_gradient_mixed {
info.fill_gradient = first_fill_gradient;
}
}
}
}
@ -191,6 +213,7 @@ impl InfopanelPane {
|| is_raster_select || is_raster_shape || matches!(
tool,
Tool::PaintBucket | Tool::RegionSelect | Tool::MagicWand | Tool::QuickSelect
| Tool::Warp | Tool::Liquify | Tool::Gradient
);
if !has_options {
@ -414,6 +437,62 @@ impl InfopanelPane {
});
}
Tool::Warp => {
ui.horizontal(|ui| {
ui.label("Grid:");
let cols = shared.raster_settings.warp_grid_cols;
let rows = shared.raster_settings.warp_grid_rows;
for (label, c, r) in [("3×3", 3u32, 3u32), ("4×4", 4, 4), ("5×5", 5, 5), ("8×8", 8, 8)] {
let selected = cols == c && rows == r;
if ui.selectable_label(selected, label).clicked() {
shared.raster_settings.warp_grid_cols = c;
shared.raster_settings.warp_grid_rows = r;
}
}
});
ui.small("Enter to commit · Escape to cancel");
}
Tool::Liquify => {
use crate::tools::LiquifyMode;
ui.horizontal(|ui| {
ui.label("Mode:");
for (label, mode) in [
("Push", LiquifyMode::Push),
("Pucker", LiquifyMode::Pucker),
("Bloat", LiquifyMode::Bloat),
("Smooth", LiquifyMode::Smooth),
("Reconstruct", LiquifyMode::Reconstruct),
] {
let selected = shared.raster_settings.liquify_mode == mode;
if ui.selectable_label(selected, label).clicked() {
shared.raster_settings.liquify_mode = mode;
}
}
});
ui.horizontal(|ui| {
ui.label("Radius:");
ui.add(
egui::Slider::new(
&mut shared.raster_settings.liquify_radius,
5.0_f32..=500.0,
)
.step_by(1.0),
);
});
ui.horizontal(|ui| {
ui.label("Strength:");
ui.add(
egui::Slider::new(
&mut shared.raster_settings.liquify_strength,
0.01_f32..=1.0,
)
.step_by(0.01),
);
});
ui.small("Enter to commit · Escape to cancel");
}
Tool::Polygon => {
// Number of sides
ui.horizontal(|ui| {
@ -461,6 +540,22 @@ impl InfopanelPane {
});
}
Tool::Gradient if active_is_raster => {
ui.horizontal(|ui| {
ui.label("Opacity:");
ui.add(egui::Slider::new(
&mut shared.raster_settings.gradient_opacity,
0.0_f32..=1.0,
).custom_formatter(|v, _| format!("{:.0}%", v * 100.0)));
});
ui.add_space(4.0);
gradient_stop_editor(
ui,
&mut shared.raster_settings.gradient,
&mut self.selected_tool_gradient_stop,
);
}
_ => {}
}
@ -680,28 +775,72 @@ impl InfopanelPane {
self.shape_section_open = true;
ui.add_space(4.0);
// Fill color
// Fill — determine current fill type
let has_gradient = matches!(&info.fill_gradient, Some(Some(_)));
let has_solid = matches!(&info.fill_color, Some(Some(_)));
let fill_is_none = matches!(&info.fill_color, Some(None))
&& matches!(&info.fill_gradient, Some(None));
let fill_mixed = info.fill_color.is_none() && info.fill_gradient.is_none();
// Fill type toggle row
ui.horizontal(|ui| {
ui.label("Fill:");
match info.fill_color {
Some(Some(color)) => {
let mut rgba = [color.r, color.g, color.b, color.a];
if ui.color_edit_button_srgba_unmultiplied(&mut rgba).changed() {
let new_color = ShapeColor::rgba(rgba[0], rgba[1], rgba[2], rgba[3]);
let action = SetShapePropertiesAction::set_fill_color(
layer_id, time, face_ids.clone(), Some(new_color),
if fill_mixed {
ui.label("--");
} else {
if ui.selectable_label(fill_is_none, "None").clicked() && !fill_is_none {
let action = SetFillPaintAction::solid(
layer_id, time, face_ids.clone(), None,
);
shared.pending_actions.push(Box::new(action));
}
if ui.selectable_label(has_solid || (!has_gradient && !fill_is_none), "Solid").clicked() && !has_solid {
// Switch to solid: use existing color or default to black
let color = info.fill_color.flatten()
.unwrap_or(ShapeColor::rgba(0, 0, 0, 255));
let action = SetFillPaintAction::solid(
layer_id, time, face_ids.clone(), Some(color),
);
shared.pending_actions.push(Box::new(action));
}
if ui.selectable_label(has_gradient, "Gradient").clicked() && !has_gradient {
let grad = info.fill_gradient.clone().flatten()
.unwrap_or_default();
let action = SetFillPaintAction::gradient(
layer_id, time, face_ids.clone(), Some(grad),
);
shared.pending_actions.push(Box::new(action));
}
}
Some(None) => {
ui.label("None");
}
None => {
ui.label("--");
}
});
// Solid fill color editor
if !fill_mixed && has_solid {
if let Some(Some(color)) = info.fill_color {
ui.horizontal(|ui| {
let mut rgba = [color.r, color.g, color.b, color.a];
if ui.color_edit_button_srgba_unmultiplied(&mut rgba).changed() {
let new_color = ShapeColor::rgba(rgba[0], rgba[1], rgba[2], rgba[3]);
let action = SetFillPaintAction::solid(
layer_id, time, face_ids.clone(), Some(new_color),
);
shared.pending_actions.push(Box::new(action));
}
});
}
}
// Gradient fill editor
if !fill_mixed && has_gradient {
if let Some(Some(mut grad)) = info.fill_gradient.clone() {
if gradient_stop_editor(ui, &mut grad, &mut self.selected_shape_gradient_stop) {
let action = SetFillPaintAction::gradient(
layer_id, time, face_ids.clone(), Some(grad),
);
shared.pending_actions.push(Box::new(action));
}
}
}
// Stroke color
ui.horizontal(|ui| {

View File

@ -68,6 +68,7 @@ pub enum WebcamRecordCommand {
pub mod toolbar;
pub mod stage;
pub mod gradient_editor;
pub mod timeline;
pub mod infopanel;
pub mod outliner;

View File

@ -0,0 +1,137 @@
// GPU gradient fill shader.
//
// Reads the anchor canvas (before_pixels), composites a gradient over it, and
// writes the result to the display canvas. All color values in the canvas are
// linear premultiplied RGBA. The stop colors passed via `stops` are linear
// straight-alpha [0..1] (sRGBlinear conversion is done on the CPU).
//
// Dispatch: ceil(canvas_w / 8) × ceil(canvas_h / 8) × 1
struct Params {
canvas_w: u32,
canvas_h: u32,
start_x: f32,
start_y: f32,
end_x: f32,
end_y: f32,
opacity: f32,
extend_mode: u32, // 0 = Pad, 1 = Reflect, 2 = Repeat
num_stops: u32,
kind: u32, // 0 = Linear, 1 = Radial
_pad1: u32,
_pad2: u32,
}
// 32 bytes per stop (8 × f32), matching `GpuGradientStop` on the Rust side.
struct GradientStop {
position: f32,
r: f32, // linear [0..1], straight-alpha
g: f32,
b: f32,
a: f32,
_pad0: f32,
_pad1: f32,
_pad2: f32,
}
@group(0) @binding(0) var<uniform> params: Params;
@group(0) @binding(1) var src: texture_2d<f32>;
@group(0) @binding(2) var<storage, read> stops: array<GradientStop>;
@group(0) @binding(3) var dst: texture_storage_2d<rgba8unorm, write>;
fn apply_extend(t: f32) -> f32 {
if params.extend_mode == 0u {
// Pad: clamp to [0, 1]
return clamp(t, 0.0, 1.0);
} else if params.extend_mode == 1u {
// Reflect: 0101...
let t_abs = abs(t);
let period = floor(t_abs);
let frac = t_abs - period;
if (u32(period) & 1u) == 0u {
return frac;
} else {
return 1.0 - frac;
}
} else {
// Repeat: tile [0, 1)
return t - floor(t);
}
}
fn eval_gradient(t: f32) -> vec4<f32> {
let n = params.num_stops;
if n == 0u { return vec4<f32>(0.0); }
let s0 = stops[0];
if t <= s0.position {
return vec4<f32>(s0.r, s0.g, s0.b, s0.a);
}
let sn = stops[n - 1u];
if t >= sn.position {
return vec4<f32>(sn.r, sn.g, sn.b, sn.a);
}
for (var i = 0u; i < n - 1u; i++) {
let sa = stops[i];
let sb = stops[i + 1u];
if t >= sa.position && t <= sb.position {
let span = sb.position - sa.position;
let f = select(0.0, (t - sa.position) / span, span > 0.0001);
return mix(
vec4<f32>(sa.r, sa.g, sa.b, sa.a),
vec4<f32>(sb.r, sb.g, sb.b, sb.a),
f,
);
}
}
return vec4<f32>(sn.r, sn.g, sn.b, sn.a);
}
@compute @workgroup_size(8, 8)
fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
if gid.x >= params.canvas_w || gid.y >= params.canvas_h { return; }
// Anchor pixel (linear premultiplied RGBA).
let src_px = textureLoad(src, vec2<i32>(i32(gid.x), i32(gid.y)), 0);
let dx = params.end_x - params.start_x;
let dy = params.end_y - params.start_y;
let px = f32(gid.x) + 0.5;
let py = f32(gid.y) + 0.5;
var t_raw: f32 = 0.0;
if params.kind == 1u {
// Radial: center at start point, radius = |end-start|.
let radius = sqrt(dx * dx + dy * dy);
if radius >= 0.5 {
let pdx = px - params.start_x;
let pdy = py - params.start_y;
t_raw = sqrt(pdx * pdx + pdy * pdy) / radius;
}
} else {
// Linear: project pixel centre onto gradient axis (start end).
let len2 = dx * dx + dy * dy;
if len2 >= 1.0 {
let fx = px - params.start_x;
let fy = py - params.start_y;
t_raw = (fx * dx + fy * dy) / len2;
}
}
let t = apply_extend(t_raw);
let grad = eval_gradient(t); // straight-alpha linear RGBA
// Effective alpha: gradient alpha × tool opacity.
let a = grad.a * params.opacity;
// Alpha-over composite.
// src_px.rgb is premultiplied (= straight_rgb * src_a).
// Output is also premultiplied.
let out_a = a + src_px.a * (1.0 - a);
let out_rgb = grad.rgb * a + src_px.rgb * (1.0 - a);
textureStore(dst, vec2<i32>(i32(gid.x), i32(gid.y)), vec4<f32>(out_rgb, out_a));
}

View File

@ -0,0 +1,92 @@
// GPU liquify-brush shader.
//
// Updates a per-pixel displacement map (array of vec2f) for one brush step.
// Each pixel within the brush radius receives a displacement contribution
// weighted by a Gaussian falloff.
//
// Modes:
// 0 = Push displace in brush-drag direction (dx, dy)
// 1 = Pucker pull toward brush center
// 2 = Bloat push away from brush center
// 3 = Smooth blend toward average of 4 cardinal neighbours
// 4 = Reconstruct blend toward zero (gradually undo)
//
// Dispatch: ceil((2*radius+1) / 8) × ceil((2*radius+1) / 8) × 1
// The CPU clips invocation IDs to the valid map range.
struct Params {
cx: f32, // brush center x (canvas pixels)
cy: f32, // brush center y
radius: f32, // brush radius (canvas pixels)
strength: f32, // effect strength [0..1]
dx: f32, // push direction x (normalised by caller, Push mode only)
dy: f32, // push direction y
mode: u32, // 0=Push 1=Pucker 2=Bloat 3=Smooth 4=Reconstruct
map_w: u32,
map_h: u32,
_pad0: u32,
_pad1: u32,
}
@group(0) @binding(0) var<uniform> params: Params;
@group(0) @binding(1) var<storage, read_write> disp: array<vec2f>;
@compute @workgroup_size(8, 8)
fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
// Offset invocation into the brush bounding box so gid(0,0) = (cx-r, cy-r).
let base_x = floor(params.cx - params.radius);
let base_y = floor(params.cy - params.radius);
let px = base_x + f32(gid.x);
let py = base_y + f32(gid.y);
// Clip to displacement map bounds.
if px < 0.0 || py < 0.0 { return; }
let map_x = u32(px);
let map_y = u32(py);
if map_x >= params.map_w || map_y >= params.map_h { return; }
let ddx = px - params.cx;
let ddy = py - params.cy;
let dist2 = ddx * ddx + ddy * ddy;
let r2 = params.radius * params.radius;
if dist2 > r2 { return; }
// Gaussian influence: 1 at center, ~0.01 at edge (sigma = radius/2.15)
let influence = params.strength * exp(-dist2 / (r2 * 0.2));
let idx = map_y * params.map_w + map_x;
var d = disp[idx];
switch params.mode {
case 0u: { // Push
d = d + vec2f(params.dx, params.dy) * influence * params.radius;
}
case 1u: { // Pucker toward center
let len = sqrt(dist2) + 0.0001;
d = d + vec2f(-ddx / len, -ddy / len) * influence * params.radius;
}
case 2u: { // Bloat away from center
let len = sqrt(dist2) + 0.0001;
d = d + vec2f(ddx / len, ddy / len) * influence * params.radius;
}
case 3u: { // Smooth blend toward average of 4 neighbours
let xi = i32(map_x);
let yi = i32(map_y);
let w = i32(params.map_w);
let h = i32(params.map_h);
let l = disp[u32(clamp(yi, 0, h-1)) * params.map_w + u32(clamp(xi - 1, 0, w-1))];
let r = disp[u32(clamp(yi, 0, h-1)) * params.map_w + u32(clamp(xi + 1, 0, w-1))];
let u = disp[u32(clamp(yi - 1, 0, h-1)) * params.map_w + u32(clamp(xi, 0, w-1))];
let dn = disp[u32(clamp(yi + 1, 0, h-1)) * params.map_w + u32(clamp(xi, 0, w-1))];
let avg = (l + r + u + dn) * 0.25;
d = mix(d, avg, influence * 0.5);
}
case 4u: { // Reconstruct blend toward zero
d = mix(d, vec2f(0.0), influence * 0.5);
}
default: {}
}
disp[idx] = d;
}

View File

@ -0,0 +1,103 @@
// GPU warp-apply shader.
//
// Two modes selected by grid_cols / grid_rows:
//
// grid_cols == 0 (Liquify / per-pixel mode)
// disp[] is a full canvas-sized array<vec2f>. Each pixel reads its own entry.
//
// grid_cols > 0 (Warp control-point mode)
// disp[] contains only grid_cols * grid_rows vec2f displacements (one per
// control point). The shader bilinearly interpolates them so the CPU never
// needs to build or upload the full per-pixel buffer.
//
// Dispatch: ceil(dst_w / 8) × ceil(dst_h / 8) × 1
struct Params {
src_w: u32,
src_h: u32,
dst_w: u32,
dst_h: u32,
grid_cols: u32, // 0 = per-pixel mode
grid_rows: u32,
_pad0: u32,
_pad1: u32,
}
@group(0) @binding(0) var<uniform> params: Params;
@group(0) @binding(1) var src: texture_2d<f32>;
@group(0) @binding(2) var<storage, read> disp: array<vec2f>;
@group(0) @binding(3) var dst: texture_storage_2d<rgba8unorm, write>;
// Manual bilinear sample with clamp-to-edge (textureSample forbidden in compute shaders).
fn bilinear_sample(px: f32, py: f32) -> vec4<f32> {
let sw = i32(params.src_w);
let sh = i32(params.src_h);
let ix = i32(floor(px - 0.5));
let iy = i32(floor(py - 0.5));
let fx = fract(px - 0.5);
let fy = fract(py - 0.5);
let x0 = clamp(ix, 0, sw - 1);
let x1 = clamp(ix + 1, 0, sw - 1);
let y0 = clamp(iy, 0, sh - 1);
let y1 = clamp(iy + 1, 0, sh - 1);
let s00 = textureLoad(src, vec2<i32>(x0, y0), 0);
let s10 = textureLoad(src, vec2<i32>(x1, y0), 0);
let s01 = textureLoad(src, vec2<i32>(x0, y1), 0);
let s11 = textureLoad(src, vec2<i32>(x1, y1), 0);
return mix(mix(s00, s10, fx), mix(s01, s11, fx), fy);
}
// Bilinearly interpolate the control-point displacement grid.
fn grid_displacement(px: u32, py: u32) -> vec2f {
let cols = params.grid_cols;
let rows = params.grid_rows;
// Normalised position in grid space [0 .. cols-1] × [0 .. rows-1].
let gx = f32(px) / f32(params.dst_w - 1u) * f32(cols - 1u);
let gy = f32(py) / f32(params.dst_h - 1u) * f32(rows - 1u);
let col0 = u32(floor(gx));
let row0 = u32(floor(gy));
let col1 = min(col0 + 1u, cols - 1u);
let row1 = min(row0 + 1u, rows - 1u);
let fx = gx - floor(gx);
let fy = gy - floor(gy);
let d00 = disp[row0 * cols + col0];
let d10 = disp[row0 * cols + col1];
let d01 = disp[row1 * cols + col0];
let d11 = disp[row1 * cols + col1];
return d00 * (1.0 - fx) * (1.0 - fy)
+ d10 * fx * (1.0 - fy)
+ d01 * (1.0 - fx) * fy
+ d11 * fx * fy;
}
@compute @workgroup_size(8, 8)
fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
if gid.x >= params.dst_w || gid.y >= params.dst_h { return; }
var d: vec2f;
if params.grid_cols > 0u {
d = grid_displacement(gid.x, gid.y);
} else {
d = disp[gid.y * params.dst_w + gid.x];
}
let sx = f32(gid.x) + d.x;
let sy = f32(gid.y) + d.y;
var color: vec4<f32>;
if sx < 0.0 || sy < 0.0 || sx >= f32(params.src_w) || sy >= f32(params.src_h) {
color = vec4<f32>(0.0);
} else {
color = bilinear_sample(sx + 0.5, sy + 0.5);
}
textureStore(dst, vec2<i32>(i32(gid.x), i32(gid.y)), color);
}

File diff suppressed because it is too large Load Diff

View File

@ -99,6 +99,39 @@ pub struct RasterToolSettings {
// --- Marquee select shape ---
/// Whether the rectangular select tool draws a rect or an ellipse.
pub select_shape: SelectionShape,
// --- Warp ---
pub warp_grid_cols: u32,
pub warp_grid_rows: u32,
// --- Liquify ---
pub liquify_mode: LiquifyMode,
pub liquify_radius: f32,
pub liquify_strength: f32,
// --- Gradient ---
pub gradient: lightningbeam_core::gradient::ShapeGradient,
pub gradient_opacity: f32,
}
/// Brush mode for the Liquify tool.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum LiquifyMode {
#[default]
Push,
Pucker,
Bloat,
Smooth,
Reconstruct,
}
impl LiquifyMode {
pub fn as_u32(self) -> u32 {
match self {
LiquifyMode::Push => 0,
LiquifyMode::Pucker => 1,
LiquifyMode::Bloat => 2,
LiquifyMode::Smooth => 3,
LiquifyMode::Reconstruct => 4,
}
}
}
/// Shape mode for the rectangular-select tool.
@ -168,6 +201,13 @@ impl Default for RasterToolSettings {
fill_threshold_mode: FillThresholdMode::Absolute,
quick_select_radius: 20.0,
select_shape: SelectionShape::Rect,
warp_grid_cols: 4,
warp_grid_rows: 4,
liquify_mode: LiquifyMode::Push,
liquify_radius: 50.0,
liquify_strength: 0.5,
gradient: lightningbeam_core::gradient::ShapeGradient::default(),
gradient_opacity: 1.0,
}
}
}