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