Compare commits

..

No commits in common. "89721d4c0efe91c9f03bc63ca03c8e91de0196db" and "bc7d997cff972c677d1b3d0dbafba0d415f3f281" have entirely different histories.

66 changed files with 1433 additions and 13387 deletions

View File

@ -1,27 +1,3 @@
# 1.0.2-alpha:
Changes:
- All vector shapes on a layer go into a unified shape rather than separate shapes
- Keyboard shortcuts are now user-configurable
- Added webcam support in video editor
- Background can now be transparent
- Video thumbnails are now displayed on the clip
- Virtual keyboard, piano roll and node editor now have a quick switcher
- Add electric guitar preset
- Layers can now be grouped
- Layers can be reordered by dragging
- Added VU meters to audio layers and mix
- Added raster image editing
- Added brush, airbrush, dodge/burn, sponge, pattern stamp, healing brush, clone stamp, blur/sharpen, magic wand and quick select tools
- Added support for MyPaint .myb brushes
- UI now uses CSS styling to support future user styles
- Added image export
Bugfixes:
- Toolbar now only shows tools that can be used on the current layer
- Fix NAM model loading
- Fix menu width and mouse following
- Export dialog now remembers the previous export filename
# 1.0.1-alpha: # 1.0.1-alpha:
Changes: Changes:
- Added real-time amp simulation via NAM - Added real-time amp simulation via NAM

View File

@ -32,9 +32,7 @@ pub mod region_split;
pub mod toggle_group_expansion; pub mod toggle_group_expansion;
pub mod group_layers; pub mod group_layers;
pub mod raster_stroke; pub mod raster_stroke;
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;
@ -65,6 +63,4 @@ pub use region_split::RegionSplitAction;
pub use toggle_group_expansion::ToggleGroupExpansionAction; pub use toggle_group_expansion::ToggleGroupExpansionAction;
pub use group_layers::GroupLayersAction; pub use group_layers::GroupLayersAction;
pub use raster_stroke::RasterStrokeAction; pub use raster_stroke::RasterStrokeAction;
pub use raster_fill::RasterFillAction;
pub use move_layer::MoveLayerAction; pub use move_layer::MoveLayerAction;
pub use set_fill_paint::SetFillPaintAction;

View File

@ -1,66 +0,0 @@
//! Raster flood-fill action — records and undoes a paint bucket fill on a RasterLayer.
use crate::action::Action;
use crate::document::Document;
use crate::layer::AnyLayer;
use uuid::Uuid;
pub struct RasterFillAction {
layer_id: Uuid,
time: f64,
buffer_before: Vec<u8>,
buffer_after: Vec<u8>,
width: u32,
height: u32,
name: String,
}
impl RasterFillAction {
pub fn new(
layer_id: Uuid,
time: f64,
buffer_before: Vec<u8>,
buffer_after: Vec<u8>,
width: u32,
height: u32,
) -> Self {
Self { layer_id, time, buffer_before, buffer_after, width, height, name: "Flood fill".to_string() }
}
pub fn with_description(mut self, name: &str) -> Self {
self.name = name.to_string();
self
}
}
impl Action for RasterFillAction {
fn execute(&mut self, document: &mut Document) -> Result<(), String> {
let layer = document.get_layer_mut(&self.layer_id)
.ok_or_else(|| format!("Layer {} not found", self.layer_id))?;
let raster = match layer {
AnyLayer::Raster(rl) => rl,
_ => return Err("Not a raster layer".to_string()),
};
let kf = raster.ensure_keyframe_at(self.time, self.width, self.height);
kf.raw_pixels = self.buffer_after.clone();
kf.texture_dirty = true;
Ok(())
}
fn rollback(&mut self, document: &mut Document) -> Result<(), String> {
let layer = document.get_layer_mut(&self.layer_id)
.ok_or_else(|| format!("Layer {} not found", self.layer_id))?;
let raster = match layer {
AnyLayer::Raster(rl) => rl,
_ => return Err("Not a raster layer".to_string()),
};
let kf = raster.ensure_keyframe_at(self.time, self.width, self.height);
kf.raw_pixels = self.buffer_before.clone();
kf.texture_dirty = true;
Ok(())
}
fn description(&self) -> String {
self.name.clone()
}
}

View File

@ -49,14 +49,12 @@ impl Action for RasterStrokeAction {
fn execute(&mut self, document: &mut Document) -> Result<(), String> { fn execute(&mut self, document: &mut Document) -> Result<(), String> {
let kf = get_keyframe_mut(document, &self.layer_id, self.time, self.width, self.height)?; let kf = get_keyframe_mut(document, &self.layer_id, self.time, self.width, self.height)?;
kf.raw_pixels = self.buffer_after.clone(); kf.raw_pixels = self.buffer_after.clone();
kf.texture_dirty = true;
Ok(()) Ok(())
} }
fn rollback(&mut self, document: &mut Document) -> Result<(), String> { fn rollback(&mut self, document: &mut Document) -> Result<(), String> {
let kf = get_keyframe_mut(document, &self.layer_id, self.time, self.width, self.height)?; let kf = get_keyframe_mut(document, &self.layer_id, self.time, self.width, self.height)?;
kf.raw_pixels = self.buffer_before.clone(); kf.raw_pixels = self.buffer_before.clone();
kf.texture_dirty = true;
Ok(()) Ok(())
} }

View File

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

View File

@ -15,26 +15,17 @@
//! The GPU compute shader (`brush_dab.wgsl`) is the authoritative implementation. //! The GPU compute shader (`brush_dab.wgsl`) is the authoritative implementation.
//! //!
//! ### Dab placement //! ### Dab placement
//! Follows the libmypaint model: distance-based and time-based contributions are //! Dabs are placed along the stroke polyline at intervals of
//! **summed** into a single `partial_dabs` accumulator. A dab fires whenever the //! `spacing = radius * dabs_per_radius`. Fractional remainder is tracked across
//! accumulator reaches 1.0. //! consecutive calls via `StrokeState`.
//!
//! Rate (dabs per pixel) = dabs_per_actual_radius / actual_radius
//! + dabs_per_basic_radius / base_radius
//! Time contribution added per call = dt × dabs_per_second
//!
//! ### Opacity
//! Matches libmypaint's `opaque_linearize` formula. `dabs_per_pixel` is a fixed
//! brush-level estimate of how many dabs overlap at any pixel:
//!
//! `dabs_per_pixel = 1 + opaque_linearize × ((dabs_per_actual + dabs_per_basic) × 2 - 1)`
//! `per_dab_alpha = 1 - (1 - raw_opacity) ^ (1 / dabs_per_pixel)`
//!
//! With `opaque_linearize = 0` the raw opacity is used directly per dab.
//! //!
//! ### Blending //! ### Blending
//! Normal mode uses the standard "over" operator on premultiplied RGBA. //! Normal mode uses the standard "over" operator on premultiplied RGBA:
//! Erase mode subtracts from destination alpha. //! ```text
//! result_a = opa_a + (1 - opa_a) * bottom_a
//! result_rgb = opa_a * top_rgb + (1 - opa_a) * bottom_rgb
//! ```
//! Erase mode: subtract `opa_a` from the destination alpha and premultiply.
use image::RgbaImage; use image::RgbaImage;
use crate::raster_layer::{RasterBlendMode, StrokeRecord}; use crate::raster_layer::{RasterBlendMode, StrokeRecord};
@ -74,48 +65,20 @@ pub struct GpuDab {
/// Blend mode: 0 = Normal, 1 = Erase, 2 = Smudge /// Blend mode: 0 = Normal, 1 = Erase, 2 = Smudge
pub blend_mode: u32, pub blend_mode: u32,
/// Elliptical dab aspect ratio (1.0 = circle) pub _pad0: u32,
pub elliptical_dab_ratio: f32, pub _pad1: u32,
/// Elliptical dab rotation angle in radians pub _pad2: u32,
pub elliptical_dab_angle: f32,
/// Lock alpha: 0.0 = modify alpha normally, 1.0 = don't modify destination alpha
pub lock_alpha: f32,
} }
/// Transient brush stroke state (tracks position and randomness between segments) /// Transient brush stroke state (tracks partial dab position between segments)
pub struct StrokeState { pub struct StrokeState {
/// Fractional dab accumulator — reaches 1.0 when the next dab should fire. /// Distance along the path already "consumed" toward the next dab (in pixels)
/// Initialised to 1.0 so the very first call always emits at least one dab. pub distance_since_last_dab: f32,
pub partial_dabs: f32,
/// Exponentially-smoothed cursor X for slow_tracking
pub smooth_x: f32,
/// Exponentially-smoothed cursor Y for slow_tracking
pub smooth_y: f32,
/// Whether smooth_x/y have been initialised yet
pub smooth_initialized: bool,
/// xorshift32 seed for jitter and radius variation
pub rng_seed: u32,
/// Accumulated per-dab hue shift
pub color_h_phase: f32,
/// Accumulated per-dab value shift
pub color_v_phase: f32,
/// Accumulated per-dab saturation shift
pub color_s_phase: f32,
} }
impl StrokeState { impl StrokeState {
pub fn new() -> Self { pub fn new() -> Self {
Self { Self { distance_since_last_dab: 0.0 }
// Start at 1.0 so the first call always emits the stroke-start dab.
partial_dabs: 1.0,
smooth_x: 0.0,
smooth_y: 0.0,
smooth_initialized: false,
rng_seed: 0xDEAD_BEEF,
color_h_phase: 0.0,
color_v_phase: 0.0,
color_s_phase: 0.0,
}
} }
} }
@ -123,198 +86,36 @@ impl Default for StrokeState {
fn default() -> Self { Self::new() } fn default() -> Self { Self::new() }
} }
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/// xorshift32 — fast, no-alloc PRNG. Returns a value in [0, 1).
#[inline]
fn xorshift(seed: &mut u32) -> f32 {
let mut s = *seed;
s ^= s << 13;
s ^= s >> 17;
s ^= s << 5;
*seed = s;
(s as f32) / (u32::MAX as f32)
}
/// Box-Muller Gaussian sample with mean 0 and std-dev 1.
/// Consumes two xorshift samples; the second half of the pair is discarded
/// (acceptable for brush jitter which doesn't need correlated pairs).
#[inline]
fn gaussian(seed: &mut u32) -> f32 {
let u1 = xorshift(seed).max(1e-7); // avoid ln(0)
let u2 = xorshift(seed);
(-2.0 * u1.ln()).sqrt() * (2.0 * std::f32::consts::PI * u2).cos()
}
/// Convert linear RGB (not premultiplied) to HSV.
fn rgb_to_hsv(r: f32, g: f32, b: f32) -> (f32, f32, f32) {
let max = r.max(g).max(b);
let min = r.min(g).min(b);
let delta = max - min;
let v = max;
let s = if max > 1e-6 { delta / max } else { 0.0 };
let h = if delta < 1e-6 {
0.0
} else if max == r {
((g - b) / delta).rem_euclid(6.0) / 6.0
} else if max == g {
((b - r) / delta + 2.0) / 6.0
} else {
((r - g) / delta + 4.0) / 6.0
};
(h, s, v)
}
/// Convert HSV to linear RGB.
fn hsv_to_rgb(h: f32, s: f32, v: f32) -> (f32, f32, f32) {
let h6 = h.rem_euclid(1.0) * 6.0;
let i = h6.floor() as i32;
let f = h6 - i as f32;
let p = v * (1.0 - s);
let q = v * (1.0 - s * f);
let t = v * (1.0 - s * (1.0 - f));
match i % 6 {
0 => (v, t, p),
1 => (q, v, p),
2 => (p, v, t),
3 => (p, q, v),
4 => (t, p, v),
_ => (v, p, q),
}
}
// ---------------------------------------------------------------------------
// Per-dab effects helper
// ---------------------------------------------------------------------------
/// Apply per-dab randomness and color-shift effects, matching libmypaint.
///
/// Returns `(ex, ey, radius, opacity, cr, cg, cb)` ready for the dab emitter.
///
/// Opacity uses the `opaque_linearize` formula (same fixed brush-level estimate
/// whether called from the single-point path or the drag path).
///
/// Jitter uses Gaussian noise (matching libmypaint), not uniform.
///
/// Radius jitter applies an opacity correction `× (base_r / jittered_r)²` to
/// keep perceived ink-amount constant as radius varies (matches libmypaint).
fn apply_dab_effects(
state: &mut StrokeState,
bs: &crate::brush_settings::BrushSettings,
x: f32, y: f32,
base_radius: f32, // radius_at_pressure(pressure), before jitter
pressure: f32,
color: [f32; 4],
) -> (f32, f32, f32, f32, f32, f32, f32) {
// ---- Opacity (libmypaint opaque_linearize formula) --------------------
// Estimate average dab overlap per pixel from brush settings (fixed, not
// speed-dependent), then convert stroke-level opacity to per-dab alpha.
let raw_dpp = ((bs.dabs_per_actual_radius + bs.dabs_per_radius) * 2.0).max(1.0);
let dabs_per_pixel = (1.0 + bs.opaque_linearize * (raw_dpp - 1.0)).max(1.0);
let raw_o = bs.opacity_at_pressure(pressure);
let mut opacity = 1.0 - (1.0 - raw_o).powf(1.0 / dabs_per_pixel);
// ---- Radius jitter (Gaussian in log-space, matching libmypaint) -------
let mut radius = base_radius;
if bs.radius_by_random != 0.0 {
let noise = gaussian(&mut state.rng_seed) * bs.radius_by_random;
let jittered_log = bs.radius_log + noise;
radius = jittered_log.exp().clamp(0.5, 500.0);
// Opacity correction: keep ink-amount constant as radius varies.
let alpha_correction = (base_radius / radius).powi(2);
opacity = (opacity * alpha_correction).clamp(0.0, 1.0);
}
// ---- Position jitter + fixed offset (Gaussian, matching libmypaint) ---
let mut ex = x;
let mut ey = y;
if bs.offset_by_random != 0.0 || bs.offset_x != 0.0 || bs.offset_y != 0.0 {
// libmypaint uses base_radius (no-pressure) for the jitter scale.
let base_r_fixed = bs.radius_log.exp();
ex += gaussian(&mut state.rng_seed) * bs.offset_by_random * base_r_fixed
+ bs.offset_x * base_r_fixed;
ey += gaussian(&mut state.rng_seed) * bs.offset_by_random * base_r_fixed
+ bs.offset_y * base_r_fixed;
}
// ---- Per-dab color phase shifts ---------------------------------------
state.color_h_phase += bs.change_color_h;
state.color_v_phase += bs.change_color_v;
state.color_s_phase += bs.change_color_hsv_s;
let (mut cr, mut cg, mut cb) = (color[0], color[1], color[2]);
let ca = color[3];
if ca > 1e-6
&& (bs.change_color_h != 0.0
|| bs.change_color_v != 0.0
|| bs.change_color_hsv_s != 0.0)
{
let (ur, ug, ub) = (cr / ca, cg / ca, cb / ca);
let (mut h, mut s, mut v) = rgb_to_hsv(ur, ug, ub);
h = (h + state.color_h_phase).rem_euclid(1.0);
v = (v + state.color_v_phase).clamp(0.0, 1.0);
s = (s + state.color_s_phase).clamp(0.0, 1.0);
let (r2, g2, b2) = hsv_to_rgb(h, s, v);
cr = r2 * ca;
cg = g2 * ca;
cb = b2 * ca;
}
(ex, ey, radius, opacity.clamp(0.0, 1.0), cr, cg, cb)
}
// ---------------------------------------------------------------------------
// Brush engine
// ---------------------------------------------------------------------------
/// Pure-Rust MyPaint-style Gaussian dab brush engine /// Pure-Rust MyPaint-style Gaussian dab brush engine
pub struct BrushEngine; pub struct BrushEngine;
impl BrushEngine { impl BrushEngine {
/// Compute the list of GPU dabs for a stroke segment. /// Compute the list of GPU dabs for a stroke segment.
/// ///
/// `dt` is the elapsed time in seconds since the previous call for this /// Uses the same dab-spacing logic as [`apply_stroke_with_state`] but produces
/// stroke. Pass `0.0` on the very first call (stroke start). /// [`GpuDab`] structs for upload to the GPU compute pipeline instead of painting
/// into a pixel buffer.
/// ///
/// Follows the libmypaint spacing model: distance-based and time-based /// Also returns the union bounding box of all dabs as `(x0, y0, x1, y1)` in
/// contributions are **summed** in a single `partial_dabs` accumulator. /// integer canvas pixel coordinates (clamped to non-negative values; `x0==i32::MAX`
/// A dab is emitted whenever `partial_dabs` reaches 1.0. /// when the returned Vec is empty).
///
/// Also returns the union bounding box of all dabs as `(x0, y0, x1, y1)`.
pub fn compute_dabs( pub fn compute_dabs(
stroke: &StrokeRecord, stroke: &StrokeRecord,
state: &mut StrokeState, state: &mut StrokeState,
dt: f32,
) -> (Vec<GpuDab>, (i32, i32, i32, i32)) { ) -> (Vec<GpuDab>, (i32, i32, i32, i32)) {
let mut dabs: Vec<GpuDab> = Vec::new(); let mut dabs: Vec<GpuDab> = Vec::new();
let mut bbox = (i32::MAX, i32::MAX, i32::MIN, i32::MIN); let mut bbox = (i32::MAX, i32::MAX, i32::MIN, i32::MIN);
let bs = &stroke.brush_settings;
// Determine blend mode, allowing brush settings to override Normal let blend_mode_u = match stroke.blend_mode {
let base_blend = match stroke.blend_mode {
RasterBlendMode::Normal if bs.eraser > 0.5 => RasterBlendMode::Erase,
RasterBlendMode::Normal if bs.smudge > 0.5 => RasterBlendMode::Smudge,
other => other,
};
let blend_mode_u = match base_blend {
RasterBlendMode::Normal => 0u32, RasterBlendMode::Normal => 0u32,
RasterBlendMode::Erase => 1u32, RasterBlendMode::Erase => 1u32,
RasterBlendMode::Smudge => 2u32, RasterBlendMode::Smudge => 2u32,
RasterBlendMode::CloneStamp => 3u32,
RasterBlendMode::Healing => 4u32,
RasterBlendMode::PatternStamp => 5u32,
RasterBlendMode::DodgeBurn => 6u32,
RasterBlendMode::Sponge => 7u32,
RasterBlendMode::BlurSharpen => 8u32,
}; };
let push_dab = |dabs: &mut Vec<GpuDab>, let push_dab = |dabs: &mut Vec<GpuDab>,
bbox: &mut (i32, i32, i32, i32), bbox: &mut (i32, i32, i32, i32),
x: f32, y: f32, x: f32, y: f32,
radius: f32, opacity: f32, radius: f32, opacity: f32,
cr: f32, cg: f32, cb: f32,
ndx: f32, ndy: f32, smudge_dist: f32| { ndx: f32, ndy: f32, smudge_dist: f32| {
let r_fringe = radius + 1.0; let r_fringe = radius + 1.0;
bbox.0 = bbox.0.min((x - r_fringe).floor() as i32); bbox.0 = bbox.0.min((x - r_fringe).floor() as i32);
@ -323,83 +124,32 @@ impl BrushEngine {
bbox.3 = bbox.3.max((y + r_fringe).ceil() as i32); bbox.3 = bbox.3.max((y + r_fringe).ceil() as i32);
dabs.push(GpuDab { dabs.push(GpuDab {
x, y, radius, x, y, radius,
hardness: bs.hardness, hardness: stroke.brush_settings.hardness,
opacity, opacity,
color_r: cr, color_r: stroke.color[0],
color_g: cg, color_g: stroke.color[1],
color_b: cb, color_b: stroke.color[2],
// Clone stamp: color_r/color_g hold source canvas X/Y, so color_a = 1.0 color_a: stroke.color[3],
// (blend strength is opa_weight × opacity × 1.0 in the shader).
color_a: if base_blend.uses_brush_color() { stroke.color[3] } else { 1.0 },
ndx, ndy, smudge_dist, ndx, ndy, smudge_dist,
blend_mode: blend_mode_u, blend_mode: blend_mode_u,
elliptical_dab_ratio: bs.elliptical_dab_ratio.max(1.0), _pad0: 0, _pad1: 0, _pad2: 0,
elliptical_dab_angle: bs.elliptical_dab_angle.to_radians(),
lock_alpha: bs.lock_alpha,
}); });
}; };
// Time-based accumulation: dt × dabs_per_second contributes to partial_dabs
// regardless of whether the cursor moved.
// Cap dt to 0.1 s to avoid a burst of dabs after a long pause.
let dt_capped = dt.min(0.1);
state.partial_dabs += dt_capped * bs.dabs_per_second;
// ----------------------------------------------------------------
// Single-point path: emit time-based (and stroke-start) dabs.
// The caller is responsible for timing; we just fire whenever
// partial_dabs ≥ 1.0.
// ----------------------------------------------------------------
if stroke.points.len() < 2 { if stroke.points.len() < 2 {
if let Some(pt) = stroke.points.first() { if let Some(pt) = stroke.points.first() {
if !state.smooth_initialized { let r = stroke.brush_settings.radius_at_pressure(pt.pressure);
state.smooth_x = pt.x; let raw_o = stroke.brush_settings.opacity_at_pressure(pt.pressure);
state.smooth_y = pt.y; let o = 1.0 - (1.0 - raw_o).powf(stroke.brush_settings.dabs_per_radius * 0.5);
state.smooth_initialized = true; // Single-tap smudge has no direction — skip (same as CPU engine)
} if !matches!(stroke.blend_mode, RasterBlendMode::Smudge) {
while state.partial_dabs >= 1.0 { push_dab(&mut dabs, &mut bbox, pt.x, pt.y, r, o, 0.0, 0.0, 0.0);
state.partial_dabs -= 1.0;
let base_r = bs.radius_at_pressure(pt.pressure);
let (ex, ey, r, o, cr, cg, cb) = apply_dab_effects(
state, bs, pt.x, pt.y, base_r, pt.pressure, stroke.color,
);
if !matches!(base_blend, RasterBlendMode::Smudge) {
let tp = &stroke.tool_params;
let (cr2, cg2, cb2, ndx2, ndy2) = match base_blend {
RasterBlendMode::CloneStamp | RasterBlendMode::Healing =>
(tp[0], tp[1], 0.0, 0.0, 0.0),
RasterBlendMode::PatternStamp =>
(cr, cg, cb, tp[0], tp[1]),
RasterBlendMode::DodgeBurn | RasterBlendMode::Sponge =>
(tp[0], 0.0, 0.0, 0.0, 0.0),
RasterBlendMode::BlurSharpen =>
(tp[0], 0.0, 0.0, tp[1], 0.0),
_ => (cr, cg, cb, 0.0, 0.0),
};
push_dab(&mut dabs, &mut bbox, ex, ey, r, o, cr2, cg2, cb2,
ndx2, ndy2, 0.0);
}
} }
state.distance_since_last_dab = 0.0;
} }
return (dabs, bbox); return (dabs, bbox);
} }
// ----------------------------------------------------------------
// Drag path: walk the polyline, accumulating partial_dabs from
// both distance-based and time-based contributions.
// ----------------------------------------------------------------
// Track the last smoothed position so that any residual time-based
// dabs can be emitted at the end of the segment walk.
let mut last_smooth_x = state.smooth_x;
let mut last_smooth_y = state.smooth_y;
let mut last_pressure = stroke.points.last()
.map(|p| p.pressure)
.unwrap_or(1.0);
// Fixed base radius (no pressure) used for the basic-radius spacing rate.
let base_radius_fixed = bs.radius_log.exp();
for window in stroke.points.windows(2) { for window in stroke.points.windows(2) {
let p0 = &window[0]; let p0 = &window[0];
let p1 = &window[1]; let p1 = &window[1];
@ -409,143 +159,45 @@ impl BrushEngine {
let seg_len = (dx * dx + dy * dy).sqrt(); let seg_len = (dx * dx + dy * dy).sqrt();
if seg_len < 1e-4 { continue; } if seg_len < 1e-4 { continue; }
last_pressure = p1.pressure;
let mut t = 0.0f32; let mut t = 0.0f32;
while t < 1.0 { while t < 1.0 {
let pressure = p0.pressure + t * (p1.pressure - p0.pressure); let pressure = p0.pressure + t * (p1.pressure - p0.pressure);
let radius_for_rate = bs.radius_at_pressure(pressure); let radius = stroke.brush_settings.radius_at_pressure(pressure);
let spacing = (radius * stroke.brush_settings.dabs_per_radius).max(0.5);
// Dab rate = sum of distance-based contributions (dabs per pixel). let dist_to_next = spacing - state.distance_since_last_dab;
// Matches libmypaint: dabs_per_actual/actual_r + dabs_per_basic/base_r. let seg_t_to_next = (dist_to_next / seg_len).max(0.0);
// For elliptical brushes use the minor-axis radius so dabs connect
// when moving perpendicular to the major axis.
let eff_radius = if bs.elliptical_dab_ratio > 1.001 {
radius_for_rate / bs.elliptical_dab_ratio
} else {
radius_for_rate
};
let rate_actual = if bs.dabs_per_actual_radius > 0.0 {
bs.dabs_per_actual_radius / eff_radius
} else { 0.0 };
let rate_basic = if bs.dabs_per_radius > 0.0 {
bs.dabs_per_radius / base_radius_fixed
} else { 0.0 };
let rate = rate_actual + rate_basic; // dabs per pixel
let remaining = 1.0 - state.partial_dabs;
let pixels_to_next = if rate > 1e-8 { remaining / rate } else { f32::MAX };
let seg_t_to_next = (pixels_to_next / seg_len).max(0.0);
if seg_t_to_next > 1.0 - t { if seg_t_to_next > 1.0 - t {
// Won't reach the next dab within this segment. state.distance_since_last_dab += seg_len * (1.0 - t);
if rate > 1e-8 {
state.partial_dabs += (1.0 - t) * seg_len * rate;
}
break; break;
} }
t += seg_t_to_next; t += seg_t_to_next;
let pressure2 = p0.pressure + t * (p1.pressure - p0.pressure);
// Stroke threshold gating
if pressure2 < bs.stroke_threshold {
state.partial_dabs = 0.0;
continue;
}
let base_r2 = bs.radius_at_pressure(pressure2);
// Slow tracking: exponential position smoothing
let x2 = p0.x + t * dx; let x2 = p0.x + t * dx;
let y2 = p0.y + t * dy; let y2 = p0.y + t * dy;
if !state.smooth_initialized { let pressure2 = p0.pressure + t * (p1.pressure - p0.pressure);
state.smooth_x = x2; state.smooth_y = y2; let radius2 = stroke.brush_settings.radius_at_pressure(pressure2);
state.smooth_initialized = true; let raw_opacity = stroke.brush_settings.opacity_at_pressure(pressure2);
} // Normalize per-dab opacity so dense dabs don't saturate faster than sparse ones.
// spacing_px ≈ 1 / rate (pixels per dab), used as time-constant scale // Formula: per_dab = 1 (1 raw)^(dabs_per_radius / 2)
let spacing_px = if rate > 1e-8 { 1.0 / rate } else { 1.0 }; // Derivation: N = 2/dabs_per_radius dabs cover one full diameter at the centre;
let k = if bs.slow_tracking > 0.0 { // accumulated = 1 (1 per_dab)^N = raw → per_dab = 1 (1raw)^(dabs_per_radius/2)
(-spacing_px / bs.slow_tracking.max(0.1)).exp() let opacity2 = 1.0 - (1.0 - raw_opacity).powf(stroke.brush_settings.dabs_per_radius * 0.5);
} else { 0.0 };
state.smooth_x = state.smooth_x * k + x2 * (1.0 - k);
state.smooth_y = state.smooth_y * k + y2 * (1.0 - k);
last_smooth_x = state.smooth_x;
last_smooth_y = state.smooth_y;
let (sx, sy) = (state.smooth_x, state.smooth_y); if matches!(stroke.blend_mode, RasterBlendMode::Smudge) {
let (ex, ey, radius2, opacity2, cr, cg, cb) = apply_dab_effects(
state, bs, sx, sy, base_r2, pressure2, stroke.color,
);
if matches!(base_blend, RasterBlendMode::Smudge) {
let ndx = dx / seg_len; let ndx = dx / seg_len;
let ndy = dy / seg_len; let ndy = dy / seg_len;
// strength=1.0 → sample from 1 dab back (drag pixels with us). let smudge_dist =
// strength=0.0 → sample from current position (no change). (radius2 * stroke.brush_settings.dabs_per_radius).max(1.0);
// smudge_radius_log is repurposed as a linear [0,1] strength value here.
let smudge_dist = spacing_px * bs.smudge_radius_log.clamp(0.0, 1.0);
push_dab(&mut dabs, &mut bbox, push_dab(&mut dabs, &mut bbox,
ex, ey, radius2, opacity2, cr, cg, cb, x2, y2, radius2, opacity2, ndx, ndy, smudge_dist);
ndx, ndy, smudge_dist);
} else { } else {
let tp = &stroke.tool_params;
let (cr2, cg2, cb2, ndx2, ndy2) = match base_blend {
RasterBlendMode::CloneStamp | RasterBlendMode::Healing =>
(tp[0], tp[1], 0.0, 0.0, 0.0),
RasterBlendMode::PatternStamp =>
(cr, cg, cb, tp[0], tp[1]),
RasterBlendMode::DodgeBurn | RasterBlendMode::Sponge =>
(tp[0], 0.0, 0.0, 0.0, 0.0),
RasterBlendMode::BlurSharpen =>
(tp[0], 0.0, 0.0, tp[1], 0.0),
_ => (cr, cg, cb, 0.0, 0.0),
};
push_dab(&mut dabs, &mut bbox, push_dab(&mut dabs, &mut bbox,
ex, ey, radius2, opacity2, cr2, cg2, cb2, x2, y2, radius2, opacity2, 0.0, 0.0, 0.0);
ndx2, ndy2, 0.0);
} }
state.partial_dabs = 0.0; state.distance_since_last_dab = 0.0;
}
}
// Emit any residual time-based dabs (partial_dabs ≥ 1.0 from the dt
// contribution not consumed by distance-based movement) at the last
// known cursor position.
if state.partial_dabs >= 1.0 && !matches!(base_blend, RasterBlendMode::Smudge) {
// Initialise smooth position if we never entered the segment loop.
if !state.smooth_initialized {
if let Some(pt) = stroke.points.last() {
state.smooth_x = pt.x;
state.smooth_y = pt.y;
state.smooth_initialized = true;
last_smooth_x = state.smooth_x;
last_smooth_y = state.smooth_y;
}
}
while state.partial_dabs >= 1.0 {
state.partial_dabs -= 1.0;
let base_r = bs.radius_at_pressure(last_pressure);
let (ex, ey, r, o, cr, cg, cb) = apply_dab_effects(
state, bs,
last_smooth_x, last_smooth_y,
base_r, last_pressure, stroke.color,
);
let tp = &stroke.tool_params;
let (cr2, cg2, cb2, ndx2, ndy2) = match base_blend {
RasterBlendMode::CloneStamp | RasterBlendMode::Healing =>
(tp[0], tp[1], 0.0, 0.0, 0.0),
RasterBlendMode::PatternStamp =>
(cr, cg, cb, tp[0], tp[1]),
RasterBlendMode::DodgeBurn | RasterBlendMode::Sponge =>
(tp[0], 0.0, 0.0, 0.0, 0.0),
RasterBlendMode::BlurSharpen =>
(tp[0], 0.0, 0.0, tp[1], 0.0),
_ => (cr, cg, cb, 0.0, 0.0),
};
push_dab(&mut dabs, &mut bbox, ex, ey, r, o, cr2, cg2, cb2,
ndx2, ndy2, 0.0);
} }
} }

View File

@ -1,215 +1,63 @@
//! Brush settings for the raster paint engine //! Brush settings for the raster paint engine
//! //!
//! Settings that describe the appearance and behavior of a paint brush. //! Settings that describe the appearance and behavior of a paint brush.
//! Compatible with MyPaint .myb brush file format. //! Compatible with MyPaint .myb brush file format (subset).
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::sync::OnceLock;
/// Settings for a paint brush — mirrors the MyPaint .myb settings schema. /// Settings for a paint brush
///
/// All fields correspond directly to MyPaint JSON keys. Fields marked
/// "parse-only" are stored so that .myb files round-trip cleanly; they will
/// be used when the dynamic-input system is wired up in a future task.
#[derive(Clone, Debug, Serialize, Deserialize)] #[derive(Clone, Debug, Serialize, Deserialize)]
pub struct BrushSettings { pub struct BrushSettings {
// ── Core shape ──────────────────────────────────────────────────────────
/// log(radius) base value; actual radius = exp(radius_log) /// log(radius) base value; actual radius = exp(radius_log)
pub radius_log: f32, pub radius_log: f32,
/// Edge hardness 0.0 (fully soft/gaussian) to 1.0 (hard edge) /// Edge hardness 0.0 (fully soft/gaussian) to 1.0 (hard edge)
pub hardness: f32, pub hardness: f32,
/// Base opacity 0.01.0 /// Base opacity 0.01.0
pub opaque: f32, pub opaque: f32,
/// Additional opacity multiplier (opaque_multiply) /// Dab spacing as fraction of radius (smaller = denser strokes)
pub opaque_multiply: f32,
/// Dabs per basic_radius distance (MyPaint: dabs_per_basic_radius)
pub dabs_per_radius: f32, pub dabs_per_radius: f32,
/// Dabs per actual (pressure-modified) radius distance
pub dabs_per_actual_radius: f32,
// ── Elliptical dab ──────────────────────────────────────────────────────
/// Dab aspect ratio ≥ 1.0 (1.0 = circle, 3.0 = 3:1 ellipse)
pub elliptical_dab_ratio: f32,
/// Elliptical dab rotation angle in degrees (0180)
pub elliptical_dab_angle: f32,
// ── Jitter / offset ─────────────────────────────────────────────────────
/// Random radius variation (log-scale, 0 = none)
pub radius_by_random: f32,
/// Random positional jitter in units of radius
pub offset_by_random: f32,
/// Fixed X offset in units of radius
pub offset_x: f32,
/// Fixed Y offset in units of radius
pub offset_y: f32,
// ── Position tracking ───────────────────────────────────────────────────
/// Slow position tracking — higher = brush lags behind cursor more
pub slow_tracking: f32,
/// Per-dab position tracking smoothing
pub slow_tracking_per_dab: f32,
// ── Color ───────────────────────────────────────────────────────────────
/// HSV hue (0.01.0); usually overridden by stroke color /// HSV hue (0.01.0); usually overridden by stroke color
pub color_h: f32, pub color_h: f32,
/// HSV saturation (0.01.0) /// HSV saturation (0.01.0)
pub color_s: f32, pub color_s: f32,
/// HSV value (0.01.0) /// HSV value (0.01.0)
pub color_v: f32, pub color_v: f32,
/// Per-dab hue shift (accumulates over the stroke)
pub change_color_h: f32,
/// Per-dab HSV value shift
pub change_color_v: f32,
/// Per-dab HSV saturation shift
pub change_color_hsv_s: f32,
/// Per-dab HSL lightness shift
pub change_color_l: f32,
/// Per-dab HSL saturation shift
pub change_color_hsl_s: f32,
// ── Blend ───────────────────────────────────────────────────────────────
/// Lock alpha channel (0 = off, 1 = on — don't modify destination alpha)
pub lock_alpha: f32,
/// Eraser strength (>0.5 activates erase blend when tool mode is Normal)
pub eraser: f32,
// ── Smudge ──────────────────────────────────────────────────────────────
/// Smudge amount (>0.5 activates smudge blend when tool mode is Normal)
pub smudge: f32,
/// How quickly the smudge color updates (0 = instant, 1 = slow)
pub smudge_length: f32,
/// Smudge pickup radius offset (log-scale added to radius_log)
pub smudge_radius_log: f32,
// ── Stroke gating ───────────────────────────────────────────────────────
/// Minimum pressure required to emit dabs (0 = always emit)
pub stroke_threshold: f32,
// ── Pressure dynamics ───────────────────────────────────────────────────
/// How much pressure increases/decreases radius /// How much pressure increases/decreases radius
/// Final radius = exp(radius_log + pressure_radius_gain * pressure)
pub pressure_radius_gain: f32, pub pressure_radius_gain: f32,
/// How much pressure increases/decreases opacity /// How much pressure increases/decreases opacity
/// Final opacity = opaque * (1 + pressure_opacity_gain * (pressure - 0.5))
pub pressure_opacity_gain: f32, pub pressure_opacity_gain: f32,
// ── Parse-only: future input curve system ───────────────────────────────
pub opaque_linearize: f32,
pub anti_aliasing: f32,
pub dabs_per_second: f32,
pub offset_by_speed: f32,
pub offset_by_speed_slowness: f32,
pub speed1_slowness: f32,
pub speed2_slowness: f32,
pub speed1_gamma: f32,
pub speed2_gamma: f32,
pub direction_filter: f32,
pub stroke_duration_log: f32,
pub stroke_holdtime: f32,
pub pressure_gain_log: f32,
pub smudge_transparency: f32,
pub smudge_length_log: f32,
pub smudge_bucket: f32,
pub paint_mode: f32,
pub colorize: f32,
pub posterize: f32,
pub posterize_num: f32,
pub snap_to_pixel: f32,
pub custom_input: f32,
pub custom_input_slowness: f32,
pub gridmap_scale: f32,
pub gridmap_scale_x: f32,
pub gridmap_scale_y: f32,
pub restore_color: f32,
pub offset_angle: f32,
pub offset_angle_asc: f32,
pub offset_angle_view: f32,
pub offset_angle_2: f32,
pub offset_angle_2_asc: f32,
pub offset_angle_2_view: f32,
pub offset_angle_adj: f32,
pub offset_multiplier: f32,
} }
impl BrushSettings { impl BrushSettings {
/// Default soft round brush (smooth Gaussian falloff) /// Default soft round brush (smooth Gaussian falloff)
pub fn default_round_soft() -> Self { pub fn default_round_soft() -> Self {
Self { Self {
radius_log: 2.0, radius_log: 2.0, // radius ≈ 7.4 px
hardness: 0.1, hardness: 0.1,
opaque: 0.8, opaque: 0.8,
opaque_multiply: 0.0, dabs_per_radius: 0.25,
dabs_per_radius: 2.0,
dabs_per_actual_radius: 2.0,
elliptical_dab_ratio: 1.0,
elliptical_dab_angle: 90.0,
radius_by_random: 0.0,
offset_by_random: 0.0,
offset_x: 0.0,
offset_y: 0.0,
slow_tracking: 0.0,
slow_tracking_per_dab: 0.0,
color_h: 0.0, color_h: 0.0,
color_s: 0.0, color_s: 0.0,
color_v: 0.0, color_v: 0.0,
change_color_h: 0.0,
change_color_v: 0.0,
change_color_hsv_s: 0.0,
change_color_l: 0.0,
change_color_hsl_s: 0.0,
lock_alpha: 0.0,
eraser: 0.0,
smudge: 0.0,
smudge_length: 0.5,
smudge_radius_log: 0.0,
stroke_threshold: 0.0,
pressure_radius_gain: 0.5, pressure_radius_gain: 0.5,
pressure_opacity_gain: 1.0, pressure_opacity_gain: 1.0,
opaque_linearize: 0.9,
anti_aliasing: 1.0,
dabs_per_second: 0.0,
offset_by_speed: 0.0,
offset_by_speed_slowness: 1.0,
speed1_slowness: 0.04,
speed2_slowness: 0.8,
speed1_gamma: 4.0,
speed2_gamma: 4.0,
direction_filter: 2.0,
stroke_duration_log: 4.0,
stroke_holdtime: 0.0,
pressure_gain_log: 0.0,
smudge_transparency: 0.0,
smudge_length_log: 0.0,
smudge_bucket: 0.0,
paint_mode: 1.0,
colorize: 0.0,
posterize: 0.0,
posterize_num: 0.05,
snap_to_pixel: 0.0,
custom_input: 0.0,
custom_input_slowness: 0.0,
gridmap_scale: 0.0,
gridmap_scale_x: 1.0,
gridmap_scale_y: 1.0,
restore_color: 0.0,
offset_angle: 0.0,
offset_angle_asc: 0.0,
offset_angle_view: 0.0,
offset_angle_2: 0.0,
offset_angle_2_asc: 0.0,
offset_angle_2_view: 0.0,
offset_angle_adj: 0.0,
offset_multiplier: 0.0,
} }
} }
/// Default hard round brush (sharp edge) /// Default hard round brush (sharp edge)
pub fn default_round_hard() -> Self { pub fn default_round_hard() -> Self {
Self { Self {
radius_log: 2.0,
hardness: 0.9, hardness: 0.9,
opaque: 1.0, opaque: 1.0,
dabs_per_radius: 2.0, dabs_per_radius: 0.2,
color_h: 0.0,
color_s: 0.0,
color_v: 0.0,
pressure_radius_gain: 0.3, pressure_radius_gain: 0.3,
pressure_opacity_gain: 0.8, pressure_opacity_gain: 0.8,
..Self::default_round_soft()
} }
} }
@ -225,10 +73,10 @@ impl BrushSettings {
o.clamp(0.0, 1.0) o.clamp(0.0, 1.0)
} }
/// Parse a MyPaint .myb JSON brush file. /// Parse a MyPaint .myb JSON brush file (subset).
/// ///
/// Reads all known settings from `settings[key].base_value`. /// Reads `radius_logarithmic`, `hardness`, `opaque`, `dabs_per_basic_radius`,
/// Unknown keys are silently ignored for forward compatibility. /// `color_h`, `color_s`, `color_v` from the `settings` key's `base_value` fields.
pub fn from_myb(json: &str) -> Result<Self, String> { pub fn from_myb(json: &str) -> Result<Self, String> {
let v: serde_json::Value = let v: serde_json::Value =
serde_json::from_str(json).map_err(|e| format!("JSON parse error: {e}"))?; serde_json::from_str(json).map_err(|e| format!("JSON parse error: {e}"))?;
@ -244,13 +92,15 @@ impl BrushSettings {
.unwrap_or(default) .unwrap_or(default)
}; };
// Pressure dynamics: approximate from the pressure input curve endpoints // Pressure dynamics: read from the "inputs" mapping of radius/opacity
// For simplicity, look for the pressure input point in radius_logarithmic
let pressure_radius_gain = settings let pressure_radius_gain = settings
.get("radius_logarithmic") .get("radius_logarithmic")
.and_then(|s| s.get("inputs")) .and_then(|s| s.get("inputs"))
.and_then(|inp| inp.get("pressure")) .and_then(|inp| inp.get("pressure"))
.and_then(|pts| pts.as_array()) .and_then(|pts| pts.as_array())
.and_then(|arr| { .and_then(|arr| {
// arr = [[x0,y0],[x1,y1],...] approximate as linear gain at x=1.0
if arr.len() >= 2 { if arr.len() >= 2 {
let y0 = arr[0].get(1)?.as_f64()? as f32; let y0 = arr[0].get(1)?.as_f64()? as f32;
let y1 = arr[arr.len() - 1].get(1)?.as_f64()? as f32; let y1 = arr[arr.len() - 1].get(1)?.as_f64()? as f32;
@ -278,81 +128,15 @@ impl BrushSettings {
.unwrap_or(1.0); .unwrap_or(1.0);
Ok(Self { Ok(Self {
// Core shape
radius_log: read_base("radius_logarithmic", 2.0), radius_log: read_base("radius_logarithmic", 2.0),
hardness: read_base("hardness", 0.8).clamp(0.0, 1.0), hardness: read_base("hardness", 0.5).clamp(0.0, 1.0),
opaque: read_base("opaque", 1.0).clamp(0.0, 2.0), opaque: read_base("opaque", 1.0).clamp(0.0, 1.0),
opaque_multiply: read_base("opaque_multiply", 0.0), dabs_per_radius: read_base("dabs_per_basic_radius", 0.25).clamp(0.01, 10.0),
dabs_per_radius: read_base("dabs_per_basic_radius", 0.0).max(0.0),
dabs_per_actual_radius: read_base("dabs_per_actual_radius", 2.0).max(0.0),
// Elliptical dab
elliptical_dab_ratio: read_base("elliptical_dab_ratio", 1.0).max(1.0),
elliptical_dab_angle: read_base("elliptical_dab_angle", 90.0),
// Jitter / offset
radius_by_random: read_base("radius_by_random", 0.0),
offset_by_random: read_base("offset_by_random", 0.0),
offset_x: read_base("offset_x", 0.0),
offset_y: read_base("offset_y", 0.0),
// Tracking
slow_tracking: read_base("slow_tracking", 0.0),
slow_tracking_per_dab: read_base("slow_tracking_per_dab", 0.0),
// Color
color_h: read_base("color_h", 0.0), color_h: read_base("color_h", 0.0),
color_s: read_base("color_s", 0.0), color_s: read_base("color_s", 0.0),
color_v: read_base("color_v", 0.0), color_v: read_base("color_v", 0.0),
change_color_h: read_base("change_color_h", 0.0),
change_color_v: read_base("change_color_v", 0.0),
change_color_hsv_s: read_base("change_color_hsv_s", 0.0),
change_color_l: read_base("change_color_l", 0.0),
change_color_hsl_s: read_base("change_color_hsl_s", 0.0),
// Blend
lock_alpha: read_base("lock_alpha", 0.0).clamp(0.0, 1.0),
eraser: read_base("eraser", 0.0).clamp(0.0, 1.0),
// Smudge
smudge: read_base("smudge", 0.0).clamp(0.0, 1.0),
smudge_length: read_base("smudge_length", 0.5).clamp(0.0, 1.0),
smudge_radius_log: read_base("smudge_radius_log", 0.0),
// Stroke gating
stroke_threshold: read_base("stroke_threshold", 0.0).clamp(0.0, 0.5),
// Pressure dynamics
pressure_radius_gain, pressure_radius_gain,
pressure_opacity_gain, pressure_opacity_gain,
// Parse-only
opaque_linearize: read_base("opaque_linearize", 0.9),
anti_aliasing: read_base("anti_aliasing", 1.0),
dabs_per_second: read_base("dabs_per_second", 0.0),
offset_by_speed: read_base("offset_by_speed", 0.0),
offset_by_speed_slowness: read_base("offset_by_speed_slowness", 1.0),
speed1_slowness: read_base("speed1_slowness", 0.04),
speed2_slowness: read_base("speed2_slowness", 0.8),
speed1_gamma: read_base("speed1_gamma", 4.0),
speed2_gamma: read_base("speed2_gamma", 4.0),
direction_filter: read_base("direction_filter", 2.0),
stroke_duration_log: read_base("stroke_duration_logarithmic", 4.0),
stroke_holdtime: read_base("stroke_holdtime", 0.0),
pressure_gain_log: read_base("pressure_gain_log", 0.0),
smudge_transparency: read_base("smudge_transparency", 0.0),
smudge_length_log: read_base("smudge_length_log", 0.0),
smudge_bucket: read_base("smudge_bucket", 0.0),
paint_mode: read_base("paint_mode", 1.0),
colorize: read_base("colorize", 0.0),
posterize: read_base("posterize", 0.0),
posterize_num: read_base("posterize_num", 0.05),
snap_to_pixel: read_base("snap_to_pixel", 0.0),
custom_input: read_base("custom_input", 0.0),
custom_input_slowness: read_base("custom_input_slowness", 0.0),
gridmap_scale: read_base("gridmap_scale", 0.0),
gridmap_scale_x: read_base("gridmap_scale_x", 1.0),
gridmap_scale_y: read_base("gridmap_scale_y", 1.0),
restore_color: read_base("restore_color", 0.0),
offset_angle: read_base("offset_angle", 0.0),
offset_angle_asc: read_base("offset_angle_asc", 0.0),
offset_angle_view: read_base("offset_angle_view", 0.0),
offset_angle_2: read_base("offset_angle_2", 0.0),
offset_angle_2_asc: read_base("offset_angle_2_asc", 0.0),
offset_angle_2_view: read_base("offset_angle_2_view", 0.0),
offset_angle_adj: read_base("offset_angle_adj", 0.0),
offset_multiplier: read_base("offset_multiplier", 0.0),
}) })
} }
} }
@ -362,41 +146,3 @@ impl Default for BrushSettings {
Self::default_round_soft() Self::default_round_soft()
} }
} }
// ---------------------------------------------------------------------------
// Bundled brush presets
// ---------------------------------------------------------------------------
/// A named brush preset backed by a bundled .myb file.
pub struct BrushPreset {
pub name: &'static str,
pub settings: BrushSettings,
}
/// Returns the list of bundled brush presets (parsed once from embedded .myb files).
///
/// Sources: mypaint/mypaint-brushes — CC0 1.0 Universal (Public Domain)
pub fn bundled_brushes() -> &'static [BrushPreset] {
static CACHE: OnceLock<Vec<BrushPreset>> = OnceLock::new();
CACHE.get_or_init(|| {
let mut v = Vec::new();
macro_rules! brush {
($name:literal, $path:literal) => {
if let Ok(s) = BrushSettings::from_myb(include_str!($path)) {
v.push(BrushPreset { name: $name, settings: s });
}
};
}
brush!("Pencil", "../../../src/assets/brushes/pencil.myb");
brush!("Pen", "../../../src/assets/brushes/pen.myb");
brush!("Charcoal", "../../../src/assets/brushes/charcoal.myb");
brush!("Brush", "../../../src/assets/brushes/brush.myb");
brush!("Dry Brush", "../../../src/assets/brushes/dry_brush.myb");
brush!("Ink", "../../../src/assets/brushes/ink_blot.myb");
brush!("Calligraphy", "../../../src/assets/brushes/calligraphy.myb");
brush!("Airbrush", "../../../src/assets/brushes/airbrush.myb");
brush!("Chalk", "../../../src/assets/brushes/chalk.myb");
brush!("Liner", "../../../src/assets/brushes/liner.myb");
v
})
}

View File

@ -114,8 +114,6 @@ 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,
} }
@ -243,7 +241,6 @@ 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() {
@ -375,7 +372,6 @@ 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() {

View File

@ -390,59 +390,6 @@ impl VideoExportSettings {
} }
} }
// ── Image export ─────────────────────────────────────────────────────────────
/// Image export formats (single-frame still image)
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum ImageFormat {
Png,
Jpeg,
WebP,
}
impl ImageFormat {
pub fn name(self) -> &'static str {
match self { Self::Png => "PNG", Self::Jpeg => "JPEG", Self::WebP => "WebP" }
}
pub fn extension(self) -> &'static str {
match self { Self::Png => "png", Self::Jpeg => "jpg", Self::WebP => "webp" }
}
/// Whether quality (1100) applies to this format.
pub fn has_quality(self) -> bool { matches!(self, Self::Jpeg | Self::WebP) }
}
/// Settings for exporting a single frame as a still image.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ImageExportSettings {
pub format: ImageFormat,
/// Document time (seconds) of the frame to render.
pub time: f64,
/// Override width; None = use document canvas width.
pub width: Option<u32>,
/// Override height; None = use document canvas height.
pub height: Option<u32>,
/// Encode quality 1100 (JPEG / WebP only).
pub quality: u8,
/// Preserve the alpha channel in the output (respect document background alpha).
/// When false, the image is composited onto an opaque background before encoding.
/// Only meaningful for formats that support alpha (PNG, WebP).
pub allow_transparency: bool,
}
impl Default for ImageExportSettings {
fn default() -> Self {
Self { format: ImageFormat::Png, time: 0.0, width: None, height: None, quality: 90, allow_transparency: false }
}
}
impl ImageExportSettings {
pub fn validate(&self) -> Result<(), String> {
if let Some(w) = self.width { if w == 0 { return Err("Width must be > 0".into()); } }
if let Some(h) = self.height { if h == 0 { return Err("Height must be > 0".into()); } }
Ok(())
}
}
/// Progress updates during export /// Progress updates during export
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub enum ExportProgress { pub enum ExportProgress {

View File

@ -1,198 +1,8 @@
//! Flood fill algorithms for paint bucket tool //! Flood fill algorithm for paint bucket tool
//! //!
//! This module contains two fill implementations: //! This module implements a flood fill that tracks which curves each point
//! - `flood_fill` — vector curve-boundary fill (used by vector paint bucket) //! touches. Instead of filling with pixels, it returns boundary points that
//! - `raster_flood_fill` — pixel BFS fill with configurable threshold, soft //! can be used to construct a filled shape from exact curve geometry.
//! edge, and optional selection clipping (used by raster paint bucket)
// ── Raster flood fill ─────────────────────────────────────────────────────────
/// Which pixel to compare against when deciding if a neighbor should be filled.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FillThresholdMode {
/// Compare each candidate pixel to the original seed pixel (Photoshop default).
Absolute,
/// Compare each candidate pixel to the pixel it was reached from (spreads
/// through gradients without a global seed-color reference).
Relative,
}
/// BFS / global scan flood fill mask.
///
/// Returns a `Vec<Option<f32>>` of length `width × height`:
/// - `Some(d)` — pixel is within the fill region; `d` is the color distance
/// from its comparison color (0.0 at seed, up to `threshold` at the edge).
/// - `None` — pixel is outside the fill region.
///
/// # Parameters
/// - `pixels` raw RGBA buffer (read-only)
/// - `width/height` canvas dimensions
/// - `seed_x/y` click coordinates (canvas pixel indices, 0-based)
/// - `threshold` max color distance to include
/// - `mode` Absolute = compare to seed; Relative = compare to BFS parent
/// - `contiguous` true = BFS from seed (connected region only);
/// false = scan every pixel against seed color globally
/// - `selection` optional clip mask; pixels outside are never included
pub fn raster_fill_mask(
pixels: &[u8],
width: u32,
height: u32,
seed_x: i32,
seed_y: i32,
threshold: f32,
mode: FillThresholdMode,
contiguous: bool,
selection: Option<&crate::selection::RasterSelection>,
) -> Vec<Option<f32>> {
use std::collections::VecDeque;
let w = width as i32;
let h = height as i32;
let n = (width * height) as usize;
let mut dist_map: Vec<Option<f32>> = vec![None; n];
if seed_x < 0 || seed_y < 0 || seed_x >= w || seed_y >= h {
return dist_map;
}
let seed_idx = (seed_y * w + seed_x) as usize;
let seed_color = [
pixels[seed_idx * 4],
pixels[seed_idx * 4 + 1],
pixels[seed_idx * 4 + 2],
pixels[seed_idx * 4 + 3],
];
if contiguous {
// BFS: only connected pixels within threshold.
let mut parent_color: Vec<[u8; 4]> = vec![[0; 4]; n];
let mut queue: VecDeque<(i32, i32)> = VecDeque::new();
dist_map[seed_idx] = Some(0.0);
parent_color[seed_idx] = seed_color;
queue.push_back((seed_x, seed_y));
let dirs: [(i32, i32); 4] = [(0, -1), (0, 1), (-1, 0), (1, 0)];
while let Some((cx, cy)) = queue.pop_front() {
let ci = (cy * w + cx) as usize;
let compare_color = match mode {
FillThresholdMode::Absolute => seed_color,
FillThresholdMode::Relative => parent_color[ci],
};
for (dx, dy) in dirs {
let nx = cx + dx;
let ny = cy + dy;
if nx < 0 || ny < 0 || nx >= w || ny >= h { continue; }
let ni = (ny * w + nx) as usize;
if dist_map[ni].is_some() { continue; }
if let Some(sel) = selection {
if !sel.contains_pixel(nx, ny) { continue; }
}
let npx = [pixels[ni*4], pixels[ni*4+1], pixels[ni*4+2], pixels[ni*4+3]];
let d = color_distance(npx, compare_color);
if d <= threshold {
dist_map[ni] = Some(d);
parent_color[ni] = npx;
queue.push_back((nx, ny));
}
}
}
} else {
// Global scan: every pixel compared against seed color (Absolute mode).
for row in 0..h {
for col in 0..w {
if let Some(sel) = selection {
if !sel.contains_pixel(col, row) { continue; }
}
let ni = (row * w + col) as usize;
let npx = [pixels[ni*4], pixels[ni*4+1], pixels[ni*4+2], pixels[ni*4+3]];
let d = color_distance(npx, seed_color);
if d <= threshold {
dist_map[ni] = Some(d);
}
}
}
}
dist_map
}
/// Pixel flood fill for the raster paint bucket tool.
///
/// Calls [`raster_fill_mask`] then alpha-composites `fill_color` over each
/// matched pixel. `softness` controls a fade zone near the fill boundary.
pub fn raster_flood_fill(
pixels: &mut Vec<u8>,
width: u32,
height: u32,
seed_x: i32,
seed_y: i32,
fill_color: [u8; 4],
threshold: f32,
softness: f32,
mode: FillThresholdMode,
contiguous: bool,
selection: Option<&crate::selection::RasterSelection>,
) {
let dist_map = raster_fill_mask(pixels, width, height, seed_x, seed_y,
threshold, mode, contiguous, selection);
let n = (width * height) as usize;
let fr = fill_color[0] as f32 / 255.0;
let fg = fill_color[1] as f32 / 255.0;
let fb = fill_color[2] as f32 / 255.0;
let fa_base = fill_color[3] as f32 / 255.0;
let falloff_start = if softness <= 0.0 || threshold <= 0.0 {
1.0_f32
} else {
1.0 - softness / 100.0
};
for i in 0..n {
if let Some(d) = dist_map[i] {
let alpha = if threshold <= 0.0 {
fa_base
} else {
let t = d / threshold;
if t <= falloff_start {
fa_base
} else {
let frac = (t - falloff_start) / (1.0 - falloff_start).max(1e-6);
fa_base * (1.0 - frac)
}
};
if alpha <= 0.0 { continue; }
let dst_r = pixels[i * 4 ] as f32 / 255.0;
let dst_g = pixels[i * 4 + 1] as f32 / 255.0;
let dst_b = pixels[i * 4 + 2] as f32 / 255.0;
let dst_a = pixels[i * 4 + 3] as f32 / 255.0;
let inv_a = 1.0 - alpha;
let out_a = alpha + dst_a * inv_a;
if out_a > 0.0 {
pixels[i*4 ] = ((fr * alpha + dst_r * dst_a * inv_a) / out_a * 255.0).round() as u8;
pixels[i*4+1] = ((fg * alpha + dst_g * dst_a * inv_a) / out_a * 255.0).round() as u8;
pixels[i*4+2] = ((fb * alpha + dst_b * dst_a * inv_a) / out_a * 255.0).round() as u8;
pixels[i*4+3] = (out_a * 255.0).round() as u8;
}
}
}
}
fn color_distance(a: [u8; 4], b: [u8; 4]) -> f32 {
let dr = a[0] as f32 - b[0] as f32;
let dg = a[1] as f32 - b[1] as f32;
let db = a[2] as f32 - b[2] as f32;
let da = a[3] as f32 - b[3] as f32;
(dr * dr + dg * dg + db * db + da * da).sqrt()
}
// ── Vector (curve-boundary) flood fill ───────────────────────────────────────
// The following is the original vector-layer flood fill, kept for the vector
// paint bucket tool.
use crate::curve_segment::CurveSegment; use crate::curve_segment::CurveSegment;
use crate::quadtree::{BoundingBox, Quadtree}; use crate::quadtree::{BoundingBox, Quadtree};

View File

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

View File

@ -52,8 +52,6 @@ pub mod webcam;
pub mod raster_layer; 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 gradient;
#[cfg(debug_assertions)] #[cfg(debug_assertions)]
pub mod test_mode; pub mod test_mode;

View File

@ -1,194 +0,0 @@
//! CPU-side raster drawing primitives for geometric shapes on raster layers.
//!
//! All coordinates are in canvas pixels (f32). The pixel buffer is RGBA u8,
//! 4 bytes per pixel, row-major, top-left origin.
/// RGBA color as `[R, G, B, A]` bytes.
pub type Rgba = [u8; 4];
// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------
/// Alpha-composite `color` (RGBA) onto `pixels[idx..idx+4]` with an extra
/// `coverage` factor (0.0 = transparent, 1.0 = full color alpha).
#[inline]
fn blend_at(pixels: &mut [u8], idx: usize, color: Rgba, coverage: f32) {
let a = (color[3] as f32 / 255.0) * coverage;
if a <= 0.0 { return; }
let inv = 1.0 - a;
pixels[idx] = (color[0] as f32 * a + pixels[idx] as f32 * inv) as u8;
pixels[idx + 1] = (color[1] as f32 * a + pixels[idx + 1] as f32 * inv) as u8;
pixels[idx + 2] = (color[2] as f32 * a + pixels[idx + 2] as f32 * inv) as u8;
pixels[idx + 3] = ((a + pixels[idx + 3] as f32 / 255.0 * inv) * 255.0).min(255.0) as u8;
}
/// Write a pixel at integer canvas coordinates, clipped to canvas bounds.
#[inline]
fn put(pixels: &mut [u8], w: u32, h: u32, x: i32, y: i32, color: Rgba, coverage: f32) {
if x < 0 || y < 0 || x >= w as i32 || y >= h as i32 { return; }
let idx = (y as u32 * w + x as u32) as usize * 4;
blend_at(pixels, idx, color, coverage);
}
/// Draw an anti-aliased filled disk at (`cx`, `cy`) with the given `radius`.
fn draw_disk(pixels: &mut [u8], w: u32, h: u32, cx: f32, cy: f32, radius: f32, color: Rgba) {
let r = (radius + 1.0) as i32;
let ix = cx as i32;
let iy = cy as i32;
for dy in -r..=r {
for dx in -r..=r {
let px = ix + dx;
let py = iy + dy;
let dist = ((px as f32 - cx).powi(2) + (py as f32 - cy).powi(2)).sqrt();
let cov = (radius + 0.5 - dist).clamp(0.0, 1.0);
if cov > 0.0 {
put(pixels, w, h, px, py, color, cov);
}
}
}
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/// Draw a thick line from (`x0`, `y0`) to (`x1`, `y1`) by stamping
/// anti-aliased disks of radius `thickness / 2` at every half-pixel step.
pub fn draw_line(
pixels: &mut [u8], w: u32, h: u32,
x0: f32, y0: f32, x1: f32, y1: f32,
color: Rgba, thickness: f32,
) {
let radius = (thickness / 2.0).max(0.5);
let dx = x1 - x0;
let dy = y1 - y0;
let len = (dx * dx + dy * dy).sqrt();
if len < 0.5 {
draw_disk(pixels, w, h, x0, y0, radius, color);
return;
}
let steps = ((len * 2.0).ceil() as i32).max(1);
for i in 0..=steps {
let t = i as f32 / steps as f32;
draw_disk(pixels, w, h, x0 + dx * t, y0 + dy * t, radius, color);
}
}
/// Draw a rectangle with corners (`x0`, `y0`) and (`x1`, `y1`).
///
/// `stroke` draws the four edges; `fill` fills the interior. Either may be
/// `None` to skip that part.
pub fn draw_rect(
pixels: &mut [u8], w: u32, h: u32,
x0: f32, y0: f32, x1: f32, y1: f32,
stroke: Option<Rgba>, fill: Option<Rgba>, thickness: f32,
) {
let (lx, rx) = if x0 <= x1 { (x0, x1) } else { (x1, x0) };
let (ty, by) = if y0 <= y1 { (y0, y1) } else { (y1, y0) };
if let Some(fc) = fill {
let px0 = lx.ceil() as i32;
let py0 = ty.ceil() as i32;
let px1 = rx.floor() as i32;
let py1 = by.floor() as i32;
for py in py0..=py1 {
for px in px0..=px1 {
put(pixels, w, h, px, py, fc, 1.0);
}
}
}
if let Some(sc) = stroke {
draw_line(pixels, w, h, lx, ty, rx, ty, sc, thickness); // top
draw_line(pixels, w, h, rx, ty, rx, by, sc, thickness); // right
draw_line(pixels, w, h, rx, by, lx, by, sc, thickness); // bottom
draw_line(pixels, w, h, lx, by, lx, ty, sc, thickness); // left
}
}
/// Draw an ellipse centred at (`cx`, `cy`) with semi-axes `rx` and `ry`.
///
/// `stroke` draws the outline; `fill` fills the interior via scanline.
pub fn draw_ellipse(
pixels: &mut [u8], w: u32, h: u32,
cx: f32, cy: f32, rx: f32, ry: f32,
stroke: Option<Rgba>, fill: Option<Rgba>, thickness: f32,
) {
if rx <= 0.0 || ry <= 0.0 { return; }
if let Some(fc) = fill {
let py0 = (cy - ry).ceil() as i32;
let py1 = (cy + ry).floor() as i32;
for py in py0..=py1 {
let dy = py as f32 - cy;
let t = 1.0 - (dy / ry).powi(2);
if t <= 0.0 { continue; }
let x_ext = rx * t.sqrt();
let px0 = (cx - x_ext).ceil() as i32;
let px1 = (cx + x_ext).floor() as i32;
for px in px0..=px1 {
put(pixels, w, h, px, py, fc, 1.0);
}
}
}
if let Some(sc) = stroke {
let radius = (thickness / 2.0).max(0.5);
// Ramanujan's perimeter approximation for step count.
let perim = std::f32::consts::PI
* (3.0 * (rx + ry) - ((3.0 * rx + ry) * (rx + 3.0 * ry)).sqrt());
let steps = ((perim * 2.0).ceil() as i32).max(16);
for i in 0..steps {
let t = i as f32 / steps as f32 * std::f32::consts::TAU;
draw_disk(pixels, w, h, cx + rx * t.cos(), cy + ry * t.sin(), radius, sc);
}
}
}
/// Draw a closed polygon given world-space `vertices` (at least 2).
///
/// `stroke` draws the outline; `fill` fills the interior via scanline.
pub fn draw_polygon(
pixels: &mut [u8], w: u32, h: u32,
vertices: &[(f32, f32)],
stroke: Option<Rgba>, fill: Option<Rgba>, thickness: f32,
) {
let n = vertices.len();
if n < 2 { return; }
if let Some(fc) = fill {
let min_y = vertices.iter().map(|v| v.1).fold(f32::MAX, f32::min).ceil() as i32;
let max_y = vertices.iter().map(|v| v.1).fold(f32::MIN, f32::max).floor() as i32;
let mut xs: Vec<f32> = Vec::with_capacity(n);
for py in min_y..=max_y {
xs.clear();
let scan_y = py as f32 + 0.5;
for i in 0..n {
let (x0, y0) = vertices[i];
let (x1, y1) = vertices[(i + 1) % n];
if (y0 <= scan_y && scan_y < y1) || (y1 <= scan_y && scan_y < y0) {
xs.push(x0 + (scan_y - y0) / (y1 - y0) * (x1 - x0));
}
}
xs.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
let mut j = 0;
while j + 1 < xs.len() {
let px0 = xs[j].ceil() as i32;
let px1 = xs[j + 1].floor() as i32;
for px in px0..=px1 {
put(pixels, w, h, px, py, fc, 1.0);
}
j += 2;
}
}
}
if let Some(sc) = stroke {
for i in 0..n {
let (x0, y0) = vertices[i];
let (x1, y1) = vertices[(i + 1) % n];
draw_line(pixels, w, h, x0, y0, x1, y1, sc, thickness);
}
}
}

View File

@ -18,18 +18,6 @@ pub enum RasterBlendMode {
Erase, Erase,
/// Smudge / blend surrounding pixels /// Smudge / blend surrounding pixels
Smudge, Smudge,
/// Clone stamp: copy pixels from a source region
CloneStamp,
/// Healing brush: color-corrected clone stamp (preserves source texture, shifts color to match destination)
Healing,
/// Pattern stamp: paint with a repeating procedural tile pattern
PatternStamp,
/// Dodge / Burn: lighten (dodge) or darken (burn) existing pixels
DodgeBurn,
/// Sponge: saturate or desaturate existing pixels
Sponge,
/// Blur / Sharpen: soften or crisp up existing pixels
BlurSharpen,
} }
impl Default for RasterBlendMode { impl Default for RasterBlendMode {
@ -38,15 +26,6 @@ impl Default for RasterBlendMode {
} }
} }
impl RasterBlendMode {
/// Returns false for blend modes that operate on existing pixels and don't
/// use the brush color at all (clone, heal, dodge/burn, sponge).
/// Used by brush_engine.rs to decide whether color_a should be 1.0 or stroke.color[3].
pub fn uses_brush_color(self) -> bool {
!matches!(self, Self::CloneStamp | Self::Healing | Self::DodgeBurn | Self::Sponge | Self::BlurSharpen)
}
}
/// A single point along a stroke /// A single point along a stroke
#[derive(Clone, Debug, Serialize, Deserialize)] #[derive(Clone, Debug, Serialize, Deserialize)]
pub struct StrokePoint { pub struct StrokePoint {
@ -69,13 +48,6 @@ pub struct StrokeRecord {
/// RGBA linear color [r, g, b, a] /// RGBA linear color [r, g, b, a]
pub color: [f32; 4], pub color: [f32; 4],
pub blend_mode: RasterBlendMode, pub blend_mode: RasterBlendMode,
/// Generic tool parameters — encoding depends on blend_mode:
/// - CloneStamp / Healing: [offset_x, offset_y, 0, 0]
/// - PatternStamp: [pattern_type, pattern_scale, 0, 0]
/// - DodgeBurn / Sponge: [mode, 0, 0, 0]
/// - all others: [0, 0, 0, 0]
#[serde(default)]
pub tool_params: [f32; 4],
pub points: Vec<StrokePoint>, pub points: Vec<StrokePoint>,
} }
@ -113,14 +85,8 @@ 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 {
@ -139,7 +105,6 @@ impl RasterKeyframe {
stroke_log: Vec::new(), stroke_log: Vec::new(),
tween_after: TweenType::Hold, tween_after: TweenType::Hold,
raw_pixels: Vec::new(), raw_pixels: Vec::new(),
texture_dirty: true,
} }
} }
} }

View File

@ -18,7 +18,7 @@ use std::collections::HashMap;
use std::sync::Arc; use std::sync::Arc;
use uuid::Uuid; use uuid::Uuid;
use vello::kurbo::Rect; use vello::kurbo::Rect;
use vello::peniko::{Blob, Fill, ImageAlphaType, ImageBrush, ImageData, ImageFormat, ImageQuality}; use vello::peniko::{Blob, Fill, ImageAlphaType, ImageBrush, ImageData, ImageFormat};
use vello::Scene; use vello::Scene;
/// Cache for decoded image data to avoid re-decoding every frame /// Cache for decoded image data to avoid re-decoding every frame
@ -88,53 +88,14 @@ fn decode_image_asset(asset: &ImageAsset) -> Option<ImageBrush> {
// Per-Layer Rendering for HDR Compositing Pipeline // Per-Layer Rendering for HDR Compositing Pipeline
// ============================================================================ // ============================================================================
/// A single decoded video frame ready for GPU upload, with its document-space transform.
pub struct VideoRenderInstance {
/// sRGB RGBA8 pixel data (straight alpha — as decoded by ffmpeg).
pub rgba_data: Arc<Vec<u8>>,
pub width: u32,
pub height: u32,
/// Affine transform that maps from video-pixel space to document space.
/// Composed from the clip's animated position/rotation/scale properties.
pub transform: Affine,
/// Final opacity [0,1] after cascading layer and instance opacity.
pub opacity: f32,
}
/// Type of rendered layer for compositor handling /// Type of rendered layer for compositor handling
#[derive(Clone, Debug)]
pub enum RenderedLayerType { pub enum RenderedLayerType {
/// Vector / group layer — Vello scene in `RenderedLayer::scene` is used. /// Regular content layer (vector, video) - composite its scene
Vector, Content,
/// Raster keyframe — bypass Vello; compositor uploads pixels via GPU texture cache. /// Effect layer - apply effects to current composite state
Raster {
kf_id: Uuid,
width: u32,
height: u32,
/// True when `raw_pixels` changed since the last upload; forces a cache re-upload.
dirty: bool,
/// Accumulated parent-clip affine (IDENTITY for top-level layers).
/// Compositor composes this with the camera into the blit matrix.
transform: Affine,
},
/// Video layer — bypass Vello; each active clip instance carries decoded frame data.
Video {
instances: Vec<VideoRenderInstance>,
},
/// Floating raster selection — blitted immediately above its parent layer.
Float {
canvas_id: Uuid,
x: i32,
y: i32,
width: u32,
height: u32,
/// Accumulated parent-clip affine (IDENTITY for top-level layers).
transform: Affine,
/// CPU pixel data (sRGB-premultiplied RGBA8). Arc so the per-frame clone is O(1).
/// Used by the export compositor; the live compositor reads the GPU canvas directly.
pixels: std::sync::Arc<Vec<u8>>,
},
/// Effect layer — applied as a post-process pass on the HDR accumulator.
Effect { Effect {
/// Active effect instances at the current time
effect_instances: Vec<ClipInstance>, effect_instances: Vec<ClipInstance>,
}, },
} }
@ -143,7 +104,7 @@ pub enum RenderedLayerType {
pub struct RenderedLayer { pub struct RenderedLayer {
/// The layer's unique identifier /// The layer's unique identifier
pub layer_id: Uuid, pub layer_id: Uuid,
/// Vello scene — only populated for `RenderedLayerType::Vector`. /// The Vello scene containing the layer's rendered content
pub scene: Scene, pub scene: Scene,
/// Layer opacity (0.0 to 1.0) /// Layer opacity (0.0 to 1.0)
pub opacity: f32, pub opacity: f32,
@ -151,12 +112,12 @@ pub struct RenderedLayer {
pub blend_mode: BlendMode, pub blend_mode: BlendMode,
/// Whether this layer has any visible content /// Whether this layer has any visible content
pub has_content: bool, pub has_content: bool,
/// Layer variant — determines how the compositor renders this entry. /// Type of layer for compositor (content vs effect)
pub layer_type: RenderedLayerType, pub layer_type: RenderedLayerType,
} }
impl RenderedLayer { impl RenderedLayer {
/// Create a new vector layer with default settings. /// Create a new rendered layer with default settings
pub fn new(layer_id: Uuid) -> Self { pub fn new(layer_id: Uuid) -> Self {
Self { Self {
layer_id, layer_id,
@ -164,11 +125,11 @@ impl RenderedLayer {
opacity: 1.0, opacity: 1.0,
blend_mode: BlendMode::Normal, blend_mode: BlendMode::Normal,
has_content: false, has_content: false,
layer_type: RenderedLayerType::Vector, layer_type: RenderedLayerType::Content,
} }
} }
/// Create a vector layer with specific opacity and blend mode. /// Create with specific opacity and blend mode
pub fn with_settings(layer_id: Uuid, opacity: f32, blend_mode: BlendMode) -> Self { pub fn with_settings(layer_id: Uuid, opacity: f32, blend_mode: BlendMode) -> Self {
Self { Self {
layer_id, layer_id,
@ -176,11 +137,11 @@ impl RenderedLayer {
opacity, opacity,
blend_mode, blend_mode,
has_content: false, has_content: false,
layer_type: RenderedLayerType::Vector, layer_type: RenderedLayerType::Content,
} }
} }
/// Create an effect layer with active effect instances. /// Create an effect layer with active effect instances
pub fn effect_layer(layer_id: Uuid, opacity: f32, effect_instances: Vec<ClipInstance>) -> Self { pub fn effect_layer(layer_id: Uuid, opacity: f32, effect_instances: Vec<ClipInstance>) -> Self {
let has_content = !effect_instances.is_empty(); let has_content = !effect_instances.is_empty();
Self { Self {
@ -218,14 +179,12 @@ pub fn render_document_for_compositing(
image_cache: &mut ImageCache, image_cache: &mut ImageCache,
video_manager: &std::sync::Arc<std::sync::Mutex<crate::video::VideoManager>>, video_manager: &std::sync::Arc<std::sync::Mutex<crate::video::VideoManager>>,
camera_frame: Option<&crate::webcam::CaptureFrame>, camera_frame: Option<&crate::webcam::CaptureFrame>,
floating_selection: Option<&crate::selection::RasterFloatingSelection>,
draw_checkerboard: bool,
) -> CompositeRenderResult { ) -> CompositeRenderResult {
let time = document.current_time; let time = document.current_time;
// Render background to its own scene // Render background to its own scene
let mut background = Scene::new(); let mut background = Scene::new();
render_background(document, &mut background, base_transform, draw_checkerboard); render_background(document, &mut background, base_transform);
// Check if any layers are soloed // Check if any layers are soloed
let any_soloed = document.visible_layers().any(|layer| layer.soloed()); let any_soloed = document.visible_layers().any(|layer| layer.soloed());
@ -258,36 +217,6 @@ pub fn render_document_for_compositing(
rendered_layers.push(rendered); rendered_layers.push(rendered);
} }
// Insert the floating raster selection immediately above its parent layer.
// This ensures it composites at the correct z-position in both edit and export.
if let Some(float_sel) = floating_selection {
if let Some(pos) = rendered_layers.iter().position(|l| l.layer_id == float_sel.layer_id) {
// Inherit the parent layer's transform so the float follows it into
// any transformed clip context.
let parent_transform = match &rendered_layers[pos].layer_type {
RenderedLayerType::Raster { transform, .. } => *transform,
_ => Affine::IDENTITY,
};
let float_entry = RenderedLayer {
layer_id: Uuid::nil(), // sentinel — not a real document layer
scene: Scene::new(),
opacity: 1.0,
blend_mode: crate::gpu::BlendMode::Normal,
has_content: !float_sel.pixels.is_empty(),
layer_type: RenderedLayerType::Float {
canvas_id: float_sel.canvas_id,
x: float_sel.x,
y: float_sel.y,
width: float_sel.width,
height: float_sel.height,
transform: parent_transform,
pixels: std::sync::Arc::clone(&float_sel.pixels),
},
};
rendered_layers.insert(pos + 1, float_entry);
}
}
CompositeRenderResult { CompositeRenderResult {
background, background,
layers: rendered_layers, layers: rendered_layers,
@ -340,74 +269,21 @@ pub fn render_layer_isolated(
rendered.has_content = false; rendered.has_content = false;
} }
AnyLayer::Video(video_layer) => { AnyLayer::Video(video_layer) => {
use crate::animation::TransformProperty;
let layer_opacity = layer.opacity();
let mut video_mgr = video_manager.lock().unwrap(); let mut video_mgr = video_manager.lock().unwrap();
let mut instances = Vec::new(); // Only pass camera_frame for the layer that has camera enabled
let layer_camera_frame = if video_layer.camera_enabled { camera_frame } else { None };
for clip_instance in &video_layer.clip_instances { render_video_layer_to_scene(
let Some(video_clip) = document.video_clips.get(&clip_instance.clip_id) else { continue }; document,
let Some(clip_time) = clip_instance.remap_time(time, video_clip.duration) else { continue }; time,
let Some(frame) = video_mgr.get_frame(&clip_instance.clip_id, clip_time) else { continue }; video_layer,
&mut rendered.scene,
// Evaluate animated transform properties. base_transform,
let anim = &video_layer.layer.animation_data; 1.0, // Full opacity - layer opacity handled in compositing
let id = clip_instance.id; &mut video_mgr,
let t = &clip_instance.transform; layer_camera_frame,
let x = anim.eval(&crate::animation::AnimationTarget::Object { id, property: TransformProperty::X }, time, t.x); );
let y = anim.eval(&crate::animation::AnimationTarget::Object { id, property: TransformProperty::Y }, time, t.y); rendered.has_content = !video_layer.clip_instances.is_empty()
let rotation = anim.eval(&crate::animation::AnimationTarget::Object { id, property: TransformProperty::Rotation }, time, t.rotation); || (video_layer.camera_enabled && camera_frame.is_some());
let scale_x = anim.eval(&crate::animation::AnimationTarget::Object { id, property: TransformProperty::ScaleX }, time, t.scale_x);
let scale_y = anim.eval(&crate::animation::AnimationTarget::Object { id, property: TransformProperty::ScaleY }, time, t.scale_y);
let skew_x = anim.eval(&crate::animation::AnimationTarget::Object { id, property: TransformProperty::SkewX }, time, t.skew_x);
let skew_y = anim.eval(&crate::animation::AnimationTarget::Object { id, property: TransformProperty::SkewY }, time, t.skew_y);
let inst_opacity = anim.eval(&crate::animation::AnimationTarget::Object { id, property: TransformProperty::Opacity }, time, clip_instance.opacity);
let cx = video_clip.width / 2.0;
let cy = video_clip.height / 2.0;
let skew_transform = if skew_x != 0.0 || skew_y != 0.0 {
let sx = if skew_x != 0.0 { Affine::new([1.0, 0.0, skew_x.to_radians().tan(), 1.0, 0.0, 0.0]) } else { Affine::IDENTITY };
let sy = if skew_y != 0.0 { Affine::new([1.0, skew_y.to_radians().tan(), 0.0, 1.0, 0.0, 0.0]) } else { Affine::IDENTITY };
Affine::translate((cx, cy)) * sx * sy * Affine::translate((-cx, -cy))
} else { Affine::IDENTITY };
let clip_transform = Affine::translate((x, y))
* Affine::rotate(rotation.to_radians())
* Affine::scale_non_uniform(scale_x, scale_y)
* skew_transform;
instances.push(VideoRenderInstance {
rgba_data: frame.rgba_data.clone(),
width: frame.width,
height: frame.height,
transform: base_transform * clip_transform,
opacity: (layer_opacity * inst_opacity) as f32,
});
}
// Camera / webcam frame.
if instances.is_empty() && video_layer.camera_enabled {
if let Some(frame) = camera_frame {
let vw = frame.width as f64;
let vh = frame.height as f64;
let scale = (document.width / vw).min(document.height / vh);
let ox = (document.width - vw * scale) / 2.0;
let oy = (document.height - vh * scale) / 2.0;
let cam_transform = base_transform
* Affine::translate((ox, oy))
* Affine::scale(scale);
instances.push(VideoRenderInstance {
rgba_data: frame.rgba_data.clone(),
width: frame.width,
height: frame.height,
transform: cam_transform,
opacity: layer_opacity as f32,
});
}
}
rendered.has_content = !instances.is_empty();
rendered.layer_type = RenderedLayerType::Video { instances };
} }
AnyLayer::Effect(effect_layer) => { AnyLayer::Effect(effect_layer) => {
// Effect layers are processed during compositing, not rendered to scene // Effect layers are processed during compositing, not rendered to scene
@ -431,16 +307,9 @@ pub fn render_layer_isolated(
rendered.has_content = !group_layer.children.is_empty(); rendered.has_content = !group_layer.children.is_empty();
} }
AnyLayer::Raster(raster_layer) => { AnyLayer::Raster(raster_layer) => {
if let Some(kf) = raster_layer.keyframe_at(time) { render_raster_layer_to_scene(raster_layer, time, &mut rendered.scene, base_transform);
rendered.has_content = kf.has_pixels(); rendered.has_content = raster_layer.keyframe_at(time)
rendered.layer_type = RenderedLayerType::Raster { .map_or(false, |kf| kf.has_pixels());
kf_id: kf.id,
width: kf.width,
height: kf.height,
dirty: kf.texture_dirty,
transform: base_transform,
};
}
} }
} }
@ -494,11 +363,35 @@ fn render_raster_layer_to_scene(
// decode the sRGB channels without premultiplying again. // decode the sRGB channels without premultiplying again.
alpha_type: ImageAlphaType::AlphaPremultiplied, alpha_type: ImageAlphaType::AlphaPremultiplied,
}; };
let brush = ImageBrush::new(image_data).with_quality(ImageQuality::Low); let brush = ImageBrush::new(image_data);
let canvas_rect = Rect::new(0.0, 0.0, kf.width as f64, kf.height as f64); let canvas_rect = Rect::new(0.0, 0.0, kf.width as f64, kf.height as f64);
scene.fill(Fill::NonZero, base_transform, &brush, None, &canvas_rect); scene.fill(Fill::NonZero, base_transform, &brush, None, &canvas_rect);
} }
/// Render a video layer to an isolated scene (for compositing pipeline)
fn render_video_layer_to_scene(
document: &Document,
time: f64,
layer: &crate::layer::VideoLayer,
scene: &mut Scene,
base_transform: Affine,
parent_opacity: f64,
video_manager: &mut crate::video::VideoManager,
camera_frame: Option<&crate::webcam::CaptureFrame>,
) {
// Render using the existing function but to this isolated scene
render_video_layer(
document,
time,
layer,
scene,
base_transform,
parent_opacity,
video_manager,
camera_frame,
);
}
// ============================================================================ // ============================================================================
// Legacy Single-Scene Rendering (kept for backwards compatibility) // Legacy Single-Scene Rendering (kept for backwards compatibility)
// ============================================================================ // ============================================================================
@ -522,8 +415,8 @@ pub fn render_document_with_transform(
image_cache: &mut ImageCache, image_cache: &mut ImageCache,
video_manager: &std::sync::Arc<std::sync::Mutex<crate::video::VideoManager>>, video_manager: &std::sync::Arc<std::sync::Mutex<crate::video::VideoManager>>,
) { ) {
// 1. Draw background (with checkerboard for transparent backgrounds — UI path) // 1. Draw background
render_background(document, scene, base_transform, true); render_background(document, scene, base_transform);
// 2. Recursively render the root graphics object at current time // 2. Recursively render the root graphics object at current time
let time = document.current_time; let time = document.current_time;
@ -543,12 +436,12 @@ pub fn render_document_with_transform(
} }
/// Draw the document background /// Draw the document background
fn render_background(document: &Document, scene: &mut Scene, base_transform: Affine, draw_checkerboard: bool) { fn render_background(document: &Document, scene: &mut Scene, base_transform: Affine) {
let background_rect = Rect::new(0.0, 0.0, document.width, document.height); let background_rect = Rect::new(0.0, 0.0, document.width, document.height);
let bg = &document.background_color; let bg = &document.background_color;
// Draw checkerboard behind transparent backgrounds (UI-only; skip in export) // Draw checkerboard behind transparent backgrounds
if draw_checkerboard && bg.a < 255 { if bg.a < 255 {
use vello::peniko::{Blob, Color, Extend, ImageAlphaType, ImageData, ImageQuality}; use vello::peniko::{Blob, Color, Extend, ImageAlphaType, ImageData, ImageQuality};
// 2x2 pixel checkerboard pattern: light/dark alternating // 2x2 pixel checkerboard pattern: light/dark alternating
let light: [u8; 4] = [204, 204, 204, 255]; let light: [u8; 4] = [204, 204, 204, 255];
@ -1030,25 +923,7 @@ fn render_video_layer(
} }
} }
/// Compute start/end canvas points for a linear gradient across a bounding box. /// Render a vector layer with all its clip instances and shape instances
///
/// 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.
@ -1067,7 +942,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() && face.gradient_fill.is_none() { if face.fill_color.is_none() && face.image_fill.is_none() {
continue; // No fill to render continue; // No fill to render
} }
@ -1088,19 +963,7 @@ pub fn render_dcel(
} }
} }
// Gradient fill (takes priority over solid colour fill) // Color 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;

View File

@ -15,16 +15,6 @@ pub enum RasterSelection {
Rect(i32, i32, i32, i32), Rect(i32, i32, i32, i32),
/// Closed freehand lasso polygon. /// Closed freehand lasso polygon.
Lasso(Vec<(i32, i32)>), Lasso(Vec<(i32, i32)>),
/// Per-pixel boolean mask (e.g. from magic wand flood fill).
/// `data` is row-major, length = width × height.
Mask {
data: Vec<bool>,
width: u32,
height: u32,
/// Top-left canvas pixel of the mask's bounding canvas region.
origin_x: i32,
origin_y: i32,
},
} }
impl RasterSelection { impl RasterSelection {
@ -39,23 +29,6 @@ impl RasterSelection {
let y1 = pts.iter().map(|p| p.1).max().unwrap_or(0); let y1 = pts.iter().map(|p| p.1).max().unwrap_or(0);
(x0, y0, x1, y1) (x0, y0, x1, y1)
} }
Self::Mask { data, width, height, origin_x, origin_y } => {
let w = *width as i32;
let mut bx0 = i32::MAX; let mut by0 = i32::MAX;
let mut bx1 = i32::MIN; let mut by1 = i32::MIN;
for row in 0..*height as i32 {
for col in 0..w {
if data[(row * w + col) as usize] {
bx0 = bx0.min(origin_x + col);
by0 = by0.min(origin_y + row);
bx1 = bx1.max(origin_x + col + 1);
by1 = by1.max(origin_y + row + 1);
}
}
}
if bx0 == i32::MAX { (*origin_x, *origin_y, *origin_x, *origin_y) }
else { (bx0, by0, bx1, by1) }
}
} }
} }
@ -64,14 +37,6 @@ impl RasterSelection {
match self { match self {
Self::Rect(x0, y0, x1, y1) => px >= *x0 && px < *x1 && py >= *y0 && py < *y1, Self::Rect(x0, y0, x1, y1) => px >= *x0 && px < *x1 && py >= *y0 && py < *y1,
Self::Lasso(pts) => point_in_polygon(px, py, pts), Self::Lasso(pts) => point_in_polygon(px, py, pts),
Self::Mask { data, width, height, origin_x, origin_y } => {
let lx = px - origin_x;
let ly = py - origin_y;
if lx < 0 || ly < 0 || lx >= *width as i32 || ly >= *height as i32 {
return false;
}
data[(ly * *width as i32 + lx) as usize]
}
} }
} }
} }
@ -104,9 +69,7 @@ fn point_in_polygon(px: i32, py: i32, polygon: &[(i32, i32)]) -> bool {
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub struct RasterFloatingSelection { pub struct RasterFloatingSelection {
/// sRGB-encoded premultiplied RGBA, width × height × 4 bytes. /// sRGB-encoded premultiplied RGBA, width × height × 4 bytes.
/// Wrapped in Arc so the renderer can clone a reference each frame (O(1)) pub pixels: Vec<u8>,
/// instead of copying megabytes of pixel data.
pub pixels: std::sync::Arc<Vec<u8>>,
pub width: u32, pub width: u32,
pub height: u32, pub height: u32,
/// Top-left position in canvas pixel coordinates. /// Top-left position in canvas pixel coordinates.
@ -118,7 +81,7 @@ pub struct RasterFloatingSelection {
/// Snapshot of `raw_pixels` before the cut/paste was initiated, used for /// Snapshot of `raw_pixels` before the cut/paste was initiated, used for
/// undo (via `RasterStrokeAction`) when the float is committed, and for /// undo (via `RasterStrokeAction`) when the float is committed, and for
/// Cancel (Escape) to restore the canvas without creating an undo entry. /// Cancel (Escape) to restore the canvas without creating an undo entry.
pub canvas_before: std::sync::Arc<Vec<u8>>, pub canvas_before: Vec<u8>,
/// Key for this float's GPU canvas in `GpuBrushEngine::canvases`. /// Key for this float's GPU canvas in `GpuBrushEngine::canvases`.
/// Allows painting strokes directly onto the float buffer (B) without /// Allows painting strokes directly onto the float buffer (B) without
/// touching the layer canvas (A). /// touching the layer canvas (A).

View File

@ -11,10 +11,9 @@ use vello::kurbo::Point;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub enum Tool { pub enum Tool {
// ── Vector / shared tools ──────────────────────────────────────────────
/// Selection tool - select and move objects /// Selection tool - select and move objects
Select, Select,
/// Draw/Brush tool - freehand drawing (vector) / paintbrush (raster) /// Draw/Pen tool - freehand drawing
Draw, Draw,
/// Transform tool - scale, rotate, skew /// Transform tool - scale, rotate, skew
Transform, Transform,
@ -38,48 +37,12 @@ pub enum Tool {
RegionSelect, RegionSelect,
/// Split tool - split audio/video clips at a point /// Split tool - split audio/video clips at a point
Split, Split,
// ── Raster brush tools ────────────────────────────────────────────────
/// Pencil tool - hard-edged raster brush
Pencil,
/// Pen tool - pressure-sensitive raster pen
Pen,
/// Airbrush tool - soft spray raster brush
Airbrush,
/// Erase tool - erase raster pixels /// Erase tool - erase raster pixels
Erase, Erase,
/// Smudge tool - smudge/blend raster pixels /// Smudge tool - smudge/blend raster pixels
Smudge, Smudge,
/// Clone Stamp - copy pixels from a source point /// Lasso select tool - freehand selection on raster layers
CloneStamp,
/// Healing Brush - content-aware pixel repair
HealingBrush,
/// Pattern Stamp - paint with a repeating pattern
PatternStamp,
/// Dodge/Burn - lighten or darken pixels
DodgeBurn,
/// Sponge - saturate or desaturate pixels
Sponge,
/// Blur/Sharpen - blur or sharpen pixel regions
BlurSharpen,
// ── Raster fill / shape ───────────────────────────────────────────────
/// Gradient tool - fill with a gradient
Gradient,
/// Custom Shape tool - draw from a shape library
CustomShape,
// ── Raster selection tools ────────────────────────────────────────────
/// Elliptical marquee selection
SelectEllipse,
/// Lasso select tool - freehand / polygonal / magnetic selection
SelectLasso, SelectLasso,
/// Magic Wand - select by colour similarity
MagicWand,
/// Quick Select - brush-based smart selection
QuickSelect,
// ── Raster transform tools ────────────────────────────────────────────
/// Warp / perspective transform
Warp,
/// Liquify - freeform pixel warping
Liquify,
} }
/// Region select mode /// Region select mode
@ -97,23 +60,6 @@ impl Default for RegionSelectMode {
} }
} }
/// Lasso selection sub-mode
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum LassoMode {
/// Freehand lasso (existing, implemented)
Freehand,
/// Click-to-place polygonal lasso
Polygonal,
/// Magnetically snaps to edges
Magnetic,
}
impl Default for LassoMode {
fn default() -> Self {
Self::Freehand
}
}
/// Tool state tracking for interactive operations /// Tool state tracking for interactive operations
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub enum ToolState { pub enum ToolState {
@ -284,7 +230,7 @@ impl Tool {
pub fn display_name(self) -> &'static str { pub fn display_name(self) -> &'static str {
match self { match self {
Tool::Select => "Select", Tool::Select => "Select",
Tool::Draw => "Brush", Tool::Draw => "Draw",
Tool::Transform => "Transform", Tool::Transform => "Transform",
Tool::Rectangle => "Rectangle", Tool::Rectangle => "Rectangle",
Tool::Ellipse => "Ellipse", Tool::Ellipse => "Ellipse",
@ -296,25 +242,9 @@ impl Tool {
Tool::Text => "Text", Tool::Text => "Text",
Tool::RegionSelect => "Region Select", Tool::RegionSelect => "Region Select",
Tool::Split => "Split", Tool::Split => "Split",
Tool::Pencil => "Pencil", Tool::Erase => "Erase",
Tool::Pen => "Pen",
Tool::Airbrush => "Airbrush",
Tool::Erase => "Eraser",
Tool::Smudge => "Smudge", Tool::Smudge => "Smudge",
Tool::CloneStamp => "Clone Stamp",
Tool::HealingBrush => "Healing Brush",
Tool::PatternStamp => "Pattern Stamp",
Tool::DodgeBurn => "Dodge / Burn",
Tool::Sponge => "Sponge",
Tool::BlurSharpen => "Blur / Sharpen",
Tool::Gradient => "Gradient",
Tool::CustomShape => "Custom Shape",
Tool::SelectEllipse => "Elliptical Select",
Tool::SelectLasso => "Lasso Select", Tool::SelectLasso => "Lasso Select",
Tool::MagicWand => "Magic Wand",
Tool::QuickSelect => "Quick Select",
Tool::Warp => "Warp",
Tool::Liquify => "Liquify",
} }
} }
@ -337,23 +267,6 @@ impl Tool {
Tool::Erase => "erase.svg", Tool::Erase => "erase.svg",
Tool::Smudge => "smudge.svg", Tool::Smudge => "smudge.svg",
Tool::SelectLasso => "lasso.svg", Tool::SelectLasso => "lasso.svg",
// Not yet implemented — use the placeholder icon
Tool::Pencil
| Tool::Pen
| Tool::Airbrush
| Tool::CloneStamp
| Tool::HealingBrush
| Tool::PatternStamp
| Tool::DodgeBurn
| Tool::Sponge
| Tool::BlurSharpen
| Tool::Gradient
| Tool::CustomShape
| Tool::SelectEllipse
| Tool::MagicWand
| Tool::QuickSelect
| Tool::Warp
| Tool::Liquify => "todo.svg",
} }
} }
@ -381,23 +294,7 @@ impl Tool {
match layer_type { match layer_type {
None | Some(LayerType::Vector) => Tool::all(), None | Some(LayerType::Vector) => Tool::all(),
Some(LayerType::Audio) | Some(LayerType::Video) => &[Tool::Select, Tool::Split], Some(LayerType::Audio) | Some(LayerType::Video) => &[Tool::Select, Tool::Split],
Some(LayerType::Raster) => &[ Some(LayerType::Raster) => &[Tool::Select, Tool::SelectLasso, Tool::Draw, Tool::Erase, Tool::Smudge, Tool::Eyedropper],
// Brush tools
Tool::Draw, Tool::Pencil, Tool::Pen, Tool::Airbrush,
Tool::Erase, Tool::Smudge,
Tool::CloneStamp, Tool::HealingBrush, Tool::PatternStamp,
Tool::DodgeBurn, Tool::Sponge, Tool::BlurSharpen,
// Fill / shape
Tool::PaintBucket, Tool::Gradient,
Tool::Rectangle, Tool::Ellipse, Tool::Polygon, Tool::Line, Tool::CustomShape,
// Selection
Tool::Select, Tool::SelectLasso,
Tool::MagicWand, Tool::QuickSelect,
// Transform
Tool::Transform, Tool::Warp, Tool::Liquify,
// Utility
Tool::Eyedropper,
],
_ => &[Tool::Select], _ => &[Tool::Select],
} }
} }

View File

@ -1,6 +1,6 @@
[package] [package]
name = "lightningbeam-editor" name = "lightningbeam-editor"
version = "1.0.2-alpha" version = "1.0.1-alpha"
edition = "2021" edition = "2021"
description = "Multimedia editor for audio, video and 2D animation" description = "Multimedia editor for audio, video and 2D animation"
license = "GPL-3.0-or-later" license = "GPL-3.0-or-later"

View File

@ -124,7 +124,7 @@
"children": [ "children": [
{ {
"type": "vertical-grid", "type": "vertical-grid",
"percent": 67, "percent": 30,
"children": [ "children": [
{ "type": "pane", "name": "toolbar" }, { "type": "pane", "name": "toolbar" },
{ "type": "pane", "name": "infopanel" } { "type": "pane", "name": "infopanel" }

View File

@ -43,30 +43,11 @@ impl CustomCursor {
Tool::Polygon => CustomCursor::Polygon, Tool::Polygon => CustomCursor::Polygon,
Tool::BezierEdit => CustomCursor::BezierEdit, Tool::BezierEdit => CustomCursor::BezierEdit,
Tool::Text => CustomCursor::Text, Tool::Text => CustomCursor::Text,
Tool::RegionSelect => CustomCursor::Select, Tool::RegionSelect => CustomCursor::Select, // Reuse select cursor for now
Tool::Split => CustomCursor::Select, Tool::Split => CustomCursor::Select, // Reuse select cursor for now
Tool::Erase => CustomCursor::Draw, Tool::Erase => CustomCursor::Draw, // Reuse draw cursor for raster erase
Tool::Smudge => CustomCursor::Draw, Tool::Smudge => CustomCursor::Draw, // Reuse draw cursor for raster smudge
Tool::SelectLasso => CustomCursor::Select, Tool::SelectLasso => CustomCursor::Select, // Reuse select cursor for lasso
// Raster brush tools — use draw cursor until implemented
Tool::Pencil
| Tool::Pen
| Tool::Airbrush
| Tool::CloneStamp
| Tool::HealingBrush
| Tool::PatternStamp
| Tool::DodgeBurn
| Tool::Sponge
| Tool::BlurSharpen => CustomCursor::Draw,
// Selection tools — use select cursor until implemented
Tool::SelectEllipse
| Tool::MagicWand
| Tool::QuickSelect => CustomCursor::Select,
// Other tools — use select cursor until implemented
Tool::Gradient
| Tool::CustomShape
| Tool::Warp
| Tool::Liquify => CustomCursor::Select,
} }
} }

View File

@ -5,40 +5,9 @@
use eframe::egui; use eframe::egui;
use std::collections::VecDeque; use std::collections::VecDeque;
use std::sync::{Mutex, OnceLock};
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
const FRAME_HISTORY_SIZE: usize = 60; // Track last 60 frames for FPS stats const FRAME_HISTORY_SIZE: usize = 60; // Track last 60 frames for FPS stats
/// Timing breakdown for the GPU prepare() pass, written by the render thread.
#[derive(Debug, Clone, Default)]
pub struct PrepareTiming {
pub total_ms: f64,
pub removals_ms: f64,
pub gpu_dispatches_ms: f64,
pub scene_build_ms: f64,
pub composite_ms: f64,
}
static LAST_PREPARE_TIMING: OnceLock<Mutex<PrepareTiming>> = OnceLock::new();
/// Called from `VelloCallback::prepare()` every frame to update the timing snapshot.
pub fn update_prepare_timing(
total_ms: f64,
removals_ms: f64,
gpu_dispatches_ms: f64,
scene_build_ms: f64,
composite_ms: f64,
) {
let cell = LAST_PREPARE_TIMING.get_or_init(|| Mutex::new(PrepareTiming::default()));
if let Ok(mut t) = cell.lock() {
t.total_ms = total_ms;
t.removals_ms = removals_ms;
t.gpu_dispatches_ms = gpu_dispatches_ms;
t.scene_build_ms = scene_build_ms;
t.composite_ms = composite_ms;
}
}
const DEVICE_REFRESH_INTERVAL: Duration = Duration::from_secs(2); // Refresh devices every 2 seconds const DEVICE_REFRESH_INTERVAL: Duration = Duration::from_secs(2); // Refresh devices every 2 seconds
const MEMORY_REFRESH_INTERVAL: Duration = Duration::from_millis(500); // Refresh memory every 500ms const MEMORY_REFRESH_INTERVAL: Duration = Duration::from_millis(500); // Refresh memory every 500ms
@ -59,9 +28,6 @@ pub struct DebugStats {
pub audio_input_devices: Vec<String>, pub audio_input_devices: Vec<String>,
pub has_pointer: bool, pub has_pointer: bool,
// GPU prepare() timing breakdown (from render thread)
pub prepare_timing: PrepareTiming,
// Performance metrics for each section // Performance metrics for each section
pub timing_memory_us: u64, pub timing_memory_us: u64,
pub timing_gpu_us: u64, pub timing_gpu_us: u64,
@ -204,12 +170,6 @@ impl DebugStatsCollector {
let timing_total_us = collection_start.elapsed().as_micros() as u64; let timing_total_us = collection_start.elapsed().as_micros() as u64;
let prepare_timing = LAST_PREPARE_TIMING
.get()
.and_then(|m| m.lock().ok())
.map(|t| t.clone())
.unwrap_or_default();
DebugStats { DebugStats {
fps_current, fps_current,
fps_min, fps_min,
@ -224,7 +184,6 @@ impl DebugStatsCollector {
midi_devices, midi_devices,
audio_input_devices, audio_input_devices,
has_pointer, has_pointer,
prepare_timing,
timing_memory_us, timing_memory_us,
timing_gpu_us, timing_gpu_us,
timing_midi_us, timing_midi_us,
@ -272,16 +231,6 @@ pub fn render_debug_overlay(ctx: &egui::Context, stats: &DebugStats) {
ui.add_space(8.0); ui.add_space(8.0);
// GPU prepare() timing section
let pt = &stats.prepare_timing;
ui.colored_label(egui::Color32::YELLOW, format!("GPU prepare: {:.2} ms", pt.total_ms));
ui.label(format!(" removals: {:.2} ms", pt.removals_ms));
ui.label(format!(" gpu_dispatch: {:.2} ms", pt.gpu_dispatches_ms));
ui.label(format!(" scene_build: {:.2} ms", pt.scene_build_ms));
ui.label(format!(" composite: {:.2} ms", pt.composite_ms));
ui.add_space(8.0);
// Memory section with timing // Memory section with timing
ui.colored_label(egui::Color32::YELLOW, format!("Memory: ({}µs)", stats.timing_memory_us)); ui.colored_label(egui::Color32::YELLOW, format!("Memory: ({}µs)", stats.timing_memory_us));
ui.label(format!("Physical: {} MB", stats.memory_physical_mb)); ui.label(format!("Physical: {} MB", stats.memory_physical_mb));

View File

@ -3,29 +3,13 @@
//! Provides a user interface for configuring and starting audio/video exports. //! Provides a user interface for configuring and starting audio/video exports.
use eframe::egui; use eframe::egui;
use lightningbeam_core::export::{ use lightningbeam_core::export::{AudioExportSettings, AudioFormat, VideoExportSettings, VideoCodec, VideoQuality};
AudioExportSettings, AudioFormat,
ImageExportSettings, ImageFormat,
VideoExportSettings, VideoCodec, VideoQuality,
};
use std::path::PathBuf; use std::path::PathBuf;
/// Hint about document content, used to pick a smart default export type.
pub struct DocumentHint {
pub has_video: bool,
pub has_audio: bool,
pub has_raster: bool,
pub has_vector: bool,
pub current_time: f64,
pub doc_width: u32,
pub doc_height: u32,
}
/// Export type selection /// Export type selection
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ExportType { pub enum ExportType {
Audio, Audio,
Image,
Video, Video,
} }
@ -33,7 +17,6 @@ pub enum ExportType {
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub enum ExportResult { pub enum ExportResult {
AudioOnly(AudioExportSettings, PathBuf), AudioOnly(AudioExportSettings, PathBuf),
Image(ImageExportSettings, PathBuf),
VideoOnly(VideoExportSettings, PathBuf), VideoOnly(VideoExportSettings, PathBuf),
VideoWithAudio(VideoExportSettings, AudioExportSettings, PathBuf), VideoWithAudio(VideoExportSettings, AudioExportSettings, PathBuf),
} }
@ -49,9 +32,6 @@ pub struct ExportDialog {
/// Audio export settings /// Audio export settings
pub audio_settings: AudioExportSettings, pub audio_settings: AudioExportSettings,
/// Image export settings
pub image_settings: ImageExportSettings,
/// Video export settings /// Video export settings
pub video_settings: VideoExportSettings, pub video_settings: VideoExportSettings,
@ -75,15 +55,6 @@ pub struct ExportDialog {
/// Output directory /// Output directory
pub output_dir: PathBuf, pub output_dir: PathBuf,
/// Project name from the last `open()` call — used to detect file switches.
current_project: String,
/// Export type used the last time the user actually clicked Export for `current_project`.
last_export_type: Option<ExportType>,
/// Full path of the most recent successful export. Restored as the default on next open.
last_exported_path: Option<PathBuf>,
} }
impl Default for ExportDialog { impl Default for ExportDialog {
@ -100,7 +71,6 @@ impl Default for ExportDialog {
open: false, open: false,
export_type: ExportType::Audio, export_type: ExportType::Audio,
audio_settings: AudioExportSettings::standard_mp3(), audio_settings: AudioExportSettings::standard_mp3(),
image_settings: ImageExportSettings::default(),
video_settings: VideoExportSettings::default(), video_settings: VideoExportSettings::default(),
include_audio: true, include_audio: true,
output_path: None, output_path: None,
@ -108,56 +78,23 @@ impl Default for ExportDialog {
show_advanced: false, show_advanced: false,
selected_video_preset: 0, selected_video_preset: 0,
output_filename: String::new(), output_filename: String::new(),
current_project: String::new(),
last_export_type: None,
last_exported_path: None,
output_dir: music_dir, output_dir: music_dir,
} }
} }
} }
impl ExportDialog { impl ExportDialog {
/// Open the dialog with default settings, using `hint` to pick a smart default tab. /// Open the dialog with default settings
pub fn open(&mut self, timeline_duration: f64, project_name: &str, hint: &DocumentHint) { pub fn open(&mut self, timeline_duration: f64, project_name: &str) {
self.open = true; self.open = true;
self.audio_settings.end_time = timeline_duration; self.audio_settings.end_time = timeline_duration;
self.video_settings.end_time = timeline_duration; self.video_settings.end_time = timeline_duration;
self.image_settings.time = hint.current_time;
// Propagate document dimensions as defaults (None means "use doc size").
self.image_settings.width = None;
self.image_settings.height = None;
self.error_message = None; self.error_message = None;
// Determine export type: prefer the type used last time for this file, // Pre-populate filename from project name if not already set
// then fall back to document-content hints. if self.output_filename.is_empty() || !self.output_filename.contains(project_name) {
let same_project = self.current_project == project_name; let ext = self.audio_settings.format.extension();
self.export_type = if same_project && self.last_export_type.is_some() { self.output_filename = format!("{}.{}", project_name, ext);
self.last_export_type.unwrap()
} else {
let only_audio = hint.has_audio && !hint.has_video && !hint.has_raster && !hint.has_vector;
let only_raster = hint.has_raster && !hint.has_video && !hint.has_audio && !hint.has_vector;
if hint.has_video { ExportType::Video }
else if only_audio { ExportType::Audio }
else if only_raster { ExportType::Image }
else { self.export_type } // keep current as fallback
};
self.current_project = project_name.to_owned();
// Restore the last exported path if available; otherwise default to project name.
if let Some(ref last) = self.last_exported_path.clone() {
if let Some(dir) = last.parent() { self.output_dir = dir.to_path_buf(); }
if let Some(name) = last.file_name() { self.output_filename = name.to_string_lossy().into_owned(); }
} else if self.output_filename.is_empty() || !self.output_filename.contains(project_name) {
self.output_filename = format!("{}.{}", project_name, self.current_extension());
}
}
/// Extension for the currently selected export type.
fn current_extension(&self) -> &'static str {
match self.export_type {
ExportType::Audio => self.audio_settings.format.extension(),
ExportType::Image => self.image_settings.format.extension(),
ExportType::Video => self.video_settings.codec.container_format(),
} }
} }
@ -169,7 +106,10 @@ impl ExportDialog {
/// Update the filename extension to match the current format /// Update the filename extension to match the current format
fn update_filename_extension(&mut self) { fn update_filename_extension(&mut self) {
let ext = self.current_extension(); let ext = match self.export_type {
ExportType::Audio => self.audio_settings.format.extension(),
ExportType::Video => self.video_settings.codec.container_format(),
};
// Replace extension in filename // Replace extension in filename
if let Some(dot_pos) = self.output_filename.rfind('.') { if let Some(dot_pos) = self.output_filename.rfind('.') {
self.output_filename.truncate(dot_pos + 1); self.output_filename.truncate(dot_pos + 1);
@ -198,7 +138,6 @@ impl ExportDialog {
let window_title = match self.export_type { let window_title = match self.export_type {
ExportType::Audio => "Export Audio", ExportType::Audio => "Export Audio",
ExportType::Image => "Export Image",
ExportType::Video => "Export Video", ExportType::Video => "Export Video",
}; };
@ -217,14 +156,11 @@ impl ExportDialog {
// Export type selection (tabs) // Export type selection (tabs)
ui.horizontal(|ui| { ui.horizontal(|ui| {
for (variant, label) in [ if ui.selectable_value(&mut self.export_type, ExportType::Audio, "Audio").clicked() {
(ExportType::Audio, "Audio"),
(ExportType::Image, "Image"),
(ExportType::Video, "Video"),
] {
if ui.selectable_value(&mut self.export_type, variant, label).clicked() {
self.update_filename_extension(); self.update_filename_extension();
} }
if ui.selectable_value(&mut self.export_type, ExportType::Video, "Video").clicked() {
self.update_filename_extension();
} }
}); });
@ -235,7 +171,6 @@ impl ExportDialog {
// Basic settings // Basic settings
match self.export_type { match self.export_type {
ExportType::Audio => self.render_audio_basic(ui), ExportType::Audio => self.render_audio_basic(ui),
ExportType::Image => self.render_image_settings(ui),
ExportType::Video => self.render_video_basic(ui), ExportType::Video => self.render_video_basic(ui),
} }
@ -253,7 +188,6 @@ impl ExportDialog {
ui.add_space(8.0); ui.add_space(8.0);
match self.export_type { match self.export_type {
ExportType::Audio => self.render_audio_advanced(ui), ExportType::Audio => self.render_audio_advanced(ui),
ExportType::Image => self.render_image_advanced(ui),
ExportType::Video => self.render_video_advanced(ui), ExportType::Video => self.render_video_advanced(ui),
} }
} }
@ -326,62 +260,6 @@ impl ExportDialog {
}); });
} }
/// Render basic image export settings (format, quality, transparency).
fn render_image_settings(&mut self, ui: &mut egui::Ui) {
// Format
ui.horizontal(|ui| {
ui.label("Format:");
let prev = self.image_settings.format;
egui::ComboBox::from_id_salt("image_format")
.selected_text(self.image_settings.format.name())
.show_ui(ui, |ui| {
ui.selectable_value(&mut self.image_settings.format, ImageFormat::Png, "PNG");
ui.selectable_value(&mut self.image_settings.format, ImageFormat::Jpeg, "JPEG");
ui.selectable_value(&mut self.image_settings.format, ImageFormat::WebP, "WebP");
});
if self.image_settings.format != prev {
self.update_filename_extension();
}
});
// Quality (JPEG / WebP only)
if self.image_settings.format.has_quality() {
ui.horizontal(|ui| {
ui.label("Quality:");
ui.add(egui::Slider::new(&mut self.image_settings.quality, 1..=100));
});
}
// Transparency (PNG / WebP only — JPEG has no alpha)
if self.image_settings.format != ImageFormat::Jpeg {
ui.checkbox(&mut self.image_settings.allow_transparency, "Allow transparency");
}
}
/// Render advanced image export settings (time, resolution override).
fn render_image_advanced(&mut self, ui: &mut egui::Ui) {
// Time (which frame to export)
ui.horizontal(|ui| {
ui.label("Time:");
ui.add(egui::DragValue::new(&mut self.image_settings.time)
.speed(0.01)
.range(0.0..=f64::MAX)
.suffix(" s"));
});
// Resolution override (None = use document size; 0 means "use doc size")
ui.horizontal(|ui| {
ui.label("Size:");
let mut w = self.image_settings.width.unwrap_or(0);
let mut h = self.image_settings.height.unwrap_or(0);
let changed_w = ui.add(egui::DragValue::new(&mut w).range(0..=u32::MAX).prefix("W ")).changed();
let changed_h = ui.add(egui::DragValue::new(&mut h).range(0..=u32::MAX).prefix("H ")).changed();
if changed_w { self.image_settings.width = if w == 0 { None } else { Some(w) }; }
if changed_h { self.image_settings.height = if h == 0 { None } else { Some(h) }; }
ui.weak("(0 = document size)");
});
}
/// Render advanced audio settings (sample rate, channels, bit depth, bitrate, time range) /// Render advanced audio settings (sample rate, channels, bit depth, bitrate, time range)
fn render_audio_advanced(&mut self, ui: &mut egui::Ui) { fn render_audio_advanced(&mut self, ui: &mut egui::Ui) {
ui.horizontal(|ui| { ui.horizontal(|ui| {
@ -541,7 +419,6 @@ impl ExportDialog {
fn render_time_range(&mut self, ui: &mut egui::Ui) { fn render_time_range(&mut self, ui: &mut egui::Ui) {
let (start_time, end_time) = match self.export_type { let (start_time, end_time) = match self.export_type {
ExportType::Audio => (&mut self.audio_settings.start_time, &mut self.audio_settings.end_time), ExportType::Audio => (&mut self.audio_settings.start_time, &mut self.audio_settings.end_time),
ExportType::Image => return, // image uses a single time field, not a range
ExportType::Video => (&mut self.video_settings.start_time, &mut self.video_settings.end_time), ExportType::Video => (&mut self.video_settings.start_time, &mut self.video_settings.end_time),
}; };
@ -563,35 +440,26 @@ impl ExportDialog {
ui.label(format!("Duration: {:.2} seconds", duration)); ui.label(format!("Duration: {:.2} seconds", duration));
} }
/// Render output file selection UI — single OS save-file dialog. /// Render output file selection UI
fn render_output_selection(&mut self, ui: &mut egui::Ui) { fn render_output_selection(&mut self, ui: &mut egui::Ui) {
ui.horizontal(|ui| { ui.horizontal(|ui| {
// Show the current path (truncated if long).
let full_path = self.build_output_path();
let path_str = full_path.display().to_string();
ui.label("Save to:"); ui.label("Save to:");
ui.add(egui::Label::new( let dir_text = self.output_dir.display().to_string();
egui::RichText::new(&path_str).weak() ui.label(&dir_text);
).truncate()); if ui.button("Change...").clicked() {
if let Some(dir) = rfd::FileDialog::new()
.set_directory(&self.output_dir)
.pick_folder()
{
self.output_dir = dir;
}
}
}); });
if ui.button("Choose location...").clicked() { ui.horizontal(|ui| {
let ext = self.current_extension(); ui.label("Filename:");
let mut dialog = rfd::FileDialog::new() ui.text_edit_singleline(&mut self.output_filename);
.set_directory(&self.output_dir) });
.set_file_name(&self.output_filename)
.add_filter(ext.to_uppercase(), &[ext]);
if let Some(path) = dialog.save_file() {
if let Some(dir) = path.parent() {
self.output_dir = dir.to_path_buf();
}
if let Some(name) = path.file_name() {
self.output_filename = name.to_string_lossy().into_owned();
// Ensure the extension matches the selected format.
self.update_filename_extension();
}
}
}
} }
/// Handle export button click /// Handle export button click
@ -603,18 +471,7 @@ impl ExportDialog {
let output_path = self.output_path.clone().unwrap(); let output_path = self.output_path.clone().unwrap();
// Remember this export type and path for next time the dialog is opened.
self.last_export_type = Some(self.export_type);
self.last_exported_path = Some(output_path.clone());
let result = match self.export_type { let result = match self.export_type {
ExportType::Image => {
if let Err(err) = self.image_settings.validate() {
self.error_message = Some(err);
return None;
}
Some(ExportResult::Image(self.image_settings.clone(), output_path))
}
ExportType::Audio => { ExportType::Audio => {
// Validate audio settings // Validate audio settings
if let Err(err) = self.audio_settings.validate() { if let Err(err) = self.audio_settings.validate() {

View File

@ -1,70 +0,0 @@
//! Image encoding — save raw RGBA bytes as PNG / JPEG / WebP.
use lightningbeam_core::export::ImageFormat;
use std::path::Path;
/// Encode `pixels` (raw RGBA8, top-left origin) and write to `path`.
///
/// * `allow_transparency` — when true the alpha channel is preserved (PNG/WebP);
/// when false each pixel is composited onto black before encoding.
pub fn save_rgba_image(
pixels: &[u8],
width: u32,
height: u32,
format: ImageFormat,
quality: u8,
allow_transparency: bool,
path: &Path,
) -> Result<(), String> {
use image::{ImageBuffer, Rgba};
let img = ImageBuffer::<Rgba<u8>, _>::from_raw(width, height, pixels.to_vec())
.ok_or_else(|| "Pixel buffer size mismatch".to_string())?;
match format {
ImageFormat::Png => {
if allow_transparency {
img.save(path).map_err(|e| format!("PNG save failed: {e}"))
} else {
let flat = flatten_alpha(img);
flat.save(path).map_err(|e| format!("PNG save failed: {e}"))
}
}
ImageFormat::Jpeg => {
use image::codecs::jpeg::JpegEncoder;
use image::DynamicImage;
use std::fs::File;
use std::io::BufWriter;
// Flatten alpha onto black before JPEG encoding (JPEG has no alpha).
let flat = flatten_alpha(img);
let rgb_img = DynamicImage::ImageRgb8(flat).to_rgb8();
let file = File::create(path).map_err(|e| format!("Cannot create file: {e}"))?;
let writer = BufWriter::new(file);
let mut encoder = JpegEncoder::new_with_quality(writer, quality);
encoder.encode_image(&rgb_img).map_err(|e| format!("JPEG encode failed: {e}"))
}
ImageFormat::WebP => {
if allow_transparency {
img.save(path).map_err(|e| format!("WebP save failed: {e}"))
} else {
let flat = flatten_alpha(img);
flat.save(path).map_err(|e| format!("WebP save failed: {e}"))
}
}
}
}
/// Composite RGBA pixels onto an opaque black background, returning an RGB image.
fn flatten_alpha(img: image::ImageBuffer<image::Rgba<u8>, Vec<u8>>) -> image::ImageBuffer<image::Rgb<u8>, Vec<u8>> {
use image::{ImageBuffer, Rgb};
ImageBuffer::from_fn(img.width(), img.height(), |x, y| {
let p = img.get_pixel(x, y);
let a = p[3] as f32 / 255.0;
Rgb([
(p[0] as f32 * a) as u8,
(p[1] as f32 * a) as u8,
(p[2] as f32 * a) as u8,
])
})
}

View File

@ -5,13 +5,12 @@
pub mod audio_exporter; pub mod audio_exporter;
pub mod dialog; pub mod dialog;
pub mod image_exporter;
pub mod video_exporter; pub mod video_exporter;
pub mod readback_pipeline; pub mod readback_pipeline;
pub mod perf_metrics; pub mod perf_metrics;
pub mod cpu_yuv_converter; pub mod cpu_yuv_converter;
use lightningbeam_core::export::{AudioExportSettings, ImageExportSettings, VideoExportSettings, ExportProgress}; use lightningbeam_core::export::{AudioExportSettings, VideoExportSettings, ExportProgress};
use lightningbeam_core::document::Document; use lightningbeam_core::document::Document;
use lightningbeam_core::renderer::ImageCache; use lightningbeam_core::renderer::ImageCache;
use lightningbeam_core::video::VideoManager; use lightningbeam_core::video::VideoManager;
@ -67,25 +66,6 @@ pub struct VideoExportState {
perf_metrics: Option<perf_metrics::ExportMetrics>, perf_metrics: Option<perf_metrics::ExportMetrics>,
} }
/// State for a single-frame image export (runs on the GPU render thread, one frame per update).
pub struct ImageExportState {
pub settings: ImageExportSettings,
pub output_path: PathBuf,
/// Resolved pixel dimensions (after applying any width/height overrides).
pub width: u32,
pub height: u32,
/// True once rendering has been submitted; the next call reads back and encodes.
pub rendered: bool,
/// GPU resources allocated on the first render call.
pub gpu_resources: Option<video_exporter::ExportGpuResources>,
/// Output RGBA texture — kept separate from gpu_resources to avoid split-borrow issues.
pub output_texture: Option<wgpu::Texture>,
/// View for output_texture.
pub output_texture_view: Option<wgpu::TextureView>,
/// Staging buffer for synchronous GPU→CPU readback.
pub staging_buffer: Option<wgpu::Buffer>,
}
/// Export orchestrator that manages the export process /// Export orchestrator that manages the export process
pub struct ExportOrchestrator { pub struct ExportOrchestrator {
/// Channel for receiving progress updates (video or audio-only export) /// Channel for receiving progress updates (video or audio-only export)
@ -102,9 +82,6 @@ pub struct ExportOrchestrator {
/// Parallel audio+video export state /// Parallel audio+video export state
parallel_export: Option<ParallelExportState>, parallel_export: Option<ParallelExportState>,
/// Single-frame image export state
image_state: Option<ImageExportState>,
} }
/// State for parallel audio+video export /// State for parallel audio+video export
@ -138,7 +115,6 @@ impl ExportOrchestrator {
cancel_flag: Arc::new(AtomicBool::new(false)), cancel_flag: Arc::new(AtomicBool::new(false)),
video_state: None, video_state: None,
parallel_export: None, parallel_export: None,
image_state: None,
} }
} }
@ -470,8 +446,12 @@ impl ExportOrchestrator {
/// Check if an export is in progress /// Check if an export is in progress
pub fn is_exporting(&self) -> bool { pub fn is_exporting(&self) -> bool {
if self.parallel_export.is_some() { return true; } // Check parallel export first
if self.image_state.is_some() { return true; } if self.parallel_export.is_some() {
return true;
}
// Check single export
if let Some(handle) = &self.thread_handle { if let Some(handle) = &self.thread_handle {
!handle.is_finished() !handle.is_finished()
} else { } else {
@ -479,171 +459,6 @@ impl ExportOrchestrator {
} }
} }
/// Enqueue a single-frame image export. Call `render_image_frame()` from the
/// egui update loop (where the wgpu device/queue are available) to complete it.
pub fn start_image_export(
&mut self,
settings: ImageExportSettings,
output_path: PathBuf,
doc_width: u32,
doc_height: u32,
) {
self.cancel_flag.store(false, Ordering::Relaxed);
let width = settings.width.unwrap_or(doc_width).max(1);
let height = settings.height.unwrap_or(doc_height).max(1);
self.image_state = Some(ImageExportState {
settings,
output_path,
width,
height,
rendered: false,
gpu_resources: None,
output_texture: None,
output_texture_view: None,
staging_buffer: None,
});
}
/// Drive the single-frame image export. Returns `Ok(true)` when done (success or
/// cancelled), `Ok(false)` if another call is needed next frame.
pub fn render_image_frame(
&mut self,
document: &mut Document,
device: &wgpu::Device,
queue: &wgpu::Queue,
renderer: &mut vello::Renderer,
image_cache: &mut ImageCache,
video_manager: &Arc<std::sync::Mutex<VideoManager>>,
floating_selection: Option<&lightningbeam_core::selection::RasterFloatingSelection>,
) -> Result<bool, String> {
if self.cancel_flag.load(Ordering::Relaxed) {
self.image_state = None;
return Ok(true);
}
let state = match self.image_state.as_mut() {
Some(s) => s,
None => return Ok(true),
};
if !state.rendered {
// ── First call: render the frame to the GPU output texture ────────
let w = state.width;
let h = state.height;
if state.gpu_resources.is_none() {
state.gpu_resources = Some(video_exporter::ExportGpuResources::new(device, w, h));
}
if state.output_texture.is_none() {
let tex = device.create_texture(&wgpu::TextureDescriptor {
label: Some("image_export_output"),
size: wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 },
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Rgba8Unorm,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
});
state.output_texture_view = Some(tex.create_view(&wgpu::TextureViewDescriptor::default()));
state.output_texture = Some(tex);
}
// Borrow separately to avoid a split-borrow conflict (gpu mutably, view immutably).
let gpu = state.gpu_resources.as_mut().unwrap();
let output_view = state.output_texture_view.as_ref().unwrap();
let mut encoder = video_exporter::render_frame_to_gpu_rgba(
document,
state.settings.time,
w, h,
device, queue, renderer, image_cache, video_manager,
gpu,
output_view,
floating_selection,
state.settings.allow_transparency,
)?;
queue.submit(Some(encoder.finish()));
// Create a staging buffer for synchronous readback.
// wgpu requires bytes_per_row to be a multiple of 256.
let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT;
let bytes_per_row = (w * 4 + align - 1) / align * align;
let staging = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("image_export_staging"),
size: (bytes_per_row * h) as u64,
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
mapped_at_creation: false,
});
let mut copy_enc = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
label: Some("image_export_copy"),
});
let output_tex = state.output_texture.as_ref().unwrap();
copy_enc.copy_texture_to_buffer(
wgpu::TexelCopyTextureInfo {
texture: output_tex,
mip_level: 0,
origin: wgpu::Origin3d::ZERO,
aspect: wgpu::TextureAspect::All,
},
wgpu::TexelCopyBufferInfo {
buffer: &staging,
layout: wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(bytes_per_row),
rows_per_image: Some(h),
},
},
wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 },
);
queue.submit(Some(copy_enc.finish()));
state.staging_buffer = Some(staging);
state.rendered = true;
return Ok(false); // Come back next frame to read the result.
}
// ── Second call: map the staging buffer, encode, and save ─────────────
let staging = match state.staging_buffer.as_ref() {
Some(b) => b,
None => { self.image_state = None; return Ok(true); }
};
// Map synchronously.
let slice = staging.slice(..);
slice.map_async(wgpu::MapMode::Read, |_| {});
let _ = device.poll(wgpu::PollType::wait_indefinitely());
let w = state.width;
let h = state.height;
let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT;
let bytes_per_row = (w * 4 + align - 1) / align * align;
let pixels: Vec<u8> = {
let mapped = slice.get_mapped_range();
// Strip row padding: copy only w*4 bytes from each bytes_per_row-wide row.
let mut out = Vec::with_capacity((w * h * 4) as usize);
for row in 0..h {
let start = (row * bytes_per_row) as usize;
out.extend_from_slice(&mapped[start..start + (w * 4) as usize]);
}
out
};
staging.unmap();
let result = image_exporter::save_rgba_image(
&pixels, w, h,
state.settings.format,
state.settings.quality,
state.settings.allow_transparency,
&state.output_path,
);
self.image_state = None;
result.map(|_| true)
}
/// Wait for the export to complete /// Wait for the export to complete
/// ///
/// This blocks until the export thread finishes. /// This blocks until the export thread finishes.
@ -1109,8 +924,6 @@ impl ExportOrchestrator {
document, timestamp, width, height, document, timestamp, width, height,
device, queue, renderer, image_cache, video_manager, device, queue, renderer, image_cache, video_manager,
gpu_resources, &acquired.rgba_texture_view, gpu_resources, &acquired.rgba_texture_view,
None, // No floating selection during video export
false, // Video export is never transparent
)?; )?;
let render_end = Instant::now(); let render_end = Instant::now();

View File

@ -79,10 +79,6 @@ pub struct ExportGpuResources {
pub linear_to_srgb_bind_group_layout: wgpu::BindGroupLayout, pub linear_to_srgb_bind_group_layout: wgpu::BindGroupLayout,
/// Sampler for linear to sRGB conversion /// Sampler for linear to sRGB conversion
pub linear_to_srgb_sampler: wgpu::Sampler, pub linear_to_srgb_sampler: wgpu::Sampler,
/// Canvas blit pipeline for raster/video/float layers (bypasses Vello).
pub canvas_blit: crate::gpu_brush::CanvasBlitPipeline,
/// Per-keyframe GPU texture cache for raster layers during export.
pub raster_cache: std::collections::HashMap<uuid::Uuid, crate::gpu_brush::CanvasPair>,
} }
impl ExportGpuResources { impl ExportGpuResources {
@ -239,8 +235,6 @@ impl ExportGpuResources {
..Default::default() ..Default::default()
}); });
let canvas_blit = crate::gpu_brush::CanvasBlitPipeline::new(device);
Self { Self {
buffer_pool, buffer_pool,
compositor, compositor,
@ -257,8 +251,6 @@ impl ExportGpuResources {
linear_to_srgb_pipeline, linear_to_srgb_pipeline,
linear_to_srgb_bind_group_layout, linear_to_srgb_bind_group_layout,
linear_to_srgb_sampler, linear_to_srgb_sampler,
canvas_blit,
raster_cache: std::collections::HashMap::new(),
} }
} }
@ -710,233 +702,6 @@ pub fn render_frame_to_rgba(
Ok(()) Ok(())
} }
/// Composite all layers from `composite_result` into `gpu_resources.hdr_texture_view`.
///
/// Shared by both export functions. Handles every layer type:
/// - Vector/Group: Vello scene → sRGB → linear → composite
/// - Raster: upload pixels to `raster_cache` (if needed) → GPU blit → composite
/// - Video: sRGB straight-alpha → linear premultiplied → transient GPU texture → blit → composite
/// - Float: sRGB-premultiplied → linear → transient GPU texture → blit → composite
/// - Effect: apply post-process on the HDR accumulator
fn composite_document_to_hdr(
composite_result: &lightningbeam_core::renderer::CompositeRenderResult,
document: &Document,
device: &wgpu::Device,
queue: &wgpu::Queue,
renderer: &mut vello::Renderer,
gpu_resources: &mut ExportGpuResources,
width: u32,
height: u32,
allow_transparency: bool,
) -> Result<(), String> {
use vello::kurbo::Affine;
let layer_spec = BufferSpec::new(width, height, BufferFormat::Rgba8Srgb);
let hdr_spec = BufferSpec::new(width, height, BufferFormat::Rgba16Float);
let layer_render_params = vello::RenderParams {
base_color: vello::peniko::Color::TRANSPARENT,
width, height,
antialiasing_method: vello::AaConfig::Area,
};
// --- Background ---
let bg_srgb = gpu_resources.buffer_pool.acquire(device, layer_spec);
let bg_hdr = gpu_resources.buffer_pool.acquire(device, hdr_spec);
if let (Some(bg_srgb_view), Some(bg_hdr_view)) = (
gpu_resources.buffer_pool.get_view(bg_srgb),
gpu_resources.buffer_pool.get_view(bg_hdr),
) {
renderer.render_to_texture(device, queue, &composite_result.background, bg_srgb_view, &layer_render_params)
.map_err(|e| format!("Failed to render background: {e}"))?;
let mut enc = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("export_bg_srgb_to_linear") });
gpu_resources.srgb_to_linear.convert(device, &mut enc, bg_srgb_view, bg_hdr_view);
queue.submit(Some(enc.finish()));
let bg_layer = CompositorLayer::normal(bg_hdr, 1.0);
let mut enc = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("export_bg_composite") });
// When transparency is allowed, start from transparent black so the background's
// native alpha is preserved. Otherwise force an opaque black underlay.
let clear = if allow_transparency { [0.0, 0.0, 0.0, 0.0] } else { [0.0, 0.0, 0.0, 1.0] };
gpu_resources.compositor.composite(device, queue, &mut enc, &[bg_layer],
&gpu_resources.buffer_pool, &gpu_resources.hdr_texture_view, Some(clear));
queue.submit(Some(enc.finish()));
}
gpu_resources.buffer_pool.release(bg_srgb);
gpu_resources.buffer_pool.release(bg_hdr);
// --- Layers ---
for rendered_layer in &composite_result.layers {
if !rendered_layer.has_content { continue; }
match &rendered_layer.layer_type {
RenderedLayerType::Vector => {
let srgb_handle = gpu_resources.buffer_pool.acquire(device, layer_spec);
let hdr_layer_handle = gpu_resources.buffer_pool.acquire(device, hdr_spec);
if let (Some(srgb_view), Some(hdr_layer_view)) = (
gpu_resources.buffer_pool.get_view(srgb_handle),
gpu_resources.buffer_pool.get_view(hdr_layer_handle),
) {
renderer.render_to_texture(device, queue, &rendered_layer.scene, srgb_view, &layer_render_params)
.map_err(|e| format!("Failed to render layer: {e}"))?;
let mut enc = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("export_layer_srgb_to_linear") });
gpu_resources.srgb_to_linear.convert(device, &mut enc, srgb_view, hdr_layer_view);
queue.submit(Some(enc.finish()));
let compositor_layer = CompositorLayer::new(hdr_layer_handle, rendered_layer.opacity, rendered_layer.blend_mode);
let mut enc = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("export_layer_composite") });
gpu_resources.compositor.composite(device, queue, &mut enc, &[compositor_layer], &gpu_resources.buffer_pool, &gpu_resources.hdr_texture_view, None);
queue.submit(Some(enc.finish()));
}
gpu_resources.buffer_pool.release(srgb_handle);
gpu_resources.buffer_pool.release(hdr_layer_handle);
}
RenderedLayerType::Raster { kf_id, width: cw, height: ch, transform: layer_transform, dirty: _ } => {
let raw_pixels = document.get_layer(&rendered_layer.layer_id)
.and_then(|l| match l {
lightningbeam_core::layer::AnyLayer::Raster(rl) => rl.keyframe_at(document.current_time),
_ => None,
})
.filter(|kf| !kf.raw_pixels.is_empty())
.map(|kf| kf.raw_pixels.clone());
if let Some(pixels) = raw_pixels {
if !gpu_resources.raster_cache.contains_key(kf_id) {
let canvas = crate::gpu_brush::CanvasPair::new(device, *cw, *ch);
canvas.upload(queue, &pixels);
gpu_resources.raster_cache.insert(*kf_id, canvas);
}
if let Some(canvas) = gpu_resources.raster_cache.get(kf_id) {
let hdr_layer_handle = gpu_resources.buffer_pool.acquire(device, hdr_spec);
if let Some(hdr_layer_view) = gpu_resources.buffer_pool.get_view(hdr_layer_handle) {
let bt = crate::gpu_brush::BlitTransform::new(*layer_transform, *cw, *ch, width, height);
gpu_resources.canvas_blit.blit(device, queue, canvas.src_view(), hdr_layer_view, &bt, None);
let compositor_layer = CompositorLayer::new(hdr_layer_handle, rendered_layer.opacity, rendered_layer.blend_mode);
let mut enc = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("export_raster_composite") });
gpu_resources.compositor.composite(device, queue, &mut enc, &[compositor_layer], &gpu_resources.buffer_pool, &gpu_resources.hdr_texture_view, None);
queue.submit(Some(enc.finish()));
}
gpu_resources.buffer_pool.release(hdr_layer_handle);
}
}
}
RenderedLayerType::Video { instances } => {
for inst in instances {
if inst.rgba_data.is_empty() { continue; }
let hdr_layer_handle = gpu_resources.buffer_pool.acquire(device, hdr_spec);
if let Some(hdr_layer_view) = gpu_resources.buffer_pool.get_view(hdr_layer_handle) {
// sRGB straight-alpha → linear premultiplied
let linear: Vec<u8> = inst.rgba_data.chunks_exact(4).flat_map(|p| {
let a = p[3] as f32 / 255.0;
let lin = |c: u8| -> f32 {
let f = c as f32 / 255.0;
if f <= 0.04045 { f / 12.92 } else { ((f + 0.055) / 1.055).powf(2.4) }
};
let r = (lin(p[0]) * a * 255.0 + 0.5) as u8;
let g = (lin(p[1]) * a * 255.0 + 0.5) as u8;
let b = (lin(p[2]) * a * 255.0 + 0.5) as u8;
[r, g, b, p[3]]
}).collect();
let tex = upload_transient_texture(device, queue, &linear, inst.width, inst.height, Some("export_video_frame_tex"));
let tex_view = tex.create_view(&Default::default());
let bt = crate::gpu_brush::BlitTransform::new(inst.transform, inst.width, inst.height, width, height);
gpu_resources.canvas_blit.blit(device, queue, &tex_view, hdr_layer_view, &bt, None);
let compositor_layer = CompositorLayer::new(hdr_layer_handle, inst.opacity, lightningbeam_core::gpu::BlendMode::Normal);
let mut enc = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("export_video_composite") });
gpu_resources.compositor.composite(device, queue, &mut enc, &[compositor_layer], &gpu_resources.buffer_pool, &gpu_resources.hdr_texture_view, None);
queue.submit(Some(enc.finish()));
}
gpu_resources.buffer_pool.release(hdr_layer_handle);
}
}
RenderedLayerType::Float { x: float_x, y: float_y, width: fw, height: fh, transform: layer_transform, pixels, .. } => {
if !pixels.is_empty() {
// sRGB-premultiplied → linear-premultiplied
let linear: Vec<u8> = pixels.chunks_exact(4).flat_map(|p| {
let lin = |c: u8| -> u8 {
let f = c as f32 / 255.0;
let l = if f <= 0.04045 { f / 12.92 } else { ((f + 0.055) / 1.055).powf(2.4) };
(l * 255.0 + 0.5) as u8
};
[lin(p[0]), lin(p[1]), lin(p[2]), p[3]]
}).collect();
let tex = upload_transient_texture(device, queue, &linear, *fw, *fh, Some("export_float_tex"));
let tex_view = tex.create_view(&Default::default());
let hdr_layer_handle = gpu_resources.buffer_pool.acquire(device, hdr_spec);
if let Some(hdr_layer_view) = gpu_resources.buffer_pool.get_view(hdr_layer_handle) {
let float_to_vp = *layer_transform * Affine::translate((*float_x as f64, *float_y as f64));
let bt = crate::gpu_brush::BlitTransform::new(float_to_vp, *fw, *fh, width, height);
gpu_resources.canvas_blit.blit(device, queue, &tex_view, hdr_layer_view, &bt, None);
let compositor_layer = CompositorLayer::normal(hdr_layer_handle, 1.0);
let mut enc = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("export_float_composite") });
gpu_resources.compositor.composite(device, queue, &mut enc, &[compositor_layer], &gpu_resources.buffer_pool, &gpu_resources.hdr_texture_view, None);
queue.submit(Some(enc.finish()));
}
gpu_resources.buffer_pool.release(hdr_layer_handle);
}
}
RenderedLayerType::Effect { effect_instances } => {
let current_time = document.current_time;
for effect_instance in effect_instances {
let Some(effect_def) = document.get_effect_definition(&effect_instance.clip_id) else { continue; };
if !gpu_resources.effect_processor.is_compiled(&effect_def.id) {
let success = gpu_resources.effect_processor.compile_effect(device, effect_def);
if !success { eprintln!("Failed to compile effect: {}", effect_def.name); continue; }
}
let effect_inst = lightningbeam_core::effect::EffectInstance::new(
effect_def,
effect_instance.timeline_start,
effect_instance.timeline_start + effect_instance.effective_duration(lightningbeam_core::effect::EFFECT_DURATION),
);
let effect_output_handle = gpu_resources.buffer_pool.acquire(device, hdr_spec);
if let Some(effect_output_view) = gpu_resources.buffer_pool.get_view(effect_output_handle) {
let mut enc = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("export_effect") });
let applied = gpu_resources.effect_processor.apply_effect(
device, queue, &mut enc, effect_def, &effect_inst,
&gpu_resources.hdr_texture_view, effect_output_view, width, height, current_time,
);
if applied {
queue.submit(Some(enc.finish()));
let effect_layer = CompositorLayer::normal(effect_output_handle, rendered_layer.opacity);
let mut copy_enc = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("export_effect_copy") });
// Replace the accumulator with the processed result.
gpu_resources.compositor.composite(device, queue, &mut copy_enc, &[effect_layer], &gpu_resources.buffer_pool, &gpu_resources.hdr_texture_view, Some([0.0, 0.0, 0.0, 0.0]));
queue.submit(Some(copy_enc.finish()));
}
}
gpu_resources.buffer_pool.release(effect_output_handle);
}
}
}
}
gpu_resources.buffer_pool.next_frame();
Ok(())
}
/// Upload `pixels` to a transient `Rgba8Unorm` GPU texture (TEXTURE_BINDING | COPY_DST).
fn upload_transient_texture(
device: &wgpu::Device,
queue: &wgpu::Queue,
pixels: &[u8],
width: u32,
height: u32,
label: Option<&'static str>,
) -> wgpu::Texture {
let tex = device.create_texture(&wgpu::TextureDescriptor {
label,
size: wgpu::Extent3d { width, height, depth_or_array_layers: 1 },
mip_level_count: 1, sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Rgba8Unorm,
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
view_formats: &[],
});
queue.write_texture(
wgpu::TexelCopyTextureInfo { texture: &tex, mip_level: 0, origin: wgpu::Origin3d::ZERO, aspect: wgpu::TextureAspect::All },
pixels,
wgpu::TexelCopyBufferLayout { offset: 0, bytes_per_row: Some(width * 4), rows_per_image: Some(height) },
wgpu::Extent3d { width, height, depth_or_array_layers: 1 },
);
tex
}
/// Render a document frame using the HDR compositing pipeline with effects /// Render a document frame using the HDR compositing pipeline with effects
/// ///
/// This function uses the same rendering pipeline as the stage preview, /// This function uses the same rendering pipeline as the stage preview,
@ -983,12 +748,193 @@ pub fn render_frame_to_rgba_hdr(
image_cache, image_cache,
video_manager, video_manager,
None, // No webcam during export None, // No webcam during export
None, // No floating selection during export
false, // No checkerboard in export
); );
// Video export is never transparent. // Buffer specs for layer rendering
composite_document_to_hdr(&composite_result, document, device, queue, renderer, gpu_resources, width, height, false)?; let layer_spec = BufferSpec::new(width, height, BufferFormat::Rgba8Srgb);
let hdr_spec = BufferSpec::new(width, height, BufferFormat::Rgba16Float);
// Render parameters for Vello (transparent background for layers)
let layer_render_params = vello::RenderParams {
base_color: vello::peniko::Color::TRANSPARENT,
width,
height,
antialiasing_method: vello::AaConfig::Area,
};
// First, render background and composite it
let bg_srgb_handle = gpu_resources.buffer_pool.acquire(device, layer_spec);
let bg_hdr_handle = gpu_resources.buffer_pool.acquire(device, hdr_spec);
if let (Some(bg_srgb_view), Some(bg_hdr_view)) = (
gpu_resources.buffer_pool.get_view(bg_srgb_handle),
gpu_resources.buffer_pool.get_view(bg_hdr_handle),
) {
// Render background scene
renderer.render_to_texture(device, queue, &composite_result.background, bg_srgb_view, &layer_render_params)
.map_err(|e| format!("Failed to render background: {}", e))?;
// Convert sRGB to linear HDR
let mut convert_encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
label: Some("export_bg_srgb_to_linear_encoder"),
});
gpu_resources.srgb_to_linear.convert(device, &mut convert_encoder, bg_srgb_view, bg_hdr_view);
queue.submit(Some(convert_encoder.finish()));
// Composite background onto HDR texture (first layer, clears to black for export)
let bg_compositor_layer = CompositorLayer::normal(bg_hdr_handle, 1.0);
let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
label: Some("export_bg_composite_encoder"),
});
// Clear to black for export (unlike stage preview which has gray background)
gpu_resources.compositor.composite(
device,
queue,
&mut encoder,
&[bg_compositor_layer],
&gpu_resources.buffer_pool,
&gpu_resources.hdr_texture_view,
Some([0.0, 0.0, 0.0, 1.0]),
);
queue.submit(Some(encoder.finish()));
}
gpu_resources.buffer_pool.release(bg_srgb_handle);
gpu_resources.buffer_pool.release(bg_hdr_handle);
// Now render and composite each layer incrementally
for rendered_layer in &composite_result.layers {
if !rendered_layer.has_content {
continue;
}
match &rendered_layer.layer_type {
RenderedLayerType::Content => {
// Regular content layer - render to sRGB, convert to linear, then composite
let srgb_handle = gpu_resources.buffer_pool.acquire(device, layer_spec);
let hdr_layer_handle = gpu_resources.buffer_pool.acquire(device, hdr_spec);
if let (Some(srgb_view), Some(hdr_layer_view)) = (
gpu_resources.buffer_pool.get_view(srgb_handle),
gpu_resources.buffer_pool.get_view(hdr_layer_handle),
) {
// Render layer scene to sRGB buffer
renderer.render_to_texture(device, queue, &rendered_layer.scene, srgb_view, &layer_render_params)
.map_err(|e| format!("Failed to render layer: {}", e))?;
// Convert sRGB to linear HDR
let mut convert_encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
label: Some("export_layer_srgb_to_linear_encoder"),
});
gpu_resources.srgb_to_linear.convert(device, &mut convert_encoder, srgb_view, hdr_layer_view);
queue.submit(Some(convert_encoder.finish()));
// Composite this layer onto the HDR accumulator with its opacity
let compositor_layer = CompositorLayer::new(
hdr_layer_handle,
rendered_layer.opacity,
rendered_layer.blend_mode,
);
let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
label: Some("export_layer_composite_encoder"),
});
gpu_resources.compositor.composite(
device,
queue,
&mut encoder,
&[compositor_layer],
&gpu_resources.buffer_pool,
&gpu_resources.hdr_texture_view,
None, // Don't clear - blend onto existing content
);
queue.submit(Some(encoder.finish()));
}
gpu_resources.buffer_pool.release(srgb_handle);
gpu_resources.buffer_pool.release(hdr_layer_handle);
}
RenderedLayerType::Effect { effect_instances } => {
// Effect layer - apply effects to the current HDR accumulator
let current_time = document.current_time;
for effect_instance in effect_instances {
// Get effect definition from document
let Some(effect_def) = document.get_effect_definition(&effect_instance.clip_id) else {
continue;
};
// Compile effect if needed
if !gpu_resources.effect_processor.is_compiled(&effect_def.id) {
let success = gpu_resources.effect_processor.compile_effect(device, effect_def);
if !success {
eprintln!("Failed to compile effect: {}", effect_def.name);
continue;
}
}
// Create EffectInstance from ClipInstance for the processor
let effect_inst = lightningbeam_core::effect::EffectInstance::new(
effect_def,
effect_instance.timeline_start,
effect_instance.timeline_start + effect_instance.effective_duration(lightningbeam_core::effect::EFFECT_DURATION),
);
// Acquire temp buffer for effect output (HDR format)
let effect_output_handle = gpu_resources.buffer_pool.acquire(device, hdr_spec);
if let Some(effect_output_view) = gpu_resources.buffer_pool.get_view(effect_output_handle) {
let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
label: Some("export_effect_encoder"),
});
// Apply effect: HDR accumulator → effect output buffer
let applied = gpu_resources.effect_processor.apply_effect(
device,
queue,
&mut encoder,
effect_def,
&effect_inst,
&gpu_resources.hdr_texture_view,
effect_output_view,
width,
height,
current_time,
);
if applied {
queue.submit(Some(encoder.finish()));
// Copy effect output back to HDR accumulator
let mut copy_encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
label: Some("export_effect_copy_encoder"),
});
// Use compositor to copy (replacing content)
let effect_layer = CompositorLayer::normal(
effect_output_handle,
rendered_layer.opacity, // Apply effect layer opacity
);
gpu_resources.compositor.composite(
device,
queue,
&mut copy_encoder,
&[effect_layer],
&gpu_resources.buffer_pool,
&gpu_resources.hdr_texture_view,
Some([0.0, 0.0, 0.0, 0.0]), // Clear with transparent (we're replacing)
);
queue.submit(Some(copy_encoder.finish()));
}
}
gpu_resources.buffer_pool.release(effect_output_handle);
}
}
}
}
// Advance frame counter for buffer cleanup
gpu_resources.buffer_pool.next_frame();
// Use persistent output texture (already created in ExportGpuResources) // Use persistent output texture (already created in ExportGpuResources)
let output_view = &gpu_resources.output_texture_view; let output_view = &gpu_resources.output_texture_view;
@ -1172,8 +1118,6 @@ pub fn render_frame_to_gpu_rgba(
video_manager: &Arc<std::sync::Mutex<VideoManager>>, video_manager: &Arc<std::sync::Mutex<VideoManager>>,
gpu_resources: &mut ExportGpuResources, gpu_resources: &mut ExportGpuResources,
rgba_texture_view: &wgpu::TextureView, rgba_texture_view: &wgpu::TextureView,
floating_selection: Option<&lightningbeam_core::selection::RasterFloatingSelection>,
allow_transparency: bool,
) -> Result<wgpu::CommandEncoder, String> { ) -> Result<wgpu::CommandEncoder, String> {
use vello::kurbo::Affine; use vello::kurbo::Affine;
@ -1190,11 +1134,176 @@ pub fn render_frame_to_gpu_rgba(
image_cache, image_cache,
video_manager, video_manager,
None, // No webcam during export None, // No webcam during export
floating_selection,
false, // No checkerboard in export
); );
composite_document_to_hdr(&composite_result, document, device, queue, renderer, gpu_resources, width, height, allow_transparency)?; // Buffer specs for layer rendering
let layer_spec = BufferSpec::new(width, height, BufferFormat::Rgba8Srgb);
let hdr_spec = BufferSpec::new(width, height, BufferFormat::Rgba16Float);
// Render parameters for Vello (transparent background for layers)
let layer_render_params = vello::RenderParams {
base_color: vello::peniko::Color::TRANSPARENT,
width,
height,
antialiasing_method: vello::AaConfig::Area,
};
// Render background and composite it
let bg_srgb_handle = gpu_resources.buffer_pool.acquire(device, layer_spec);
let bg_hdr_handle = gpu_resources.buffer_pool.acquire(device, hdr_spec);
if let (Some(bg_srgb_view), Some(bg_hdr_view)) = (
gpu_resources.buffer_pool.get_view(bg_srgb_handle),
gpu_resources.buffer_pool.get_view(bg_hdr_handle),
) {
renderer.render_to_texture(device, queue, &composite_result.background, bg_srgb_view, &layer_render_params)
.map_err(|e| format!("Failed to render background: {}", e))?;
let mut convert_encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
label: Some("export_bg_srgb_to_linear_encoder"),
});
gpu_resources.srgb_to_linear.convert(device, &mut convert_encoder, bg_srgb_view, bg_hdr_view);
queue.submit(Some(convert_encoder.finish()));
let bg_compositor_layer = CompositorLayer::normal(bg_hdr_handle, 1.0);
let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
label: Some("export_bg_composite_encoder"),
});
gpu_resources.compositor.composite(
device,
queue,
&mut encoder,
&[bg_compositor_layer],
&gpu_resources.buffer_pool,
&gpu_resources.hdr_texture_view,
Some([0.0, 0.0, 0.0, 1.0]),
);
queue.submit(Some(encoder.finish()));
}
gpu_resources.buffer_pool.release(bg_srgb_handle);
gpu_resources.buffer_pool.release(bg_hdr_handle);
// Render and composite each layer incrementally
for rendered_layer in &composite_result.layers {
if !rendered_layer.has_content {
continue;
}
match &rendered_layer.layer_type {
RenderedLayerType::Content => {
let srgb_handle = gpu_resources.buffer_pool.acquire(device, layer_spec);
let hdr_layer_handle = gpu_resources.buffer_pool.acquire(device, hdr_spec);
if let (Some(srgb_view), Some(hdr_layer_view)) = (
gpu_resources.buffer_pool.get_view(srgb_handle),
gpu_resources.buffer_pool.get_view(hdr_layer_handle),
) {
renderer.render_to_texture(device, queue, &rendered_layer.scene, srgb_view, &layer_render_params)
.map_err(|e| format!("Failed to render layer: {}", e))?;
let mut convert_encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
label: Some("export_layer_srgb_to_linear_encoder"),
});
gpu_resources.srgb_to_linear.convert(device, &mut convert_encoder, srgb_view, hdr_layer_view);
queue.submit(Some(convert_encoder.finish()));
let compositor_layer = CompositorLayer::normal(hdr_layer_handle, rendered_layer.opacity);
let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
label: Some("export_layer_composite_encoder"),
});
gpu_resources.compositor.composite(
device,
queue,
&mut encoder,
&[compositor_layer],
&gpu_resources.buffer_pool,
&gpu_resources.hdr_texture_view,
None,
);
queue.submit(Some(encoder.finish()));
}
gpu_resources.buffer_pool.release(srgb_handle);
gpu_resources.buffer_pool.release(hdr_layer_handle);
}
RenderedLayerType::Effect { effect_instances } => {
// Effect layer - apply effects to the current HDR accumulator
let current_time = document.current_time;
for effect_instance in effect_instances {
// Get effect definition from document
let Some(effect_def) = document.get_effect_definition(&effect_instance.clip_id) else {
continue;
};
// Compile effect if needed
if !gpu_resources.effect_processor.is_compiled(&effect_def.id) {
let success = gpu_resources.effect_processor.compile_effect(device, effect_def);
if !success {
eprintln!("Failed to compile effect: {}", effect_def.name);
continue;
}
}
// Create EffectInstance from ClipInstance for the processor
let effect_inst = lightningbeam_core::effect::EffectInstance::new(
effect_def,
effect_instance.timeline_start,
effect_instance.timeline_start + effect_instance.effective_duration(lightningbeam_core::effect::EFFECT_DURATION),
);
// Acquire temp buffer for effect output (HDR format)
let effect_output_handle = gpu_resources.buffer_pool.acquire(device, hdr_spec);
if let Some(effect_output_view) = gpu_resources.buffer_pool.get_view(effect_output_handle) {
let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
label: Some("export_effect_encoder"),
});
// Apply effect: HDR accumulator → effect output buffer
let applied = gpu_resources.effect_processor.apply_effect(
device,
queue,
&mut encoder,
effect_def,
&effect_inst,
&gpu_resources.hdr_texture_view,
effect_output_view,
width,
height,
current_time,
);
if applied {
// Copy effect output back to HDR accumulator
encoder.copy_texture_to_texture(
wgpu::TexelCopyTextureInfo {
texture: gpu_resources.buffer_pool.get_texture(effect_output_handle).unwrap(),
mip_level: 0,
origin: wgpu::Origin3d::ZERO,
aspect: wgpu::TextureAspect::All,
},
wgpu::TexelCopyTextureInfo {
texture: &gpu_resources.hdr_texture,
mip_level: 0,
origin: wgpu::Origin3d::ZERO,
aspect: wgpu::TextureAspect::All,
},
wgpu::Extent3d {
width,
height,
depth_or_array_layers: 1,
},
);
}
queue.submit(Some(encoder.finish()));
}
gpu_resources.buffer_pool.release(effect_output_handle);
}
}
}
}
// Convert HDR to sRGB (linear → sRGB), render directly to external RGBA texture // Convert HDR to sRGB (linear → sRGB), render directly to external RGBA texture
let output_view = rgba_texture_view; let output_view = rgba_texture_view;

File diff suppressed because it is too large Load Diff

View File

@ -429,26 +429,24 @@ impl AppAction {
/// `Tool::Split` has no tool-shortcut action (it's triggered via the menu). /// `Tool::Split` has no tool-shortcut action (it's triggered via the menu).
pub fn tool_app_action(tool: lightningbeam_core::tool::Tool) -> Option<AppAction> { pub fn tool_app_action(tool: lightningbeam_core::tool::Tool) -> Option<AppAction> {
use lightningbeam_core::tool::Tool; use lightningbeam_core::tool::Tool;
match tool { Some(match tool {
Tool::Select => Some(AppAction::ToolSelect), Tool::Select => AppAction::ToolSelect,
Tool::Draw => Some(AppAction::ToolDraw), Tool::Draw => AppAction::ToolDraw,
Tool::Transform => Some(AppAction::ToolTransform), Tool::Transform => AppAction::ToolTransform,
Tool::Rectangle => Some(AppAction::ToolRectangle), Tool::Rectangle => AppAction::ToolRectangle,
Tool::Ellipse => Some(AppAction::ToolEllipse), Tool::Ellipse => AppAction::ToolEllipse,
Tool::PaintBucket => Some(AppAction::ToolPaintBucket), Tool::PaintBucket => AppAction::ToolPaintBucket,
Tool::Eyedropper => Some(AppAction::ToolEyedropper), Tool::Eyedropper => AppAction::ToolEyedropper,
Tool::Line => Some(AppAction::ToolLine), Tool::Line => AppAction::ToolLine,
Tool::Polygon => Some(AppAction::ToolPolygon), Tool::Polygon => AppAction::ToolPolygon,
Tool::BezierEdit => Some(AppAction::ToolBezierEdit), Tool::BezierEdit => AppAction::ToolBezierEdit,
Tool::Text => Some(AppAction::ToolText), Tool::Text => AppAction::ToolText,
Tool::RegionSelect => Some(AppAction::ToolRegionSelect), Tool::RegionSelect => AppAction::ToolRegionSelect,
Tool::Erase => Some(AppAction::ToolErase), Tool::Erase => AppAction::ToolErase,
Tool::Smudge => Some(AppAction::ToolSmudge), Tool::Smudge => AppAction::ToolSmudge,
Tool::SelectLasso => Some(AppAction::ToolSelectLasso), Tool::SelectLasso => AppAction::ToolSelectLasso,
Tool::Split => Some(AppAction::ToolSplit), Tool::Split => AppAction::ToolSplit,
// New tools have no keybinding yet })
_ => None,
}
} }
// === Default bindings === // === Default bindings ===

View File

@ -13,8 +13,6 @@ use uuid::Uuid;
mod panes; mod panes;
use panes::{PaneInstance, PaneRenderer}; use panes::{PaneInstance, PaneRenderer};
mod tools;
mod widgets; mod widgets;
mod menu; mod menu;
@ -28,8 +26,6 @@ mod waveform_gpu;
mod cqt_gpu; mod cqt_gpu;
mod gpu_brush; mod gpu_brush;
mod raster_tool;
mod config; mod config;
use config::AppConfig; use config::AppConfig;
@ -336,7 +332,6 @@ mod tool_icons {
pub static ERASE: &[u8] = include_bytes!("../../../src/assets/erase.svg"); pub static ERASE: &[u8] = include_bytes!("../../../src/assets/erase.svg");
pub static SMUDGE: &[u8] = include_bytes!("../../../src/assets/smudge.svg"); pub static SMUDGE: &[u8] = include_bytes!("../../../src/assets/smudge.svg");
pub static LASSO: &[u8] = include_bytes!("../../../src/assets/lasso.svg"); pub static LASSO: &[u8] = include_bytes!("../../../src/assets/lasso.svg");
pub static TODO: &[u8] = include_bytes!("../../../src/assets/todo.svg");
} }
/// Embedded focus icon SVGs /// Embedded focus icon SVGs
@ -404,28 +399,11 @@ impl ToolIconCache {
Tool::Polygon => tool_icons::POLYGON, Tool::Polygon => tool_icons::POLYGON,
Tool::BezierEdit => tool_icons::BEZIER_EDIT, Tool::BezierEdit => tool_icons::BEZIER_EDIT,
Tool::Text => tool_icons::TEXT, Tool::Text => tool_icons::TEXT,
Tool::RegionSelect => tool_icons::SELECT, Tool::RegionSelect => tool_icons::SELECT, // Reuse select icon for now
Tool::Split => tool_icons::SPLIT, Tool::Split => tool_icons::SPLIT,
Tool::Erase => tool_icons::ERASE, Tool::Erase => tool_icons::ERASE,
Tool::Smudge => tool_icons::SMUDGE, Tool::Smudge => tool_icons::SMUDGE,
Tool::SelectLasso => tool_icons::LASSO, Tool::SelectLasso => tool_icons::LASSO,
// Not yet implemented — use placeholder icon
Tool::Pencil
| Tool::Pen
| Tool::Airbrush
| Tool::CloneStamp
| Tool::HealingBrush
| Tool::PatternStamp
| Tool::DodgeBurn
| Tool::Sponge
| Tool::BlurSharpen
| Tool::Gradient
| Tool::CustomShape
| Tool::SelectEllipse
| Tool::MagicWand
| Tool::QuickSelect
| Tool::Warp
| Tool::Liquify => tool_icons::TODO,
}; };
if let Some(texture) = rasterize_svg(svg_data, tool.icon_file(), 180, ctx) { if let Some(texture) = rasterize_svg(svg_data, tool.icon_file(), 180, ctx) {
self.icons.insert(tool, texture); self.icons.insert(tool, texture);
@ -788,10 +766,12 @@ struct EditorApp {
draw_simplify_mode: lightningbeam_core::tool::SimplifyMode, // Current simplification mode for draw tool draw_simplify_mode: lightningbeam_core::tool::SimplifyMode, // Current simplification mode for draw tool
rdp_tolerance: f64, // RDP simplification tolerance (default: 10.0) rdp_tolerance: f64, // RDP simplification tolerance (default: 10.0)
schneider_max_error: f64, // Schneider curve fitting max error (default: 30.0) schneider_max_error: f64, // Schneider curve fitting max error (default: 30.0)
/// All per-tool raster paint settings (brush, eraser, smudge, clone, pattern, dodge/burn, sponge). // Raster brush settings
raster_settings: tools::RasterToolSettings, brush_radius: f32, // brush radius in pixels
/// GPU-rendered brush preview pixel buffers, shared with VelloCallback::prepare(). brush_opacity: f32, // brush opacity 0.01.0
brush_preview_pixels: std::sync::Arc<std::sync::Mutex<Vec<(u32, u32, Vec<u8>)>>>, brush_hardness: f32, // brush hardness 0.01.0
brush_spacing: f32, // dabs_per_radius (fraction of radius per dab)
brush_use_fg: bool, // true = paint with FG (stroke) color, false = BG (fill) color
// Audio engine integration // Audio engine integration
#[allow(dead_code)] // Must be kept alive to maintain audio output #[allow(dead_code)] // Must be kept alive to maintain audio output
audio_stream: Option<cpal::Stream>, audio_stream: Option<cpal::Stream>,
@ -861,7 +841,6 @@ struct EditorApp {
// Region select state // Region select state
region_selection: Option<lightningbeam_core::selection::RegionSelection>, region_selection: Option<lightningbeam_core::selection::RegionSelection>,
region_select_mode: lightningbeam_core::tool::RegionSelectMode, region_select_mode: lightningbeam_core::tool::RegionSelectMode,
lasso_mode: lightningbeam_core::tool::LassoMode,
// VU meter levels // VU meter levels
input_level: f32, input_level: f32,
@ -956,9 +935,6 @@ impl EditorApp {
#[cfg(debug_assertions)] #[cfg(debug_assertions)]
cc.egui_ctx.style_mut(|style| style.debug.show_unaligned = false); cc.egui_ctx.style_mut(|style| style.debug.show_unaligned = false);
// Disable egui's built-in Ctrl+Plus/Minus zoom — we handle zoom ourselves.
cc.egui_ctx.options_mut(|o| o.zoom_with_keyboard = false);
// Load application config // Load application config
let config = AppConfig::load(); let config = AppConfig::load();
@ -1073,8 +1049,11 @@ impl EditorApp {
draw_simplify_mode: lightningbeam_core::tool::SimplifyMode::Smooth, // Default to smooth curves draw_simplify_mode: lightningbeam_core::tool::SimplifyMode::Smooth, // Default to smooth curves
rdp_tolerance: 10.0, // Default RDP tolerance rdp_tolerance: 10.0, // Default RDP tolerance
schneider_max_error: 30.0, // Default Schneider max error schneider_max_error: 30.0, // Default Schneider max error
raster_settings: tools::RasterToolSettings::default(), brush_radius: 10.0,
brush_preview_pixels: std::sync::Arc::new(std::sync::Mutex::new(Vec::new())), brush_opacity: 1.0,
brush_hardness: 0.5,
brush_spacing: 0.1,
brush_use_fg: true,
audio_stream, audio_stream,
audio_controller, audio_controller,
audio_event_rx, audio_event_rx,
@ -1118,7 +1097,6 @@ impl EditorApp {
polygon_sides: 5, // Default to pentagon polygon_sides: 5, // Default to pentagon
region_selection: None, region_selection: None,
region_select_mode: lightningbeam_core::tool::RegionSelectMode::default(), region_select_mode: lightningbeam_core::tool::RegionSelectMode::default(),
lasso_mode: lightningbeam_core::tool::LassoMode::default(),
input_level: 0.0, input_level: 0.0,
output_level: (0.0, 0.0), output_level: (0.0, 0.0),
track_levels: HashMap::new(), track_levels: HashMap::new(),
@ -1874,8 +1852,7 @@ impl EditorApp {
let cy = y0 + row; let cy = y0 + row;
let inside = match sel { let inside = match sel {
RasterSelection::Rect(..) => true, RasterSelection::Rect(..) => true,
RasterSelection::Lasso(_) | RasterSelection::Mask { .. } => RasterSelection::Lasso(_) => sel.contains_pixel(cx as i32, cy as i32),
sel.contains_pixel(cx as i32, cy as i32),
}; };
if inside { if inside {
let src = ((cy * canvas_w + cx) * 4) as usize; let src = ((cy * canvas_w + cx) * 4) as usize;
@ -1999,8 +1976,7 @@ impl EditorApp {
let action = RasterStrokeAction::new( let action = RasterStrokeAction::new(
float.layer_id, float.time, float.layer_id, float.time,
std::sync::Arc::try_unwrap(float.canvas_before).unwrap_or_else(|a| (*a).clone()), float.canvas_before, canvas_after,
canvas_after,
w, h, w, h,
); );
if let Err(e) = self.action_executor.execute(Box::new(action)) { if let Err(e) = self.action_executor.execute(Box::new(action)) {
@ -2019,7 +1995,7 @@ impl EditorApp {
let document = self.action_executor.document_mut(); let document = self.action_executor.document_mut();
let Some(AnyLayer::Raster(rl)) = document.get_layer_mut(&float.layer_id) else { return }; let Some(AnyLayer::Raster(rl)) = document.get_layer_mut(&float.layer_id) else { return };
let Some(kf) = rl.keyframe_at_mut(float.time) else { return }; let Some(kf) = rl.keyframe_at_mut(float.time) else { return };
kf.raw_pixels = std::sync::Arc::try_unwrap(float.canvas_before).unwrap_or_else(|a| (*a).clone()); kf.raw_pixels = float.canvas_before;
} }
/// Drop (discard) the floating selection keeping the hole punched in the /// Drop (discard) the floating selection keeping the hole punched in the
@ -2039,8 +2015,7 @@ impl EditorApp {
let (w, h) = (kf.width, kf.height); let (w, h) = (kf.width, kf.height);
let action = RasterStrokeAction::new( let action = RasterStrokeAction::new(
float.layer_id, float.time, float.layer_id, float.time,
std::sync::Arc::try_unwrap(float.canvas_before).unwrap_or_else(|a| (*a).clone()), float.canvas_before, canvas_after,
canvas_after,
w, h, w, h,
); );
if let Err(e) = self.action_executor.execute(Box::new(action)) { if let Err(e) = self.action_executor.execute(Box::new(action)) {
@ -2061,7 +2036,7 @@ impl EditorApp {
if matches!(document.get_layer(&layer_id), Some(AnyLayer::Raster(_))) { if matches!(document.get_layer(&layer_id), Some(AnyLayer::Raster(_))) {
if let Some(float) = &self.selection.raster_floating { if let Some(float) = &self.selection.raster_floating {
self.clipboard_manager.copy(ClipboardContent::RasterPixels { self.clipboard_manager.copy(ClipboardContent::RasterPixels {
pixels: (*float.pixels).clone(), pixels: float.pixels.clone(),
width: float.width, width: float.width,
height: float.height, height: float.height,
}); });
@ -2445,14 +2420,14 @@ impl EditorApp {
use lightningbeam_core::selection::{RasterFloatingSelection, RasterSelection}; use lightningbeam_core::selection::{RasterFloatingSelection, RasterSelection};
self.selection.raster_floating = Some(RasterFloatingSelection { self.selection.raster_floating = Some(RasterFloatingSelection {
pixels: std::sync::Arc::new(pixels), pixels,
width, width,
height, height,
x: paste_x, x: paste_x,
y: paste_y, y: paste_y,
layer_id, layer_id,
time: self.playback_time, time: self.playback_time,
canvas_before: std::sync::Arc::new(canvas_before), canvas_before,
canvas_id: uuid::Uuid::new_v4(), canvas_id: uuid::Uuid::new_v4(),
}); });
// Update the marquee to show the floating selection bounds. // Update the marquee to show the floating selection bounds.
@ -2871,42 +2846,14 @@ impl EditorApp {
} }
MenuAction::Export => { MenuAction::Export => {
println!("Menu: Export"); println!("Menu: Export");
// Open export dialog with calculated timeline endpoint
let timeline_endpoint = self.action_executor.document().calculate_timeline_endpoint(); let timeline_endpoint = self.action_executor.document().calculate_timeline_endpoint();
// Derive project name from the .beam file path, falling back to document name
let project_name = self.current_file_path.as_ref() let project_name = self.current_file_path.as_ref()
.and_then(|p| p.file_stem()) .and_then(|p| p.file_stem())
.map(|s| s.to_string_lossy().into_owned()) .map(|s| s.to_string_lossy().into_owned())
.unwrap_or_else(|| self.action_executor.document().name.clone()); .unwrap_or_else(|| self.action_executor.document().name.clone());
self.export_dialog.open(timeline_endpoint, &project_name);
// Build document hint for smart export-type defaulting.
let hint = {
use lightningbeam_core::layer::AnyLayer;
use export::dialog::DocumentHint;
fn scan(layers: &[AnyLayer], hint: &mut DocumentHint) {
for l in layers {
match l {
AnyLayer::Video(_) => hint.has_video = true,
AnyLayer::Audio(_) => hint.has_audio = true,
AnyLayer::Raster(_) => hint.has_raster = true,
AnyLayer::Vector(_) | AnyLayer::Effect(_) => hint.has_vector = true,
AnyLayer::Group(g) => scan(&g.children, hint),
}
}
}
let doc = self.action_executor.document();
let mut h = DocumentHint {
has_video: false,
has_audio: false,
has_raster: false,
has_vector: false,
current_time: doc.current_time,
doc_width: doc.width as u32,
doc_height: doc.height as u32,
};
scan(&doc.root.children, &mut h);
h
};
self.export_dialog.open(timeline_endpoint, &project_name, &hint);
} }
MenuAction::Quit => { MenuAction::Quit => {
println!("Menu: Quit"); println!("Menu: Quit");
@ -4488,9 +4435,16 @@ impl eframe::App for EditorApp {
fn update(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) { fn update(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) {
let _frame_start = std::time::Instant::now(); let _frame_start = std::time::Instant::now();
// Disable egui's built-in Ctrl+Plus/Minus zoom behavior
// We handle zoom ourselves for the Stage pane
ctx.options_mut(|o| {
o.zoom_with_keyboard = false;
});
// Force continuous repaint if we have pending waveform updates // Force continuous repaint if we have pending waveform updates
// This ensures thumbnails update immediately when waveform data arrives // This ensures thumbnails update immediately when waveform data arrives
if !self.audio_pools_with_new_waveforms.is_empty() { if !self.audio_pools_with_new_waveforms.is_empty() {
println!("🔄 [UPDATE] Pending waveform updates for pools: {:?}", self.audio_pools_with_new_waveforms);
ctx.request_repaint(); ctx.request_repaint();
} }
@ -5210,17 +5164,6 @@ impl eframe::App for EditorApp {
let export_started = if let Some(orchestrator) = &mut self.export_orchestrator { let export_started = if let Some(orchestrator) = &mut self.export_orchestrator {
match export_result { match export_result {
ExportResult::Image(settings, output_path) => {
println!("🖼 [MAIN] Starting image export: {}", output_path.display());
let doc = self.action_executor.document();
orchestrator.start_image_export(
settings,
output_path,
doc.width as u32,
doc.height as u32,
);
false // image export is silent (no progress dialog)
}
ExportResult::AudioOnly(settings, output_path) => { ExportResult::AudioOnly(settings, output_path) => {
println!("🎵 [MAIN] Starting audio-only export: {}", output_path.display()); println!("🎵 [MAIN] Starting audio-only export: {}", output_path.display());
@ -5331,7 +5274,6 @@ impl eframe::App for EditorApp {
let mut temp_image_cache = lightningbeam_core::renderer::ImageCache::new(); let mut temp_image_cache = lightningbeam_core::renderer::ImageCache::new();
if let Some(renderer) = &mut temp_renderer { if let Some(renderer) = &mut temp_renderer {
// Drive incremental video export.
if let Ok(has_more) = orchestrator.render_next_video_frame( if let Ok(has_more) = orchestrator.render_next_video_frame(
self.action_executor.document_mut(), self.action_executor.document_mut(),
device, device,
@ -5341,24 +5283,10 @@ impl eframe::App for EditorApp {
&self.video_manager, &self.video_manager,
) { ) {
if has_more { if has_more {
// More frames to render - request repaint for next frame
ctx.request_repaint(); ctx.request_repaint();
} }
} }
// Drive single-frame image export (two-frame async: render then readback).
match orchestrator.render_image_frame(
self.action_executor.document_mut(),
device,
queue,
renderer,
&mut temp_image_cache,
&self.video_manager,
self.selection.raster_floating.as_ref(),
) {
Ok(false) => { ctx.request_repaint(); } // readback pending
Ok(true) => {} // done or cancelled
Err(e) => { eprintln!("Image export failed: {e}"); }
}
} }
} }
} }
@ -5577,7 +5505,11 @@ impl eframe::App for EditorApp {
draw_simplify_mode: &mut self.draw_simplify_mode, draw_simplify_mode: &mut self.draw_simplify_mode,
rdp_tolerance: &mut self.rdp_tolerance, rdp_tolerance: &mut self.rdp_tolerance,
schneider_max_error: &mut self.schneider_max_error, schneider_max_error: &mut self.schneider_max_error,
raster_settings: &mut self.raster_settings, brush_radius: &mut self.brush_radius,
brush_opacity: &mut self.brush_opacity,
brush_hardness: &mut self.brush_hardness,
brush_spacing: &mut self.brush_spacing,
brush_use_fg: &mut self.brush_use_fg,
audio_controller: self.audio_controller.as_ref(), audio_controller: self.audio_controller.as_ref(),
video_manager: &self.video_manager, video_manager: &self.video_manager,
playback_time: &mut self.playback_time, playback_time: &mut self.playback_time,
@ -5618,7 +5550,6 @@ impl eframe::App for EditorApp {
script_saved: &mut self.script_saved, script_saved: &mut self.script_saved,
region_selection: &mut self.region_selection, region_selection: &mut self.region_selection,
region_select_mode: &mut self.region_select_mode, region_select_mode: &mut self.region_select_mode,
lasso_mode: &mut self.lasso_mode,
pending_graph_loads: &self.pending_graph_loads, pending_graph_loads: &self.pending_graph_loads,
clipboard_consumed: &mut clipboard_consumed, clipboard_consumed: &mut clipboard_consumed,
keymap: &self.keymap, keymap: &self.keymap,
@ -5629,7 +5560,6 @@ impl eframe::App for EditorApp {
test_mode: &mut self.test_mode, test_mode: &mut self.test_mode,
#[cfg(debug_assertions)] #[cfg(debug_assertions)]
synthetic_input: &mut synthetic_input_storage, synthetic_input: &mut synthetic_input_storage,
brush_preview_pixels: &self.brush_preview_pixels,
}, },
pane_instances: &mut self.pane_instances, pane_instances: &mut self.pane_instances,
}; };

View File

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

View File

@ -11,15 +11,12 @@
/// - Document settings (when nothing is focused) /// - Document settings (when nothing is focused)
use eframe::egui::{self, DragValue, Ui}; 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::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
@ -28,36 +25,13 @@ pub struct InfopanelPane {
tool_section_open: bool, tool_section_open: bool,
/// Whether the shape properties section is expanded /// Whether the shape properties section is expanded
shape_section_open: bool, shape_section_open: bool,
/// Index of the selected paint brush preset (None = custom / unset)
selected_brush_preset: Option<usize>,
/// Whether the paint brush picker is expanded
brush_picker_expanded: bool,
/// Index of the selected eraser brush preset
selected_eraser_preset: Option<usize>,
/// Whether the eraser brush picker is expanded
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 { impl InfopanelPane {
pub fn new() -> Self { pub fn new() -> Self {
let presets = bundled_brushes();
let default_eraser_idx = presets.iter().position(|p| p.name == "Brush");
Self { Self {
tool_section_open: true, tool_section_open: true,
shape_section_open: true, shape_section_open: true,
selected_brush_preset: None,
brush_picker_expanded: false,
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,
} }
} }
} }
@ -73,8 +47,6 @@ 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>,
} }
@ -86,7 +58,6 @@ 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,
} }
@ -149,32 +120,21 @@ 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,8 +151,7 @@ impl InfopanelPane {
.and_then(|id| shared.action_executor.document().get_layer(&id)) .and_then(|id| shared.action_executor.document().get_layer(&id))
.map_or(false, |l| matches!(l, AnyLayer::Raster(_))); .map_or(false, |l| matches!(l, AnyLayer::Raster(_)));
let raster_tool_def = active_is_raster.then(|| crate::tools::raster_tool_def(&tool)).flatten(); let is_raster_paint_tool = active_is_raster && matches!(tool, Tool::Draw | Tool::Erase | Tool::Smudge);
let is_raster_paint_tool = raster_tool_def.is_some();
// Only show tool options for tools that have options // Only show tool options for tools that have options
let is_vector_tool = !active_is_raster && matches!( let is_vector_tool = !active_is_raster && matches!(
@ -200,30 +159,23 @@ impl InfopanelPane {
Tool::Select | Tool::BezierEdit | Tool::Draw | Tool::Rectangle Tool::Select | Tool::BezierEdit | Tool::Draw | Tool::Rectangle
| Tool::Ellipse | Tool::Line | Tool::Polygon | Tool::Ellipse | Tool::Line | Tool::Polygon
); );
let is_raster_transform = active_is_raster let has_options = is_vector_tool || is_raster_paint_tool || matches!(
&& matches!(tool, Tool::Transform)
&& shared.selection.raster_floating.is_some();
let is_raster_select = active_is_raster && matches!(tool, Tool::Select);
let is_raster_shape = active_is_raster && matches!(
tool, tool,
Tool::Rectangle | Tool::Ellipse | Tool::Line | Tool::Polygon Tool::PaintBucket | Tool::RegionSelect
);
let has_options = is_vector_tool || is_raster_paint_tool || is_raster_transform
|| 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 { if !has_options {
return; return;
} }
let header_label = if is_raster_transform { let header_label = if is_raster_paint_tool {
"Raster Transform" match tool {
Tool::Erase => "Eraser",
Tool::Smudge => "Smudge",
_ => "Brush",
}
} else { } else {
raster_tool_def.map(|d| d.header_label()).unwrap_or("Tool Options") "Tool Options"
}; };
egui::CollapsingHeader::new(header_label) egui::CollapsingHeader::new(header_label)
@ -238,23 +190,6 @@ impl InfopanelPane {
ui.add_space(2.0); ui.add_space(2.0);
} }
// Raster transform tool hint.
if is_raster_transform {
ui.label("Drag handles to move, scale, or rotate.");
ui.add_space(4.0);
ui.label("Enter — apply Esc — cancel");
ui.add_space(4.0);
return;
}
// Raster paint tool: delegate to per-tool impl.
if let Some(def) = raster_tool_def {
def.render_ui(ui, shared.raster_settings);
if def.show_brush_preset_picker() {
self.render_raster_tool_options(ui, shared, def.is_eraser());
}
}
match tool { match tool {
Tool::Draw if !is_raster_paint_tool => { Tool::Draw if !is_raster_paint_tool => {
// Stroke width // Stroke width
@ -307,43 +242,7 @@ impl InfopanelPane {
} }
Tool::PaintBucket => { Tool::PaintBucket => {
if active_is_raster { // Gap tolerance
use crate::tools::FillThresholdMode;
ui.horizontal(|ui| {
ui.label("Threshold:");
ui.add(
egui::Slider::new(
&mut shared.raster_settings.fill_threshold,
0.0_f32..=255.0,
)
.step_by(1.0),
);
});
ui.horizontal(|ui| {
ui.label("Softness:");
ui.add(
egui::Slider::new(
&mut shared.raster_settings.fill_softness,
0.0_f32..=100.0,
)
.custom_formatter(|v, _| format!("{:.0}%", v)),
);
});
ui.horizontal(|ui| {
ui.label("Mode:");
ui.selectable_value(
&mut shared.raster_settings.fill_threshold_mode,
FillThresholdMode::Absolute,
"Absolute",
);
ui.selectable_value(
&mut shared.raster_settings.fill_threshold_mode,
FillThresholdMode::Relative,
"Relative",
);
});
} else {
// Vector: gap tolerance
ui.horizontal(|ui| { ui.horizontal(|ui| {
ui.label("Gap Tolerance:"); ui.label("Gap Tolerance:");
ui.add( ui.add(
@ -353,145 +252,6 @@ impl InfopanelPane {
); );
}); });
} }
}
Tool::Select if is_raster_select => {
use crate::tools::SelectionShape;
ui.horizontal(|ui| {
ui.label("Shape:");
ui.selectable_value(
&mut shared.raster_settings.select_shape,
SelectionShape::Rect,
"Rectangle",
);
ui.selectable_value(
&mut shared.raster_settings.select_shape,
SelectionShape::Ellipse,
"Ellipse",
);
});
}
Tool::MagicWand => {
use crate::tools::FillThresholdMode;
ui.horizontal(|ui| {
ui.label("Threshold:");
ui.add(
egui::Slider::new(
&mut shared.raster_settings.wand_threshold,
0.0_f32..=255.0,
)
.step_by(1.0),
);
});
ui.horizontal(|ui| {
ui.label("Mode:");
ui.selectable_value(
&mut shared.raster_settings.wand_mode,
FillThresholdMode::Absolute,
"Absolute",
);
ui.selectable_value(
&mut shared.raster_settings.wand_mode,
FillThresholdMode::Relative,
"Relative",
);
});
ui.checkbox(&mut shared.raster_settings.wand_contiguous, "Contiguous");
}
Tool::QuickSelect => {
use crate::tools::FillThresholdMode;
ui.horizontal(|ui| {
ui.label("Radius:");
ui.add(
egui::Slider::new(
&mut shared.raster_settings.quick_select_radius,
1.0_f32..=200.0,
)
.step_by(1.0),
);
});
ui.horizontal(|ui| {
ui.label("Threshold:");
ui.add(
egui::Slider::new(
&mut shared.raster_settings.wand_threshold,
0.0_f32..=255.0,
)
.step_by(1.0),
);
});
ui.horizontal(|ui| {
ui.label("Mode:");
ui.selectable_value(
&mut shared.raster_settings.wand_mode,
FillThresholdMode::Absolute,
"Absolute",
);
ui.selectable_value(
&mut shared.raster_settings.wand_mode,
FillThresholdMode::Relative,
"Relative",
);
});
}
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
@ -540,20 +300,48 @@ impl InfopanelPane {
}); });
} }
Tool::Gradient if active_is_raster => { // Raster paint tools
Tool::Draw | Tool::Erase | Tool::Smudge if is_raster_paint_tool => {
// Color source toggle (Draw tool only)
if matches!(tool, Tool::Draw) {
ui.horizontal(|ui| {
ui.label("Color:");
ui.selectable_value(shared.brush_use_fg, true, "FG");
ui.selectable_value(shared.brush_use_fg, false, "BG");
});
}
ui.horizontal(|ui| {
ui.label("Size:");
ui.add(
egui::Slider::new(shared.brush_radius, 1.0_f32..=200.0)
.logarithmic(true)
.suffix(" px"),
);
});
if !matches!(tool, Tool::Smudge) {
ui.horizontal(|ui| { ui.horizontal(|ui| {
ui.label("Opacity:"); ui.label("Opacity:");
ui.add(egui::Slider::new( ui.add(
&mut shared.raster_settings.gradient_opacity, egui::Slider::new(shared.brush_opacity, 0.0_f32..=1.0)
0.0_f32..=1.0, .custom_formatter(|v, _| format!("{:.0}%", v * 100.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,
); );
});
}
ui.horizontal(|ui| {
ui.label("Hardness:");
ui.add(
egui::Slider::new(shared.brush_hardness, 0.0_f32..=1.0)
.custom_formatter(|v, _| format!("{:.0}%", v * 100.0)),
);
});
ui.horizontal(|ui| {
ui.label("Spacing:");
ui.add(
egui::Slider::new(shared.brush_spacing, 0.01_f32..=1.0)
.logarithmic(true)
.custom_formatter(|v, _| format!("{:.0}%", v * 100.0)),
);
});
} }
_ => {} _ => {}
@ -563,192 +351,6 @@ impl InfopanelPane {
}); });
} }
/// Render all options for a raster paint tool (brush picker + sliders).
/// `is_eraser` drives which shared state is read/written.
fn render_raster_tool_options(
&mut self,
ui: &mut Ui,
shared: &mut SharedPaneState,
is_eraser: bool,
) {
self.render_brush_preset_grid(ui, shared, is_eraser);
ui.add_space(2.0);
let rs = &mut shared.raster_settings;
if !is_eraser {
ui.horizontal(|ui| {
ui.label("Color:");
ui.selectable_value(&mut rs.brush_use_fg, true, "FG");
ui.selectable_value(&mut rs.brush_use_fg, false, "BG");
});
}
macro_rules! field {
($eraser:ident, $brush:ident) => {
if is_eraser { &mut rs.$eraser } else { &mut rs.$brush }
}
}
ui.horizontal(|ui| {
ui.label("Size:");
ui.add(egui::Slider::new(field!(eraser_radius, brush_radius), 1.0_f32..=200.0).logarithmic(true).suffix(" px"));
});
ui.horizontal(|ui| {
ui.label("Opacity:");
ui.add(egui::Slider::new(field!(eraser_opacity, brush_opacity), 0.0_f32..=1.0)
.custom_formatter(|v, _| format!("{:.0}%", v * 100.0)));
});
ui.horizontal(|ui| {
ui.label("Hardness:");
ui.add(egui::Slider::new(field!(eraser_hardness, brush_hardness), 0.0_f32..=1.0)
.custom_formatter(|v, _| format!("{:.0}%", v * 100.0)));
});
ui.horizontal(|ui| {
ui.label("Spacing:");
ui.add(egui::Slider::new(field!(eraser_spacing, brush_spacing), 0.01_f32..=1.0)
.logarithmic(true)
.custom_formatter(|v, _| format!("{:.0}%", v * 100.0)));
});
}
/// Render the brush preset thumbnail grid (collapsible).
/// `is_eraser` drives which picker state and which shared settings are updated.
fn render_brush_preset_grid(&mut self, ui: &mut Ui, shared: &mut SharedPaneState, is_eraser: bool) {
let presets = bundled_brushes();
if presets.is_empty() { return; }
// Build preview TextureHandles from GPU-rendered pixel data when available.
if self.brush_preview_textures.len() != presets.len() {
if let Ok(previews) = shared.brush_preview_pixels.try_lock() {
if previews.len() == presets.len() {
self.brush_preview_textures.clear();
for (idx, (w, h, pixels)) in previews.iter().enumerate() {
let image = egui::ColorImage::from_rgba_premultiplied(
[*w as usize, *h as usize],
pixels,
);
let handle = ui.ctx().load_texture(
format!("brush_preview_{}", presets[idx].name),
image,
egui::TextureOptions::LINEAR,
);
self.brush_preview_textures.push(handle);
}
}
}
}
// Read picker state into locals to avoid multiple &mut self borrows.
let mut expanded = if is_eraser { self.eraser_picker_expanded } else { self.brush_picker_expanded };
let mut selected = if is_eraser { self.selected_eraser_preset } else { self.selected_brush_preset };
let gap = 3.0;
let cols = 2usize;
let avail_w = ui.available_width();
let cell_w = ((avail_w - gap * (cols as f32 - 1.0)) / cols as f32).max(50.0);
let cell_h = 80.0;
if !expanded {
// Collapsed: show just the currently selected preset as a single wide cell.
let show_idx = selected.unwrap_or(0);
if let Some(preset) = presets.get(show_idx) {
let full_w = avail_w.max(50.0);
let (rect, resp) = ui.allocate_exact_size(egui::vec2(full_w, cell_h), egui::Sense::click());
let painter = ui.painter();
let bg = if resp.hovered() {
egui::Color32::from_rgb(50, 56, 70)
} else {
egui::Color32::from_rgb(45, 65, 95)
};
painter.rect_filled(rect, 4.0, bg);
painter.rect_stroke(rect, 4.0, egui::Stroke::new(1.5, egui::Color32::from_rgb(80, 140, 220)), egui::StrokeKind::Middle);
let preview_rect = egui::Rect::from_min_size(
rect.min + egui::vec2(4.0, 4.0),
egui::vec2(rect.width() - 8.0, cell_h - 22.0),
);
if let Some(tex) = self.brush_preview_textures.get(show_idx) {
painter.image(tex.id(), preview_rect,
egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)),
egui::Color32::WHITE);
}
painter.text(egui::pos2(rect.center().x, rect.max.y - 9.0),
egui::Align2::CENTER_CENTER, preset.name,
egui::FontId::proportional(9.5), egui::Color32::from_rgb(140, 190, 255));
if resp.clicked() { expanded = true; }
}
} else {
// Expanded: full grid; clicking a preset selects it and collapses.
for (row_idx, chunk) in presets.chunks(cols).enumerate() {
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = gap;
for (col_idx, preset) in chunk.iter().enumerate() {
let idx = row_idx * cols + col_idx;
let is_sel = selected == Some(idx);
let (rect, resp) = ui.allocate_exact_size(egui::vec2(cell_w, cell_h), egui::Sense::click());
let painter = ui.painter();
let bg = if is_sel {
egui::Color32::from_rgb(45, 65, 95)
} else if resp.hovered() {
egui::Color32::from_rgb(45, 50, 62)
} else {
egui::Color32::from_rgb(32, 36, 44)
};
painter.rect_filled(rect, 4.0, bg);
if is_sel {
painter.rect_stroke(rect, 4.0, egui::Stroke::new(1.5, egui::Color32::from_rgb(80, 140, 220)), egui::StrokeKind::Middle);
}
let preview_rect = egui::Rect::from_min_size(
rect.min + egui::vec2(4.0, 4.0),
egui::vec2(cell_w - 8.0, cell_h - 22.0),
);
if let Some(tex) = self.brush_preview_textures.get(idx) {
painter.image(tex.id(), preview_rect,
egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)),
egui::Color32::WHITE);
}
painter.text(egui::pos2(rect.center().x, rect.max.y - 9.0),
egui::Align2::CENTER_CENTER, preset.name,
egui::FontId::proportional(9.5),
if is_sel { egui::Color32::from_rgb(140, 190, 255) } else { egui::Color32::from_gray(160) });
if resp.clicked() {
selected = Some(idx);
expanded = false;
let s = &preset.settings;
let rs = &mut shared.raster_settings;
if is_eraser {
rs.eraser_opacity = s.opaque.clamp(0.0, 1.0);
rs.eraser_hardness = s.hardness.clamp(0.0, 1.0);
rs.eraser_spacing = s.dabs_per_radius;
rs.active_eraser_settings = s.clone();
} else {
rs.brush_opacity = s.opaque.clamp(0.0, 1.0);
rs.brush_hardness = s.hardness.clamp(0.0, 1.0);
rs.brush_spacing = s.dabs_per_radius;
rs.active_brush_settings = s.clone();
// If the user was on a preset-backed tool (Pencil/Pen/Airbrush)
// and manually picked a different brush, revert to the generic tool.
if matches!(*shared.selected_tool, Tool::Pencil | Tool::Pen | Tool::Airbrush) {
*shared.selected_tool = Tool::Draw;
}
}
}
}
});
ui.add_space(gap);
}
}
// Write back picker state.
if is_eraser {
self.eraser_picker_expanded = expanded;
self.selected_eraser_preset = selected;
} else {
self.brush_picker_expanded = expanded;
self.selected_brush_preset = selected;
}
}
// Transform section: deferred to Phase 2 (DCEL elements don't have instance transforms) // Transform section: deferred to Phase 2 (DCEL elements don't have instance transforms)
/// Render shape properties section (fill/stroke) /// Render shape properties section (fill/stroke)
@ -775,72 +377,28 @@ impl InfopanelPane {
self.shape_section_open = true; self.shape_section_open = true;
ui.add_space(4.0); ui.add_space(4.0);
// Fill — determine current fill type // Fill color
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:");
if fill_mixed { match info.fill_color {
ui.label("--"); Some(Some(color)) => {
} 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 = SetFillPaintAction::solid( let action = SetShapePropertiesAction::set_fill_color(
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 => {
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 // Stroke color
ui.horizontal(|ui| { ui.horizontal(|ui| {
@ -1306,40 +864,6 @@ impl InfopanelPane {
} }
} }
/// Draw a brush dab preview into `rect` approximating the brush falloff shape.
///
/// Renders N concentric filled circles from outermost to innermost. Because each
/// inner circle overwrites the pixels of all outer circles beneath it, the visible
/// alpha at distance `d` from the centre equals the alpha of the innermost circle
/// whose radius ≥ `d`. This step-approximates the actual brush falloff formula:
/// `opa = ((1 r) / (1 hardness))²` for `r > hardness`, 1 inside the hard core.
fn paint_brush_dab(painter: &egui::Painter, rect: egui::Rect, s: &BrushSettings) {
let center = rect.center();
let max_r = (rect.width().min(rect.height()) / 2.0 - 2.0).max(1.0);
let h = s.hardness;
let a = s.opaque;
const N: usize = 12;
for i in 0..N {
// t: normalized radial position of this ring, 1.0 = outermost edge
let t = 1.0 - i as f32 / N as f32;
let r = max_r * t;
let opa_weight = if h >= 1.0 || t <= h {
1.0f32
} else {
let x = (1.0 - t) / (1.0 - h).max(1e-4);
(x * x).min(1.0)
};
let alpha = (opa_weight * a * 220.0).min(220.0) as u8;
painter.circle_filled(
center, r,
egui::Color32::from_rgba_unmultiplied(200, 200, 220, alpha),
);
}
}
/// Convert MIDI note number to note name (e.g. 60 -> "C4") /// Convert MIDI note number to note name (e.g. 60 -> "C4")
fn midi_note_name(note: u8) -> String { fn midi_note_name(note: u8) -> String {
const NAMES: [&str; 12] = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"]; const NAMES: [&str; 12] = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"];

View File

@ -68,7 +68,6 @@ 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;
@ -188,8 +187,13 @@ pub struct SharedPaneState<'a> {
pub draw_simplify_mode: &'a mut lightningbeam_core::tool::SimplifyMode, pub draw_simplify_mode: &'a mut lightningbeam_core::tool::SimplifyMode,
pub rdp_tolerance: &'a mut f64, pub rdp_tolerance: &'a mut f64,
pub schneider_max_error: &'a mut f64, pub schneider_max_error: &'a mut f64,
/// All per-tool raster paint settings (replaces 20+ individual fields). /// Raster brush settings
pub raster_settings: &'a mut crate::tools::RasterToolSettings, pub brush_radius: &'a mut f32,
pub brush_opacity: &'a mut f32,
pub brush_hardness: &'a mut f32,
pub brush_spacing: &'a mut f32,
/// Whether the brush paints with the foreground (fill) color (true) or background (stroke) color (false)
pub brush_use_fg: &'a mut bool,
/// Audio engine controller for playback control (wrapped in Arc<Mutex<>> for thread safety) /// Audio engine controller for playback control (wrapped in Arc<Mutex<>> for thread safety)
pub audio_controller: Option<&'a std::sync::Arc<std::sync::Mutex<daw_backend::EngineController>>>, pub audio_controller: Option<&'a std::sync::Arc<std::sync::Mutex<daw_backend::EngineController>>>,
/// Video manager for video decoding and frame caching /// Video manager for video decoding and frame caching
@ -265,8 +269,6 @@ pub struct SharedPaneState<'a> {
pub region_selection: &'a mut Option<lightningbeam_core::selection::RegionSelection>, pub region_selection: &'a mut Option<lightningbeam_core::selection::RegionSelection>,
/// Region select mode (Rectangle or Lasso) /// Region select mode (Rectangle or Lasso)
pub region_select_mode: &'a mut lightningbeam_core::tool::RegionSelectMode, pub region_select_mode: &'a mut lightningbeam_core::tool::RegionSelectMode,
/// Lasso select sub-mode (Freehand / Polygonal / Magnetic)
pub lasso_mode: &'a mut lightningbeam_core::tool::LassoMode,
/// Counter for in-flight graph preset loads — increment when sending a /// Counter for in-flight graph preset loads — increment when sending a
/// GraphLoadPreset command so the repaint loop stays alive until the /// GraphLoadPreset command so the repaint loop stays alive until the
/// audio thread sends GraphPresetLoaded back /// audio thread sends GraphPresetLoaded back
@ -289,10 +291,6 @@ pub struct SharedPaneState<'a> {
/// Synthetic input from test mode replay (debug builds only) /// Synthetic input from test mode replay (debug builds only)
#[cfg(debug_assertions)] #[cfg(debug_assertions)]
pub synthetic_input: &'a mut Option<crate::test_mode::SyntheticInput>, pub synthetic_input: &'a mut Option<crate::test_mode::SyntheticInput>,
/// GPU-rendered brush preview thumbnails. Populated by `VelloCallback::prepare()`
/// on the first frame; panes (e.g. infopanel) convert the pixel data to egui
/// TextureHandles. Each entry is `(width, height, sRGB-premultiplied RGBA bytes)`.
pub brush_preview_pixels: &'a std::sync::Arc<std::sync::Mutex<Vec<(u32, u32, Vec<u8>)>>>,
} }
/// Trait for pane rendering /// Trait for pane rendering

View File

@ -1,27 +0,0 @@
// Alpha composite compute shader.
//
// Composites the accumulated-dab scratch buffer C on top of the source buffer A,
// writing the result into the output buffer B:
//
// B[px] = C[px] + A[px] * (1 C[px].a) (Porter-Duff src-over, C over A)
//
// All textures are Rgba8Unorm, linear premultiplied RGBA.
// Dispatch: ceil(w/8) × ceil(h/8) × 1.
@group(0) @binding(0) var tex_a: texture_2d<f32>; // source (A)
@group(0) @binding(1) var tex_c: texture_2d<f32>; // accumulated dabs (C)
@group(0) @binding(2) var tex_b: texture_storage_2d<rgba8unorm, write>; // output (B)
@compute @workgroup_size(8, 8)
fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
let dims = textureDimensions(tex_a);
if gid.x >= dims.x || gid.y >= dims.y { return; }
let coord = vec2<i32>(i32(gid.x), i32(gid.y));
let a = textureLoad(tex_a, coord, 0);
let c = textureLoad(tex_c, coord, 0);
// Porter-Duff src-over: C is the foreground (dabs), A is the background.
// out = c + a * (1 - c.a)
textureStore(tex_b, coord, c + a * (1.0 - c.a));
}

View File

@ -20,7 +20,7 @@ struct GpuDab {
x: f32, y: f32, radius: f32, hardness: f32, // bytes 015 x: f32, y: f32, radius: f32, hardness: f32, // bytes 015
opacity: f32, color_r: f32, color_g: f32, color_b: f32, // bytes 1631 opacity: f32, color_r: f32, color_g: f32, color_b: f32, // bytes 1631
color_a: f32, ndx: f32, ndy: f32, smudge_dist: f32, // bytes 3247 color_a: f32, ndx: f32, ndy: f32, smudge_dist: f32, // bytes 3247
blend_mode: u32, elliptical_dab_ratio: f32, elliptical_dab_angle: f32, lock_alpha: f32, // bytes 4863 blend_mode: u32, _pad0: u32, _pad1: u32, _pad2: u32, // bytes 4863
} }
struct Params { struct Params {
@ -76,20 +76,7 @@ fn bilinear_sample(px: f32, py: f32) -> vec4<f32> {
fn apply_dab(current: vec4<f32>, dab: GpuDab, px: i32, py: i32) -> vec4<f32> { fn apply_dab(current: vec4<f32>, dab: GpuDab, px: i32, py: i32) -> vec4<f32> {
let dx = f32(px) + 0.5 - dab.x; let dx = f32(px) + 0.5 - dab.x;
let dy = f32(py) + 0.5 - dab.y; let dy = f32(py) + 0.5 - dab.y;
let rr = (dx * dx + dy * dy) / (dab.radius * dab.radius);
// Normalised squared distance supports circular and elliptical dabs.
var rr: f32;
if dab.elliptical_dab_ratio > 1.001 {
// Rotate into the dab's local frame.
// Major axis is along dab.elliptical_dab_angle; minor axis is compressed by ratio.
let c = cos(dab.elliptical_dab_angle);
let s = sin(dab.elliptical_dab_angle);
let dx_r = dx * c + dy * s; // along major axis
let dy_r = (-dx * s + dy * c) * dab.elliptical_dab_ratio; // minor axis compressed
rr = (dx_r * dx_r + dy_r * dy_r) / (dab.radius * dab.radius);
} else {
rr = (dx * dx + dy * dy) / (dab.radius * dab.radius);
}
if rr > 1.0 { return current; } if rr > 1.0 { return current; }
// Quadratic falloff: flat inner core, smooth quadratic outer zone. // Quadratic falloff: flat inner core, smooth quadratic outer zone.
@ -107,17 +94,15 @@ fn apply_dab(current: vec4<f32>, dab: GpuDab, px: i32, py: i32) -> vec4<f32> {
} }
if dab.blend_mode == 0u { if dab.blend_mode == 0u {
// Normal: "over" operator on premultiplied RGBA. // Normal: "over" operator
// If lock_alpha > 0.5, preserve the destination alpha unchanged.
let dab_a = opa_weight * dab.opacity * dab.color_a; let dab_a = opa_weight * dab.opacity * dab.color_a;
if dab_a <= 0.0 { return current; } if dab_a <= 0.0 { return current; }
let ba = 1.0 - dab_a; let ba = 1.0 - dab_a;
let out_a = select(dab_a + ba * current.a, current.a, dab.lock_alpha > 0.5);
return vec4<f32>( return vec4<f32>(
dab_a * dab.color_r + ba * current.r, dab_a * dab.color_r + ba * current.r,
dab_a * dab.color_g + ba * current.g, dab_a * dab.color_g + ba * current.g,
dab_a * dab.color_b + ba * current.b, dab_a * dab.color_b + ba * current.b,
out_a, dab_a + ba * current.a,
); );
} else if dab.blend_mode == 1u { } else if dab.blend_mode == 1u {
// Erase: multiplicative alpha reduction // Erase: multiplicative alpha reduction
@ -126,7 +111,7 @@ fn apply_dab(current: vec4<f32>, dab: GpuDab, px: i32, py: i32) -> vec4<f32> {
let new_a = current.a * (1.0 - dab_a); let new_a = current.a * (1.0 - dab_a);
let scale = select(0.0, new_a / current.a, current.a > 1e-6); let scale = select(0.0, new_a / current.a, current.a > 1e-6);
return vec4<f32>(current.r * scale, current.g * scale, current.b * scale, new_a); return vec4<f32>(current.r * scale, current.g * scale, current.b * scale, new_a);
} else if dab.blend_mode == 2u { } else {
// Smudge: directional warp sample from position behind the stroke direction // Smudge: directional warp sample from position behind the stroke direction
let alpha = opa_weight * dab.opacity; let alpha = opa_weight * dab.opacity;
if alpha <= 0.0 { return current; } if alpha <= 0.0 { return current; }
@ -140,192 +125,6 @@ fn apply_dab(current: vec4<f32>, dab: GpuDab, px: i32, py: i32) -> vec4<f32> {
alpha * src.b + da * current.b, alpha * src.b + da * current.b,
alpha * src.a + da * current.a, alpha * src.a + da * current.a,
); );
} else if dab.blend_mode == 3u {
// Clone stamp: sample from (this_pixel + offset) in the source canvas.
// color_r/color_g store the world-space offset (source_world - drag_start_world)
// computed once when the stroke begins. Each pixel samples its own source texel.
let alpha = opa_weight * dab.opacity;
if alpha <= 0.0 { return current; }
let src_x = f32(px) + 0.5 + dab.color_r;
let src_y = f32(py) + 0.5 + dab.color_g;
let src = bilinear_sample(src_x, src_y);
let ba = 1.0 - alpha;
return vec4<f32>(
alpha * src.r + ba * current.r,
alpha * src.g + ba * current.g,
alpha * src.b + ba * current.b,
alpha * src.a + ba * current.a,
);
} else if dab.blend_mode == 5u {
// Pattern stamp: procedural tiling pattern using brush color.
// ndx = pattern_type (0=Checker, 1=Dots, 2=H-Lines, 3=V-Lines, 4=Diagonal, 5=Crosshatch)
// ndy = pattern_scale (tile size in pixels, >= 1.0)
let scale = max(dab.ndy, 1.0);
let pt = u32(dab.ndx);
// Fractional position within the tile [0.0, 1.0)
let tx = fract(f32(px) / scale);
let ty = fract(f32(py) / scale);
var on: bool;
if pt == 0u { // Checkerboard
let cx = u32(floor(f32(px) / scale));
let cy = u32(floor(f32(py) / scale));
on = (cx + cy) % 2u == 0u;
} else if pt == 1u { // Polka dots (r 0.35 of cell radius)
let ddx = tx - 0.5; let ddy = ty - 0.5;
on = ddx * ddx + ddy * ddy < 0.1225;
} else if pt == 2u { // Horizontal lines (50% duty)
on = ty < 0.5;
} else if pt == 3u { // Vertical lines (50% duty)
on = tx < 0.5;
} else if pt == 4u { // Diagonal \ (top-left bottom-right)
on = fract((f32(px) + f32(py)) / scale) < 0.5;
} else if pt == 5u { // Diagonal / (top-right bottom-left)
on = fract((f32(px) - f32(py)) / scale) < 0.5;
} else { // Crosshatch (type 6+)
on = tx < 0.4 || ty < 0.4;
}
if !on { return current; }
// Paint with brush color same compositing as Normal blend
let dab_a = opa_weight * dab.opacity * dab.color_a;
if dab_a <= 0.0 { return current; }
let ba = 1.0 - dab_a;
return vec4<f32>(
dab_a * dab.color_r + ba * current.r,
dab_a * dab.color_g + ba * current.g,
dab_a * dab.color_b + ba * current.b,
dab_a + ba * current.a,
);
} else if dab.blend_mode == 4u {
// Healing brush: per-pixel color-corrected clone stamp.
// color_r/color_g = source offset (ox, oy), same as clone stamp.
// For each pixel: result = src_pixel + (local_dest_mean - local_src_mean)
// Means are computed from 4 cardinal neighbors at ±half-radius per-pixel, no banding.
let alpha = opa_weight * dab.opacity;
if alpha <= 0.0 { return current; }
let cw = i32(params.canvas_w);
let ch = i32(params.canvas_h);
let ox = dab.color_r;
let oy = dab.color_g;
let hr = max(dab.radius * 0.5, 1.0);
let ihr = i32(hr);
// Per-pixel DESTINATION mean: 4 cardinal neighbors from canvas_src (pre-batch state)
let d_n = textureLoad(canvas_src, vec2<i32>(px, clamp(py - ihr, 0, ch - 1)), 0);
let d_s = textureLoad(canvas_src, vec2<i32>(px, clamp(py + ihr, 0, ch - 1)), 0);
let d_w = textureLoad(canvas_src, vec2<i32>(clamp(px - ihr, 0, cw - 1), py ), 0);
let d_e = textureLoad(canvas_src, vec2<i32>(clamp(px + ihr, 0, cw - 1), py ), 0);
let d_mean = (d_n + d_s + d_w + d_e) * 0.25;
// Per-pixel SOURCE mean: 4 cardinal neighbors at offset position (bilinear for sub-pixel offsets)
let spx = f32(px) + 0.5 + ox;
let spy = f32(py) + 0.5 + oy;
let s_mean = (bilinear_sample(spx, spy - hr)
+ bilinear_sample(spx, spy + hr)
+ bilinear_sample(spx - hr, spy )
+ bilinear_sample(spx + hr, spy )) * 0.25;
// Source pixel + color correction
let s_pixel = bilinear_sample(spx, spy);
let corrected = clamp(s_pixel + (d_mean - s_mean), vec4<f32>(0.0), vec4<f32>(1.0));
let ba = 1.0 - alpha;
return vec4<f32>(
alpha * corrected.r + ba * current.r,
alpha * corrected.g + ba * current.g,
alpha * corrected.b + ba * current.b,
alpha * corrected.a + ba * current.a,
);
} else if dab.blend_mode == 6u {
// Dodge / Burn: power-curve exposure adjustment.
// color_r: 0.0 = dodge, 1.0 = burn
// Uses pow(channel, gamma) which is asymmetric across channels:
// burn (gamma > 1): low channels compressed toward 0 faster than high ones saturation increases
// dodge (gamma < 1): low channels lifted faster than high ones saturation decreases
// This matches the behaviour of GIMP / Photoshop dodge-burn tools.
let s = opa_weight * dab.opacity;
if s <= 0.0 { return current; }
let rgb = max(current.rgb, vec3<f32>(0.0));
var adjusted: vec3<f32>;
if dab.color_r < 0.5 {
// Dodge: gamma < 1 brightens
adjusted = pow(rgb, vec3<f32>(max(1.0 - s, 0.001)));
} else {
// Burn: gamma > 1 darkens and increases saturation
adjusted = pow(rgb, vec3<f32>(1.0 + s));
}
return vec4<f32>(clamp(adjusted, vec3<f32>(0.0), vec3<f32>(1.0)), current.a);
} else if dab.blend_mode == 7u {
// Sponge: saturate or desaturate existing pixels.
// color_r: 0.0 = saturate, 1.0 = desaturate
// Computes luminance, then moves RGB toward (desaturate) or away from (saturate) it.
let s = opa_weight * dab.opacity;
if s <= 0.0 { return current; }
let luma = dot(current.rgb, vec3<f32>(0.2126, 0.7152, 0.0722));
let luma_vec = vec3<f32>(luma);
var adjusted: vec3<f32>;
if dab.color_r < 0.5 {
// Saturate: push RGB away from luma (increase chroma)
adjusted = clamp(current.rgb + s * (current.rgb - luma_vec), vec3<f32>(0.0), vec3<f32>(1.0));
} else {
// Desaturate: blend RGB toward luma
adjusted = mix(current.rgb, luma_vec, s);
}
return vec4<f32>(adjusted, current.a);
} else if dab.blend_mode == 8u {
// Blur / Sharpen: 5×5 separable Gaussian kernel.
// color_r: 0.0 = blur, 1.0 = sharpen
// ndx: kernel radius in canvas pixels (> 0)
//
// Samples are placed on a grid at ±step and ±2*step per axis, where step = kr/2.
// Weights are exp(-x²/2σ²) with σ = step, factored as a separable product.
// This gives a true Gaussian falloff rather than a flat ring, so edges blend
// into a smooth gradient rather than a flat averaged zone.
let s = opa_weight * dab.opacity;
if s <= 0.0 { return current; }
let kr = max(dab.ndx, 1.0);
let cx2 = f32(px) + 0.5;
let cy2 = f32(py) + 0.5;
let step = kr * 0.5;
// 1-D Gaussian weights at distances 0, ±step, ±2*step (σ = step):
// exp(0) = 1.0, exp(-0.5) 0.6065, exp(-2.0) 0.1353
var gauss = array<f32, 5>(0.1353, 0.6065, 1.0, 0.6065, 0.1353);
var blur_sum = vec4<f32>(0.0);
var blur_w = 0.0;
for (var iy = 0; iy < 5; iy++) {
for (var ix = 0; ix < 5; ix++) {
let w = gauss[ix] * gauss[iy];
let spx = cx2 + (f32(ix) - 2.0) * step;
let spy = cy2 + (f32(iy) - 2.0) * step;
blur_sum += bilinear_sample(spx, spy) * w;
blur_w += w;
}
}
let blurred = blur_sum / blur_w;
let c = textureLoad(canvas_src, vec2<i32>(px, py), 0);
var result: vec4<f32>;
if dab.color_r < 0.5 {
// Blur: blend current toward the Gaussian-weighted local average.
result = mix(current, blurred, s);
} else {
// Sharpen: unsharp mask push pixel away from the local average.
// sharpened = 2*src - blurred highlights diverge, shadows diverge.
let sharpened = clamp(c * 2.0 - blurred, vec4<f32>(0.0), vec4<f32>(1.0));
result = mix(current, sharpened, s);
}
return result;
} else {
return current;
} }
} }

View File

@ -1,31 +1,30 @@
// Canvas blit shader. // Canvas blit shader.
// //
// Renders a GPU raster canvas (at document resolution) into an Rgba16Float HDR // Renders a GPU raster canvas (at document resolution) into an Rgba16Float HDR
// buffer (at viewport resolution), applying a general affine transform that maps // buffer (at viewport resolution), applying the camera transform (pan + zoom)
// viewport UV [0,1]² directly to canvas UV [0,1]². // to map document-space pixels to viewport-space pixels.
//
// The combined inverse transform (viewport UV canvas UV) is pre-computed on the
// CPU and uploaded as a column-major 3×3 matrix packed into three vec4 uniforms.
// //
// The canvas stores premultiplied linear RGBA. We output it as-is so the HDR // The canvas stores premultiplied linear RGBA. We output it as-is so the HDR
// compositor sees the same premultiplied-linear format it always works with, // compositor sees the same premultiplied-linear format it always works with,
// bypassing the sRGB intermediate used for Vello layers. // bypassing the sRGB intermediate used for Vello layers.
// //
// Any viewport pixel whose corresponding canvas coordinate falls outside [0,1)² // Any viewport pixel whose corresponding document coordinate falls outside
// outputs transparent black. // [0, canvas_w) × [0, canvas_h) outputs transparent black.
struct BlitTransform { struct CameraParams {
/// Column 0 of the viewport_uv canvas_uv affine matrix (+ padding). pan_x: f32,
col0: vec4<f32>, pan_y: f32,
/// Column 1 (+ padding). zoom: f32,
col1: vec4<f32>, canvas_w: f32,
/// Column 2: translation column col2.xy = translation, col2.z = 1 (+ padding). canvas_h: f32,
col2: vec4<f32>, viewport_w: f32,
viewport_h: f32,
_pad: f32,
} }
@group(0) @binding(0) var canvas_tex: texture_2d<f32>; @group(0) @binding(0) var canvas_tex: texture_2d<f32>;
@group(0) @binding(1) var canvas_sampler: sampler; @group(0) @binding(1) var canvas_sampler: sampler;
@group(0) @binding(2) var<uniform> transform: BlitTransform; @group(0) @binding(2) var<uniform> camera: CameraParams;
/// Selection mask: R8Unorm, 255 = inside selection (keep), 0 = outside (discard). /// Selection mask: R8Unorm, 255 = inside selection (keep), 0 = outside (discard).
/// A 1×1 all-white texture is bound when no selection is active. /// A 1×1 all-white texture is bound when no selection is active.
@group(0) @binding(3) var mask_tex: texture_2d<f32>; @group(0) @binding(3) var mask_tex: texture_2d<f32>;
@ -49,10 +48,14 @@ fn vs_main(@builtin(vertex_index) vertex_index: u32) -> VertexOutput {
@fragment @fragment
fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> { fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> {
// Apply the combined inverse transform: viewport UV canvas UV. // Map viewport UV [0,1] viewport pixel
let m = mat3x3<f32>(transform.col0.xyz, transform.col1.xyz, transform.col2.xyz); let vp = in.uv * vec2<f32>(camera.viewport_w, camera.viewport_h);
let canvas_uv_h = m * vec3<f32>(in.uv.x, in.uv.y, 1.0);
let canvas_uv = canvas_uv_h.xy; // Map viewport pixel document pixel (inverse camera transform)
let doc = (vp - vec2<f32>(camera.pan_x, camera.pan_y)) / camera.zoom;
// Map document pixel canvas UV [0,1]
let canvas_uv = doc / vec2<f32>(camera.canvas_w, camera.canvas_h);
// Out-of-bounds transparent // Out-of-bounds transparent
if canvas_uv.x < 0.0 || canvas_uv.x > 1.0 if canvas_uv.x < 0.0 || canvas_uv.x > 1.0

View File

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

View File

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

View File

@ -1,78 +0,0 @@
// GPU affine-transform resample shader.
//
// For each output pixel, computes the corresponding source pixel via an inverse
// 2D affine transform (no perspective) and bilinear-samples from the source texture.
//
// Used by the raster selection transform tool: the source is the immutable "anchor"
// canvas (original float pixels), the destination is the current float canvas.
//
// CPU precomputes the inverse affine matrix components and the output bounding box.
// The shader just does the per-pixel mapping and bilinear interpolation.
//
// Dispatch: ceil(dst_w / 8) × ceil(dst_h / 8) × 1
struct Params {
// Inverse affine: src_pixel = A * out_pixel + b
// For output pixel center (ox, oy), source pixel is:
// sx = a00*ox + a01*oy + b0
// sy = a10*ox + a11*oy + b1
a00: f32, a01: f32,
a10: f32, a11: f32,
b0: f32, b1: f32,
src_w: u32, src_h: u32,
dst_w: u32, dst_h: u32,
_pad0: u32, _pad1: u32, // pad to 48 bytes (3 × 16)
}
@group(0) @binding(0) var<uniform> params: Params;
@group(0) @binding(1) var src: texture_2d<f32>;
@group(0) @binding(2) 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);
}
@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; }
let ox = f32(gid.x);
let oy = f32(gid.y);
// Map output pixel index source pixel position via inverse affine.
// We use pixel centers (ox + 0.5, oy + 0.5) in the forward transform, but the
// b0/b1 precomputation on the CPU already accounts for the +0.5 offset, so ox/oy
// are used directly here (the CPU bakes +0.5 into b).
let sx = params.a00 * ox + params.a01 * oy + params.b0;
let sy = params.a10 * ox + params.a11 * oy + params.b1;
var color: vec4<f32>;
if sx < 0.0 || sy < 0.0 || sx >= f32(params.src_w) || sy >= f32(params.src_h) {
// Outside source bounds transparent
color = vec4<f32>(0.0);
} else {
// Bilinear sample at pixel center
color = bilinear_sample(sx + 0.5, sy + 0.5);
}
textureStore(dst, vec2<i32>(i32(gid.x), i32(gid.y)), color);
}

View File

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

File diff suppressed because it is too large Load Diff

View File

@ -5,8 +5,7 @@
use eframe::egui; use eframe::egui;
use lightningbeam_core::layer::{AnyLayer, LayerType}; use lightningbeam_core::layer::{AnyLayer, LayerType};
use lightningbeam_core::tool::{Tool, RegionSelectMode, LassoMode}; use lightningbeam_core::tool::{Tool, RegionSelectMode};
use lightningbeam_core::brush_settings::bundled_brushes;
use crate::keymap::tool_app_action; use crate::keymap::tool_app_action;
use super::{NodePath, PaneRenderer, SharedPaneState}; use super::{NodePath, PaneRenderer, SharedPaneState};
@ -102,7 +101,7 @@ impl PaneRenderer for ToolbarPane {
} }
// Draw sub-tool arrow indicator for tools with modes // Draw sub-tool arrow indicator for tools with modes
let has_sub_tools = matches!(tool, Tool::RegionSelect | Tool::SelectLasso); let has_sub_tools = matches!(tool, Tool::RegionSelect);
if has_sub_tools { if has_sub_tools {
let arrow_size = 6.0; let arrow_size = 6.0;
let margin = 4.0; let margin = 4.0;
@ -126,22 +125,6 @@ impl PaneRenderer for ToolbarPane {
// Check for click first // Check for click first
if response.clicked() { if response.clicked() {
*shared.selected_tool = *tool; *shared.selected_tool = *tool;
// Preset-backed tools: auto-select the matching bundled brush.
let preset_name = match tool {
Tool::Pencil => Some("Pencil"),
Tool::Pen => Some("Pen"),
Tool::Airbrush => Some("Airbrush"),
_ => None,
};
if let Some(name) = preset_name {
if let Some(preset) = bundled_brushes().iter().find(|p| p.name == name) {
let s = &preset.settings;
shared.raster_settings.brush_opacity = s.opaque.clamp(0.0, 1.0);
shared.raster_settings.brush_hardness = s.hardness.clamp(0.0, 1.0);
shared.raster_settings.brush_spacing = s.dabs_per_radius;
shared.raster_settings.active_brush_settings = s.clone();
}
}
} }
// Right-click context menu for tools with sub-options // Right-click context menu for tools with sub-options
@ -167,33 +150,6 @@ impl PaneRenderer for ToolbarPane {
ui.close(); ui.close();
} }
} }
Tool::SelectLasso => {
ui.set_min_width(130.0);
if ui.selectable_label(
*shared.lasso_mode == LassoMode::Freehand,
"Freehand",
).clicked() {
*shared.lasso_mode = LassoMode::Freehand;
*shared.selected_tool = Tool::SelectLasso;
ui.close();
}
if ui.selectable_label(
*shared.lasso_mode == LassoMode::Polygonal,
"Polygonal",
).clicked() {
*shared.lasso_mode = LassoMode::Polygonal;
*shared.selected_tool = Tool::SelectLasso;
ui.close();
}
if ui.selectable_label(
*shared.lasso_mode == LassoMode::Magnetic,
"Magnetic",
).clicked() {
*shared.lasso_mode = LassoMode::Magnetic;
*shared.selected_tool = Tool::SelectLasso;
ui.close();
}
}
_ => {} _ => {}
} }
}); });
@ -220,13 +176,6 @@ impl PaneRenderer for ToolbarPane {
RegionSelectMode::Lasso => "Lasso", RegionSelectMode::Lasso => "Lasso",
}; };
format!("{} - {}{}\nRight-click for options", tool.display_name(), mode, hint) format!("{} - {}{}\nRight-click for options", tool.display_name(), mode, hint)
} else if *tool == Tool::SelectLasso {
let mode = match *shared.lasso_mode {
LassoMode::Freehand => "Freehand",
LassoMode::Polygonal => "Polygonal",
LassoMode::Magnetic => "Magnetic",
};
format!("{} - {}{}\nRight-click for options", tool.display_name(), mode, hint)
} else { } else {
format!("{}{}", tool.display_name(), hint) format!("{}{}", tool.display_name(), hint)
}; };

View File

@ -1,758 +0,0 @@
//! Unified raster tool interface.
//!
//! Every raster tool operates on three GPU textures of identical dimensions:
//!
//! | Buffer | Access | Purpose |
//! |--------|--------|---------|
//! | **A** | Read-only | Source pixels, uploaded from layer/float at mousedown. |
//! | **B** | Write-only | Output / display. Compositor shows B while the tool is active. |
//! | **C** | Read+Write | Scratch. Dabs accumulate here across the stroke; composite A+C→B each frame. |
//!
//! All three are `Rgba8Unorm` with the same pixel dimensions. The framework
//! allocates and validates them in [`begin_raster_workspace`]; tools only
//! dispatch shaders.
use std::sync::Arc;
use uuid::Uuid;
use eframe::egui;
// ── WorkspaceSource ──────────────────────────────────────────────────────────
/// Describes whether the tool is operating on a raster layer or a floating selection.
#[derive(Clone, Debug)]
pub enum WorkspaceSource {
/// Operating on the full raster layer.
Layer {
layer_id: Uuid,
time: f64,
/// The keyframe's own UUID (the A-canvas key in `GpuBrushEngine`).
kf_id: Uuid,
/// Full canvas dimensions (may differ from workspace dims for floating selections).
canvas_w: u32,
canvas_h: u32,
},
/// Operating on the floating selection.
Float,
}
// ── RasterWorkspace ───────────────────────────────────────────────────────────
/// GPU buffer IDs and metadata for a single tool operation.
///
/// Created by [`begin_raster_workspace`] on mousedown. All three canvas UUIDs
/// index into `GpuBrushEngine::canvases` and are valid for the lifetime of the
/// active tool. They are queued for removal in `pending_canvas_removals` after
/// commit or cancel.
#[derive(Debug)]
pub struct RasterWorkspace {
/// A canvas (Rgba8Unorm) — source pixels, uploaded at mousedown, read-only for tools.
pub a_canvas_id: Uuid,
/// B canvas (Rgba8Unorm) — output / display; compositor shows this while active.
pub b_canvas_id: Uuid,
/// C canvas (Rgba8Unorm) — scratch; tools accumulate dabs here across the stroke.
pub c_canvas_id: Uuid,
/// Optional R8Unorm selection mask (same pixel dimensions as A/B/C).
/// `None` means the entire workspace is selected.
pub mask_texture: Option<Arc<wgpu::Texture>>,
/// Pixel dimensions. A, B, C, and mask are all guaranteed to be this size.
pub width: u32,
pub height: u32,
/// Top-left position in document-pixel space.
/// `(0, 0)` for a layer workspace; `(float.x, float.y)` for a float workspace.
pub x: i32,
pub y: i32,
/// Where the workspace came from — drives commit behaviour.
pub source: WorkspaceSource,
/// CPU snapshot taken at mousedown for undo / cancel.
/// Length is always `width * height * 4` (sRGB premultiplied RGBA).
pub before_pixels: Vec<u8>,
}
impl RasterWorkspace {
/// Panic-safe bounds check. Asserts that every GPU canvas exists and has
/// the dimensions declared by this workspace. Called by the framework
/// before `begin()` and before each `update()`.
pub fn validate(&self, gpu: &crate::gpu_brush::GpuBrushEngine) {
for (name, id) in [
("A", self.a_canvas_id),
("B", self.b_canvas_id),
("C", self.c_canvas_id),
] {
let canvas = gpu.canvases.get(&id).unwrap_or_else(|| {
panic!(
"RasterWorkspace::validate: buffer '{}' (id={}) not found in GpuBrushEngine",
name, id
)
});
assert_eq!(
canvas.width, self.width,
"RasterWorkspace::validate: buffer '{}' width {} != workspace width {}",
name, canvas.width, self.width
);
assert_eq!(
canvas.height, self.height,
"RasterWorkspace::validate: buffer '{}' height {} != workspace height {}",
name, canvas.height, self.height
);
}
let expected = (self.width * self.height * 4) as usize;
assert_eq!(
self.before_pixels.len(), expected,
"RasterWorkspace::validate: before_pixels.len()={} != expected {}",
self.before_pixels.len(), expected
);
}
/// Returns the three canvas UUIDs as an array (convenient for bulk removal).
pub fn canvas_ids(&self) -> [Uuid; 3] {
[self.a_canvas_id, self.b_canvas_id, self.c_canvas_id]
}
}
// ── WorkspaceInitPacket ───────────────────────────────────────────────────────
/// Data sent to `prepare()` on the first frame to create and upload the A/B/C canvases.
///
/// The canvas UUIDs are pre-allocated in `begin_raster_workspace()` (UI thread).
/// The actual `wgpu::Texture` creation and pixel upload happens in `prepare()`.
pub struct WorkspaceInitPacket {
/// A canvas UUID (already in `RasterWorkspace::a_canvas_id`).
pub a_canvas_id: Uuid,
/// Pixel data to upload to A. Length must equal `width * height * 4`.
pub a_pixels: Vec<u8>,
/// B canvas UUID.
pub b_canvas_id: Uuid,
/// C canvas UUID.
pub c_canvas_id: Uuid,
pub width: u32,
pub height: u32,
}
// ── ActiveToolRender ──────────────────────────────────────────────────────────
/// Passed to `VelloRenderContext` so the compositor can blit the tool's B output
/// in the correct position in the layer stack.
///
/// While an `ActiveToolRender` is set:
/// - If `layer_id == Some(id)`: blit B at that layer's compositor slot.
/// - If `layer_id == None`: blit B at the float's compositor slot.
#[derive(Clone, Debug)]
pub struct ActiveToolRender {
/// B canvas to blit.
pub b_canvas_id: Uuid,
/// Position of the B canvas in document space.
pub x: i32,
pub y: i32,
/// Pixel dimensions of the B canvas.
pub width: u32,
pub height: u32,
/// `Some(layer_id)` → B replaces this layer's render slot.
/// `None` → B replaces the float render slot.
pub layer_id: Option<Uuid>,
}
// ── PendingGpuWork ────────────────────────────────────────────────────────────
/// GPU work to execute in `VelloCallback::prepare()`.
///
/// Tools compute dab lists and other CPU-side data in `update()` (UI thread),
/// store them as a `Box<dyn PendingGpuWork>`, and return that work through
/// `RasterTool::take_pending_gpu_work()` each frame. `prepare()` then calls
/// `execute()` with the render-thread `device`/`queue`/`gpu`.
///
/// `execute()` takes `&self` so the work object need not be consumed; it lives
/// in the `VelloRenderContext` (which is immutable in `prepare()`).
pub trait PendingGpuWork: Send + Sync {
fn execute(
&self,
device: &wgpu::Device,
queue: &wgpu::Queue,
gpu: &mut crate::gpu_brush::GpuBrushEngine,
);
}
// ── RasterTool trait ──────────────────────────────────────────────────────────
/// Unified interface for all raster tools.
///
/// All methods run on the UI thread. They update the tool's internal state
/// and store pending GPU op descriptors in fields that `StagePane` forwards
/// to `VelloRenderContext` for execution by `VelloCallback::prepare()`.
pub trait RasterTool: Send + Sync {
/// Called on **mousedown** after [`begin_raster_workspace`] has allocated and
/// validated A, B, and C. The tool should initialise its internal state and
/// optionally queue an initial GPU dispatch (e.g. identity composite for
/// transform so the handle frame appears immediately).
fn begin(
&mut self,
ws: &RasterWorkspace,
pos: egui::Vec2,
dt: f32,
settings: &crate::tools::RasterToolSettings,
);
/// Called every frame while the pointer is held (including the first drag frame).
/// The tool should accumulate new work into C and queue a composite A+C→B pass.
/// `dt` is the elapsed time in seconds since the previous call; used by time-based
/// brushes (airbrush, etc.) to fire dabs at the correct rate when stationary.
fn update(
&mut self,
ws: &RasterWorkspace,
pos: egui::Vec2,
dt: f32,
settings: &crate::tools::RasterToolSettings,
);
/// Called on **pointer release**. Returns `true` if a GPU readback of B should
/// be performed and the result committed to the document. Returns `false` if
/// the operation was a no-op (e.g. the pointer never moved).
fn finish(&mut self, ws: &RasterWorkspace) -> bool;
/// Called on **Escape** or tool switch mid-stroke. The caller restores the
/// source pixels from `ws.before_pixels` without creating an undo entry; the
/// tool just cleans up internal state.
fn cancel(&mut self, ws: &RasterWorkspace);
/// Called once per frame (in the VelloCallback construction, UI thread) to
/// extract pending GPU work accumulated by `begin()` / `update()`.
///
/// The tool clears its internal pending work and returns it. `prepare()` on
/// the render thread then calls `work.execute()`. Default: no GPU work.
fn take_pending_gpu_work(&mut self) -> Option<Box<dyn PendingGpuWork>> {
None
}
}
// ── BrushRasterTool ───────────────────────────────────────────────────────────
use lightningbeam_core::brush_engine::{BrushEngine, GpuDab, StrokeState};
use lightningbeam_core::brush_settings::BrushSettings;
use lightningbeam_core::raster_layer::{RasterBlendMode, StrokePoint, StrokeRecord};
/// GPU work for one frame of a brush stroke: dispatch dabs into C, then composite A+C→B.
struct PendingBrushWork {
dabs: Vec<GpuDab>,
bbox: (i32, i32, i32, i32),
a_id: Uuid,
b_id: Uuid,
c_id: Uuid,
canvas_w: u32,
canvas_h: u32,
}
impl PendingGpuWork for PendingBrushWork {
fn execute(
&self,
device: &wgpu::Device,
queue: &wgpu::Queue,
gpu: &mut crate::gpu_brush::GpuBrushEngine,
) {
// 1. Accumulate this frame's dabs into C (if any).
if !self.dabs.is_empty() {
gpu.render_dabs(device, queue, self.c_id, &self.dabs, self.bbox, self.canvas_w, self.canvas_h);
}
// 2. Always composite A + C → B so B shows A's content even with no dabs this frame.
// On begin() with empty C this initialises B = A, avoiding a transparent flash.
gpu.composite_a_c_to_b(device, queue, self.a_id, self.c_id, self.b_id, self.canvas_w, self.canvas_h);
}
}
/// Raster tool for paint brushes (Normal blend mode).
///
/// Each `update()` call computes new dabs for that frame and stores them as
/// `PendingBrushWork`. `take_pending_gpu_work()` hands the work to `prepare()`
/// which dispatches the dab and composite shaders on the render thread.
pub struct BrushRasterTool {
color: [f32; 4],
brush: BrushSettings,
blend_mode: RasterBlendMode,
stroke_state: StrokeState,
last_point: Option<StrokePoint>,
pending: Option<Box<PendingBrushWork>>,
/// True after at least one non-empty frame (so finish() knows a commit is needed).
has_dabs: bool,
/// Offset to convert world coordinates to canvas-local coordinates.
canvas_offset_x: i32,
canvas_offset_y: i32,
}
impl BrushRasterTool {
/// Create a new brush tool.
///
/// `color` — linear premultiplied RGBA, matches the format expected by `GpuDab`.
pub fn new(
color: [f32; 4],
brush: BrushSettings,
blend_mode: RasterBlendMode,
) -> Self {
Self {
color,
brush,
blend_mode,
stroke_state: StrokeState::new(),
last_point: None,
pending: None,
has_dabs: false,
canvas_offset_x: 0,
canvas_offset_y: 0,
}
}
fn make_stroke_point(pos: egui::Vec2, off_x: i32, off_y: i32) -> StrokePoint {
StrokePoint {
x: pos.x - off_x as f32,
y: pos.y - off_y as f32,
pressure: 1.0,
tilt_x: 0.0,
tilt_y: 0.0,
timestamp: 0.0,
}
}
fn dispatch_dabs(
&mut self,
ws: &RasterWorkspace,
pt: StrokePoint,
dt: f32,
) {
// Use a 2-point segment when we have a previous point so the engine
// interpolates dabs along the path. First mousedown uses a single point.
let points = match self.last_point.take() {
Some(prev) => vec![prev, pt.clone()],
None => vec![pt.clone()],
};
let record = StrokeRecord {
brush_settings: self.brush.clone(),
color: self.color,
blend_mode: self.blend_mode,
tool_params: [0.0; 4],
points,
};
let (dabs, bbox) = BrushEngine::compute_dabs(&record, &mut self.stroke_state, dt);
if !dabs.is_empty() {
self.has_dabs = true;
self.pending = Some(Box::new(PendingBrushWork {
dabs,
bbox,
a_id: ws.a_canvas_id,
b_id: ws.b_canvas_id,
c_id: ws.c_canvas_id,
canvas_w: ws.width,
canvas_h: ws.height,
}));
}
self.last_point = Some(pt);
}
}
impl RasterTool for BrushRasterTool {
fn begin(&mut self, ws: &RasterWorkspace, pos: egui::Vec2, _dt: f32, _settings: &crate::tools::RasterToolSettings) {
self.canvas_offset_x = ws.x;
self.canvas_offset_y = ws.y;
let pt = Self::make_stroke_point(pos, ws.x, ws.y);
self.dispatch_dabs(ws, pt, 0.0);
// Always ensure a composite is queued on begin() so B is initialised from A
// on the first frame even if no dabs fired (large spacing, etc.).
if self.pending.is_none() {
self.pending = Some(Box::new(PendingBrushWork {
dabs: vec![],
bbox: (0, 0, ws.width as i32, ws.height as i32),
a_id: ws.a_canvas_id,
b_id: ws.b_canvas_id,
c_id: ws.c_canvas_id,
canvas_w: ws.width,
canvas_h: ws.height,
}));
}
}
fn update(&mut self, ws: &RasterWorkspace, pos: egui::Vec2, dt: f32, _settings: &crate::tools::RasterToolSettings) {
let pt = Self::make_stroke_point(pos, ws.x, ws.y);
self.dispatch_dabs(ws, pt, dt);
}
fn finish(&mut self, _ws: &RasterWorkspace) -> bool {
self.has_dabs
}
fn cancel(&mut self, _ws: &RasterWorkspace) {
self.pending = None;
self.has_dabs = false;
}
fn take_pending_gpu_work(&mut self) -> Option<Box<dyn PendingGpuWork>> {
self.pending.take().map(|w| w as Box<dyn PendingGpuWork>)
}
}
// ── EffectBrushTool ───────────────────────────────────────────────────────────
/// Raster tool for effect brushes (Blur, Sharpen, Dodge, Burn, Sponge, Desaturate).
///
/// C accumulates a per-pixel influence weight (R channel, 0255).
/// The composite pass applies the effect to A, scaled by C.r, writing to B:
/// `B = lerp(A, effect(A), C.r)`
///
/// Using C as an influence map (rather than accumulating modified pixels) prevents
/// overlapping dabs from compounding the effect beyond the C.r cap (255).
///
/// # GPU implementation (TODO)
/// Requires a dedicated `effect_brush_composite.wgsl` shader that reads A and C,
/// applies the blend-mode-specific filter to A, and blends by C.r → B.
pub struct EffectBrushTool {
brush: BrushSettings,
blend_mode: RasterBlendMode,
has_dabs: bool,
}
impl EffectBrushTool {
pub fn new(brush: BrushSettings, blend_mode: RasterBlendMode) -> Self {
Self { brush, blend_mode, has_dabs: false }
}
}
impl RasterTool for EffectBrushTool {
fn begin(&mut self, _ws: &RasterWorkspace, _pos: egui::Vec2, _dt: f32, _settings: &crate::tools::RasterToolSettings) {}
fn update(&mut self, _ws: &RasterWorkspace, _pos: egui::Vec2, _dt: f32, _settings: &crate::tools::RasterToolSettings) {
self.has_dabs = true; // placeholder
}
fn finish(&mut self, _ws: &RasterWorkspace) -> bool { self.has_dabs }
fn cancel(&mut self, _ws: &RasterWorkspace) { self.has_dabs = false; }
// GPU shaders not yet implemented; take_pending_gpu_work returns None (default).
}
// ── SmudgeTool ────────────────────────────────────────────────────────────────
/// Raster tool for the smudge brush.
///
/// `begin()`: copy A → C so C starts with the source pixels for color pickup.
/// `update()`: dispatch smudge dabs using `blend_mode=2` (reads C as source,
/// writes smear to C); then composite C over A → B.
/// Because the smudge shader reads from `canvas_src` (C.src) and writes to
/// `canvas_dst` (C.dst), existing dabs are preserved in the smear history.
///
/// # GPU implementation (TODO)
/// Requires an initial A → C copy in `begin()` (via GPU copy command).
/// The smudge dab dispatch then uses `render_dabs(c_id, smudge_dabs, ...)`.
/// The composite pass is `composite_a_c_to_b` (same as BrushRasterTool).
pub struct SmudgeTool {
brush: BrushSettings,
has_dabs: bool,
}
impl SmudgeTool {
pub fn new(brush: BrushSettings) -> Self {
Self { brush, has_dabs: false }
}
}
impl RasterTool for SmudgeTool {
fn begin(&mut self, _ws: &RasterWorkspace, _pos: egui::Vec2, _dt: f32, _settings: &crate::tools::RasterToolSettings) {}
fn update(&mut self, _ws: &RasterWorkspace, _pos: egui::Vec2, _dt: f32, _settings: &crate::tools::RasterToolSettings) {
self.has_dabs = true; // placeholder
}
fn finish(&mut self, _ws: &RasterWorkspace) -> bool { self.has_dabs }
fn cancel(&mut self, _ws: &RasterWorkspace) { self.has_dabs = false; }
// GPU shaders not yet implemented; take_pending_gpu_work returns None (default).
}
// ── GradientRasterTool ────────────────────────────────────────────────────────
use crate::gpu_brush::GpuGradientStop;
use lightningbeam_core::gradient::{GradientExtend, GradientType, ShapeGradient};
fn gradient_stops_to_gpu(gradient: &ShapeGradient) -> Vec<GpuGradientStop> {
gradient.stops.iter().map(|s| {
GpuGradientStop::from_srgb_u8(s.position, s.color.r, s.color.g, s.color.b, s.color.a)
}).collect()
}
fn gradient_extend_to_u32(extend: GradientExtend) -> u32 {
match extend {
GradientExtend::Pad => 0,
GradientExtend::Reflect => 1,
GradientExtend::Repeat => 2,
}
}
fn gradient_kind_to_u32(kind: GradientType) -> u32 {
match kind {
GradientType::Linear => 0,
GradientType::Radial => 1,
}
}
struct PendingGradientWork {
a_id: Uuid,
b_id: Uuid,
stops: Vec<GpuGradientStop>,
start: (f32, f32),
end: (f32, f32),
opacity: f32,
extend_mode: u32,
kind: u32,
}
impl PendingGpuWork for PendingGradientWork {
fn execute(&self, device: &wgpu::Device, queue: &wgpu::Queue, gpu: &mut crate::gpu_brush::GpuBrushEngine) {
gpu.apply_gradient_fill(
device, queue,
&self.a_id, &self.b_id,
&self.stops,
self.start, self.end,
self.opacity, self.extend_mode, self.kind,
);
}
}
/// Raster tool for gradient fills.
///
/// `begin()` records the canvas-local start position.
/// `update()` recomputes gradient parameters from settings and queues a
/// `PendingGradientWork` that calls `apply_gradient_fill` in `prepare()`.
/// `finish()` returns whether any gradient was dispatched.
pub struct GradientRasterTool {
start_canvas: egui::Vec2,
end_canvas: egui::Vec2,
pending: Option<Box<PendingGradientWork>>,
has_dispatched: bool,
}
impl GradientRasterTool {
pub fn new() -> Self {
Self {
start_canvas: egui::Vec2::ZERO,
end_canvas: egui::Vec2::ZERO,
pending: None,
has_dispatched: false,
}
}
}
impl RasterTool for GradientRasterTool {
fn begin(&mut self, ws: &RasterWorkspace, pos: egui::Vec2, _dt: f32, _settings: &crate::tools::RasterToolSettings) {
let canvas_pos = pos - egui::vec2(ws.x as f32, ws.y as f32);
self.start_canvas = canvas_pos;
self.end_canvas = canvas_pos;
}
fn update(&mut self, ws: &RasterWorkspace, pos: egui::Vec2, _dt: f32, settings: &crate::tools::RasterToolSettings) {
self.end_canvas = pos - egui::vec2(ws.x as f32, ws.y as f32);
let gradient = &settings.gradient;
self.pending = Some(Box::new(PendingGradientWork {
a_id: ws.a_canvas_id,
b_id: ws.b_canvas_id,
stops: gradient_stops_to_gpu(gradient),
start: (self.start_canvas.x, self.start_canvas.y),
end: (self.end_canvas.x, self.end_canvas.y),
opacity: settings.gradient_opacity,
extend_mode: gradient_extend_to_u32(gradient.extend),
kind: gradient_kind_to_u32(gradient.kind),
}));
self.has_dispatched = true;
}
fn finish(&mut self, _ws: &RasterWorkspace) -> bool { self.has_dispatched }
fn cancel(&mut self, _ws: &RasterWorkspace) {
self.pending = None;
self.has_dispatched = false;
}
fn take_pending_gpu_work(&mut self) -> Option<Box<dyn PendingGpuWork>> {
self.pending.take().map(|w| w as Box<dyn PendingGpuWork>)
}
}
// ── TransformRasterTool ───────────────────────────────────────────────────────
use crate::gpu_brush::RasterTransformGpuParams;
struct PendingTransformWork {
a_id: Uuid,
b_id: Uuid,
params: RasterTransformGpuParams,
}
impl PendingGpuWork for PendingTransformWork {
fn execute(&self, device: &wgpu::Device, queue: &wgpu::Queue, gpu: &mut crate::gpu_brush::GpuBrushEngine) {
gpu.render_transform(device, queue, &self.a_id, &self.b_id, self.params);
}
}
/// Raster tool for affine transforms (move, scale, rotate, shear).
///
/// `begin()` stores the initial canvas dimensions and queues an identity
/// transform so B is initialised on the first frame.
/// `update()` recomputes the inverse affine matrix from the current handle
/// positions and queues a new `PendingTransformWork`.
///
/// The inverse matrix maps output pixel coordinates back to source pixel
/// coordinates: `src = M_inv * dst + b`
/// where `M_inv = [[a00, a01], [a10, a11]]` and `b = [b0, b1]`.
///
/// # GPU implementation
/// Fully wired — uses `GpuBrushEngine::render_transform`. Handle interaction
/// logic (drag, rotate, scale) is handled by the tool's `update()` caller in
/// `stage.rs` which computes and passes in the `RasterTransformGpuParams`.
pub struct TransformRasterTool {
pending: Option<Box<PendingTransformWork>>,
has_dispatched: bool,
canvas_w: u32,
canvas_h: u32,
}
impl TransformRasterTool {
pub fn new() -> Self {
Self {
pending: None,
has_dispatched: false,
canvas_w: 0,
canvas_h: 0,
}
}
/// Queue a transform with the given inverse-affine matrix.
/// Called by the stage handler after computing handle positions.
pub fn set_transform(
&mut self,
ws: &RasterWorkspace,
params: RasterTransformGpuParams,
) {
self.pending = Some(Box::new(PendingTransformWork {
a_id: ws.a_canvas_id,
b_id: ws.b_canvas_id,
params,
}));
self.has_dispatched = true;
}
}
impl RasterTool for TransformRasterTool {
fn begin(&mut self, ws: &RasterWorkspace, _pos: egui::Vec2, _dt: f32, _settings: &crate::tools::RasterToolSettings) {
self.canvas_w = ws.width;
self.canvas_h = ws.height;
// Queue identity transform so B shows the source immediately.
let identity = RasterTransformGpuParams {
a00: 1.0, a01: 0.0,
a10: 0.0, a11: 1.0,
b0: 0.0, b1: 0.0,
src_w: ws.width, src_h: ws.height,
dst_w: ws.width, dst_h: ws.height,
_pad0: 0, _pad1: 0,
};
self.set_transform(ws, identity);
}
fn update(&mut self, _ws: &RasterWorkspace, _pos: egui::Vec2, _dt: f32, _settings: &crate::tools::RasterToolSettings) {
// Handle interaction and matrix updates are driven from stage.rs via set_transform().
}
fn finish(&mut self, _ws: &RasterWorkspace) -> bool { self.has_dispatched }
fn cancel(&mut self, _ws: &RasterWorkspace) {
self.pending = None;
self.has_dispatched = false;
}
fn take_pending_gpu_work(&mut self) -> Option<Box<dyn PendingGpuWork>> {
self.pending.take().map(|w| w as Box<dyn PendingGpuWork>)
}
}
// ── WarpRasterTool ────────────────────────────────────────────────────────────
/// Raster tool for warp / mesh deformation.
///
/// Uses a displacement buffer (managed by `GpuBrushEngine`) that maps each
/// output pixel to a source offset. The displacement grid is updated by
/// dragging control points; the warp shader reads anchor pixels + displacement
/// → B each frame.
///
/// # GPU implementation (TODO)
/// Requires: `create_displacement_buf`, `apply_warp` already exist in
/// `GpuBrushEngine`. Wire brush-drag interaction to update displacement
/// entries and call `apply_warp`.
pub struct WarpRasterTool {
has_dispatched: bool,
}
impl WarpRasterTool {
pub fn new() -> Self { Self { has_dispatched: false } }
}
impl RasterTool for WarpRasterTool {
fn begin(&mut self, _ws: &RasterWorkspace, _pos: egui::Vec2, _dt: f32, _settings: &crate::tools::RasterToolSettings) {}
fn update(&mut self, _ws: &RasterWorkspace, _pos: egui::Vec2, _dt: f32, _settings: &crate::tools::RasterToolSettings) {
self.has_dispatched = true; // placeholder
}
fn finish(&mut self, _ws: &RasterWorkspace) -> bool { self.has_dispatched }
fn cancel(&mut self, _ws: &RasterWorkspace) { self.has_dispatched = false; }
// take_pending_gpu_work: default (None) — full GPU wiring is TODO.
}
// ── LiquifyRasterTool ─────────────────────────────────────────────────────────
/// Raster tool for liquify (per-pixel displacement painting).
///
/// Similar to `WarpRasterTool` but uses a full per-pixel displacement map
/// (grid_cols = grid_rows = 0 in `apply_warp`) painted by brush strokes.
/// Each dab accumulates displacement in the push/pull/swirl direction.
///
/// # GPU implementation (TODO)
/// Requires: a dab-to-displacement shader that accumulates per-pixel offsets
/// into the displacement buffer, then `apply_warp` reads it → B.
pub struct LiquifyRasterTool {
has_dispatched: bool,
}
impl LiquifyRasterTool {
pub fn new() -> Self { Self { has_dispatched: false } }
}
impl RasterTool for LiquifyRasterTool {
fn begin(&mut self, _ws: &RasterWorkspace, _pos: egui::Vec2, _dt: f32, _settings: &crate::tools::RasterToolSettings) {}
fn update(&mut self, _ws: &RasterWorkspace, _pos: egui::Vec2, _dt: f32, _settings: &crate::tools::RasterToolSettings) {
self.has_dispatched = true; // placeholder
}
fn finish(&mut self, _ws: &RasterWorkspace) -> bool { self.has_dispatched }
fn cancel(&mut self, _ws: &RasterWorkspace) { self.has_dispatched = false; }
// take_pending_gpu_work: default (None) — full GPU wiring is TODO.
}
// ── SelectionTool ─────────────────────────────────────────────────────────────
/// Raster selection tool (Magic Wand / Quick Select).
///
/// C (RGBA8) acts as the growing selection; C.r = mask value (0 or 255).
/// Each `update()` frame a flood-fill / region-grow shader extends C.r.
/// The composite pass draws A + a tinted overlay from C.r → B so the user
/// sees the growing selection boundary.
///
/// `finish()` returns false (commit does not write pixels back to the layer;
/// instead the caller extracts C.r into the standalone `R8Unorm` selection
/// texture via `shared.raster_selection`).
///
/// # GPU implementation (TODO)
/// Requires: a flood-fill compute shader seeded by the click position that
/// grows the selection in C.r; and a composite shader that tints selected
/// pixels blue/cyan for preview.
pub struct SelectionTool {
has_selection: bool,
}
impl SelectionTool {
pub fn new() -> Self { Self { has_selection: false } }
}
impl RasterTool for SelectionTool {
fn begin(&mut self, _ws: &RasterWorkspace, _pos: egui::Vec2, _dt: f32, _settings: &crate::tools::RasterToolSettings) {}
fn update(&mut self, _ws: &RasterWorkspace, _pos: egui::Vec2, _dt: f32, _settings: &crate::tools::RasterToolSettings) {
self.has_selection = true; // placeholder
}
/// Selection tools never trigger a pixel readback/commit on mouseup.
/// The caller reads C.r directly into the selection mask texture.
fn finish(&mut self, _ws: &RasterWorkspace) -> bool { false }
fn cancel(&mut self, _ws: &RasterWorkspace) { self.has_selection = false; }
// take_pending_gpu_work: default (None) — full GPU wiring is TODO.
}

View File

@ -1,60 +0,0 @@
use super::{BrushParams, RasterToolDef, RasterToolSettings};
use eframe::egui;
use lightningbeam_core::{brush_settings::BrushSettings, raster_layer::RasterBlendMode};
pub struct BlurSharpenTool;
pub static BLUR_SHARPEN: BlurSharpenTool = BlurSharpenTool;
impl RasterToolDef for BlurSharpenTool {
fn blend_mode(&self) -> RasterBlendMode { RasterBlendMode::BlurSharpen }
fn header_label(&self) -> &'static str { "Blur / Sharpen" }
fn brush_params(&self, s: &RasterToolSettings) -> BrushParams {
BrushParams {
base_settings: BrushSettings::default(),
radius: s.blur_sharpen_radius,
opacity: s.blur_sharpen_strength,
hardness: s.blur_sharpen_hardness,
spacing: s.blur_sharpen_spacing,
}
}
fn tool_params(&self, s: &RasterToolSettings) -> [f32; 4] {
[s.blur_sharpen_mode as f32, s.blur_sharpen_kernel, 0.0, 0.0]
}
fn show_brush_preset_picker(&self) -> bool { false }
fn render_ui(&self, ui: &mut egui::Ui, s: &mut RasterToolSettings) {
ui.horizontal(|ui| {
if ui.selectable_label(s.blur_sharpen_mode == 0, "Blur").clicked() {
s.blur_sharpen_mode = 0;
}
if ui.selectable_label(s.blur_sharpen_mode == 1, "Sharpen").clicked() {
s.blur_sharpen_mode = 1;
}
});
ui.horizontal(|ui| {
ui.label("Size:");
ui.add(egui::Slider::new(&mut s.blur_sharpen_radius, 1.0_f32..=500.0).logarithmic(true).suffix(" px"));
});
ui.horizontal(|ui| {
ui.label("Strength:");
ui.add(egui::Slider::new(&mut s.blur_sharpen_strength, 0.0_f32..=1.0)
.custom_formatter(|v, _| format!("{:.0}%", v * 100.0)));
});
ui.horizontal(|ui| {
ui.label("Hardness:");
ui.add(egui::Slider::new(&mut s.blur_sharpen_hardness, 0.0_f32..=1.0)
.custom_formatter(|v, _| format!("{:.0}%", v * 100.0)));
});
ui.horizontal(|ui| {
ui.label("Kernel:");
ui.add(egui::Slider::new(&mut s.blur_sharpen_kernel, 1.0_f32..=20.0)
.logarithmic(true)
.custom_formatter(|v, _| format!("{:.1} px", v)));
});
ui.horizontal(|ui| {
ui.label("Spacing:");
ui.add(egui::Slider::new(&mut s.blur_sharpen_spacing, 0.5_f32..=20.0)
.logarithmic(true)
.custom_formatter(|v, _| format!("{:.1}", v)));
});
}
}

View File

@ -1,29 +0,0 @@
use super::{BrushParams, RasterToolDef, RasterToolSettings};
use eframe::egui;
use lightningbeam_core::raster_layer::RasterBlendMode;
pub struct CloneStampTool;
pub static CLONE_STAMP: CloneStampTool = CloneStampTool;
impl RasterToolDef for CloneStampTool {
fn blend_mode(&self) -> RasterBlendMode { RasterBlendMode::CloneStamp }
fn header_label(&self) -> &'static str { "Clone Stamp" }
fn brush_params(&self, s: &RasterToolSettings) -> BrushParams {
BrushParams {
base_settings: s.active_brush_settings.clone(),
radius: s.brush_radius,
opacity: s.brush_opacity,
hardness: s.brush_hardness,
spacing: s.brush_spacing,
}
}
/// For Clone Stamp, tool_params are filled by stage.rs at stroke-start time
/// (offset = clone_source - stroke_start), not from settings directly.
fn tool_params(&self, _s: &RasterToolSettings) -> [f32; 4] { [0.0; 4] }
fn uses_alt_click(&self) -> bool { true }
fn render_ui(&self, ui: &mut egui::Ui, s: &mut RasterToolSettings) {
if s.clone_source.is_none() {
ui.label("Alt+click to set source point.");
}
}
}

View File

@ -1,54 +0,0 @@
use super::{BrushParams, RasterToolDef, RasterToolSettings};
use eframe::egui;
use lightningbeam_core::{brush_settings::BrushSettings, raster_layer::RasterBlendMode};
pub struct DodgeBurnTool;
pub static DODGE_BURN: DodgeBurnTool = DodgeBurnTool;
impl RasterToolDef for DodgeBurnTool {
fn blend_mode(&self) -> RasterBlendMode { RasterBlendMode::DodgeBurn }
fn header_label(&self) -> &'static str { "Dodge / Burn" }
fn brush_params(&self, s: &RasterToolSettings) -> BrushParams {
BrushParams {
base_settings: BrushSettings::default(),
radius: s.dodge_burn_radius,
opacity: s.dodge_burn_exposure,
hardness: s.dodge_burn_hardness,
spacing: s.dodge_burn_spacing,
}
}
fn tool_params(&self, s: &RasterToolSettings) -> [f32; 4] {
[s.dodge_burn_mode as f32, 0.0, 0.0, 0.0]
}
fn show_brush_preset_picker(&self) -> bool { false }
fn render_ui(&self, ui: &mut egui::Ui, s: &mut RasterToolSettings) {
ui.horizontal(|ui| {
if ui.selectable_label(s.dodge_burn_mode == 0, "Dodge").clicked() {
s.dodge_burn_mode = 0;
}
if ui.selectable_label(s.dodge_burn_mode == 1, "Burn").clicked() {
s.dodge_burn_mode = 1;
}
});
ui.horizontal(|ui| {
ui.label("Size:");
ui.add(egui::Slider::new(&mut s.dodge_burn_radius, 1.0_f32..=500.0).logarithmic(true).suffix(" px"));
});
ui.horizontal(|ui| {
ui.label("Exposure:");
ui.add(egui::Slider::new(&mut s.dodge_burn_exposure, 0.0_f32..=1.0)
.custom_formatter(|v, _| format!("{:.0}%", v * 100.0)));
});
ui.horizontal(|ui| {
ui.label("Hardness:");
ui.add(egui::Slider::new(&mut s.dodge_burn_hardness, 0.0_f32..=1.0)
.custom_formatter(|v, _| format!("{:.0}%", v * 100.0)));
});
ui.horizontal(|ui| {
ui.label("Spacing:");
ui.add(egui::Slider::new(&mut s.dodge_burn_spacing, 0.5_f32..=20.0)
.logarithmic(true)
.custom_formatter(|v, _| format!("{:.1}", v)));
});
}
}

View File

@ -1,23 +0,0 @@
use super::{BrushParams, RasterToolDef, RasterToolSettings};
use eframe::egui;
use lightningbeam_core::raster_layer::RasterBlendMode;
pub struct EraseTool;
pub static ERASE: EraseTool = EraseTool;
impl RasterToolDef for EraseTool {
fn blend_mode(&self) -> RasterBlendMode { RasterBlendMode::Erase }
fn header_label(&self) -> &'static str { "Eraser" }
fn brush_params(&self, s: &RasterToolSettings) -> BrushParams {
BrushParams {
base_settings: s.active_eraser_settings.clone(),
radius: s.eraser_radius,
opacity: s.eraser_opacity,
hardness: s.eraser_hardness,
spacing: s.eraser_spacing,
}
}
fn tool_params(&self, _s: &RasterToolSettings) -> [f32; 4] { [0.0; 4] }
fn is_eraser(&self) -> bool { true }
fn render_ui(&self, _ui: &mut egui::Ui, _s: &mut RasterToolSettings) {}
}

View File

@ -1,28 +0,0 @@
use super::{BrushParams, RasterToolDef, RasterToolSettings};
use eframe::egui;
use lightningbeam_core::raster_layer::RasterBlendMode;
pub struct HealingBrushTool;
pub static HEALING_BRUSH: HealingBrushTool = HealingBrushTool;
impl RasterToolDef for HealingBrushTool {
fn blend_mode(&self) -> RasterBlendMode { RasterBlendMode::Healing }
fn header_label(&self) -> &'static str { "Healing Brush" }
fn brush_params(&self, s: &RasterToolSettings) -> BrushParams {
BrushParams {
base_settings: s.active_brush_settings.clone(),
radius: s.brush_radius,
opacity: s.brush_opacity,
hardness: s.brush_hardness,
spacing: s.brush_spacing,
}
}
/// tool_params are filled by stage.rs at stroke-start time (clone offset).
fn tool_params(&self, _s: &RasterToolSettings) -> [f32; 4] { [0.0; 4] }
fn uses_alt_click(&self) -> bool { true }
fn render_ui(&self, ui: &mut egui::Ui, s: &mut RasterToolSettings) {
if s.clone_source.is_none() {
ui.label("Alt+click to set source point.");
}
}
}

View File

@ -1,268 +0,0 @@
/// Per-tool module for raster painting tools.
///
/// Each tool implements `RasterToolDef`. Adding a new tool requires:
/// 1. A new file in this directory implementing `RasterToolDef`.
/// 2. One entry in `raster_tool_def()` below.
/// 3. Core changes: `RasterBlendMode` variant, `brush_engine.rs` constant, WGSL branch.
use eframe::egui;
use lightningbeam_core::{
brush_settings::BrushSettings,
raster_layer::RasterBlendMode,
tool::Tool,
};
pub mod paint;
pub mod erase;
pub mod smudge;
pub mod clone_stamp;
pub mod healing_brush;
pub mod pattern_stamp;
pub mod dodge_burn;
pub mod sponge;
pub mod blur_sharpen;
// ---------------------------------------------------------------------------
// Shared settings struct (replaces 20+ individual SharedPaneState / EditorApp fields)
// ---------------------------------------------------------------------------
/// All per-tool settings for raster painting. Owned by `EditorApp`; borrowed
/// by `SharedPaneState` as a single `&'a mut RasterToolSettings`.
pub struct RasterToolSettings {
// --- Paint brush ---
pub brush_radius: f32,
pub brush_opacity: f32,
pub brush_hardness: f32,
pub brush_spacing: f32,
/// true = paint with FG (stroke) color, false = BG (fill) color
pub brush_use_fg: bool,
pub active_brush_settings: BrushSettings,
// --- Eraser ---
pub eraser_radius: f32,
pub eraser_opacity: f32,
pub eraser_hardness: f32,
pub eraser_spacing: f32,
pub active_eraser_settings: BrushSettings,
// --- Smudge ---
pub smudge_radius: f32,
pub smudge_hardness: f32,
pub smudge_spacing: f32,
pub smudge_strength: f32,
// --- Clone / Healing ---
/// World-space source point set by Alt+click.
pub clone_source: Option<egui::Vec2>,
// --- Pattern stamp ---
pub pattern_type: u32,
pub pattern_scale: f32,
// --- Dodge / Burn ---
pub dodge_burn_radius: f32,
pub dodge_burn_hardness: f32,
pub dodge_burn_spacing: f32,
pub dodge_burn_exposure: f32,
/// 0 = dodge (lighten), 1 = burn (darken)
pub dodge_burn_mode: u32,
// --- Sponge ---
pub sponge_radius: f32,
pub sponge_hardness: f32,
pub sponge_spacing: f32,
pub sponge_flow: f32,
/// 0 = saturate, 1 = desaturate
pub sponge_mode: u32,
// --- Blur / Sharpen ---
pub blur_sharpen_radius: f32,
pub blur_sharpen_hardness: f32,
pub blur_sharpen_spacing: f32,
pub blur_sharpen_strength: f32,
/// Neighborhood kernel radius in canvas pixels (120)
pub blur_sharpen_kernel: f32,
/// 0 = blur, 1 = sharpen
pub blur_sharpen_mode: u32,
// --- Magic wand (raster) ---
/// Color-distance threshold for magic wand selection (same scale as fill_threshold).
pub wand_threshold: f32,
/// Absolute = compare to seed pixel; Relative = compare to BFS parent.
pub wand_mode: FillThresholdMode,
/// true = BFS from click (contiguous region only); false = global color scan.
pub wand_contiguous: bool,
// --- Quick Select ---
/// Brush radius in canvas pixels for the quick-select tool.
pub quick_select_radius: f32,
// --- Flood fill (Paint Bucket, raster) ---
/// Color-distance threshold (Euclidean RGBA, 0510). Pixels within this
/// distance of the comparison color are included in the fill.
pub fill_threshold: f32,
/// Soft-edge width as a percentage of the threshold (0 = hard, 100 = full fade).
pub fill_softness: f32,
/// Whether to compare each pixel to the seed pixel (Absolute) or to its BFS
/// parent pixel (Relative, spreads across gradients).
pub fill_threshold_mode: FillThresholdMode,
// --- 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.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum SelectionShape {
#[default]
Rect,
Ellipse,
}
/// Threshold comparison mode for the raster flood fill.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum FillThresholdMode {
/// Compare each candidate pixel to the original seed pixel (default).
#[default]
Absolute,
/// Compare each candidate pixel to the pixel it was reached from.
Relative,
}
impl Default for RasterToolSettings {
fn default() -> Self {
Self {
brush_radius: 10.0,
brush_opacity: 1.0,
brush_hardness: 0.5,
brush_spacing: 0.1,
brush_use_fg: true,
active_brush_settings: BrushSettings::default(),
eraser_radius: 10.0,
eraser_opacity: 1.0,
eraser_hardness: 0.5,
eraser_spacing: 0.1,
active_eraser_settings: lightningbeam_core::brush_settings::bundled_brushes()
.iter()
.find(|p| p.name == "Brush")
.map(|p| p.settings.clone())
.unwrap_or_default(),
smudge_radius: 15.0,
smudge_hardness: 0.8,
smudge_spacing: 8.0,
smudge_strength: 1.0,
clone_source: None,
pattern_type: 0,
pattern_scale: 32.0,
dodge_burn_radius: 30.0,
dodge_burn_hardness: 0.5,
dodge_burn_spacing: 3.0,
dodge_burn_exposure: 0.5,
dodge_burn_mode: 0,
sponge_radius: 30.0,
sponge_hardness: 0.5,
sponge_spacing: 3.0,
sponge_flow: 0.5,
sponge_mode: 0,
blur_sharpen_radius: 30.0,
blur_sharpen_hardness: 0.5,
blur_sharpen_spacing: 3.0,
blur_sharpen_strength: 0.5,
blur_sharpen_kernel: 5.0,
blur_sharpen_mode: 0,
wand_threshold: 15.0,
wand_mode: FillThresholdMode::Absolute,
wand_contiguous: true,
fill_threshold: 15.0,
fill_softness: 0.0,
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,
}
}
}
// ---------------------------------------------------------------------------
// Brush parameters extracted per-tool
// ---------------------------------------------------------------------------
pub struct BrushParams {
pub base_settings: BrushSettings,
pub radius: f32,
pub opacity: f32,
pub hardness: f32,
pub spacing: f32,
}
// ---------------------------------------------------------------------------
// RasterToolDef trait
// ---------------------------------------------------------------------------
pub trait RasterToolDef: Send + Sync {
fn blend_mode(&self) -> RasterBlendMode;
fn header_label(&self) -> &'static str;
fn brush_params(&self, s: &RasterToolSettings) -> BrushParams;
/// Encode tool-specific state into the 4-float `StrokeRecord::tool_params`.
fn tool_params(&self, s: &RasterToolSettings) -> [f32; 4];
/// Cursor display radius (world pixels).
fn cursor_radius(&self, s: &RasterToolSettings) -> f32 {
self.brush_params(s).radius
}
/// Render tool-specific controls in the infopanel (called before preset picker if any).
fn render_ui(&self, ui: &mut egui::Ui, s: &mut RasterToolSettings);
/// Whether to show the brush preset picker after `render_ui`.
fn show_brush_preset_picker(&self) -> bool { true }
/// Whether this tool is the eraser (drives preset picker + color UI visibility).
fn is_eraser(&self) -> bool { false }
/// Whether Alt+click sets a source point for this tool.
fn uses_alt_click(&self) -> bool { false }
}
// ---------------------------------------------------------------------------
// Lookup: Tool → &'static dyn RasterToolDef
// ---------------------------------------------------------------------------
pub fn raster_tool_def(tool: &Tool) -> Option<&'static dyn RasterToolDef> {
match tool {
Tool::Draw | Tool::Pencil | Tool::Pen | Tool::Airbrush => Some(&paint::PAINT),
Tool::Erase => Some(&erase::ERASE),
Tool::Smudge => Some(&smudge::SMUDGE),
Tool::CloneStamp => Some(&clone_stamp::CLONE_STAMP),
Tool::HealingBrush => Some(&healing_brush::HEALING_BRUSH),
Tool::PatternStamp => Some(&pattern_stamp::PATTERN_STAMP),
Tool::DodgeBurn => Some(&dodge_burn::DODGE_BURN),
Tool::Sponge => Some(&sponge::SPONGE),
Tool::BlurSharpen => Some(&blur_sharpen::BLUR_SHARPEN),
_ => None,
}
}

View File

@ -1,22 +0,0 @@
use super::{BrushParams, RasterToolDef, RasterToolSettings};
use eframe::egui;
use lightningbeam_core::raster_layer::RasterBlendMode;
pub struct PaintTool;
pub static PAINT: PaintTool = PaintTool;
impl RasterToolDef for PaintTool {
fn blend_mode(&self) -> RasterBlendMode { RasterBlendMode::Normal }
fn header_label(&self) -> &'static str { "Brush" }
fn brush_params(&self, s: &RasterToolSettings) -> BrushParams {
BrushParams {
base_settings: s.active_brush_settings.clone(),
radius: s.brush_radius,
opacity: s.brush_opacity,
hardness: s.brush_hardness,
spacing: s.brush_spacing,
}
}
fn tool_params(&self, _s: &RasterToolSettings) -> [f32; 4] { [0.0; 4] }
fn render_ui(&self, _ui: &mut egui::Ui, _s: &mut RasterToolSettings) {}
}

View File

@ -1,49 +0,0 @@
use super::{BrushParams, RasterToolDef, RasterToolSettings};
use eframe::egui;
use lightningbeam_core::raster_layer::RasterBlendMode;
pub struct PatternStampTool;
pub static PATTERN_STAMP: PatternStampTool = PatternStampTool;
const PATTERN_NAMES: &[&str] = &[
"Checkerboard", "Dots", "H-Lines", "V-Lines", "Diagonal \\", "Diagonal /", "Crosshatch",
];
impl RasterToolDef for PatternStampTool {
fn blend_mode(&self) -> RasterBlendMode { RasterBlendMode::PatternStamp }
fn header_label(&self) -> &'static str { "Pattern Stamp" }
fn brush_params(&self, s: &RasterToolSettings) -> BrushParams {
BrushParams {
base_settings: s.active_brush_settings.clone(),
radius: s.brush_radius,
opacity: s.brush_opacity,
hardness: s.brush_hardness,
spacing: s.brush_spacing,
}
}
fn tool_params(&self, s: &RasterToolSettings) -> [f32; 4] {
[s.pattern_type as f32, s.pattern_scale, 0.0, 0.0]
}
fn render_ui(&self, ui: &mut egui::Ui, s: &mut RasterToolSettings) {
let selected_name = PATTERN_NAMES
.get(s.pattern_type as usize)
.copied()
.unwrap_or("Checkerboard");
ui.horizontal(|ui| {
ui.label("Pattern:");
egui::ComboBox::from_id_salt("pattern_type")
.selected_text(selected_name)
.show_ui(ui, |ui| {
for (i, name) in PATTERN_NAMES.iter().enumerate() {
ui.selectable_value(&mut s.pattern_type, i as u32, *name);
}
});
});
ui.horizontal(|ui| {
ui.label("Scale:");
ui.add(egui::Slider::new(&mut s.pattern_scale, 4.0_f32..=256.0)
.logarithmic(true).suffix(" px"));
});
ui.add_space(4.0);
}
}

View File

@ -1,44 +0,0 @@
use super::{BrushParams, RasterToolDef, RasterToolSettings};
use eframe::egui;
use lightningbeam_core::{brush_settings::BrushSettings, raster_layer::RasterBlendMode};
pub struct SmudgeTool;
pub static SMUDGE: SmudgeTool = SmudgeTool;
impl RasterToolDef for SmudgeTool {
fn blend_mode(&self) -> RasterBlendMode { RasterBlendMode::Smudge }
fn header_label(&self) -> &'static str { "Smudge" }
fn brush_params(&self, s: &RasterToolSettings) -> BrushParams {
BrushParams {
base_settings: BrushSettings::default(),
radius: s.smudge_radius,
opacity: 1.0, // strength is a separate smudge_dist multiplier
hardness: s.smudge_hardness,
spacing: s.smudge_spacing,
}
}
fn tool_params(&self, _s: &RasterToolSettings) -> [f32; 4] { [0.0; 4] }
fn show_brush_preset_picker(&self) -> bool { false }
fn render_ui(&self, ui: &mut egui::Ui, s: &mut RasterToolSettings) {
ui.horizontal(|ui| {
ui.label("Size:");
ui.add(egui::Slider::new(&mut s.smudge_radius, 1.0_f32..=200.0).logarithmic(true).suffix(" px"));
});
ui.horizontal(|ui| {
ui.label("Strength:");
ui.add(egui::Slider::new(&mut s.smudge_strength, 0.0_f32..=1.0)
.custom_formatter(|v, _| format!("{:.0}%", v * 100.0)));
});
ui.horizontal(|ui| {
ui.label("Hardness:");
ui.add(egui::Slider::new(&mut s.smudge_hardness, 0.0_f32..=1.0)
.custom_formatter(|v, _| format!("{:.0}%", v * 100.0)));
});
ui.horizontal(|ui| {
ui.label("Spacing:");
ui.add(egui::Slider::new(&mut s.smudge_spacing, 0.5_f32..=20.0)
.logarithmic(true)
.custom_formatter(|v, _| format!("{:.1}", v)));
});
}
}

View File

@ -1,54 +0,0 @@
use super::{BrushParams, RasterToolDef, RasterToolSettings};
use eframe::egui;
use lightningbeam_core::{brush_settings::BrushSettings, raster_layer::RasterBlendMode};
pub struct SpongeTool;
pub static SPONGE: SpongeTool = SpongeTool;
impl RasterToolDef for SpongeTool {
fn blend_mode(&self) -> RasterBlendMode { RasterBlendMode::Sponge }
fn header_label(&self) -> &'static str { "Sponge" }
fn brush_params(&self, s: &RasterToolSettings) -> BrushParams {
BrushParams {
base_settings: BrushSettings::default(),
radius: s.sponge_radius,
opacity: s.sponge_flow,
hardness: s.sponge_hardness,
spacing: s.sponge_spacing,
}
}
fn tool_params(&self, s: &RasterToolSettings) -> [f32; 4] {
[s.sponge_mode as f32, 0.0, 0.0, 0.0]
}
fn show_brush_preset_picker(&self) -> bool { false }
fn render_ui(&self, ui: &mut egui::Ui, s: &mut RasterToolSettings) {
ui.horizontal(|ui| {
if ui.selectable_label(s.sponge_mode == 0, "Saturate").clicked() {
s.sponge_mode = 0;
}
if ui.selectable_label(s.sponge_mode == 1, "Desaturate").clicked() {
s.sponge_mode = 1;
}
});
ui.horizontal(|ui| {
ui.label("Size:");
ui.add(egui::Slider::new(&mut s.sponge_radius, 1.0_f32..=500.0).logarithmic(true).suffix(" px"));
});
ui.horizontal(|ui| {
ui.label("Flow:");
ui.add(egui::Slider::new(&mut s.sponge_flow, 0.0_f32..=1.0)
.custom_formatter(|v, _| format!("{:.0}%", v * 100.0)));
});
ui.horizontal(|ui| {
ui.label("Hardness:");
ui.add(egui::Slider::new(&mut s.sponge_hardness, 0.0_f32..=1.0)
.custom_formatter(|v, _| format!("{:.0}%", v * 100.0)));
});
ui.horizontal(|ui| {
ui.label("Spacing:");
ui.add(egui::Slider::new(&mut s.sponge_spacing, 0.5_f32..=20.0)
.logarithmic(true)
.custom_formatter(|v, _| format!("{:.1}", v)));
});
}
}

View File

@ -13,19 +13,6 @@ fn main() {
let wrapper_dir = Path::new(&manifest_dir).join("cmake"); let wrapper_dir = Path::new(&manifest_dir).join("cmake");
let neural_audio_dir = Path::new(&manifest_dir).join("../vendor/NeuralAudio"); let neural_audio_dir = Path::new(&manifest_dir).join("../vendor/NeuralAudio");
// Copy our patched CAPI files over the submodule versions before building.
// The upstream submodule uses `wchar_t*` on all platforms; our patch makes
// Linux/macOS use `const char*` instead, matching what the Rust FFI sends.
let capi_dir = neural_audio_dir.join("NeuralAudioCAPI");
let override_dir = Path::new(&manifest_dir).join("capi-override");
for filename in &["NeuralAudioCApi.h", "NeuralAudioCApi.cpp"] {
let src = override_dir.join(filename);
let dst = capi_dir.join(filename);
std::fs::copy(&src, &dst)
.unwrap_or_else(|e| panic!("Failed to copy {} override: {}", filename, e));
println!("cargo:rerun-if-changed=capi-override/{}", filename);
}
let mut cfg = cmake::Config::new(&wrapper_dir); let mut cfg = cmake::Config::new(&wrapper_dir);
// Force single-config generator on Unix to avoid libraries landing in Release/ subdirs // Force single-config generator on Unix to avoid libraries landing in Release/ subdirs
if !cfg!(target_os = "windows") { if !cfg!(target_os = "windows") {
@ -63,4 +50,6 @@ fn main() {
_ => {} _ => {}
} }
println!("cargo:rerun-if-changed=../vendor/NeuralAudio/NeuralAudioCAPI/NeuralAudioCApi.h");
println!("cargo:rerun-if-changed=../vendor/NeuralAudio/NeuralAudioCAPI/NeuralAudioCApi.cpp");
} }

View File

@ -1,83 +0,0 @@
#include "NeuralAudioCApi.h"
#include "NeuralModel.h"
struct NeuralModel
{
NeuralAudio::NeuralModel* model;
};
#ifdef _WIN32
NeuralModel* CreateModelFromFile(const wchar_t* modelPath)
#else
NeuralModel* CreateModelFromFile(const char* modelPath)
#endif
{
NeuralModel* model = new NeuralModel();
model->model = NeuralAudio::NeuralModel::CreateFromFile(modelPath);
return model;
}
void DeleteModel(NeuralModel* model)
{
delete model->model;
delete model;
}
void SetLSTMLoadMode(int loadMode)
{
NeuralAudio::NeuralModel::SetLSTMLoadMode((NeuralAudio::EModelLoadMode)loadMode);
}
void SetWaveNetLoadMode(int loadMode)
{
NeuralAudio::NeuralModel::SetWaveNetLoadMode((NeuralAudio::EModelLoadMode)loadMode);
}
void SetAudioInputLevelDBu(float audioDBu)
{
NeuralAudio::NeuralModel::SetAudioInputLevelDBu(audioDBu);
}
void SetDefaultMaxAudioBufferSize(int maxSize)
{
NeuralAudio::NeuralModel::SetDefaultMaxAudioBufferSize(maxSize);
}
int GetLoadMode(NeuralModel* model)
{
return model->model->GetLoadMode();
}
bool IsStatic(NeuralModel* model)
{
return model->model->IsStatic();
}
void SetMaxAudioBufferSize(NeuralModel* model, int maxSize)
{
model->model->SetMaxAudioBufferSize(maxSize);
}
float GetRecommendedInputDBAdjustment(NeuralModel* model)
{
return model->model->GetRecommendedInputDBAdjustment();
}
float GetRecommendedOutputDBAdjustment(NeuralModel* model)
{
return model->model->GetRecommendedOutputDBAdjustment();
}
float GetSampleRate(NeuralModel* model)
{
return model->model->GetSampleRate();
}
void Process(NeuralModel* model, float* input, float* output, size_t numSamples)
{
model->model->Process(input, output, numSamples);
}

View File

@ -1,50 +0,0 @@
#pragma once
#include <stddef.h>
#ifdef __cplusplus
extern "C" {
#endif
#ifdef _MSC_VER
#define NA_EXTERN extern __declspec(dllexport)
#else
#define NA_EXTERN extern
#endif
struct NeuralModel;
#ifdef _WIN32
NA_EXTERN NeuralModel* CreateModelFromFile(const wchar_t* modelPath);
#else
NA_EXTERN NeuralModel* CreateModelFromFile(const char* modelPath);
#endif
NA_EXTERN void DeleteModel(NeuralModel* model);
NA_EXTERN void SetLSTMLoadMode(int loadMode);
NA_EXTERN void SetWaveNetLoadMode(int loadMode);
NA_EXTERN void SetAudioInputLevelDBu(float audioDBu);
NA_EXTERN void SetDefaultMaxAudioBufferSize(int maxSize);
NA_EXTERN int GetLoadMode(NeuralModel* model);
NA_EXTERN bool IsStatic(NeuralModel* model);
NA_EXTERN void SetMaxAudioBufferSize(NeuralModel* model, int maxSize);
NA_EXTERN float GetRecommendedInputDBAdjustment(NeuralModel* model);
NA_EXTERN float GetRecommendedOutputDBAdjustment(NeuralModel* model);
NA_EXTERN float GetSampleRate(NeuralModel* model);
NA_EXTERN void Process(NeuralModel* model, float* input, float* output, size_t numSamples);
#ifdef __cplusplus
}
#endif

View File

@ -1,9 +0,0 @@
Brush presets sourced from the mypaint/mypaint-brushes repository.
https://github.com/mypaint/mypaint-brushes
License: CC0 1.0 Universal (Public Domain Dedication)
https://creativecommons.org/publicdomain/zero/1.0/
Contributors:
classic/ brushes — original MyPaint contributors
deevad/ brushes — David Revoy (deevad), http://www.davidrevoy.com

View File

@ -1,204 +0,0 @@
{
"comment": "MyPaint brush file",
"group": "",
"description": "An airbrush",
"notes": "A brush preset part of the Brushkit v0.6 \n created in october 2012 by David Revoy ( aka Deevad ) \n source: http://www.davidrevoy.com/article142/ressource-mypaint-brushes \n license: CC-Zero/Public-Domain",
"parent_brush_name": "",
"settings": {
"anti_aliasing": {
"base_value": 1.0,
"inputs": {}
},
"change_color_h": {
"base_value": 0.0,
"inputs": {}
},
"change_color_hsl_s": {
"base_value": 0.0,
"inputs": {}
},
"change_color_hsv_s": {
"base_value": 0.0,
"inputs": {}
},
"change_color_l": {
"base_value": 0.0,
"inputs": {}
},
"change_color_v": {
"base_value": 0.0,
"inputs": {}
},
"color_h": {
"base_value": 0.08902229845626071,
"inputs": {}
},
"color_s": {
"base_value": 1.0,
"inputs": {}
},
"color_v": {
"base_value": 0.0,
"inputs": {}
},
"colorize": {
"base_value": 0.0,
"inputs": {}
},
"custom_input": {
"base_value": 0.0,
"inputs": {
"pressure": [
[0.0, 0.0],
[1.0, 1.0]
]
}
},
"custom_input_slowness": {
"base_value": 0.71,
"inputs": {}
},
"dabs_per_actual_radius": {
"base_value": 5.75,
"inputs": {}
},
"dabs_per_basic_radius": {
"base_value": 0.0,
"inputs": {}
},
"dabs_per_second": {
"base_value": 30.0,
"inputs": {}
},
"direction_filter": {
"base_value": 2.99,
"inputs": {}
},
"elliptical_dab_angle": {
"base_value": 90.0,
"inputs": {
"direction": [
[0.0, 0.0],
[180.0, 180.0]
]
}
},
"elliptical_dab_ratio": {
"base_value": 1.0,
"inputs": {}
},
"eraser": {
"base_value": 0.0,
"inputs": {}
},
"hardness": {
"base_value": 0.48,
"inputs": {}
},
"lock_alpha": {
"base_value": 0.0,
"inputs": {}
},
"offset_by_random": {
"base_value": 0.0,
"inputs": {}
},
"offset_by_speed": {
"base_value": 0.0,
"inputs": {}
},
"offset_by_speed_slowness": {
"base_value": 1.0,
"inputs": {}
},
"opaque": {
"base_value": 0.52,
"inputs": {}
},
"opaque_linearize": {
"base_value": 2.0,
"inputs": {}
},
"opaque_multiply": {
"base_value": 0.0,
"inputs": {
"pressure": [
[0.0, 0.0],
[0.111111, 0.5],
[0.308642, 0.833333],
[1.0, 1.0]
]
}
},
"radius_by_random": {
"base_value": 0.0,
"inputs": {}
},
"radius_logarithmic": {
"base_value": 4.7,
"inputs": {
"custom": [
[-2.0, 0.45],
[2.0, -0.45]
]
}
},
"restore_color": {
"base_value": 0.0,
"inputs": {}
},
"slow_tracking": {
"base_value": 0.0,
"inputs": {}
},
"slow_tracking_per_dab": {
"base_value": 0.0,
"inputs": {}
},
"smudge": {
"base_value": 0.0,
"inputs": {}
},
"smudge_length": {
"base_value": 0.5,
"inputs": {}
},
"smudge_radius_log": {
"base_value": 0.0,
"inputs": {}
},
"speed1_gamma": {
"base_value": 4.0,
"inputs": {}
},
"speed1_slowness": {
"base_value": 0.04,
"inputs": {}
},
"speed2_gamma": {
"base_value": 4.0,
"inputs": {}
},
"speed2_slowness": {
"base_value": 0.8,
"inputs": {}
},
"stroke_duration_logarithmic": {
"base_value": 4.0,
"inputs": {}
},
"stroke_holdtime": {
"base_value": 0.0,
"inputs": {}
},
"stroke_threshold": {
"base_value": 0.0,
"inputs": {}
},
"tracking_noise": {
"base_value": 0.0,
"inputs": {}
}
},
"version": 3
}

View File

@ -1,203 +0,0 @@
{
"comment": "MyPaint brush file",
"group": "",
"parent_brush_name": "classic/brush",
"settings": {
"anti_aliasing": {
"base_value": 1.0,
"inputs": {}
},
"change_color_h": {
"base_value": 0.0,
"inputs": {}
},
"change_color_hsl_s": {
"base_value": 0.0,
"inputs": {}
},
"change_color_hsv_s": {
"base_value": 0.0,
"inputs": {}
},
"change_color_l": {
"base_value": 0.0,
"inputs": {}
},
"change_color_v": {
"base_value": 0.0,
"inputs": {}
},
"color_h": {
"base_value": 0.0,
"inputs": {}
},
"color_s": {
"base_value": 0.0,
"inputs": {}
},
"color_v": {
"base_value": 0.0,
"inputs": {}
},
"colorize": {
"base_value": 0.0,
"inputs": {}
},
"custom_input": {
"base_value": 0.0,
"inputs": {}
},
"custom_input_slowness": {
"base_value": 0.0,
"inputs": {}
},
"dabs_per_actual_radius": {
"base_value": 5.82,
"inputs": {}
},
"dabs_per_basic_radius": {
"base_value": 0.51,
"inputs": {}
},
"dabs_per_second": {
"base_value": 70.0,
"inputs": {}
},
"direction_filter": {
"base_value": 2.0,
"inputs": {}
},
"elliptical_dab_angle": {
"base_value": 90.0,
"inputs": {}
},
"elliptical_dab_ratio": {
"base_value": 1.0,
"inputs": {}
},
"eraser": {
"base_value": 0.0,
"inputs": {}
},
"hardness": {
"base_value": 0.89,
"inputs": {}
},
"lock_alpha": {
"base_value": 0.0,
"inputs": {}
},
"offset_by_random": {
"base_value": 0.0,
"inputs": {}
},
"offset_by_speed": {
"base_value": 0.0,
"inputs": {}
},
"offset_by_speed_slowness": {
"base_value": 1.0,
"inputs": {}
},
"opaque": {
"base_value": 1.0,
"inputs": {
"pressure": [
[0.0, -0.989583],
[0.38253, -0.59375],
[0.656627, 0.041667],
[1.0, 1.0]
]
}
},
"opaque_linearize": {
"base_value": 0.44,
"inputs": {}
},
"opaque_multiply": {
"base_value": 0.0,
"inputs": {
"pressure": [
[0.0, 0.0],
[0.015, 0.0],
[0.069277, 0.9375],
[0.25, 1.0],
[1.0, 1.0]
]
}
},
"radius_by_random": {
"base_value": 0.0,
"inputs": {}
},
"radius_logarithmic": {
"base_value": 1.01,
"inputs": {
"pressure": [
[0.0, -1.86375],
[0.237952, -1.42],
[0.5, -0.355],
[0.76506, 1.42],
[1.0, 2.13]
]
}
},
"restore_color": {
"base_value": 0.0,
"inputs": {}
},
"slow_tracking": {
"base_value": 4.47,
"inputs": {}
},
"slow_tracking_per_dab": {
"base_value": 2.48,
"inputs": {}
},
"smudge": {
"base_value": 0.0,
"inputs": {}
},
"smudge_length": {
"base_value": 0.5,
"inputs": {}
},
"smudge_radius_log": {
"base_value": 0.0,
"inputs": {}
},
"speed1_gamma": {
"base_value": 2.87,
"inputs": {}
},
"speed1_slowness": {
"base_value": 0.04,
"inputs": {}
},
"speed2_gamma": {
"base_value": 4.0,
"inputs": {}
},
"speed2_slowness": {
"base_value": 0.8,
"inputs": {}
},
"stroke_duration_logarithmic": {
"base_value": 4.0,
"inputs": {}
},
"stroke_holdtime": {
"base_value": 0.0,
"inputs": {}
},
"stroke_threshold": {
"base_value": 0.0,
"inputs": {}
},
"tracking_noise": {
"base_value": 0.0,
"inputs": {}
}
},
"version": 3
}

View File

@ -1,215 +0,0 @@
{
"comment": "MyPaint brush file",
"description": "",
"group": "",
"notes": "",
"parent_brush_name": "classic/calligraphy",
"settings": {
"anti_aliasing": {
"base_value": 3.53,
"inputs": {}
},
"change_color_h": {
"base_value": 0.0,
"inputs": {}
},
"change_color_hsl_s": {
"base_value": 0.0,
"inputs": {}
},
"change_color_hsv_s": {
"base_value": 0.0,
"inputs": {}
},
"change_color_l": {
"base_value": 0.0,
"inputs": {}
},
"change_color_v": {
"base_value": 0.0,
"inputs": {}
},
"color_h": {
"base_value": 0.0,
"inputs": {}
},
"color_s": {
"base_value": 0.0,
"inputs": {}
},
"color_v": {
"base_value": 0.0,
"inputs": {}
},
"colorize": {
"base_value": 0.0,
"inputs": {}
},
"custom_input": {
"base_value": 0.0,
"inputs": {}
},
"custom_input_slowness": {
"base_value": 0.0,
"inputs": {}
},
"dabs_per_actual_radius": {
"base_value": 2.2,
"inputs": {}
},
"dabs_per_basic_radius": {
"base_value": 0.0,
"inputs": {}
},
"dabs_per_second": {
"base_value": 0.0,
"inputs": {}
},
"direction_filter": {
"base_value": 2.0,
"inputs": {}
},
"elliptical_dab_angle": {
"base_value": 45.92,
"inputs": {}
},
"elliptical_dab_ratio": {
"base_value": 5.46,
"inputs": {}
},
"eraser": {
"base_value": 0.0,
"inputs": {}
},
"hardness": {
"base_value": 0.74,
"inputs": {
"pressure": [
[0.0, 0.0],
[1.0, 0.05]
],
"speed1": [
[0.0, -0.0],
[1.0, -0.04]
]
}
},
"lock_alpha": {
"base_value": 0.0,
"inputs": {}
},
"offset_by_random": {
"base_value": 0.0,
"inputs": {}
},
"offset_by_speed": {
"base_value": 0.0,
"inputs": {}
},
"offset_by_speed_slowness": {
"base_value": 1.0,
"inputs": {}
},
"opaque": {
"base_value": 1.0,
"inputs": {}
},
"opaque_linearize": {
"base_value": 0.0,
"inputs": {}
},
"opaque_multiply": {
"base_value": 0.0,
"inputs": {
"pressure": [
[0.0, 0.0],
[0.015, 0.0],
[0.015, 1.0],
[1.0, 1.0]
]
}
},
"pressure_gain_log": {
"base_value": 0.0,
"inputs": {}
},
"radius_by_random": {
"base_value": 0.0,
"inputs": {}
},
"radius_logarithmic": {
"base_value": 2.02,
"inputs": {
"pressure": [
[0.0, 0.0],
[1.0, 0.5]
],
"speed1": [
[0.0, -0.0],
[1.0, -0.12]
]
}
},
"restore_color": {
"base_value": 0.0,
"inputs": {}
},
"slow_tracking": {
"base_value": 0.65,
"inputs": {}
},
"slow_tracking_per_dab": {
"base_value": 0.8,
"inputs": {}
},
"smudge": {
"base_value": 0.0,
"inputs": {}
},
"smudge_length": {
"base_value": 0.5,
"inputs": {}
},
"smudge_radius_log": {
"base_value": 0.0,
"inputs": {}
},
"snap_to_pixel": {
"base_value": 0.0,
"inputs": {}
},
"speed1_gamma": {
"base_value": 2.87,
"inputs": {}
},
"speed1_slowness": {
"base_value": 0.04,
"inputs": {}
},
"speed2_gamma": {
"base_value": 4.0,
"inputs": {}
},
"speed2_slowness": {
"base_value": 0.8,
"inputs": {}
},
"stroke_duration_logarithmic": {
"base_value": 4.0,
"inputs": {}
},
"stroke_holdtime": {
"base_value": 0.0,
"inputs": {}
},
"stroke_threshold": {
"base_value": 0.0,
"inputs": {}
},
"tracking_noise": {
"base_value": 0.0,
"inputs": {}
}
},
"version": 3
}

View File

@ -1,219 +0,0 @@
{
"comment": "MyPaint brush file",
"description": "A chalk brush attempt, using many tiny particles on canvas to simulate grain",
"group": "",
"notes": "A brush preset part of the Brushkit v0.6 \n created in october 2012 by David Revoy ( aka Deevad ) \n source: http://www.davidrevoy.com/article142/ressource-mypaint-brushes \n license: CC-Zero/Public-Domain",
"parent_brush_name": "deevad/chalk",
"settings": {
"anti_aliasing": {
"base_value": 1.0,
"inputs": {}
},
"change_color_h": {
"base_value": 0.0,
"inputs": {}
},
"change_color_hsl_s": {
"base_value": 0.0,
"inputs": {}
},
"change_color_hsv_s": {
"base_value": 0.0,
"inputs": {}
},
"change_color_l": {
"base_value": 0.0,
"inputs": {}
},
"change_color_v": {
"base_value": 0.0,
"inputs": {}
},
"color_h": {
"base_value": 0.0,
"inputs": {}
},
"color_s": {
"base_value": 0.0,
"inputs": {}
},
"color_v": {
"base_value": 0.0,
"inputs": {}
},
"colorize": {
"base_value": 0.0,
"inputs": {}
},
"custom_input": {
"base_value": 0.0,
"inputs": {}
},
"custom_input_slowness": {
"base_value": 0.69,
"inputs": {}
},
"dabs_per_actual_radius": {
"base_value": 3.93,
"inputs": {}
},
"dabs_per_basic_radius": {
"base_value": 5.07,
"inputs": {}
},
"dabs_per_second": {
"base_value": 0.0,
"inputs": {}
},
"direction_filter": {
"base_value": 2.0,
"inputs": {}
},
"elliptical_dab_angle": {
"base_value": 90.0,
"inputs": {}
},
"elliptical_dab_ratio": {
"base_value": 1.0,
"inputs": {}
},
"eraser": {
"base_value": 0.0,
"inputs": {}
},
"hardness": {
"base_value": 0.67,
"inputs": {
"pressure": [
[0.0, -0.4],
[0.667722, -0.0625],
[1.0, 0.6]
]
}
},
"lock_alpha": {
"base_value": 0.0,
"inputs": {}
},
"offset_by_random": {
"base_value": 2.0,
"inputs": {
"pressure": [
[0.0, 0.0],
[1.0, -2.0]
],
"speed1": [
[0.0, -0.2142857142857142],
[4.0, 1.5]
],
"speed2": [
[0.0, -0.2142857142857142],
[4.0, 1.5]
]
}
},
"offset_by_speed": {
"base_value": 0.0,
"inputs": {}
},
"offset_by_speed_slowness": {
"base_value": 1.0,
"inputs": {}
},
"opaque": {
"base_value": 0.2,
"inputs": {
"pressure": [
[0.0, 0.0],
[1.0, 0.4]
]
}
},
"opaque_linearize": {
"base_value": 0.0,
"inputs": {}
},
"opaque_multiply": {
"base_value": 0.0,
"inputs": {
"pressure": [
[0.0, 0.0],
[1.0, 1.0]
]
}
},
"pressure_gain_log": {
"base_value": 0.0,
"inputs": {}
},
"radius_by_random": {
"base_value": 0.0,
"inputs": {}
},
"radius_logarithmic": {
"base_value": 0.58,
"inputs": {}
},
"restore_color": {
"base_value": 0.0,
"inputs": {}
},
"slow_tracking": {
"base_value": 2.0,
"inputs": {}
},
"slow_tracking_per_dab": {
"base_value": 0.0,
"inputs": {}
},
"smudge": {
"base_value": 0.0,
"inputs": {}
},
"smudge_length": {
"base_value": 0.5,
"inputs": {}
},
"smudge_radius_log": {
"base_value": 0.0,
"inputs": {}
},
"snap_to_pixel": {
"base_value": 0.0,
"inputs": {}
},
"speed1_gamma": {
"base_value": 4.0,
"inputs": {}
},
"speed1_slowness": {
"base_value": 0.04,
"inputs": {}
},
"speed2_gamma": {
"base_value": 4.0,
"inputs": {}
},
"speed2_slowness": {
"base_value": 0.8,
"inputs": {}
},
"stroke_duration_logarithmic": {
"base_value": 4.0,
"inputs": {}
},
"stroke_holdtime": {
"base_value": 0.0,
"inputs": {}
},
"stroke_threshold": {
"base_value": 0.0,
"inputs": {}
},
"tracking_noise": {
"base_value": 0.0,
"inputs": {}
}
},
"version": 3
}

View File

@ -1,195 +0,0 @@
{
"comment": "MyPaint brush file",
"group": "",
"parent_brush_name": "",
"settings": {
"anti_aliasing": {
"base_value": 0.0,
"inputs": {}
},
"change_color_h": {
"base_value": 0.0,
"inputs": {}
},
"change_color_hsl_s": {
"base_value": 0.0,
"inputs": {}
},
"change_color_hsv_s": {
"base_value": 0.0,
"inputs": {}
},
"change_color_l": {
"base_value": 0.0,
"inputs": {}
},
"change_color_v": {
"base_value": 0.0,
"inputs": {}
},
"color_h": {
"base_value": 0.6354166666666666,
"inputs": {}
},
"color_s": {
"base_value": 0.8807339449541285,
"inputs": {}
},
"color_v": {
"base_value": 0.42745098039215684,
"inputs": {}
},
"colorize": {
"base_value": 0.0,
"inputs": {}
},
"custom_input": {
"base_value": 0.0,
"inputs": {}
},
"custom_input_slowness": {
"base_value": 0.0,
"inputs": {}
},
"dabs_per_actual_radius": {
"base_value": 5.0,
"inputs": {}
},
"dabs_per_basic_radius": {
"base_value": 0.0,
"inputs": {}
},
"dabs_per_second": {
"base_value": 0.0,
"inputs": {}
},
"direction_filter": {
"base_value": 2.0,
"inputs": {}
},
"elliptical_dab_angle": {
"base_value": 90.0,
"inputs": {}
},
"elliptical_dab_ratio": {
"base_value": 1.0,
"inputs": {}
},
"eraser": {
"base_value": 0.0,
"inputs": {}
},
"hardness": {
"base_value": 0.2,
"inputs": {}
},
"lock_alpha": {
"base_value": 0.0,
"inputs": {}
},
"offset_by_random": {
"base_value": 1.6,
"inputs": {
"pressure": [
[0, 0],
[1.0, -1.4]
]
}
},
"offset_by_speed": {
"base_value": 0.0,
"inputs": {}
},
"offset_by_speed_slowness": {
"base_value": 1.0,
"inputs": {}
},
"opaque": {
"base_value": 0.4,
"inputs": {
"pressure": [
[0, 0],
[1.0, 0.4]
]
}
},
"opaque_linearize": {
"base_value": 0.0,
"inputs": {}
},
"opaque_multiply": {
"base_value": 0.0,
"inputs": {
"pressure": [
[0, 0],
[1.0, 1.0]
]
}
},
"radius_by_random": {
"base_value": 0.0,
"inputs": {}
},
"radius_logarithmic": {
"base_value": 0.7,
"inputs": {}
},
"restore_color": {
"base_value": 0.0,
"inputs": {}
},
"slow_tracking": {
"base_value": 2.0,
"inputs": {}
},
"slow_tracking_per_dab": {
"base_value": 0.0,
"inputs": {}
},
"smudge": {
"base_value": 0.0,
"inputs": {}
},
"smudge_length": {
"base_value": 0.5,
"inputs": {}
},
"smudge_radius_log": {
"base_value": 0.0,
"inputs": {}
},
"speed1_gamma": {
"base_value": 4.0,
"inputs": {}
},
"speed1_slowness": {
"base_value": 0.04,
"inputs": {}
},
"speed2_gamma": {
"base_value": 4.0,
"inputs": {}
},
"speed2_slowness": {
"base_value": 0.8,
"inputs": {}
},
"stroke_duration_logarithmic": {
"base_value": 4.0,
"inputs": {}
},
"stroke_holdtime": {
"base_value": 0.0,
"inputs": {}
},
"stroke_threshold": {
"base_value": 0.0,
"inputs": {}
},
"tracking_noise": {
"base_value": 0.0,
"inputs": {}
}
},
"version": 3
}

View File

@ -1,210 +0,0 @@
{
"comment": "MyPaint brush file",
"description": "",
"group": "",
"notes": "",
"parent_brush_name": "classic/dry_brush",
"settings": {
"anti_aliasing": {
"base_value": 0.0,
"inputs": {}
},
"change_color_h": {
"base_value": 0.0,
"inputs": {}
},
"change_color_hsl_s": {
"base_value": 0.0,
"inputs": {}
},
"change_color_hsv_s": {
"base_value": 0.0,
"inputs": {}
},
"change_color_l": {
"base_value": 0.0,
"inputs": {}
},
"change_color_v": {
"base_value": 0.0,
"inputs": {}
},
"color_h": {
"base_value": 0.0,
"inputs": {}
},
"color_s": {
"base_value": 0.0,
"inputs": {}
},
"color_v": {
"base_value": 0.0,
"inputs": {}
},
"colorize": {
"base_value": 0.0,
"inputs": {}
},
"custom_input": {
"base_value": 0.0,
"inputs": {}
},
"custom_input_slowness": {
"base_value": 0.0,
"inputs": {}
},
"dabs_per_actual_radius": {
"base_value": 6.0,
"inputs": {}
},
"dabs_per_basic_radius": {
"base_value": 6.0,
"inputs": {}
},
"dabs_per_second": {
"base_value": 0.0,
"inputs": {}
},
"direction_filter": {
"base_value": 2.0,
"inputs": {}
},
"elliptical_dab_angle": {
"base_value": 90.0,
"inputs": {}
},
"elliptical_dab_ratio": {
"base_value": 1.0,
"inputs": {}
},
"eraser": {
"base_value": 0.0,
"inputs": {}
},
"hardness": {
"base_value": 0.2,
"inputs": {}
},
"lock_alpha": {
"base_value": 0.0,
"inputs": {}
},
"offset_by_random": {
"base_value": 0.0,
"inputs": {
"pressure": [
[0.0, 0.0],
[1.0, 1.4]
]
}
},
"offset_by_speed": {
"base_value": 0.0,
"inputs": {}
},
"offset_by_speed_slowness": {
"base_value": 1.0,
"inputs": {}
},
"opaque": {
"base_value": 0.8,
"inputs": {
"pressure": [
[0.0, 0.0],
[1.0, 0.2]
]
}
},
"opaque_linearize": {
"base_value": 0.0,
"inputs": {}
},
"opaque_multiply": {
"base_value": 0.0,
"inputs": {
"pressure": [
[0.0, 0.0],
[1.0, 1.0]
]
}
},
"pressure_gain_log": {
"base_value": 0.0,
"inputs": {}
},
"radius_by_random": {
"base_value": 0.1,
"inputs": {}
},
"radius_logarithmic": {
"base_value": 0.6,
"inputs": {
"speed2": [
[0.0, 0.042857],
[4.0, -0.3]
]
}
},
"restore_color": {
"base_value": 0.0,
"inputs": {}
},
"slow_tracking": {
"base_value": 2.0,
"inputs": {}
},
"slow_tracking_per_dab": {
"base_value": 0.0,
"inputs": {}
},
"smudge": {
"base_value": 0.0,
"inputs": {}
},
"smudge_length": {
"base_value": 0.5,
"inputs": {}
},
"smudge_radius_log": {
"base_value": 0.0,
"inputs": {}
},
"snap_to_pixel": {
"base_value": 0.0,
"inputs": {}
},
"speed1_gamma": {
"base_value": 4.0,
"inputs": {}
},
"speed1_slowness": {
"base_value": 0.04,
"inputs": {}
},
"speed2_gamma": {
"base_value": 4.0,
"inputs": {}
},
"speed2_slowness": {
"base_value": 0.8,
"inputs": {}
},
"stroke_duration_logarithmic": {
"base_value": 4.0,
"inputs": {}
},
"stroke_holdtime": {
"base_value": 0.0,
"inputs": {}
},
"stroke_threshold": {
"base_value": 0.0,
"inputs": {}
},
"tracking_noise": {
"base_value": 0.0,
"inputs": {}
}
},
"version": 3
}

View File

@ -1,200 +0,0 @@
{
"comment": "MyPaint brush file",
"description": "",
"group": "",
"notes": "",
"parent_brush_name": "classic/ink_blot",
"settings": {
"anti_aliasing": {
"base_value": 1.0,
"inputs": {}
},
"change_color_h": {
"base_value": 0.0,
"inputs": {}
},
"change_color_hsl_s": {
"base_value": 0.0,
"inputs": {}
},
"change_color_hsv_s": {
"base_value": 0.0,
"inputs": {}
},
"change_color_l": {
"base_value": 0.0,
"inputs": {}
},
"change_color_v": {
"base_value": 0.0,
"inputs": {}
},
"color_h": {
"base_value": 0.0,
"inputs": {}
},
"color_s": {
"base_value": 0.0,
"inputs": {}
},
"color_v": {
"base_value": 0.0,
"inputs": {}
},
"colorize": {
"base_value": 0.0,
"inputs": {}
},
"custom_input": {
"base_value": 0.0,
"inputs": {}
},
"custom_input_slowness": {
"base_value": 0.0,
"inputs": {}
},
"dabs_per_actual_radius": {
"base_value": 3.32,
"inputs": {}
},
"dabs_per_basic_radius": {
"base_value": 0.0,
"inputs": {}
},
"dabs_per_second": {
"base_value": 15.0,
"inputs": {}
},
"direction_filter": {
"base_value": 2.0,
"inputs": {}
},
"elliptical_dab_angle": {
"base_value": 90.0,
"inputs": {}
},
"elliptical_dab_ratio": {
"base_value": 1.0,
"inputs": {}
},
"eraser": {
"base_value": 0.0,
"inputs": {}
},
"hardness": {
"base_value": 0.28,
"inputs": {}
},
"lock_alpha": {
"base_value": 0.0,
"inputs": {}
},
"offset_by_random": {
"base_value": 0.17,
"inputs": {}
},
"offset_by_speed": {
"base_value": 0.02,
"inputs": {
"custom": [
[-2.0, 0.0],
[2.0, 0.0]
]
}
},
"offset_by_speed_slowness": {
"base_value": 1.0,
"inputs": {}
},
"opaque": {
"base_value": 1.0,
"inputs": {}
},
"opaque_linearize": {
"base_value": 0.9,
"inputs": {}
},
"opaque_multiply": {
"base_value": 0.0,
"inputs": {
"pressure": [
[0.0, 0.0],
[1.0, 1.0]
]
}
},
"pressure_gain_log": {
"base_value": 0.0,
"inputs": {}
},
"radius_by_random": {
"base_value": 0.63,
"inputs": {}
},
"radius_logarithmic": {
"base_value": 2.5,
"inputs": {}
},
"restore_color": {
"base_value": 0.0,
"inputs": {}
},
"slow_tracking": {
"base_value": 0.0,
"inputs": {}
},
"slow_tracking_per_dab": {
"base_value": 0.0,
"inputs": {}
},
"smudge": {
"base_value": 0.0,
"inputs": {}
},
"smudge_length": {
"base_value": 0.5,
"inputs": {}
},
"smudge_radius_log": {
"base_value": 0.0,
"inputs": {}
},
"snap_to_pixel": {
"base_value": 0.0,
"inputs": {}
},
"speed1_gamma": {
"base_value": 4.0,
"inputs": {}
},
"speed1_slowness": {
"base_value": 0.04,
"inputs": {}
},
"speed2_gamma": {
"base_value": 4.0,
"inputs": {}
},
"speed2_slowness": {
"base_value": 0.8,
"inputs": {}
},
"stroke_duration_logarithmic": {
"base_value": 4.0,
"inputs": {}
},
"stroke_holdtime": {
"base_value": 0.0,
"inputs": {}
},
"stroke_threshold": {
"base_value": 0.0,
"inputs": {}
},
"tracking_noise": {
"base_value": 0.0,
"inputs": {}
}
},
"version": 3
}

View File

@ -1,189 +0,0 @@
{
"comment": "MyPaint brush file",
"group": "",
"description": "A small brush to trace regular lines",
"notes": "A brush preset part of the Brushkit v0.6 \n created in october 2012 by David Revoy ( aka Deevad ) \n source: http://www.davidrevoy.com/article142/ressource-mypaint-brushes \n license: CC-Zero/Public-Domain",
"parent_brush_name": "",
"settings": {
"anti_aliasing": {
"base_value": 2.0,
"inputs": {}
},
"change_color_h": {
"base_value": 0.0,
"inputs": {}
},
"change_color_hsl_s": {
"base_value": 0.0,
"inputs": {}
},
"change_color_hsv_s": {
"base_value": 0.0,
"inputs": {}
},
"change_color_l": {
"base_value": 0.0,
"inputs": {}
},
"change_color_v": {
"base_value": 0.0,
"inputs": {}
},
"color_h": {
"base_value": 0.1289192800566187,
"inputs": {}
},
"color_s": {
"base_value": 1.0,
"inputs": {}
},
"color_v": {
"base_value": 0.0,
"inputs": {}
},
"colorize": {
"base_value": 0.0,
"inputs": {}
},
"custom_input": {
"base_value": 0.0,
"inputs": {}
},
"custom_input_slowness": {
"base_value": 0.0,
"inputs": {}
},
"dabs_per_actual_radius": {
"base_value": 4.43,
"inputs": {}
},
"dabs_per_basic_radius": {
"base_value": 0.0,
"inputs": {}
},
"dabs_per_second": {
"base_value": 0.0,
"inputs": {}
},
"direction_filter": {
"base_value": 2.0,
"inputs": {}
},
"elliptical_dab_angle": {
"base_value": 90.0,
"inputs": {}
},
"elliptical_dab_ratio": {
"base_value": 1.0,
"inputs": {}
},
"eraser": {
"base_value": 0.0,
"inputs": {}
},
"hardness": {
"base_value": 0.8,
"inputs": {}
},
"lock_alpha": {
"base_value": 0.0,
"inputs": {}
},
"offset_by_random": {
"base_value": 0.0,
"inputs": {}
},
"offset_by_speed": {
"base_value": 0.0,
"inputs": {}
},
"offset_by_speed_slowness": {
"base_value": 1.0,
"inputs": {}
},
"opaque": {
"base_value": 1.0,
"inputs": {}
},
"opaque_linearize": {
"base_value": 0.0,
"inputs": {}
},
"opaque_multiply": {
"base_value": 0.0,
"inputs": {
"pressure": [
[0.0, 0.0],
[0.015, 0.0],
[0.015, 1.0],
[1.0, 1.0]
]
}
},
"radius_by_random": {
"base_value": 0.0,
"inputs": {}
},
"radius_logarithmic": {
"base_value": 0.7999999999999998,
"inputs": {}
},
"restore_color": {
"base_value": 0.0,
"inputs": {}
},
"slow_tracking": {
"base_value": 0.0,
"inputs": {}
},
"slow_tracking_per_dab": {
"base_value": 0.0,
"inputs": {}
},
"smudge": {
"base_value": 0.0,
"inputs": {}
},
"smudge_length": {
"base_value": 0.5,
"inputs": {}
},
"smudge_radius_log": {
"base_value": 0.0,
"inputs": {}
},
"speed1_gamma": {
"base_value": 2.87,
"inputs": {}
},
"speed1_slowness": {
"base_value": 0.04,
"inputs": {}
},
"speed2_gamma": {
"base_value": 4.0,
"inputs": {}
},
"speed2_slowness": {
"base_value": 0.8,
"inputs": {}
},
"stroke_duration_logarithmic": {
"base_value": 1.18,
"inputs": {}
},
"stroke_holdtime": {
"base_value": 10.0,
"inputs": {}
},
"stroke_threshold": {
"base_value": 0.0,
"inputs": {}
},
"tracking_noise": {
"base_value": 0.0,
"inputs": {}
}
},
"version": 3
}

View File

@ -1,215 +0,0 @@
{
"comment": "MyPaint brush file",
"description": "",
"group": "",
"notes": "",
"parent_brush_name": "classic/pen",
"settings": {
"anti_aliasing": {
"base_value": 1.0,
"inputs": {}
},
"change_color_h": {
"base_value": 0.0,
"inputs": {}
},
"change_color_hsl_s": {
"base_value": 0.0,
"inputs": {}
},
"change_color_hsv_s": {
"base_value": 0.0,
"inputs": {}
},
"change_color_l": {
"base_value": 0.0,
"inputs": {}
},
"change_color_v": {
"base_value": 0.0,
"inputs": {}
},
"color_h": {
"base_value": 0.0,
"inputs": {}
},
"color_s": {
"base_value": 0.0,
"inputs": {}
},
"color_v": {
"base_value": 0.0,
"inputs": {}
},
"colorize": {
"base_value": 0.0,
"inputs": {}
},
"custom_input": {
"base_value": 0.0,
"inputs": {}
},
"custom_input_slowness": {
"base_value": 0.0,
"inputs": {}
},
"dabs_per_actual_radius": {
"base_value": 2.2,
"inputs": {}
},
"dabs_per_basic_radius": {
"base_value": 0.0,
"inputs": {}
},
"dabs_per_second": {
"base_value": 0.0,
"inputs": {}
},
"direction_filter": {
"base_value": 2.0,
"inputs": {}
},
"elliptical_dab_angle": {
"base_value": 90.0,
"inputs": {}
},
"elliptical_dab_ratio": {
"base_value": 1.0,
"inputs": {}
},
"eraser": {
"base_value": 0.0,
"inputs": {}
},
"hardness": {
"base_value": 0.9,
"inputs": {
"pressure": [
[0.0, 0.0],
[1.0, 0.05]
],
"speed1": [
[0.0, -0.0],
[1.0, -0.09]
]
}
},
"lock_alpha": {
"base_value": 0.0,
"inputs": {}
},
"offset_by_random": {
"base_value": 0.0,
"inputs": {}
},
"offset_by_speed": {
"base_value": 0.0,
"inputs": {}
},
"offset_by_speed_slowness": {
"base_value": 1.0,
"inputs": {}
},
"opaque": {
"base_value": 1.0,
"inputs": {}
},
"opaque_linearize": {
"base_value": 0.9,
"inputs": {}
},
"opaque_multiply": {
"base_value": 0.0,
"inputs": {
"pressure": [
[0.0, 0.0],
[0.015, 0.0],
[0.015, 1.0],
[1.0, 1.0]
]
}
},
"pressure_gain_log": {
"base_value": 0.0,
"inputs": {}
},
"radius_by_random": {
"base_value": 0.0,
"inputs": {}
},
"radius_logarithmic": {
"base_value": 0.96,
"inputs": {
"pressure": [
[0.0, 0.0],
[1.0, 0.5]
],
"speed1": [
[0.0, -0.0],
[1.0, -0.15]
]
}
},
"restore_color": {
"base_value": 0.0,
"inputs": {}
},
"slow_tracking": {
"base_value": 0.65,
"inputs": {}
},
"slow_tracking_per_dab": {
"base_value": 0.8,
"inputs": {}
},
"smudge": {
"base_value": 0.0,
"inputs": {}
},
"smudge_length": {
"base_value": 0.5,
"inputs": {}
},
"smudge_radius_log": {
"base_value": 0.0,
"inputs": {}
},
"snap_to_pixel": {
"base_value": 0.0,
"inputs": {}
},
"speed1_gamma": {
"base_value": 2.87,
"inputs": {}
},
"speed1_slowness": {
"base_value": 0.04,
"inputs": {}
},
"speed2_gamma": {
"base_value": 4.0,
"inputs": {}
},
"speed2_slowness": {
"base_value": 0.8,
"inputs": {}
},
"stroke_duration_logarithmic": {
"base_value": 4.0,
"inputs": {}
},
"stroke_holdtime": {
"base_value": 0.0,
"inputs": {}
},
"stroke_threshold": {
"base_value": 0.0,
"inputs": {}
},
"tracking_noise": {
"base_value": 0.0,
"inputs": {}
}
},
"version": 3
}

View File

@ -1,195 +0,0 @@
{
"comment": "MyPaint brush file",
"group": "",
"parent_brush_name": "classic/pencil",
"settings": {
"anti_aliasing": {
"base_value": 0.0,
"inputs": {}
},
"change_color_h": {
"base_value": 0.0,
"inputs": {}
},
"change_color_hsl_s": {
"base_value": 0.0,
"inputs": {}
},
"change_color_hsv_s": {
"base_value": 0.0,
"inputs": {}
},
"change_color_l": {
"base_value": 0.0,
"inputs": {}
},
"change_color_v": {
"base_value": 0.0,
"inputs": {}
},
"color_h": {
"base_value": 0.0,
"inputs": {}
},
"color_s": {
"base_value": 0.0,
"inputs": {}
},
"color_v": {
"base_value": 0.0,
"inputs": {}
},
"colorize": {
"base_value": 0.0,
"inputs": {}
},
"custom_input": {
"base_value": 0.0,
"inputs": {}
},
"custom_input_slowness": {
"base_value": 0.0,
"inputs": {}
},
"dabs_per_actual_radius": {
"base_value": 4.0,
"inputs": {}
},
"dabs_per_basic_radius": {
"base_value": 0.0,
"inputs": {}
},
"dabs_per_second": {
"base_value": 0.0,
"inputs": {}
},
"direction_filter": {
"base_value": 2.0,
"inputs": {}
},
"elliptical_dab_angle": {
"base_value": 90.0,
"inputs": {}
},
"elliptical_dab_ratio": {
"base_value": 1.0,
"inputs": {}
},
"eraser": {
"base_value": 0.0,
"inputs": {}
},
"hardness": {
"base_value": 0.1,
"inputs": {
"pressure": [
[0.0, 0.0],
[1.0, 0.3]
]
}
},
"lock_alpha": {
"base_value": 0.0,
"inputs": {}
},
"offset_by_random": {
"base_value": 0.5,
"inputs": {
"pressure": [
[0.0, 0.0],
[1.0, -0.3]
]
}
},
"offset_by_speed": {
"base_value": 0.0,
"inputs": {}
},
"offset_by_speed_slowness": {
"base_value": 1.0,
"inputs": {}
},
"opaque": {
"base_value": 0.7,
"inputs": {}
},
"opaque_linearize": {
"base_value": 0.9,
"inputs": {}
},
"opaque_multiply": {
"base_value": 0.0,
"inputs": {
"pressure": [
[0.0, 0.0],
[1.0, 1.0]
]
}
},
"radius_by_random": {
"base_value": 0.0,
"inputs": {}
},
"radius_logarithmic": {
"base_value": 0.2,
"inputs": {}
},
"restore_color": {
"base_value": 0.0,
"inputs": {}
},
"slow_tracking": {
"base_value": 1.0,
"inputs": {}
},
"slow_tracking_per_dab": {
"base_value": 0.0,
"inputs": {}
},
"smudge": {
"base_value": 0.0,
"inputs": {}
},
"smudge_length": {
"base_value": 0.5,
"inputs": {}
},
"smudge_radius_log": {
"base_value": 0.0,
"inputs": {}
},
"speed1_gamma": {
"base_value": 4.0,
"inputs": {}
},
"speed1_slowness": {
"base_value": 0.04,
"inputs": {}
},
"speed2_gamma": {
"base_value": 4.0,
"inputs": {}
},
"speed2_slowness": {
"base_value": 0.8,
"inputs": {}
},
"stroke_duration_logarithmic": {
"base_value": 4.0,
"inputs": {}
},
"stroke_holdtime": {
"base_value": 0.0,
"inputs": {}
},
"stroke_threshold": {
"base_value": 0.0,
"inputs": {}
},
"tracking_noise": {
"base_value": 0.0,
"inputs": {}
}
},
"version": 3
}

View File

@ -1,5 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10"/>
<path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/>
<line x1="12" y1="17" x2="12.01" y2="17"/>
</svg>

Before

Width:  |  Height:  |  Size: 297 B