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_stroke;
|
||||||
pub mod raster_fill;
|
pub mod raster_fill;
|
||||||
pub mod move_layer;
|
pub mod move_layer;
|
||||||
|
pub mod set_fill_paint;
|
||||||
|
|
||||||
pub use add_clip_instance::AddClipInstanceAction;
|
pub use add_clip_instance::AddClipInstanceAction;
|
||||||
pub use add_effect::AddEffectAction;
|
pub use add_effect::AddEffectAction;
|
||||||
|
|
@ -66,3 +67,4 @@ pub use group_layers::GroupLayersAction;
|
||||||
pub use raster_stroke::RasterStrokeAction;
|
pub use raster_stroke::RasterStrokeAction;
|
||||||
pub use raster_fill::RasterFillAction;
|
pub use raster_fill::RasterFillAction;
|
||||||
pub use move_layer::MoveLayerAction;
|
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 image_fill: Option<uuid::Uuid>,
|
||||||
pub fill_rule: FillRule,
|
pub fill_rule: FillRule,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
pub gradient_fill: Option<crate::gradient::ShapeGradient>,
|
||||||
|
#[serde(default)]
|
||||||
pub deleted: bool,
|
pub deleted: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -241,6 +243,7 @@ impl Dcel {
|
||||||
fill_color: None,
|
fill_color: None,
|
||||||
image_fill: None,
|
image_fill: None,
|
||||||
fill_rule: FillRule::NonZero,
|
fill_rule: FillRule::NonZero,
|
||||||
|
gradient_fill: None,
|
||||||
deleted: false,
|
deleted: false,
|
||||||
};
|
};
|
||||||
let debug_recorder = if std::env::var("DAW_DCEL_RECORD").is_ok() {
|
let debug_recorder = if std::env::var("DAW_DCEL_RECORD").is_ok() {
|
||||||
|
|
@ -372,6 +375,7 @@ impl Dcel {
|
||||||
fill_color: None,
|
fill_color: None,
|
||||||
image_fill: None,
|
image_fill: None,
|
||||||
fill_rule: FillRule::NonZero,
|
fill_rule: FillRule::NonZero,
|
||||||
|
gradient_fill: None,
|
||||||
deleted: false,
|
deleted: false,
|
||||||
};
|
};
|
||||||
if let Some(idx) = self.free_faces.pop() {
|
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_settings;
|
||||||
pub mod brush_engine;
|
pub mod brush_engine;
|
||||||
pub mod raster_draw;
|
pub mod raster_draw;
|
||||||
|
pub mod gradient;
|
||||||
|
|
||||||
#[cfg(debug_assertions)]
|
#[cfg(debug_assertions)]
|
||||||
pub mod test_mode;
|
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).
|
/// and encoded back to PNG on save. An empty Vec means the canvas is blank (transparent).
|
||||||
#[serde(skip)]
|
#[serde(skip)]
|
||||||
pub raw_pixels: Vec<u8>,
|
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 {
|
impl RasterKeyframe {
|
||||||
/// Returns true when the pixel buffer has been initialised (non-blank).
|
/// Returns true when the pixel buffer has been initialised (non-blank).
|
||||||
pub fn has_pixels(&self) -> bool {
|
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.
|
/// Render a DCEL to a Vello scene.
|
||||||
///
|
///
|
||||||
/// Walks faces for fills and edges for strokes.
|
/// Walks faces for fills and edges for strokes.
|
||||||
|
|
@ -942,7 +960,7 @@ pub fn render_dcel(
|
||||||
if face.deleted || i == 0 {
|
if face.deleted || i == 0 {
|
||||||
continue; // Skip unbounded face and deleted faces
|
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
|
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 !filled {
|
||||||
if let Some(fill_color) = &face.fill_color {
|
if let Some(fill_color) = &face.fill_color {
|
||||||
let alpha = ((fill_color.a as f32 / 255.0) * opacity_f32 * 255.0) as u8;
|
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
|
// GpuBrushEngine
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
@ -301,8 +786,18 @@ pub struct GpuBrushEngine {
|
||||||
/// Lazily created on first raster transform use.
|
/// Lazily created on first raster transform use.
|
||||||
transform_pipeline: Option<RasterTransformPipeline>,
|
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.
|
/// Canvas texture pairs keyed by keyframe UUID.
|
||||||
pub canvases: HashMap<Uuid, CanvasPair>,
|
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.
|
/// CPU-side parameters uniform for the compute shader.
|
||||||
|
|
@ -404,8 +899,12 @@ impl GpuBrushEngine {
|
||||||
Self {
|
Self {
|
||||||
compute_pipeline,
|
compute_pipeline,
|
||||||
compute_bg_layout,
|
compute_bg_layout,
|
||||||
transform_pipeline: None,
|
transform_pipeline: None,
|
||||||
canvases: HashMap::new(),
|
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 eframe::egui::{self, DragValue, Ui};
|
||||||
use lightningbeam_core::brush_settings::{bundled_brushes, BrushSettings};
|
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::layer::{AnyLayer, LayerTrait};
|
||||||
use lightningbeam_core::selection::FocusSelection;
|
use lightningbeam_core::selection::FocusSelection;
|
||||||
use lightningbeam_core::shape::ShapeColor;
|
use lightningbeam_core::shape::ShapeColor;
|
||||||
use lightningbeam_core::tool::{SimplifyMode, Tool};
|
use lightningbeam_core::tool::{SimplifyMode, Tool};
|
||||||
use super::{NodePath, PaneRenderer, SharedPaneState};
|
use super::{NodePath, PaneRenderer, SharedPaneState};
|
||||||
|
use super::gradient_editor::gradient_stop_editor;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
/// Info panel pane state
|
/// Info panel pane state
|
||||||
|
|
@ -36,6 +38,10 @@ pub struct InfopanelPane {
|
||||||
eraser_picker_expanded: bool,
|
eraser_picker_expanded: bool,
|
||||||
/// Cached preview textures, one per preset (populated lazily).
|
/// Cached preview textures, one per preset (populated lazily).
|
||||||
brush_preview_textures: Vec<egui::TextureHandle>,
|
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 {
|
impl InfopanelPane {
|
||||||
|
|
@ -50,6 +56,8 @@ impl InfopanelPane {
|
||||||
selected_eraser_preset: default_eraser_idx,
|
selected_eraser_preset: default_eraser_idx,
|
||||||
eraser_picker_expanded: false,
|
eraser_picker_expanded: false,
|
||||||
brush_preview_textures: Vec::new(),
|
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)
|
// Shape property values (None = mixed)
|
||||||
fill_color: Option<Option<ShapeColor>>,
|
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_color: Option<Option<ShapeColor>>,
|
||||||
stroke_width: Option<f64>,
|
stroke_width: Option<f64>,
|
||||||
}
|
}
|
||||||
|
|
@ -76,6 +86,7 @@ impl Default for SelectionInfo {
|
||||||
dcel_count: 0,
|
dcel_count: 0,
|
||||||
layer_id: None,
|
layer_id: None,
|
||||||
fill_color: None,
|
fill_color: None,
|
||||||
|
fill_gradient: None,
|
||||||
stroke_color: None,
|
stroke_color: None,
|
||||||
stroke_width: None,
|
stroke_width: None,
|
||||||
}
|
}
|
||||||
|
|
@ -138,21 +149,32 @@ impl InfopanelPane {
|
||||||
// Gather fill properties from selected faces
|
// Gather fill properties from selected faces
|
||||||
let mut first_fill_color: Option<Option<ShapeColor>> = None;
|
let mut first_fill_color: Option<Option<ShapeColor>> = None;
|
||||||
let mut fill_color_mixed = false;
|
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() {
|
for &fid in shared.selection.selected_faces() {
|
||||||
let face = dcel.face(fid);
|
let face = dcel.face(fid);
|
||||||
let fc = face.fill_color;
|
let fc = face.fill_color;
|
||||||
|
let fg = face.gradient_fill.clone();
|
||||||
|
|
||||||
match first_fill_color {
|
match first_fill_color {
|
||||||
None => first_fill_color = Some(fc),
|
None => first_fill_color = Some(fc),
|
||||||
Some(prev) if prev != fc => fill_color_mixed = true,
|
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 {
|
if !fill_color_mixed {
|
||||||
info.fill_color = first_fill_color;
|
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!(
|
|| is_raster_select || is_raster_shape || matches!(
|
||||||
tool,
|
tool,
|
||||||
Tool::PaintBucket | Tool::RegionSelect | Tool::MagicWand | Tool::QuickSelect
|
Tool::PaintBucket | Tool::RegionSelect | Tool::MagicWand | Tool::QuickSelect
|
||||||
|
| Tool::Warp | Tool::Liquify | Tool::Gradient
|
||||||
);
|
);
|
||||||
|
|
||||||
if !has_options {
|
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 => {
|
Tool::Polygon => {
|
||||||
// Number of sides
|
// Number of sides
|
||||||
ui.horizontal(|ui| {
|
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;
|
self.shape_section_open = true;
|
||||||
ui.add_space(4.0);
|
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.horizontal(|ui| {
|
||||||
ui.label("Fill:");
|
ui.label("Fill:");
|
||||||
match info.fill_color {
|
if fill_mixed {
|
||||||
Some(Some(color)) => {
|
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];
|
let mut rgba = [color.r, color.g, color.b, color.a];
|
||||||
if ui.color_edit_button_srgba_unmultiplied(&mut rgba).changed() {
|
if ui.color_edit_button_srgba_unmultiplied(&mut rgba).changed() {
|
||||||
let new_color = ShapeColor::rgba(rgba[0], rgba[1], rgba[2], rgba[3]);
|
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),
|
layer_id, time, face_ids.clone(), Some(new_color),
|
||||||
);
|
);
|
||||||
shared.pending_actions.push(Box::new(action));
|
shared.pending_actions.push(Box::new(action));
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
Some(None) => {
|
}
|
||||||
ui.label("None");
|
}
|
||||||
}
|
|
||||||
None => {
|
// Gradient fill editor
|
||||||
ui.label("--");
|
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
|
// Stroke color
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
|
|
|
||||||
|
|
@ -68,6 +68,7 @@ pub enum WebcamRecordCommand {
|
||||||
|
|
||||||
pub mod toolbar;
|
pub mod toolbar;
|
||||||
pub mod stage;
|
pub mod stage;
|
||||||
|
pub mod gradient_editor;
|
||||||
pub mod timeline;
|
pub mod timeline;
|
||||||
pub mod infopanel;
|
pub mod infopanel;
|
||||||
pub mod outliner;
|
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 ---
|
// --- Marquee select shape ---
|
||||||
/// Whether the rectangular select tool draws a rect or an ellipse.
|
/// Whether the rectangular select tool draws a rect or an ellipse.
|
||||||
pub select_shape: SelectionShape,
|
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.
|
/// Shape mode for the rectangular-select tool.
|
||||||
|
|
@ -168,6 +201,13 @@ impl Default for RasterToolSettings {
|
||||||
fill_threshold_mode: FillThresholdMode::Absolute,
|
fill_threshold_mode: FillThresholdMode::Absolute,
|
||||||
quick_select_radius: 20.0,
|
quick_select_radius: 20.0,
|
||||||
select_shape: SelectionShape::Rect,
|
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