Merge branch 'rust-ui' of https://git.skyler.io/skyler/Lightningbeam into rust-ui
This commit is contained in:
commit
06973d185c
24
Changelog.md
24
Changelog.md
|
|
@ -1,3 +1,27 @@
|
|||
# 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:
|
||||
Changes:
|
||||
- Added real-time amp simulation via NAM
|
||||
|
|
|
|||
|
|
@ -32,7 +32,9 @@ pub mod region_split;
|
|||
pub mod toggle_group_expansion;
|
||||
pub mod group_layers;
|
||||
pub mod raster_stroke;
|
||||
pub mod raster_fill;
|
||||
pub mod move_layer;
|
||||
pub mod set_fill_paint;
|
||||
|
||||
pub use add_clip_instance::AddClipInstanceAction;
|
||||
pub use add_effect::AddEffectAction;
|
||||
|
|
@ -63,4 +65,6 @@ pub use region_split::RegionSplitAction;
|
|||
pub use toggle_group_expansion::ToggleGroupExpansionAction;
|
||||
pub use group_layers::GroupLayersAction;
|
||||
pub use raster_stroke::RasterStrokeAction;
|
||||
pub use raster_fill::RasterFillAction;
|
||||
pub use move_layer::MoveLayerAction;
|
||||
pub use set_fill_paint::SetFillPaintAction;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,66 @@
|
|||
//! 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()
|
||||
}
|
||||
}
|
||||
|
|
@ -49,12 +49,14 @@ impl Action for RasterStrokeAction {
|
|||
fn execute(&mut self, document: &mut Document) -> Result<(), String> {
|
||||
let kf = get_keyframe_mut(document, &self.layer_id, 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 kf = get_keyframe_mut(document, &self.layer_id, self.time, self.width, self.height)?;
|
||||
kf.raw_pixels = self.buffer_before.clone();
|
||||
kf.texture_dirty = true;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,127 @@
|
|||
//! Action that changes the fill of one or more DCEL faces.
|
||||
//!
|
||||
//! Handles both solid-colour and gradient fills, clearing the other type so they
|
||||
//! don't coexist on a face.
|
||||
|
||||
use crate::action::Action;
|
||||
use crate::dcel::FaceId;
|
||||
use crate::document::Document;
|
||||
use crate::gradient::ShapeGradient;
|
||||
use crate::layer::AnyLayer;
|
||||
use crate::shape::ShapeColor;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Snapshot of one face's fill state (both types) for undo.
|
||||
#[derive(Clone)]
|
||||
struct OldFill {
|
||||
face_id: FaceId,
|
||||
color: Option<ShapeColor>,
|
||||
gradient: Option<ShapeGradient>,
|
||||
}
|
||||
|
||||
/// Action that sets a solid-colour *or* gradient fill on a set of faces,
|
||||
/// clearing the other fill type.
|
||||
pub struct SetFillPaintAction {
|
||||
layer_id: Uuid,
|
||||
time: f64,
|
||||
face_ids: Vec<FaceId>,
|
||||
new_color: Option<ShapeColor>,
|
||||
new_gradient: Option<ShapeGradient>,
|
||||
old_fills: Vec<OldFill>,
|
||||
description: &'static str,
|
||||
}
|
||||
|
||||
impl SetFillPaintAction {
|
||||
/// Set a solid fill (clears any gradient on the same faces).
|
||||
pub fn solid(
|
||||
layer_id: Uuid,
|
||||
time: f64,
|
||||
face_ids: Vec<FaceId>,
|
||||
color: Option<ShapeColor>,
|
||||
) -> Self {
|
||||
Self {
|
||||
layer_id,
|
||||
time,
|
||||
face_ids,
|
||||
new_color: color,
|
||||
new_gradient: None,
|
||||
old_fills: Vec::new(),
|
||||
description: "Set fill colour",
|
||||
}
|
||||
}
|
||||
|
||||
/// Set a gradient fill (clears any solid colour on the same faces).
|
||||
pub fn gradient(
|
||||
layer_id: Uuid,
|
||||
time: f64,
|
||||
face_ids: Vec<FaceId>,
|
||||
gradient: Option<ShapeGradient>,
|
||||
) -> Self {
|
||||
Self {
|
||||
layer_id,
|
||||
time,
|
||||
face_ids,
|
||||
new_color: None,
|
||||
new_gradient: gradient,
|
||||
old_fills: Vec::new(),
|
||||
description: "Set gradient fill",
|
||||
}
|
||||
}
|
||||
|
||||
fn get_dcel_mut<'a>(
|
||||
document: &'a mut Document,
|
||||
layer_id: &Uuid,
|
||||
time: f64,
|
||||
) -> Result<&'a mut crate::dcel::Dcel, String> {
|
||||
let layer = document
|
||||
.get_layer_mut(layer_id)
|
||||
.ok_or_else(|| format!("Layer {} not found", layer_id))?;
|
||||
match layer {
|
||||
AnyLayer::Vector(vl) => vl
|
||||
.dcel_at_time_mut(time)
|
||||
.ok_or_else(|| format!("No keyframe at time {}", time)),
|
||||
_ => Err("Not a vector layer".to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Action for SetFillPaintAction {
|
||||
fn execute(&mut self, document: &mut Document) -> Result<(), String> {
|
||||
let dcel = Self::get_dcel_mut(document, &self.layer_id, self.time)?;
|
||||
self.old_fills.clear();
|
||||
|
||||
for &fid in &self.face_ids {
|
||||
let face = dcel.face(fid);
|
||||
self.old_fills.push(OldFill {
|
||||
face_id: fid,
|
||||
color: face.fill_color,
|
||||
gradient: face.gradient_fill.clone(),
|
||||
});
|
||||
|
||||
let face_mut = dcel.face_mut(fid);
|
||||
// Setting a gradient clears solid colour and vice-versa.
|
||||
if self.new_gradient.is_some() || self.new_color.is_none() {
|
||||
face_mut.fill_color = self.new_color;
|
||||
face_mut.gradient_fill = self.new_gradient.clone();
|
||||
} else {
|
||||
face_mut.fill_color = self.new_color;
|
||||
face_mut.gradient_fill = None;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn rollback(&mut self, document: &mut Document) -> Result<(), String> {
|
||||
let dcel = Self::get_dcel_mut(document, &self.layer_id, self.time)?;
|
||||
for old in &self.old_fills {
|
||||
let face = dcel.face_mut(old.face_id);
|
||||
face.fill_color = old.color;
|
||||
face.gradient_fill = old.gradient.clone();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn description(&self) -> String {
|
||||
self.description.to_string()
|
||||
}
|
||||
}
|
||||
|
|
@ -15,17 +15,26 @@
|
|||
//! The GPU compute shader (`brush_dab.wgsl`) is the authoritative implementation.
|
||||
//!
|
||||
//! ### Dab placement
|
||||
//! Dabs are placed along the stroke polyline at intervals of
|
||||
//! `spacing = radius * dabs_per_radius`. Fractional remainder is tracked across
|
||||
//! consecutive calls via `StrokeState`.
|
||||
//! Follows the libmypaint model: distance-based and time-based contributions are
|
||||
//! **summed** into a single `partial_dabs` accumulator. A dab fires whenever the
|
||||
//! accumulator reaches 1.0.
|
||||
//!
|
||||
//! 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
|
||||
//! Normal mode uses the standard "over" operator on premultiplied RGBA:
|
||||
//! ```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.
|
||||
//! Normal mode uses the standard "over" operator on premultiplied RGBA.
|
||||
//! Erase mode subtracts from destination alpha.
|
||||
|
||||
use image::RgbaImage;
|
||||
use crate::raster_layer::{RasterBlendMode, StrokeRecord};
|
||||
|
|
@ -65,20 +74,48 @@ pub struct GpuDab {
|
|||
|
||||
/// Blend mode: 0 = Normal, 1 = Erase, 2 = Smudge
|
||||
pub blend_mode: u32,
|
||||
pub _pad0: u32,
|
||||
pub _pad1: u32,
|
||||
pub _pad2: u32,
|
||||
/// Elliptical dab aspect ratio (1.0 = circle)
|
||||
pub elliptical_dab_ratio: f32,
|
||||
/// Elliptical dab rotation angle in radians
|
||||
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 partial dab position between segments)
|
||||
/// Transient brush stroke state (tracks position and randomness between segments)
|
||||
pub struct StrokeState {
|
||||
/// Distance along the path already "consumed" toward the next dab (in pixels)
|
||||
pub distance_since_last_dab: f32,
|
||||
/// Fractional dab accumulator — reaches 1.0 when the next dab should fire.
|
||||
/// Initialised to 1.0 so the very first call always emits at least one dab.
|
||||
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 {
|
||||
pub fn new() -> Self {
|
||||
Self { distance_since_last_dab: 0.0 }
|
||||
Self {
|
||||
// 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -86,36 +123,198 @@ impl Default for StrokeState {
|
|||
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
|
||||
pub struct BrushEngine;
|
||||
|
||||
impl BrushEngine {
|
||||
/// Compute the list of GPU dabs for a stroke segment.
|
||||
///
|
||||
/// Uses the same dab-spacing logic as [`apply_stroke_with_state`] but produces
|
||||
/// [`GpuDab`] structs for upload to the GPU compute pipeline instead of painting
|
||||
/// into a pixel buffer.
|
||||
/// `dt` is the elapsed time in seconds since the previous call for this
|
||||
/// stroke. Pass `0.0` on the very first call (stroke start).
|
||||
///
|
||||
/// Also returns the union bounding box of all dabs as `(x0, y0, x1, y1)` in
|
||||
/// integer canvas pixel coordinates (clamped to non-negative values; `x0==i32::MAX`
|
||||
/// when the returned Vec is empty).
|
||||
/// Follows the libmypaint spacing model: distance-based and time-based
|
||||
/// contributions are **summed** in a single `partial_dabs` accumulator.
|
||||
/// A dab is emitted whenever `partial_dabs` reaches 1.0.
|
||||
///
|
||||
/// Also returns the union bounding box of all dabs as `(x0, y0, x1, y1)`.
|
||||
pub fn compute_dabs(
|
||||
stroke: &StrokeRecord,
|
||||
state: &mut StrokeState,
|
||||
dt: f32,
|
||||
) -> (Vec<GpuDab>, (i32, i32, i32, i32)) {
|
||||
let mut dabs: Vec<GpuDab> = Vec::new();
|
||||
let mut bbox = (i32::MAX, i32::MAX, i32::MIN, i32::MIN);
|
||||
let bs = &stroke.brush_settings;
|
||||
|
||||
let blend_mode_u = match stroke.blend_mode {
|
||||
// Determine blend mode, allowing brush settings to override Normal
|
||||
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::Erase => 1u32,
|
||||
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>,
|
||||
bbox: &mut (i32, i32, i32, i32),
|
||||
x: f32, y: f32,
|
||||
radius: f32, opacity: f32,
|
||||
cr: f32, cg: f32, cb: f32,
|
||||
ndx: f32, ndy: f32, smudge_dist: f32| {
|
||||
let r_fringe = radius + 1.0;
|
||||
bbox.0 = bbox.0.min((x - r_fringe).floor() as i32);
|
||||
|
|
@ -124,32 +323,83 @@ impl BrushEngine {
|
|||
bbox.3 = bbox.3.max((y + r_fringe).ceil() as i32);
|
||||
dabs.push(GpuDab {
|
||||
x, y, radius,
|
||||
hardness: stroke.brush_settings.hardness,
|
||||
hardness: bs.hardness,
|
||||
opacity,
|
||||
color_r: stroke.color[0],
|
||||
color_g: stroke.color[1],
|
||||
color_b: stroke.color[2],
|
||||
color_a: stroke.color[3],
|
||||
color_r: cr,
|
||||
color_g: cg,
|
||||
color_b: cb,
|
||||
// Clone stamp: color_r/color_g hold source canvas X/Y, so color_a = 1.0
|
||||
// (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,
|
||||
blend_mode: blend_mode_u,
|
||||
_pad0: 0, _pad1: 0, _pad2: 0,
|
||||
elliptical_dab_ratio: bs.elliptical_dab_ratio.max(1.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 let Some(pt) = stroke.points.first() {
|
||||
let r = stroke.brush_settings.radius_at_pressure(pt.pressure);
|
||||
let raw_o = stroke.brush_settings.opacity_at_pressure(pt.pressure);
|
||||
let o = 1.0 - (1.0 - raw_o).powf(stroke.brush_settings.dabs_per_radius * 0.5);
|
||||
// Single-tap smudge has no direction — skip (same as CPU engine)
|
||||
if !matches!(stroke.blend_mode, RasterBlendMode::Smudge) {
|
||||
push_dab(&mut dabs, &mut bbox, pt.x, pt.y, r, o, 0.0, 0.0, 0.0);
|
||||
if !state.smooth_initialized {
|
||||
state.smooth_x = pt.x;
|
||||
state.smooth_y = pt.y;
|
||||
state.smooth_initialized = true;
|
||||
}
|
||||
while state.partial_dabs >= 1.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);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// 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) {
|
||||
let p0 = &window[0];
|
||||
let p1 = &window[1];
|
||||
|
|
@ -159,45 +409,143 @@ impl BrushEngine {
|
|||
let seg_len = (dx * dx + dy * dy).sqrt();
|
||||
if seg_len < 1e-4 { continue; }
|
||||
|
||||
last_pressure = p1.pressure;
|
||||
|
||||
let mut t = 0.0f32;
|
||||
while t < 1.0 {
|
||||
let pressure = p0.pressure + t * (p1.pressure - p0.pressure);
|
||||
let radius = stroke.brush_settings.radius_at_pressure(pressure);
|
||||
let spacing = (radius * stroke.brush_settings.dabs_per_radius).max(0.5);
|
||||
let radius_for_rate = bs.radius_at_pressure(pressure);
|
||||
|
||||
let dist_to_next = spacing - state.distance_since_last_dab;
|
||||
let seg_t_to_next = (dist_to_next / seg_len).max(0.0);
|
||||
// Dab rate = sum of distance-based contributions (dabs per pixel).
|
||||
// Matches libmypaint: dabs_per_actual/actual_r + dabs_per_basic/base_r.
|
||||
// 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 {
|
||||
state.distance_since_last_dab += seg_len * (1.0 - t);
|
||||
// Won't reach the next dab within this segment.
|
||||
if rate > 1e-8 {
|
||||
state.partial_dabs += (1.0 - t) * seg_len * rate;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
t += seg_t_to_next;
|
||||
let x2 = p0.x + t * dx;
|
||||
let y2 = p0.y + t * dy;
|
||||
let pressure2 = p0.pressure + t * (p1.pressure - p0.pressure);
|
||||
let radius2 = stroke.brush_settings.radius_at_pressure(pressure2);
|
||||
let raw_opacity = stroke.brush_settings.opacity_at_pressure(pressure2);
|
||||
// Normalize per-dab opacity so dense dabs don't saturate faster than sparse ones.
|
||||
// Formula: per_dab = 1 − (1 − raw)^(dabs_per_radius / 2)
|
||||
// Derivation: N = 2/dabs_per_radius dabs cover one full diameter at the centre;
|
||||
// accumulated = 1 − (1 − per_dab)^N = raw → per_dab = 1 − (1−raw)^(dabs_per_radius/2)
|
||||
let opacity2 = 1.0 - (1.0 - raw_opacity).powf(stroke.brush_settings.dabs_per_radius * 0.5);
|
||||
|
||||
if matches!(stroke.blend_mode, RasterBlendMode::Smudge) {
|
||||
let ndx = dx / seg_len;
|
||||
let ndy = dy / seg_len;
|
||||
let smudge_dist =
|
||||
(radius2 * stroke.brush_settings.dabs_per_radius).max(1.0);
|
||||
push_dab(&mut dabs, &mut bbox,
|
||||
x2, y2, radius2, opacity2, ndx, ndy, smudge_dist);
|
||||
} else {
|
||||
push_dab(&mut dabs, &mut bbox,
|
||||
x2, y2, radius2, opacity2, 0.0, 0.0, 0.0);
|
||||
// Stroke threshold gating
|
||||
if pressure2 < bs.stroke_threshold {
|
||||
state.partial_dabs = 0.0;
|
||||
continue;
|
||||
}
|
||||
|
||||
state.distance_since_last_dab = 0.0;
|
||||
let base_r2 = bs.radius_at_pressure(pressure2);
|
||||
|
||||
// Slow tracking: exponential position smoothing
|
||||
let x2 = p0.x + t * dx;
|
||||
let y2 = p0.y + t * dy;
|
||||
if !state.smooth_initialized {
|
||||
state.smooth_x = x2; state.smooth_y = y2;
|
||||
state.smooth_initialized = true;
|
||||
}
|
||||
// spacing_px ≈ 1 / rate (pixels per dab), used as time-constant scale
|
||||
let spacing_px = if rate > 1e-8 { 1.0 / rate } else { 1.0 };
|
||||
let k = if bs.slow_tracking > 0.0 {
|
||||
(-spacing_px / bs.slow_tracking.max(0.1)).exp()
|
||||
} 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);
|
||||
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 ndy = dy / seg_len;
|
||||
// strength=1.0 → sample from 1 dab back (drag pixels with us).
|
||||
// strength=0.0 → sample from current position (no change).
|
||||
// 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,
|
||||
ex, ey, radius2, opacity2, cr, cg, cb,
|
||||
ndx, ndy, smudge_dist);
|
||||
} 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,
|
||||
ex, ey, radius2, opacity2, cr2, cg2, cb2,
|
||||
ndx2, ndy2, 0.0);
|
||||
}
|
||||
|
||||
state.partial_dabs = 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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,63 +1,215 @@
|
|||
//! Brush settings for the raster paint engine
|
||||
//!
|
||||
//! Settings that describe the appearance and behavior of a paint brush.
|
||||
//! Compatible with MyPaint .myb brush file format (subset).
|
||||
//! Compatible with MyPaint .myb brush file format.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::OnceLock;
|
||||
|
||||
/// Settings for a paint brush
|
||||
/// Settings for a paint brush — mirrors the MyPaint .myb settings schema.
|
||||
///
|
||||
/// 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)]
|
||||
pub struct BrushSettings {
|
||||
// ── Core shape ──────────────────────────────────────────────────────────
|
||||
/// log(radius) base value; actual radius = exp(radius_log)
|
||||
pub radius_log: f32,
|
||||
/// Edge hardness 0.0 (fully soft/gaussian) to 1.0 (hard edge)
|
||||
pub hardness: f32,
|
||||
/// Base opacity 0.0–1.0
|
||||
pub opaque: f32,
|
||||
/// Dab spacing as fraction of radius (smaller = denser strokes)
|
||||
/// Additional opacity multiplier (opaque_multiply)
|
||||
pub opaque_multiply: f32,
|
||||
/// Dabs per basic_radius distance (MyPaint: dabs_per_basic_radius)
|
||||
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 (0–180)
|
||||
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.0–1.0); usually overridden by stroke color
|
||||
pub color_h: f32,
|
||||
/// HSV saturation (0.0–1.0)
|
||||
pub color_s: f32,
|
||||
/// HSV value (0.0–1.0)
|
||||
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
|
||||
/// Final radius = exp(radius_log + pressure_radius_gain * pressure)
|
||||
pub pressure_radius_gain: f32,
|
||||
/// How much pressure increases/decreases opacity
|
||||
/// Final opacity = opaque * (1 + pressure_opacity_gain * (pressure - 0.5))
|
||||
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 {
|
||||
/// Default soft round brush (smooth Gaussian falloff)
|
||||
pub fn default_round_soft() -> Self {
|
||||
Self {
|
||||
radius_log: 2.0, // radius ≈ 7.4 px
|
||||
radius_log: 2.0,
|
||||
hardness: 0.1,
|
||||
opaque: 0.8,
|
||||
dabs_per_radius: 0.25,
|
||||
opaque_multiply: 0.0,
|
||||
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_s: 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_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)
|
||||
pub fn default_round_hard() -> Self {
|
||||
Self {
|
||||
radius_log: 2.0,
|
||||
hardness: 0.9,
|
||||
opaque: 1.0,
|
||||
dabs_per_radius: 0.2,
|
||||
color_h: 0.0,
|
||||
color_s: 0.0,
|
||||
color_v: 0.0,
|
||||
dabs_per_radius: 2.0,
|
||||
pressure_radius_gain: 0.3,
|
||||
pressure_opacity_gain: 0.8,
|
||||
..Self::default_round_soft()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -73,10 +225,10 @@ impl BrushSettings {
|
|||
o.clamp(0.0, 1.0)
|
||||
}
|
||||
|
||||
/// Parse a MyPaint .myb JSON brush file (subset).
|
||||
/// Parse a MyPaint .myb JSON brush file.
|
||||
///
|
||||
/// Reads `radius_logarithmic`, `hardness`, `opaque`, `dabs_per_basic_radius`,
|
||||
/// `color_h`, `color_s`, `color_v` from the `settings` key's `base_value` fields.
|
||||
/// Reads all known settings from `settings[key].base_value`.
|
||||
/// Unknown keys are silently ignored for forward compatibility.
|
||||
pub fn from_myb(json: &str) -> Result<Self, String> {
|
||||
let v: serde_json::Value =
|
||||
serde_json::from_str(json).map_err(|e| format!("JSON parse error: {e}"))?;
|
||||
|
|
@ -92,15 +244,13 @@ impl BrushSettings {
|
|||
.unwrap_or(default)
|
||||
};
|
||||
|
||||
// Pressure dynamics: read from the "inputs" mapping of radius/opacity
|
||||
// For simplicity, look for the pressure input point in radius_logarithmic
|
||||
// Pressure dynamics: approximate from the pressure input curve endpoints
|
||||
let pressure_radius_gain = settings
|
||||
.get("radius_logarithmic")
|
||||
.and_then(|s| s.get("inputs"))
|
||||
.and_then(|inp| inp.get("pressure"))
|
||||
.and_then(|pts| pts.as_array())
|
||||
.and_then(|arr| {
|
||||
// arr = [[x0,y0],[x1,y1],...] – approximate as linear gain at x=1.0
|
||||
if arr.len() >= 2 {
|
||||
let y0 = arr[0].get(1)?.as_f64()? as f32;
|
||||
let y1 = arr[arr.len() - 1].get(1)?.as_f64()? as f32;
|
||||
|
|
@ -128,15 +278,81 @@ impl BrushSettings {
|
|||
.unwrap_or(1.0);
|
||||
|
||||
Ok(Self {
|
||||
// Core shape
|
||||
radius_log: read_base("radius_logarithmic", 2.0),
|
||||
hardness: read_base("hardness", 0.5).clamp(0.0, 1.0),
|
||||
opaque: read_base("opaque", 1.0).clamp(0.0, 1.0),
|
||||
dabs_per_radius: read_base("dabs_per_basic_radius", 0.25).clamp(0.01, 10.0),
|
||||
hardness: read_base("hardness", 0.8).clamp(0.0, 1.0),
|
||||
opaque: read_base("opaque", 1.0).clamp(0.0, 2.0),
|
||||
opaque_multiply: read_base("opaque_multiply", 0.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_s: read_base("color_s", 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_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),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -146,3 +362,41 @@ impl Default for BrushSettings {
|
|||
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
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -117,6 +117,8 @@ pub struct Face {
|
|||
pub image_fill: Option<uuid::Uuid>,
|
||||
pub fill_rule: FillRule,
|
||||
#[serde(default)]
|
||||
pub gradient_fill: Option<crate::gradient::ShapeGradient>,
|
||||
#[serde(default)]
|
||||
pub deleted: bool,
|
||||
}
|
||||
|
||||
|
|
@ -244,6 +246,7 @@ impl Dcel {
|
|||
fill_color: None,
|
||||
image_fill: None,
|
||||
fill_rule: FillRule::NonZero,
|
||||
gradient_fill: None,
|
||||
deleted: false,
|
||||
};
|
||||
let debug_recorder = if std::env::var("DAW_DCEL_RECORD").is_ok() {
|
||||
|
|
@ -375,6 +378,7 @@ impl Dcel {
|
|||
fill_color: None,
|
||||
image_fill: None,
|
||||
fill_rule: FillRule::NonZero,
|
||||
gradient_fill: None,
|
||||
deleted: false,
|
||||
};
|
||||
if let Some(idx) = self.free_faces.pop() {
|
||||
|
|
|
|||
|
|
@ -390,6 +390,59 @@ 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 (1–100) 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 1–100 (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
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum ExportProgress {
|
||||
|
|
|
|||
|
|
@ -1,8 +1,198 @@
|
|||
//! Flood fill algorithm for paint bucket tool
|
||||
//! Flood fill algorithms for paint bucket tool
|
||||
//!
|
||||
//! This module implements a flood fill that tracks which curves each point
|
||||
//! touches. Instead of filling with pixels, it returns boundary points that
|
||||
//! can be used to construct a filled shape from exact curve geometry.
|
||||
//! This module contains two fill implementations:
|
||||
//! - `flood_fill` — vector curve-boundary fill (used by vector paint bucket)
|
||||
//! - `raster_flood_fill` — pixel BFS fill with configurable threshold, soft
|
||||
//! 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::quadtree::{BoundingBox, Quadtree};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,178 @@
|
|||
//! Gradient types for vector and raster fills.
|
||||
|
||||
use crate::shape::ShapeColor;
|
||||
use kurbo::Point;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use vello::peniko::{self, Brush, Extend, Gradient};
|
||||
|
||||
// ── Stop ────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// One colour stop in a gradient.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct GradientStop {
|
||||
/// Normalised position in [0.0, 1.0].
|
||||
pub position: f32,
|
||||
pub color: ShapeColor,
|
||||
}
|
||||
|
||||
// ── Kind / Extend ────────────────────────────────────────────────────────────
|
||||
|
||||
/// Whether the gradient transitions along a line or radiates from a point.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
pub enum GradientType {
|
||||
#[default]
|
||||
Linear,
|
||||
Radial,
|
||||
}
|
||||
|
||||
/// Behaviour outside the gradient's natural [0, 1] range.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
pub enum GradientExtend {
|
||||
/// Clamp to edge colour (default).
|
||||
#[default]
|
||||
Pad,
|
||||
/// Mirror the gradient.
|
||||
Reflect,
|
||||
/// Repeat the gradient.
|
||||
Repeat,
|
||||
}
|
||||
|
||||
impl From<GradientExtend> for Extend {
|
||||
fn from(e: GradientExtend) -> Self {
|
||||
match e {
|
||||
GradientExtend::Pad => Extend::Pad,
|
||||
GradientExtend::Reflect => Extend::Reflect,
|
||||
GradientExtend::Repeat => Extend::Repeat,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── ShapeGradient ────────────────────────────────────────────────────────────
|
||||
|
||||
/// A serialisable gradient description.
|
||||
///
|
||||
/// Stops are kept sorted by position (ascending). There are always ≥ 2 stops.
|
||||
///
|
||||
/// *Rendering*: call [`to_peniko_brush`](ShapeGradient::to_peniko_brush) with
|
||||
/// explicit start/end canvas-space points. For vector faces the caller derives
|
||||
/// the points from the bounding box + `angle`; for the raster tool the caller
|
||||
/// uses the drag start/end directly.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ShapeGradient {
|
||||
pub kind: GradientType,
|
||||
/// Colour stops, sorted by position.
|
||||
pub stops: Vec<GradientStop>,
|
||||
/// Angle in degrees for Linear (0 = left→right, 90 = top→bottom).
|
||||
/// Ignored for Radial.
|
||||
pub angle: f32,
|
||||
pub extend: GradientExtend,
|
||||
}
|
||||
|
||||
impl Default for ShapeGradient {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
kind: GradientType::Linear,
|
||||
stops: vec![
|
||||
GradientStop { position: 0.0, color: ShapeColor::rgba(0, 0, 0, 255) },
|
||||
GradientStop { position: 1.0, color: ShapeColor::rgba(0, 0, 0, 0) },
|
||||
],
|
||||
angle: 0.0,
|
||||
extend: GradientExtend::Pad,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ShapeGradient {
|
||||
// ── CPU evaluation ───────────────────────────────────────────────────────
|
||||
|
||||
/// Sample RGBA at `t ∈ [0,1]` by linear interpolation between adjacent stops.
|
||||
/// Stops must be sorted ascending by position.
|
||||
pub fn eval(&self, t: f32) -> [u8; 4] {
|
||||
let t = t.clamp(0.0, 1.0);
|
||||
if self.stops.is_empty() {
|
||||
return [0, 0, 0, 0];
|
||||
}
|
||||
if self.stops.len() == 1 {
|
||||
let c = self.stops[0].color;
|
||||
return [c.r, c.g, c.b, c.a];
|
||||
}
|
||||
// Find first stop with position > t
|
||||
let i = self.stops.partition_point(|s| s.position <= t);
|
||||
if i == 0 {
|
||||
let c = self.stops[0].color;
|
||||
return [c.r, c.g, c.b, c.a];
|
||||
}
|
||||
if i >= self.stops.len() {
|
||||
let c = self.stops.last().unwrap().color;
|
||||
return [c.r, c.g, c.b, c.a];
|
||||
}
|
||||
let s0 = self.stops[i - 1];
|
||||
let s1 = self.stops[i];
|
||||
let span = s1.position - s0.position;
|
||||
let f = if span <= 0.0 { 0.0 } else { (t - s0.position) / span };
|
||||
fn lerp(a: u8, b: u8, f: f32) -> u8 {
|
||||
(a as f32 + (b as f32 - a as f32) * f).round().clamp(0.0, 255.0) as u8
|
||||
}
|
||||
[
|
||||
lerp(s0.color.r, s1.color.r, f),
|
||||
lerp(s0.color.g, s1.color.g, f),
|
||||
lerp(s0.color.b, s1.color.b, f),
|
||||
lerp(s0.color.a, s1.color.a, f),
|
||||
]
|
||||
}
|
||||
|
||||
/// Apply `extend` mode to a raw t value, returning t ∈ [0,1].
|
||||
pub fn apply_extend(&self, t_raw: f32) -> f32 {
|
||||
match self.extend {
|
||||
GradientExtend::Pad => t_raw.clamp(0.0, 1.0),
|
||||
GradientExtend::Repeat => {
|
||||
let t = t_raw.rem_euclid(1.0);
|
||||
if t < 0.0 { t + 1.0 } else { t }
|
||||
}
|
||||
GradientExtend::Reflect => {
|
||||
let t = t_raw.rem_euclid(2.0).abs();
|
||||
if t > 1.0 { 2.0 - t } else { t }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── GPU / peniko rendering ───────────────────────────────────────────────
|
||||
|
||||
/// Build a `peniko::Brush` from explicit start/end canvas-coordinate points.
|
||||
///
|
||||
/// `opacity` in [0,1] is multiplied into all stop alphas.
|
||||
pub fn to_peniko_brush(&self, start: Point, end: Point, opacity: f32) -> Brush {
|
||||
// Convert stops to peniko tuples.
|
||||
let peniko_stops: Vec<(f32, peniko::Color)> = self.stops.iter().map(|s| {
|
||||
let a_scaled = (s.color.a as f32 * opacity).round().clamp(0.0, 255.0) as u8;
|
||||
let col = peniko::Color::from_rgba8(s.color.r, s.color.g, s.color.b, a_scaled);
|
||||
(s.position, col)
|
||||
}).collect();
|
||||
|
||||
let extend: Extend = self.extend.into();
|
||||
|
||||
match self.kind {
|
||||
GradientType::Linear => {
|
||||
Brush::Gradient(
|
||||
Gradient::new_linear(start, end)
|
||||
.with_extend(extend)
|
||||
.with_stops(peniko_stops.as_slice()),
|
||||
)
|
||||
}
|
||||
GradientType::Radial => {
|
||||
let mid = Point::new(
|
||||
(start.x + end.x) * 0.5,
|
||||
(start.y + end.y) * 0.5,
|
||||
);
|
||||
let dx = end.x - start.x;
|
||||
let dy = end.y - start.y;
|
||||
let radius = ((dx * dx + dy * dy).sqrt() * 0.5) as f32;
|
||||
Brush::Gradient(
|
||||
Gradient::new_radial(mid, radius)
|
||||
.with_extend(extend)
|
||||
.with_stops(peniko_stops.as_slice()),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -53,6 +53,8 @@ pub mod webcam;
|
|||
pub mod raster_layer;
|
||||
pub mod brush_settings;
|
||||
pub mod brush_engine;
|
||||
pub mod raster_draw;
|
||||
pub mod gradient;
|
||||
|
||||
#[cfg(debug_assertions)]
|
||||
pub mod test_mode;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,194 @@
|
|||
//! 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -18,6 +18,18 @@ pub enum RasterBlendMode {
|
|||
Erase,
|
||||
/// Smudge / blend surrounding pixels
|
||||
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 {
|
||||
|
|
@ -26,6 +38,15 @@ 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
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct StrokePoint {
|
||||
|
|
@ -48,6 +69,13 @@ pub struct StrokeRecord {
|
|||
/// RGBA linear color [r, g, b, a]
|
||||
pub color: [f32; 4],
|
||||
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>,
|
||||
}
|
||||
|
||||
|
|
@ -85,8 +113,14 @@ pub struct RasterKeyframe {
|
|||
/// and encoded back to PNG on save. An empty Vec means the canvas is blank (transparent).
|
||||
#[serde(skip)]
|
||||
pub raw_pixels: Vec<u8>,
|
||||
/// Set to `true` whenever `raw_pixels` changes so the GPU texture cache can re-upload.
|
||||
/// Always `true` after load; cleared by the renderer after uploading.
|
||||
#[serde(skip, default = "default_true")]
|
||||
pub texture_dirty: bool,
|
||||
}
|
||||
|
||||
fn default_true() -> bool { true }
|
||||
|
||||
impl RasterKeyframe {
|
||||
/// Returns true when the pixel buffer has been initialised (non-blank).
|
||||
pub fn has_pixels(&self) -> bool {
|
||||
|
|
@ -105,6 +139,7 @@ impl RasterKeyframe {
|
|||
stroke_log: Vec::new(),
|
||||
tween_after: TweenType::Hold,
|
||||
raw_pixels: Vec::new(),
|
||||
texture_dirty: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ use std::collections::HashMap;
|
|||
use std::sync::Arc;
|
||||
use uuid::Uuid;
|
||||
use vello::kurbo::Rect;
|
||||
use vello::peniko::{Blob, Fill, ImageAlphaType, ImageBrush, ImageData, ImageFormat};
|
||||
use vello::peniko::{Blob, Fill, ImageAlphaType, ImageBrush, ImageData, ImageFormat, ImageQuality};
|
||||
use vello::Scene;
|
||||
|
||||
/// Cache for decoded image data to avoid re-decoding every frame
|
||||
|
|
@ -88,14 +88,53 @@ fn decode_image_asset(asset: &ImageAsset) -> Option<ImageBrush> {
|
|||
// 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
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum RenderedLayerType {
|
||||
/// Regular content layer (vector, video) - composite its scene
|
||||
Content,
|
||||
/// Effect layer - apply effects to current composite state
|
||||
/// Vector / group layer — Vello scene in `RenderedLayer::scene` is used.
|
||||
Vector,
|
||||
/// Raster keyframe — bypass Vello; compositor uploads pixels via GPU texture cache.
|
||||
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 {
|
||||
/// Active effect instances at the current time
|
||||
effect_instances: Vec<ClipInstance>,
|
||||
},
|
||||
}
|
||||
|
|
@ -104,7 +143,7 @@ pub enum RenderedLayerType {
|
|||
pub struct RenderedLayer {
|
||||
/// The layer's unique identifier
|
||||
pub layer_id: Uuid,
|
||||
/// The Vello scene containing the layer's rendered content
|
||||
/// Vello scene — only populated for `RenderedLayerType::Vector`.
|
||||
pub scene: Scene,
|
||||
/// Layer opacity (0.0 to 1.0)
|
||||
pub opacity: f32,
|
||||
|
|
@ -112,12 +151,12 @@ pub struct RenderedLayer {
|
|||
pub blend_mode: BlendMode,
|
||||
/// Whether this layer has any visible content
|
||||
pub has_content: bool,
|
||||
/// Type of layer for compositor (content vs effect)
|
||||
/// Layer variant — determines how the compositor renders this entry.
|
||||
pub layer_type: RenderedLayerType,
|
||||
}
|
||||
|
||||
impl RenderedLayer {
|
||||
/// Create a new rendered layer with default settings
|
||||
/// Create a new vector layer with default settings.
|
||||
pub fn new(layer_id: Uuid) -> Self {
|
||||
Self {
|
||||
layer_id,
|
||||
|
|
@ -125,11 +164,11 @@ impl RenderedLayer {
|
|||
opacity: 1.0,
|
||||
blend_mode: BlendMode::Normal,
|
||||
has_content: false,
|
||||
layer_type: RenderedLayerType::Content,
|
||||
layer_type: RenderedLayerType::Vector,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create with specific opacity and blend mode
|
||||
/// Create a vector layer with specific opacity and blend mode.
|
||||
pub fn with_settings(layer_id: Uuid, opacity: f32, blend_mode: BlendMode) -> Self {
|
||||
Self {
|
||||
layer_id,
|
||||
|
|
@ -137,11 +176,11 @@ impl RenderedLayer {
|
|||
opacity,
|
||||
blend_mode,
|
||||
has_content: false,
|
||||
layer_type: RenderedLayerType::Content,
|
||||
layer_type: RenderedLayerType::Vector,
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
let has_content = !effect_instances.is_empty();
|
||||
Self {
|
||||
|
|
@ -179,12 +218,14 @@ pub fn render_document_for_compositing(
|
|||
image_cache: &mut ImageCache,
|
||||
video_manager: &std::sync::Arc<std::sync::Mutex<crate::video::VideoManager>>,
|
||||
camera_frame: Option<&crate::webcam::CaptureFrame>,
|
||||
floating_selection: Option<&crate::selection::RasterFloatingSelection>,
|
||||
draw_checkerboard: bool,
|
||||
) -> CompositeRenderResult {
|
||||
let time = document.current_time;
|
||||
|
||||
// Render background to its own scene
|
||||
let mut background = Scene::new();
|
||||
render_background(document, &mut background, base_transform);
|
||||
render_background(document, &mut background, base_transform, draw_checkerboard);
|
||||
|
||||
// Check if any layers are soloed
|
||||
let any_soloed = document.visible_layers().any(|layer| layer.soloed());
|
||||
|
|
@ -217,6 +258,36 @@ pub fn render_document_for_compositing(
|
|||
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 {
|
||||
background,
|
||||
layers: rendered_layers,
|
||||
|
|
@ -269,21 +340,74 @@ pub fn render_layer_isolated(
|
|||
rendered.has_content = false;
|
||||
}
|
||||
AnyLayer::Video(video_layer) => {
|
||||
use crate::animation::TransformProperty;
|
||||
let layer_opacity = layer.opacity();
|
||||
let mut video_mgr = video_manager.lock().unwrap();
|
||||
// Only pass camera_frame for the layer that has camera enabled
|
||||
let layer_camera_frame = if video_layer.camera_enabled { camera_frame } else { None };
|
||||
render_video_layer_to_scene(
|
||||
document,
|
||||
time,
|
||||
video_layer,
|
||||
&mut rendered.scene,
|
||||
base_transform,
|
||||
1.0, // Full opacity - layer opacity handled in compositing
|
||||
&mut video_mgr,
|
||||
layer_camera_frame,
|
||||
);
|
||||
rendered.has_content = !video_layer.clip_instances.is_empty()
|
||||
|| (video_layer.camera_enabled && camera_frame.is_some());
|
||||
let mut instances = Vec::new();
|
||||
|
||||
for clip_instance in &video_layer.clip_instances {
|
||||
let Some(video_clip) = document.video_clips.get(&clip_instance.clip_id) else { continue };
|
||||
let Some(clip_time) = clip_instance.remap_time(time, video_clip.duration) else { continue };
|
||||
let Some(frame) = video_mgr.get_frame(&clip_instance.clip_id, clip_time) else { continue };
|
||||
|
||||
// Evaluate animated transform properties.
|
||||
let anim = &video_layer.layer.animation_data;
|
||||
let id = clip_instance.id;
|
||||
let t = &clip_instance.transform;
|
||||
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);
|
||||
let rotation = anim.eval(&crate::animation::AnimationTarget::Object { id, property: TransformProperty::Rotation }, time, t.rotation);
|
||||
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) => {
|
||||
// Effect layers are processed during compositing, not rendered to scene
|
||||
|
|
@ -307,9 +431,16 @@ pub fn render_layer_isolated(
|
|||
rendered.has_content = !group_layer.children.is_empty();
|
||||
}
|
||||
AnyLayer::Raster(raster_layer) => {
|
||||
render_raster_layer_to_scene(raster_layer, time, &mut rendered.scene, base_transform);
|
||||
rendered.has_content = raster_layer.keyframe_at(time)
|
||||
.map_or(false, |kf| kf.has_pixels());
|
||||
if let Some(kf) = raster_layer.keyframe_at(time) {
|
||||
rendered.has_content = kf.has_pixels();
|
||||
rendered.layer_type = RenderedLayerType::Raster {
|
||||
kf_id: kf.id,
|
||||
width: kf.width,
|
||||
height: kf.height,
|
||||
dirty: kf.texture_dirty,
|
||||
transform: base_transform,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -363,35 +494,11 @@ fn render_raster_layer_to_scene(
|
|||
// decode the sRGB channels without premultiplying again.
|
||||
alpha_type: ImageAlphaType::AlphaPremultiplied,
|
||||
};
|
||||
let brush = ImageBrush::new(image_data);
|
||||
let brush = ImageBrush::new(image_data).with_quality(ImageQuality::Low);
|
||||
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);
|
||||
}
|
||||
|
||||
/// 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)
|
||||
// ============================================================================
|
||||
|
|
@ -415,8 +522,8 @@ pub fn render_document_with_transform(
|
|||
image_cache: &mut ImageCache,
|
||||
video_manager: &std::sync::Arc<std::sync::Mutex<crate::video::VideoManager>>,
|
||||
) {
|
||||
// 1. Draw background
|
||||
render_background(document, scene, base_transform);
|
||||
// 1. Draw background (with checkerboard for transparent backgrounds — UI path)
|
||||
render_background(document, scene, base_transform, true);
|
||||
|
||||
// 2. Recursively render the root graphics object at current time
|
||||
let time = document.current_time;
|
||||
|
|
@ -436,12 +543,12 @@ pub fn render_document_with_transform(
|
|||
}
|
||||
|
||||
/// Draw the document background
|
||||
fn render_background(document: &Document, scene: &mut Scene, base_transform: Affine) {
|
||||
fn render_background(document: &Document, scene: &mut Scene, base_transform: Affine, draw_checkerboard: bool) {
|
||||
let background_rect = Rect::new(0.0, 0.0, document.width, document.height);
|
||||
let bg = &document.background_color;
|
||||
|
||||
// Draw checkerboard behind transparent backgrounds
|
||||
if bg.a < 255 {
|
||||
// Draw checkerboard behind transparent backgrounds (UI-only; skip in export)
|
||||
if draw_checkerboard && bg.a < 255 {
|
||||
use vello::peniko::{Blob, Color, Extend, ImageAlphaType, ImageData, ImageQuality};
|
||||
// 2x2 pixel checkerboard pattern: light/dark alternating
|
||||
let light: [u8; 4] = [204, 204, 204, 255];
|
||||
|
|
@ -923,7 +1030,25 @@ fn render_video_layer(
|
|||
}
|
||||
}
|
||||
|
||||
/// Render a vector layer with all its clip instances and shape instances
|
||||
/// Compute start/end canvas points for a linear gradient across a bounding box.
|
||||
///
|
||||
/// The axis is centred on the bbox midpoint and oriented at `angle_deg` degrees
|
||||
/// (0 = left→right, 90 = top→bottom). The axis extends ± half the bbox diagonal
|
||||
/// so the gradient covers the entire shape regardless of angle.
|
||||
fn gradient_bbox_endpoints(angle_deg: f32, bbox: kurbo::Rect) -> (kurbo::Point, kurbo::Point) {
|
||||
let cx = bbox.center().x;
|
||||
let cy = bbox.center().y;
|
||||
let dx = bbox.width();
|
||||
let dy = bbox.height();
|
||||
// Use half the diagonal so the full gradient fits at any angle.
|
||||
let half_len = (dx * dx + dy * dy).sqrt() * 0.5;
|
||||
let rad = (angle_deg as f64).to_radians();
|
||||
let (sin, cos) = (rad.sin(), rad.cos());
|
||||
let start = kurbo::Point::new(cx - cos * half_len, cy - sin * half_len);
|
||||
let end = kurbo::Point::new(cx + cos * half_len, cy + sin * half_len);
|
||||
(start, end)
|
||||
}
|
||||
|
||||
/// Render a DCEL to a Vello scene.
|
||||
///
|
||||
/// Walks faces for fills and edges for strokes.
|
||||
|
|
@ -942,7 +1067,7 @@ pub fn render_dcel(
|
|||
if face.deleted || i == 0 {
|
||||
continue; // Skip unbounded face and deleted faces
|
||||
}
|
||||
if face.fill_color.is_none() && face.image_fill.is_none() {
|
||||
if face.fill_color.is_none() && face.image_fill.is_none() && face.gradient_fill.is_none() {
|
||||
continue; // No fill to render
|
||||
}
|
||||
|
||||
|
|
@ -963,7 +1088,19 @@ pub fn render_dcel(
|
|||
}
|
||||
}
|
||||
|
||||
// Color fill
|
||||
// Gradient fill (takes priority over solid colour fill)
|
||||
if !filled {
|
||||
if let Some(ref grad) = face.gradient_fill {
|
||||
use kurbo::{Point, Rect};
|
||||
let bbox: Rect = vello::kurbo::Shape::bounding_box(&path);
|
||||
let (start, end) = gradient_bbox_endpoints(grad.angle, bbox);
|
||||
let brush = grad.to_peniko_brush(start, end, opacity_f32);
|
||||
scene.fill(fill_rule, base_transform, &brush, None, &path);
|
||||
filled = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Solid colour fill
|
||||
if !filled {
|
||||
if let Some(fill_color) = &face.fill_color {
|
||||
let alpha = ((fill_color.a as f32 / 255.0) * opacity_f32 * 255.0) as u8;
|
||||
|
|
|
|||
|
|
@ -15,6 +15,16 @@ pub enum RasterSelection {
|
|||
Rect(i32, i32, i32, i32),
|
||||
/// Closed freehand lasso polygon.
|
||||
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 {
|
||||
|
|
@ -29,6 +39,23 @@ impl RasterSelection {
|
|||
let y1 = pts.iter().map(|p| p.1).max().unwrap_or(0);
|
||||
(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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -37,6 +64,14 @@ impl RasterSelection {
|
|||
match self {
|
||||
Self::Rect(x0, y0, x1, y1) => px >= *x0 && px < *x1 && py >= *y0 && py < *y1,
|
||||
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]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -69,7 +104,9 @@ fn point_in_polygon(px: i32, py: i32, polygon: &[(i32, i32)]) -> bool {
|
|||
#[derive(Clone, Debug)]
|
||||
pub struct RasterFloatingSelection {
|
||||
/// sRGB-encoded premultiplied RGBA, width × height × 4 bytes.
|
||||
pub pixels: Vec<u8>,
|
||||
/// Wrapped in Arc so the renderer can clone a reference each frame (O(1))
|
||||
/// instead of copying megabytes of pixel data.
|
||||
pub pixels: std::sync::Arc<Vec<u8>>,
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
/// Top-left position in canvas pixel coordinates.
|
||||
|
|
@ -81,7 +118,7 @@ pub struct RasterFloatingSelection {
|
|||
/// Snapshot of `raw_pixels` before the cut/paste was initiated, used for
|
||||
/// undo (via `RasterStrokeAction`) when the float is committed, and for
|
||||
/// Cancel (Escape) to restore the canvas without creating an undo entry.
|
||||
pub canvas_before: Vec<u8>,
|
||||
pub canvas_before: std::sync::Arc<Vec<u8>>,
|
||||
/// Key for this float's GPU canvas in `GpuBrushEngine::canvases`.
|
||||
/// Allows painting strokes directly onto the float buffer (B) without
|
||||
/// touching the layer canvas (A).
|
||||
|
|
|
|||
|
|
@ -11,9 +11,10 @@ use vello::kurbo::Point;
|
|||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum Tool {
|
||||
// ── Vector / shared tools ──────────────────────────────────────────────
|
||||
/// Selection tool - select and move objects
|
||||
Select,
|
||||
/// Draw/Pen tool - freehand drawing
|
||||
/// Draw/Brush tool - freehand drawing (vector) / paintbrush (raster)
|
||||
Draw,
|
||||
/// Transform tool - scale, rotate, skew
|
||||
Transform,
|
||||
|
|
@ -37,12 +38,48 @@ pub enum Tool {
|
|||
RegionSelect,
|
||||
/// Split tool - split audio/video clips at a point
|
||||
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,
|
||||
/// Smudge tool - smudge/blend raster pixels
|
||||
Smudge,
|
||||
/// Lasso select tool - freehand selection on raster layers
|
||||
/// Clone Stamp - copy pixels from a source point
|
||||
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,
|
||||
/// 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
|
||||
|
|
@ -60,6 +97,23 @@ 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
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum ToolState {
|
||||
|
|
@ -230,7 +284,7 @@ impl Tool {
|
|||
pub fn display_name(self) -> &'static str {
|
||||
match self {
|
||||
Tool::Select => "Select",
|
||||
Tool::Draw => "Draw",
|
||||
Tool::Draw => "Brush",
|
||||
Tool::Transform => "Transform",
|
||||
Tool::Rectangle => "Rectangle",
|
||||
Tool::Ellipse => "Ellipse",
|
||||
|
|
@ -242,9 +296,25 @@ impl Tool {
|
|||
Tool::Text => "Text",
|
||||
Tool::RegionSelect => "Region Select",
|
||||
Tool::Split => "Split",
|
||||
Tool::Erase => "Erase",
|
||||
Tool::Pencil => "Pencil",
|
||||
Tool::Pen => "Pen",
|
||||
Tool::Airbrush => "Airbrush",
|
||||
Tool::Erase => "Eraser",
|
||||
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::MagicWand => "Magic Wand",
|
||||
Tool::QuickSelect => "Quick Select",
|
||||
Tool::Warp => "Warp",
|
||||
Tool::Liquify => "Liquify",
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -267,6 +337,23 @@ impl Tool {
|
|||
Tool::Erase => "erase.svg",
|
||||
Tool::Smudge => "smudge.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",
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -294,7 +381,23 @@ impl Tool {
|
|||
match layer_type {
|
||||
None | Some(LayerType::Vector) => Tool::all(),
|
||||
Some(LayerType::Audio) | Some(LayerType::Video) => &[Tool::Select, Tool::Split],
|
||||
Some(LayerType::Raster) => &[Tool::Select, Tool::SelectLasso, Tool::Draw, Tool::Erase, Tool::Smudge, Tool::Eyedropper],
|
||||
Some(LayerType::Raster) => &[
|
||||
// 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],
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
[package]
|
||||
name = "lightningbeam-editor"
|
||||
version = "1.0.1-alpha"
|
||||
version = "1.0.2-alpha"
|
||||
edition = "2021"
|
||||
description = "Multimedia editor for audio, video and 2D animation"
|
||||
license = "GPL-3.0-or-later"
|
||||
|
|
|
|||
|
|
@ -124,7 +124,7 @@
|
|||
"children": [
|
||||
{
|
||||
"type": "vertical-grid",
|
||||
"percent": 30,
|
||||
"percent": 67,
|
||||
"children": [
|
||||
{ "type": "pane", "name": "toolbar" },
|
||||
{ "type": "pane", "name": "infopanel" }
|
||||
|
|
|
|||
|
|
@ -43,11 +43,30 @@ impl CustomCursor {
|
|||
Tool::Polygon => CustomCursor::Polygon,
|
||||
Tool::BezierEdit => CustomCursor::BezierEdit,
|
||||
Tool::Text => CustomCursor::Text,
|
||||
Tool::RegionSelect => CustomCursor::Select, // Reuse select cursor for now
|
||||
Tool::Split => CustomCursor::Select, // Reuse select cursor for now
|
||||
Tool::Erase => CustomCursor::Draw, // Reuse draw cursor for raster erase
|
||||
Tool::Smudge => CustomCursor::Draw, // Reuse draw cursor for raster smudge
|
||||
Tool::SelectLasso => CustomCursor::Select, // Reuse select cursor for lasso
|
||||
Tool::RegionSelect => CustomCursor::Select,
|
||||
Tool::Split => CustomCursor::Select,
|
||||
Tool::Erase => CustomCursor::Draw,
|
||||
Tool::Smudge => CustomCursor::Draw,
|
||||
Tool::SelectLasso => CustomCursor::Select,
|
||||
// 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,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -5,9 +5,40 @@
|
|||
|
||||
use eframe::egui;
|
||||
use std::collections::VecDeque;
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
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 MEMORY_REFRESH_INTERVAL: Duration = Duration::from_millis(500); // Refresh memory every 500ms
|
||||
|
||||
|
|
@ -28,6 +59,9 @@ pub struct DebugStats {
|
|||
pub audio_input_devices: Vec<String>,
|
||||
pub has_pointer: bool,
|
||||
|
||||
// GPU prepare() timing breakdown (from render thread)
|
||||
pub prepare_timing: PrepareTiming,
|
||||
|
||||
// Performance metrics for each section
|
||||
pub timing_memory_us: u64,
|
||||
pub timing_gpu_us: u64,
|
||||
|
|
@ -170,6 +204,12 @@ impl DebugStatsCollector {
|
|||
|
||||
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 {
|
||||
fps_current,
|
||||
fps_min,
|
||||
|
|
@ -184,6 +224,7 @@ impl DebugStatsCollector {
|
|||
midi_devices,
|
||||
audio_input_devices,
|
||||
has_pointer,
|
||||
prepare_timing,
|
||||
timing_memory_us,
|
||||
timing_gpu_us,
|
||||
timing_midi_us,
|
||||
|
|
@ -231,6 +272,16 @@ pub fn render_debug_overlay(ctx: &egui::Context, stats: &DebugStats) {
|
|||
|
||||
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
|
||||
ui.colored_label(egui::Color32::YELLOW, format!("Memory: ({}µs)", stats.timing_memory_us));
|
||||
ui.label(format!("Physical: {} MB", stats.memory_physical_mb));
|
||||
|
|
|
|||
|
|
@ -3,13 +3,29 @@
|
|||
//! Provides a user interface for configuring and starting audio/video exports.
|
||||
|
||||
use eframe::egui;
|
||||
use lightningbeam_core::export::{AudioExportSettings, AudioFormat, VideoExportSettings, VideoCodec, VideoQuality};
|
||||
use lightningbeam_core::export::{
|
||||
AudioExportSettings, AudioFormat,
|
||||
ImageExportSettings, ImageFormat,
|
||||
VideoExportSettings, VideoCodec, VideoQuality,
|
||||
};
|
||||
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
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ExportType {
|
||||
Audio,
|
||||
Image,
|
||||
Video,
|
||||
}
|
||||
|
||||
|
|
@ -17,6 +33,7 @@ pub enum ExportType {
|
|||
#[derive(Debug, Clone)]
|
||||
pub enum ExportResult {
|
||||
AudioOnly(AudioExportSettings, PathBuf),
|
||||
Image(ImageExportSettings, PathBuf),
|
||||
VideoOnly(VideoExportSettings, PathBuf),
|
||||
VideoWithAudio(VideoExportSettings, AudioExportSettings, PathBuf),
|
||||
}
|
||||
|
|
@ -32,6 +49,9 @@ pub struct ExportDialog {
|
|||
/// Audio export settings
|
||||
pub audio_settings: AudioExportSettings,
|
||||
|
||||
/// Image export settings
|
||||
pub image_settings: ImageExportSettings,
|
||||
|
||||
/// Video export settings
|
||||
pub video_settings: VideoExportSettings,
|
||||
|
||||
|
|
@ -55,6 +75,15 @@ pub struct ExportDialog {
|
|||
|
||||
/// Output directory
|
||||
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 {
|
||||
|
|
@ -71,6 +100,7 @@ impl Default for ExportDialog {
|
|||
open: false,
|
||||
export_type: ExportType::Audio,
|
||||
audio_settings: AudioExportSettings::standard_mp3(),
|
||||
image_settings: ImageExportSettings::default(),
|
||||
video_settings: VideoExportSettings::default(),
|
||||
include_audio: true,
|
||||
output_path: None,
|
||||
|
|
@ -78,23 +108,56 @@ impl Default for ExportDialog {
|
|||
show_advanced: false,
|
||||
selected_video_preset: 0,
|
||||
output_filename: String::new(),
|
||||
current_project: String::new(),
|
||||
last_export_type: None,
|
||||
last_exported_path: None,
|
||||
output_dir: music_dir,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ExportDialog {
|
||||
/// Open the dialog with default settings
|
||||
pub fn open(&mut self, timeline_duration: f64, project_name: &str) {
|
||||
/// Open the dialog with default settings, using `hint` to pick a smart default tab.
|
||||
pub fn open(&mut self, timeline_duration: f64, project_name: &str, hint: &DocumentHint) {
|
||||
self.open = true;
|
||||
self.audio_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;
|
||||
|
||||
// Pre-populate filename from project name if not already set
|
||||
if self.output_filename.is_empty() || !self.output_filename.contains(project_name) {
|
||||
let ext = self.audio_settings.format.extension();
|
||||
self.output_filename = format!("{}.{}", project_name, ext);
|
||||
// Determine export type: prefer the type used last time for this file,
|
||||
// then fall back to document-content hints.
|
||||
let same_project = self.current_project == project_name;
|
||||
self.export_type = if same_project && self.last_export_type.is_some() {
|
||||
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(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -106,10 +169,7 @@ impl ExportDialog {
|
|||
|
||||
/// Update the filename extension to match the current format
|
||||
fn update_filename_extension(&mut self) {
|
||||
let ext = match self.export_type {
|
||||
ExportType::Audio => self.audio_settings.format.extension(),
|
||||
ExportType::Video => self.video_settings.codec.container_format(),
|
||||
};
|
||||
let ext = self.current_extension();
|
||||
// Replace extension in filename
|
||||
if let Some(dot_pos) = self.output_filename.rfind('.') {
|
||||
self.output_filename.truncate(dot_pos + 1);
|
||||
|
|
@ -138,6 +198,7 @@ impl ExportDialog {
|
|||
|
||||
let window_title = match self.export_type {
|
||||
ExportType::Audio => "Export Audio",
|
||||
ExportType::Image => "Export Image",
|
||||
ExportType::Video => "Export Video",
|
||||
};
|
||||
|
||||
|
|
@ -156,11 +217,14 @@ impl ExportDialog {
|
|||
|
||||
// Export type selection (tabs)
|
||||
ui.horizontal(|ui| {
|
||||
if ui.selectable_value(&mut self.export_type, ExportType::Audio, "Audio").clicked() {
|
||||
for (variant, label) in [
|
||||
(ExportType::Audio, "Audio"),
|
||||
(ExportType::Image, "Image"),
|
||||
(ExportType::Video, "Video"),
|
||||
] {
|
||||
if ui.selectable_value(&mut self.export_type, variant, label).clicked() {
|
||||
self.update_filename_extension();
|
||||
}
|
||||
if ui.selectable_value(&mut self.export_type, ExportType::Video, "Video").clicked() {
|
||||
self.update_filename_extension();
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -171,6 +235,7 @@ impl ExportDialog {
|
|||
// Basic settings
|
||||
match self.export_type {
|
||||
ExportType::Audio => self.render_audio_basic(ui),
|
||||
ExportType::Image => self.render_image_settings(ui),
|
||||
ExportType::Video => self.render_video_basic(ui),
|
||||
}
|
||||
|
||||
|
|
@ -188,6 +253,7 @@ impl ExportDialog {
|
|||
ui.add_space(8.0);
|
||||
match self.export_type {
|
||||
ExportType::Audio => self.render_audio_advanced(ui),
|
||||
ExportType::Image => self.render_image_advanced(ui),
|
||||
ExportType::Video => self.render_video_advanced(ui),
|
||||
}
|
||||
}
|
||||
|
|
@ -260,6 +326,62 @@ 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)
|
||||
fn render_audio_advanced(&mut self, ui: &mut egui::Ui) {
|
||||
ui.horizontal(|ui| {
|
||||
|
|
@ -419,6 +541,7 @@ impl ExportDialog {
|
|||
fn render_time_range(&mut self, ui: &mut egui::Ui) {
|
||||
let (start_time, end_time) = match self.export_type {
|
||||
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),
|
||||
};
|
||||
|
||||
|
|
@ -440,26 +563,35 @@ impl ExportDialog {
|
|||
ui.label(format!("Duration: {:.2} seconds", duration));
|
||||
}
|
||||
|
||||
/// Render output file selection UI
|
||||
/// Render output file selection UI — single OS save-file dialog.
|
||||
fn render_output_selection(&mut self, ui: &mut egui::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:");
|
||||
let dir_text = self.output_dir.display().to_string();
|
||||
ui.label(&dir_text);
|
||||
if ui.button("Change...").clicked() {
|
||||
if let Some(dir) = rfd::FileDialog::new()
|
||||
.set_directory(&self.output_dir)
|
||||
.pick_folder()
|
||||
{
|
||||
self.output_dir = dir;
|
||||
}
|
||||
}
|
||||
ui.add(egui::Label::new(
|
||||
egui::RichText::new(&path_str).weak()
|
||||
).truncate());
|
||||
});
|
||||
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Filename:");
|
||||
ui.text_edit_singleline(&mut self.output_filename);
|
||||
});
|
||||
if ui.button("Choose location...").clicked() {
|
||||
let ext = self.current_extension();
|
||||
let mut dialog = rfd::FileDialog::new()
|
||||
.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
|
||||
|
|
@ -471,7 +603,18 @@ impl ExportDialog {
|
|||
|
||||
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 {
|
||||
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 => {
|
||||
// Validate audio settings
|
||||
if let Err(err) = self.audio_settings.validate() {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,70 @@
|
|||
//! 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,
|
||||
])
|
||||
})
|
||||
}
|
||||
|
|
@ -5,12 +5,13 @@
|
|||
|
||||
pub mod audio_exporter;
|
||||
pub mod dialog;
|
||||
pub mod image_exporter;
|
||||
pub mod video_exporter;
|
||||
pub mod readback_pipeline;
|
||||
pub mod perf_metrics;
|
||||
pub mod cpu_yuv_converter;
|
||||
|
||||
use lightningbeam_core::export::{AudioExportSettings, VideoExportSettings, ExportProgress};
|
||||
use lightningbeam_core::export::{AudioExportSettings, ImageExportSettings, VideoExportSettings, ExportProgress};
|
||||
use lightningbeam_core::document::Document;
|
||||
use lightningbeam_core::renderer::ImageCache;
|
||||
use lightningbeam_core::video::VideoManager;
|
||||
|
|
@ -66,6 +67,25 @@ pub struct VideoExportState {
|
|||
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
|
||||
pub struct ExportOrchestrator {
|
||||
/// Channel for receiving progress updates (video or audio-only export)
|
||||
|
|
@ -82,6 +102,9 @@ pub struct ExportOrchestrator {
|
|||
|
||||
/// Parallel audio+video export state
|
||||
parallel_export: Option<ParallelExportState>,
|
||||
|
||||
/// Single-frame image export state
|
||||
image_state: Option<ImageExportState>,
|
||||
}
|
||||
|
||||
/// State for parallel audio+video export
|
||||
|
|
@ -115,6 +138,7 @@ impl ExportOrchestrator {
|
|||
cancel_flag: Arc::new(AtomicBool::new(false)),
|
||||
video_state: None,
|
||||
parallel_export: None,
|
||||
image_state: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -446,12 +470,8 @@ impl ExportOrchestrator {
|
|||
|
||||
/// Check if an export is in progress
|
||||
pub fn is_exporting(&self) -> bool {
|
||||
// Check parallel export first
|
||||
if self.parallel_export.is_some() {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check single export
|
||||
if self.parallel_export.is_some() { return true; }
|
||||
if self.image_state.is_some() { return true; }
|
||||
if let Some(handle) = &self.thread_handle {
|
||||
!handle.is_finished()
|
||||
} else {
|
||||
|
|
@ -459,6 +479,171 @@ 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
|
||||
///
|
||||
/// This blocks until the export thread finishes.
|
||||
|
|
@ -924,6 +1109,8 @@ impl ExportOrchestrator {
|
|||
document, timestamp, width, height,
|
||||
device, queue, renderer, image_cache, video_manager,
|
||||
gpu_resources, &acquired.rgba_texture_view,
|
||||
None, // No floating selection during video export
|
||||
false, // Video export is never transparent
|
||||
)?;
|
||||
let render_end = Instant::now();
|
||||
|
||||
|
|
|
|||
|
|
@ -79,6 +79,10 @@ pub struct ExportGpuResources {
|
|||
pub linear_to_srgb_bind_group_layout: wgpu::BindGroupLayout,
|
||||
/// Sampler for linear to sRGB conversion
|
||||
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 {
|
||||
|
|
@ -235,6 +239,8 @@ impl ExportGpuResources {
|
|||
..Default::default()
|
||||
});
|
||||
|
||||
let canvas_blit = crate::gpu_brush::CanvasBlitPipeline::new(device);
|
||||
|
||||
Self {
|
||||
buffer_pool,
|
||||
compositor,
|
||||
|
|
@ -251,6 +257,8 @@ impl ExportGpuResources {
|
|||
linear_to_srgb_pipeline,
|
||||
linear_to_srgb_bind_group_layout,
|
||||
linear_to_srgb_sampler,
|
||||
canvas_blit,
|
||||
raster_cache: std::collections::HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -702,6 +710,233 @@ pub fn render_frame_to_rgba(
|
|||
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
|
||||
///
|
||||
/// This function uses the same rendering pipeline as the stage preview,
|
||||
|
|
@ -748,193 +983,12 @@ pub fn render_frame_to_rgba_hdr(
|
|||
image_cache,
|
||||
video_manager,
|
||||
None, // No webcam during export
|
||||
None, // No floating selection during export
|
||||
false, // No checkerboard in export
|
||||
);
|
||||
|
||||
// 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,
|
||||
};
|
||||
|
||||
// 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();
|
||||
// Video export is never transparent.
|
||||
composite_document_to_hdr(&composite_result, document, device, queue, renderer, gpu_resources, width, height, false)?;
|
||||
|
||||
// Use persistent output texture (already created in ExportGpuResources)
|
||||
let output_view = &gpu_resources.output_texture_view;
|
||||
|
|
@ -1118,6 +1172,8 @@ pub fn render_frame_to_gpu_rgba(
|
|||
video_manager: &Arc<std::sync::Mutex<VideoManager>>,
|
||||
gpu_resources: &mut ExportGpuResources,
|
||||
rgba_texture_view: &wgpu::TextureView,
|
||||
floating_selection: Option<&lightningbeam_core::selection::RasterFloatingSelection>,
|
||||
allow_transparency: bool,
|
||||
) -> Result<wgpu::CommandEncoder, String> {
|
||||
use vello::kurbo::Affine;
|
||||
|
||||
|
|
@ -1134,176 +1190,11 @@ pub fn render_frame_to_gpu_rgba(
|
|||
image_cache,
|
||||
video_manager,
|
||||
None, // No webcam during export
|
||||
floating_selection,
|
||||
false, // No checkerboard in export
|
||||
);
|
||||
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
composite_document_to_hdr(&composite_result, document, device, queue, renderer, gpu_resources, width, height, allow_transparency)?;
|
||||
|
||||
// Convert HDR to sRGB (linear → sRGB), render directly to external RGBA texture
|
||||
let output_view = rgba_texture_view;
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -429,24 +429,26 @@ impl AppAction {
|
|||
/// `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> {
|
||||
use lightningbeam_core::tool::Tool;
|
||||
Some(match tool {
|
||||
Tool::Select => AppAction::ToolSelect,
|
||||
Tool::Draw => AppAction::ToolDraw,
|
||||
Tool::Transform => AppAction::ToolTransform,
|
||||
Tool::Rectangle => AppAction::ToolRectangle,
|
||||
Tool::Ellipse => AppAction::ToolEllipse,
|
||||
Tool::PaintBucket => AppAction::ToolPaintBucket,
|
||||
Tool::Eyedropper => AppAction::ToolEyedropper,
|
||||
Tool::Line => AppAction::ToolLine,
|
||||
Tool::Polygon => AppAction::ToolPolygon,
|
||||
Tool::BezierEdit => AppAction::ToolBezierEdit,
|
||||
Tool::Text => AppAction::ToolText,
|
||||
Tool::RegionSelect => AppAction::ToolRegionSelect,
|
||||
Tool::Erase => AppAction::ToolErase,
|
||||
Tool::Smudge => AppAction::ToolSmudge,
|
||||
Tool::SelectLasso => AppAction::ToolSelectLasso,
|
||||
Tool::Split => AppAction::ToolSplit,
|
||||
})
|
||||
match tool {
|
||||
Tool::Select => Some(AppAction::ToolSelect),
|
||||
Tool::Draw => Some(AppAction::ToolDraw),
|
||||
Tool::Transform => Some(AppAction::ToolTransform),
|
||||
Tool::Rectangle => Some(AppAction::ToolRectangle),
|
||||
Tool::Ellipse => Some(AppAction::ToolEllipse),
|
||||
Tool::PaintBucket => Some(AppAction::ToolPaintBucket),
|
||||
Tool::Eyedropper => Some(AppAction::ToolEyedropper),
|
||||
Tool::Line => Some(AppAction::ToolLine),
|
||||
Tool::Polygon => Some(AppAction::ToolPolygon),
|
||||
Tool::BezierEdit => Some(AppAction::ToolBezierEdit),
|
||||
Tool::Text => Some(AppAction::ToolText),
|
||||
Tool::RegionSelect => Some(AppAction::ToolRegionSelect),
|
||||
Tool::Erase => Some(AppAction::ToolErase),
|
||||
Tool::Smudge => Some(AppAction::ToolSmudge),
|
||||
Tool::SelectLasso => Some(AppAction::ToolSelectLasso),
|
||||
Tool::Split => Some(AppAction::ToolSplit),
|
||||
// New tools have no keybinding yet
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
// === Default bindings ===
|
||||
|
|
|
|||
|
|
@ -13,6 +13,8 @@ use uuid::Uuid;
|
|||
mod panes;
|
||||
use panes::{PaneInstance, PaneRenderer};
|
||||
|
||||
mod tools;
|
||||
|
||||
mod widgets;
|
||||
|
||||
mod menu;
|
||||
|
|
@ -26,6 +28,8 @@ mod waveform_gpu;
|
|||
mod cqt_gpu;
|
||||
mod gpu_brush;
|
||||
|
||||
mod raster_tool;
|
||||
|
||||
mod config;
|
||||
use config::AppConfig;
|
||||
|
||||
|
|
@ -332,6 +336,7 @@ mod tool_icons {
|
|||
pub static ERASE: &[u8] = include_bytes!("../../../src/assets/erase.svg");
|
||||
pub static SMUDGE: &[u8] = include_bytes!("../../../src/assets/smudge.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
|
||||
|
|
@ -399,11 +404,28 @@ impl ToolIconCache {
|
|||
Tool::Polygon => tool_icons::POLYGON,
|
||||
Tool::BezierEdit => tool_icons::BEZIER_EDIT,
|
||||
Tool::Text => tool_icons::TEXT,
|
||||
Tool::RegionSelect => tool_icons::SELECT, // Reuse select icon for now
|
||||
Tool::RegionSelect => tool_icons::SELECT,
|
||||
Tool::Split => tool_icons::SPLIT,
|
||||
Tool::Erase => tool_icons::ERASE,
|
||||
Tool::Smudge => tool_icons::SMUDGE,
|
||||
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) {
|
||||
self.icons.insert(tool, texture);
|
||||
|
|
@ -766,12 +788,10 @@ struct EditorApp {
|
|||
draw_simplify_mode: lightningbeam_core::tool::SimplifyMode, // Current simplification mode for draw tool
|
||||
rdp_tolerance: f64, // RDP simplification tolerance (default: 10.0)
|
||||
schneider_max_error: f64, // Schneider curve fitting max error (default: 30.0)
|
||||
// Raster brush settings
|
||||
brush_radius: f32, // brush radius in pixels
|
||||
brush_opacity: f32, // brush opacity 0.0–1.0
|
||||
brush_hardness: f32, // brush hardness 0.0–1.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
|
||||
/// All per-tool raster paint settings (brush, eraser, smudge, clone, pattern, dodge/burn, sponge).
|
||||
raster_settings: tools::RasterToolSettings,
|
||||
/// GPU-rendered brush preview pixel buffers, shared with VelloCallback::prepare().
|
||||
brush_preview_pixels: std::sync::Arc<std::sync::Mutex<Vec<(u32, u32, Vec<u8>)>>>,
|
||||
// Audio engine integration
|
||||
#[allow(dead_code)] // Must be kept alive to maintain audio output
|
||||
audio_stream: Option<cpal::Stream>,
|
||||
|
|
@ -841,6 +861,7 @@ struct EditorApp {
|
|||
// Region select state
|
||||
region_selection: Option<lightningbeam_core::selection::RegionSelection>,
|
||||
region_select_mode: lightningbeam_core::tool::RegionSelectMode,
|
||||
lasso_mode: lightningbeam_core::tool::LassoMode,
|
||||
|
||||
// VU meter levels
|
||||
input_level: f32,
|
||||
|
|
@ -935,6 +956,9 @@ impl EditorApp {
|
|||
#[cfg(debug_assertions)]
|
||||
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
|
||||
let config = AppConfig::load();
|
||||
|
||||
|
|
@ -1049,11 +1073,8 @@ impl EditorApp {
|
|||
draw_simplify_mode: lightningbeam_core::tool::SimplifyMode::Smooth, // Default to smooth curves
|
||||
rdp_tolerance: 10.0, // Default RDP tolerance
|
||||
schneider_max_error: 30.0, // Default Schneider max error
|
||||
brush_radius: 10.0,
|
||||
brush_opacity: 1.0,
|
||||
brush_hardness: 0.5,
|
||||
brush_spacing: 0.1,
|
||||
brush_use_fg: true,
|
||||
raster_settings: tools::RasterToolSettings::default(),
|
||||
brush_preview_pixels: std::sync::Arc::new(std::sync::Mutex::new(Vec::new())),
|
||||
audio_stream,
|
||||
audio_controller,
|
||||
audio_event_rx,
|
||||
|
|
@ -1097,6 +1118,7 @@ impl EditorApp {
|
|||
polygon_sides: 5, // Default to pentagon
|
||||
region_selection: None,
|
||||
region_select_mode: lightningbeam_core::tool::RegionSelectMode::default(),
|
||||
lasso_mode: lightningbeam_core::tool::LassoMode::default(),
|
||||
input_level: 0.0,
|
||||
output_level: (0.0, 0.0),
|
||||
track_levels: HashMap::new(),
|
||||
|
|
@ -1852,7 +1874,8 @@ impl EditorApp {
|
|||
let cy = y0 + row;
|
||||
let inside = match sel {
|
||||
RasterSelection::Rect(..) => true,
|
||||
RasterSelection::Lasso(_) => sel.contains_pixel(cx as i32, cy as i32),
|
||||
RasterSelection::Lasso(_) | RasterSelection::Mask { .. } =>
|
||||
sel.contains_pixel(cx as i32, cy as i32),
|
||||
};
|
||||
if inside {
|
||||
let src = ((cy * canvas_w + cx) * 4) as usize;
|
||||
|
|
@ -1976,7 +1999,8 @@ impl EditorApp {
|
|||
|
||||
let action = RasterStrokeAction::new(
|
||||
float.layer_id, float.time,
|
||||
float.canvas_before, canvas_after,
|
||||
std::sync::Arc::try_unwrap(float.canvas_before).unwrap_or_else(|a| (*a).clone()),
|
||||
canvas_after,
|
||||
w, h,
|
||||
);
|
||||
if let Err(e) = self.action_executor.execute(Box::new(action)) {
|
||||
|
|
@ -1995,7 +2019,7 @@ impl EditorApp {
|
|||
let document = self.action_executor.document_mut();
|
||||
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 };
|
||||
kf.raw_pixels = float.canvas_before;
|
||||
kf.raw_pixels = std::sync::Arc::try_unwrap(float.canvas_before).unwrap_or_else(|a| (*a).clone());
|
||||
}
|
||||
|
||||
/// Drop (discard) the floating selection keeping the hole punched in the
|
||||
|
|
@ -2015,7 +2039,8 @@ impl EditorApp {
|
|||
let (w, h) = (kf.width, kf.height);
|
||||
let action = RasterStrokeAction::new(
|
||||
float.layer_id, float.time,
|
||||
float.canvas_before, canvas_after,
|
||||
std::sync::Arc::try_unwrap(float.canvas_before).unwrap_or_else(|a| (*a).clone()),
|
||||
canvas_after,
|
||||
w, h,
|
||||
);
|
||||
if let Err(e) = self.action_executor.execute(Box::new(action)) {
|
||||
|
|
@ -2036,7 +2061,7 @@ impl EditorApp {
|
|||
if matches!(document.get_layer(&layer_id), Some(AnyLayer::Raster(_))) {
|
||||
if let Some(float) = &self.selection.raster_floating {
|
||||
self.clipboard_manager.copy(ClipboardContent::RasterPixels {
|
||||
pixels: float.pixels.clone(),
|
||||
pixels: (*float.pixels).clone(),
|
||||
width: float.width,
|
||||
height: float.height,
|
||||
});
|
||||
|
|
@ -2516,14 +2541,14 @@ impl EditorApp {
|
|||
|
||||
use lightningbeam_core::selection::{RasterFloatingSelection, RasterSelection};
|
||||
self.selection.raster_floating = Some(RasterFloatingSelection {
|
||||
pixels,
|
||||
pixels: std::sync::Arc::new(pixels),
|
||||
width,
|
||||
height,
|
||||
x: paste_x,
|
||||
y: paste_y,
|
||||
layer_id,
|
||||
time: self.playback_time,
|
||||
canvas_before,
|
||||
canvas_before: std::sync::Arc::new(canvas_before),
|
||||
canvas_id: uuid::Uuid::new_v4(),
|
||||
});
|
||||
// Update the marquee to show the floating selection bounds.
|
||||
|
|
@ -2942,14 +2967,42 @@ impl EditorApp {
|
|||
}
|
||||
MenuAction::Export => {
|
||||
println!("Menu: Export");
|
||||
// Open export dialog with calculated 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()
|
||||
.and_then(|p| p.file_stem())
|
||||
.map(|s| s.to_string_lossy().into_owned())
|
||||
.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 => {
|
||||
println!("Menu: Quit");
|
||||
|
|
@ -4535,16 +4588,9 @@ impl eframe::App for EditorApp {
|
|||
fn update(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) {
|
||||
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
|
||||
// This ensures thumbnails update immediately when waveform data arrives
|
||||
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();
|
||||
}
|
||||
|
||||
|
|
@ -5264,6 +5310,17 @@ impl eframe::App for EditorApp {
|
|||
|
||||
let export_started = if let Some(orchestrator) = &mut self.export_orchestrator {
|
||||
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) => {
|
||||
println!("🎵 [MAIN] Starting audio-only export: {}", output_path.display());
|
||||
|
||||
|
|
@ -5374,6 +5431,7 @@ impl eframe::App for EditorApp {
|
|||
let mut temp_image_cache = lightningbeam_core::renderer::ImageCache::new();
|
||||
|
||||
if let Some(renderer) = &mut temp_renderer {
|
||||
// Drive incremental video export.
|
||||
if let Ok(has_more) = orchestrator.render_next_video_frame(
|
||||
self.action_executor.document_mut(),
|
||||
device,
|
||||
|
|
@ -5383,10 +5441,24 @@ impl eframe::App for EditorApp {
|
|||
&self.video_manager,
|
||||
) {
|
||||
if has_more {
|
||||
// More frames to render - request repaint for next frame
|
||||
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}"); }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -5605,11 +5677,7 @@ impl eframe::App for EditorApp {
|
|||
draw_simplify_mode: &mut self.draw_simplify_mode,
|
||||
rdp_tolerance: &mut self.rdp_tolerance,
|
||||
schneider_max_error: &mut self.schneider_max_error,
|
||||
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,
|
||||
raster_settings: &mut self.raster_settings,
|
||||
audio_controller: self.audio_controller.as_ref(),
|
||||
video_manager: &self.video_manager,
|
||||
playback_time: &mut self.playback_time,
|
||||
|
|
@ -5650,6 +5718,7 @@ impl eframe::App for EditorApp {
|
|||
script_saved: &mut self.script_saved,
|
||||
region_selection: &mut self.region_selection,
|
||||
region_select_mode: &mut self.region_select_mode,
|
||||
lasso_mode: &mut self.lasso_mode,
|
||||
pending_graph_loads: &self.pending_graph_loads,
|
||||
clipboard_consumed: &mut clipboard_consumed,
|
||||
keymap: &self.keymap,
|
||||
|
|
@ -5660,6 +5729,7 @@ impl eframe::App for EditorApp {
|
|||
test_mode: &mut self.test_mode,
|
||||
#[cfg(debug_assertions)]
|
||||
synthetic_input: &mut synthetic_input_storage,
|
||||
brush_preview_pixels: &self.brush_preview_pixels,
|
||||
},
|
||||
pane_instances: &mut self.pane_instances,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,261 @@
|
|||
//! Gradient stop editor widget.
|
||||
//!
|
||||
//! Call [`gradient_stop_editor`] inside any egui layout; it returns `true` when
|
||||
//! `gradient` was modified.
|
||||
|
||||
use eframe::egui::{self, Color32, DragValue, Painter, Rect, Sense, Stroke, Vec2};
|
||||
use lightningbeam_core::gradient::{GradientExtend, GradientStop, GradientType, ShapeGradient};
|
||||
use lightningbeam_core::shape::ShapeColor;
|
||||
|
||||
// ── Public entry point ───────────────────────────────────────────────────────
|
||||
|
||||
/// Render an inline gradient editor.
|
||||
///
|
||||
/// * `gradient` – the gradient being edited (mutated in place).
|
||||
/// * `selected_stop` – index of the currently selected stop (persisted by caller).
|
||||
///
|
||||
/// Returns `true` if anything changed.
|
||||
pub fn gradient_stop_editor(
|
||||
ui: &mut egui::Ui,
|
||||
gradient: &mut ShapeGradient,
|
||||
selected_stop: &mut Option<usize>,
|
||||
) -> bool {
|
||||
let mut changed = false;
|
||||
|
||||
// ── Row 1: Kind + angle ───────────────────────────────────────────────
|
||||
ui.horizontal(|ui| {
|
||||
let was_linear = gradient.kind == GradientType::Linear;
|
||||
if ui.selectable_label(was_linear, "Linear").clicked() && !was_linear {
|
||||
gradient.kind = GradientType::Linear;
|
||||
changed = true;
|
||||
}
|
||||
if ui.selectable_label(!was_linear, "Radial").clicked() && was_linear {
|
||||
gradient.kind = GradientType::Radial;
|
||||
changed = true;
|
||||
}
|
||||
if gradient.kind == GradientType::Linear {
|
||||
ui.add_space(8.0);
|
||||
ui.label("Angle:");
|
||||
if ui.add(
|
||||
DragValue::new(&mut gradient.angle)
|
||||
.speed(1.0)
|
||||
.range(-360.0..=360.0)
|
||||
.suffix("°"),
|
||||
).changed() {
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// ── Gradient bar + handles ────────────────────────────────────────────
|
||||
let bar_height = 22.0_f32;
|
||||
let handle_h = 14.0_f32;
|
||||
let total_height = bar_height + handle_h + 4.0;
|
||||
let avail_w = ui.available_width();
|
||||
|
||||
let (bar_rect, bar_resp) = ui.allocate_exact_size(
|
||||
Vec2::new(avail_w, total_height),
|
||||
Sense::click(),
|
||||
);
|
||||
let painter = ui.painter_at(bar_rect);
|
||||
|
||||
let bar = Rect::from_min_size(bar_rect.min, Vec2::new(avail_w, bar_height));
|
||||
let track = Rect::from_min_size(
|
||||
egui::pos2(bar_rect.min.x, bar_rect.min.y + bar_height + 2.0),
|
||||
Vec2::new(avail_w, handle_h),
|
||||
);
|
||||
|
||||
// Draw checkerboard background (transparent indicator).
|
||||
draw_checker(&painter, bar);
|
||||
|
||||
// Draw gradient bar as N segments.
|
||||
let seg = 128_usize;
|
||||
for i in 0..seg {
|
||||
let t0 = i as f32 / seg as f32;
|
||||
let t1 = (i + 1) as f32 / seg as f32;
|
||||
let t = (t0 + t1) * 0.5;
|
||||
let [r, g, b, a] = gradient.eval(t);
|
||||
let col = Color32::from_rgba_unmultiplied(r, g, b, a);
|
||||
let x0 = bar.min.x + t0 * bar.width();
|
||||
let x1 = bar.min.x + t1 * bar.width();
|
||||
let seg_rect = Rect::from_min_max(
|
||||
egui::pos2(x0, bar.min.y),
|
||||
egui::pos2(x1, bar.max.y),
|
||||
);
|
||||
painter.rect_filled(seg_rect, 0.0, col);
|
||||
}
|
||||
// Outline.
|
||||
painter.rect_stroke(bar, 2.0, Stroke::new(1.0, Color32::from_gray(60)), eframe::egui::StrokeKind::Middle);
|
||||
|
||||
// Click on bar → add stop.
|
||||
if bar_resp.clicked() {
|
||||
if let Some(pos) = bar_resp.interact_pointer_pos() {
|
||||
if bar.contains(pos) {
|
||||
let t = ((pos.x - bar.min.x) / bar.width()).clamp(0.0, 1.0);
|
||||
let [r, g, b, a] = gradient.eval(t);
|
||||
gradient.stops.push(GradientStop {
|
||||
position: t,
|
||||
color: ShapeColor::rgba(r, g, b, a),
|
||||
});
|
||||
gradient.stops.sort_by(|a, b| a.position.partial_cmp(&b.position).unwrap());
|
||||
*selected_stop = gradient.stops.iter().position(|s| s.position == t);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Draw stop handles.
|
||||
// We need to detect drags per-handle, so allocate individual rects with the
|
||||
// regular egui input model. To avoid borrow conflicts we collect interactions
|
||||
// before mutating.
|
||||
let handle_w = 10.0_f32;
|
||||
let n_stops = gradient.stops.len();
|
||||
|
||||
let mut drag_idx: Option<usize> = None;
|
||||
let mut drag_delta: f32 = 0.0;
|
||||
let mut click_idx: Option<usize> = None;
|
||||
|
||||
// To render handles after collecting, remember their rects.
|
||||
let handle_rects: Vec<Rect> = (0..n_stops).map(|i| {
|
||||
let cx = track.min.x + gradient.stops[i].position * track.width();
|
||||
Rect::from_center_size(
|
||||
egui::pos2(cx, track.center().y),
|
||||
Vec2::new(handle_w, handle_h),
|
||||
)
|
||||
}).collect();
|
||||
|
||||
for (i, &h_rect) in handle_rects.iter().enumerate() {
|
||||
let resp = ui.interact(h_rect, ui.id().with(("grad_handle", i)), Sense::click_and_drag());
|
||||
if resp.dragged() {
|
||||
drag_idx = Some(i);
|
||||
drag_delta = resp.drag_delta().x / track.width();
|
||||
}
|
||||
if resp.clicked() {
|
||||
click_idx = Some(i);
|
||||
}
|
||||
}
|
||||
|
||||
// Apply drag.
|
||||
if let (Some(i), delta) = (drag_idx, drag_delta) {
|
||||
if delta != 0.0 {
|
||||
let new_pos = (gradient.stops[i].position + delta).clamp(0.0, 1.0);
|
||||
gradient.stops[i].position = new_pos;
|
||||
// Re-sort and track the moved stop.
|
||||
gradient.stops.sort_by(|a, b| a.position.partial_cmp(&b.position).unwrap());
|
||||
// Find new index of the moved stop (closest position match).
|
||||
if let Some(ref mut sel) = *selected_stop {
|
||||
// Re-find by position proximity.
|
||||
*sel = gradient.stops.iter().enumerate()
|
||||
.min_by(|(_, a), (_, b)| {
|
||||
let pa = (a.position - (gradient.stops.get(i).map_or(0.0, |s| s.position))).abs();
|
||||
let pb = (b.position - (gradient.stops.get(i).map_or(0.0, |s| s.position))).abs();
|
||||
pa.partial_cmp(&pb).unwrap()
|
||||
})
|
||||
.map(|(idx, _)| idx)
|
||||
.unwrap_or(0);
|
||||
}
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(i) = click_idx {
|
||||
*selected_stop = Some(i);
|
||||
}
|
||||
|
||||
// Paint handles on top (after interaction so they visually react).
|
||||
for (i, h_rect) in handle_rects.iter().enumerate() {
|
||||
let col = ShapeColor_to_Color32(gradient.stops[i].color);
|
||||
let is_selected = *selected_stop == Some(i);
|
||||
|
||||
// Draw a downward-pointing triangle.
|
||||
let cx = h_rect.center().x;
|
||||
let top = h_rect.min.y;
|
||||
let bot = h_rect.max.y;
|
||||
let hw = h_rect.width() * 0.5;
|
||||
let tri = vec![
|
||||
egui::pos2(cx, bot),
|
||||
egui::pos2(cx - hw, top),
|
||||
egui::pos2(cx + hw, top),
|
||||
];
|
||||
painter.add(egui::Shape::convex_polygon(
|
||||
tri,
|
||||
col,
|
||||
Stroke::new(if is_selected { 2.0 } else { 1.0 },
|
||||
if is_selected { Color32::WHITE } else { Color32::from_gray(100) }),
|
||||
));
|
||||
}
|
||||
|
||||
// ── Selected stop detail ──────────────────────────────────────────────
|
||||
if let Some(i) = *selected_stop {
|
||||
if i < gradient.stops.len() {
|
||||
ui.separator();
|
||||
ui.horizontal(|ui| {
|
||||
let stop = &mut gradient.stops[i];
|
||||
let mut rgba = [stop.color.r, stop.color.g, stop.color.b, stop.color.a];
|
||||
if ui.color_edit_button_srgba_unmultiplied(&mut rgba).changed() {
|
||||
stop.color = ShapeColor::rgba(rgba[0], rgba[1], rgba[2], rgba[3]);
|
||||
changed = true;
|
||||
}
|
||||
ui.label("Position:");
|
||||
if ui.add(
|
||||
DragValue::new(&mut stop.position)
|
||||
.speed(0.005)
|
||||
.range(0.0..=1.0),
|
||||
).changed() {
|
||||
gradient.stops.sort_by(|a, b| a.position.partial_cmp(&b.position).unwrap());
|
||||
changed = true;
|
||||
}
|
||||
let can_remove = gradient.stops.len() > 2;
|
||||
if ui.add_enabled(can_remove, egui::Button::new("− Remove")).clicked() {
|
||||
gradient.stops.remove(i);
|
||||
*selected_stop = None;
|
||||
changed = true;
|
||||
}
|
||||
});
|
||||
} else {
|
||||
*selected_stop = None;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Extend mode ───────────────────────────────────────────────────────
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Extend:");
|
||||
if ui.selectable_label(gradient.extend == GradientExtend::Pad, "Pad").clicked() {
|
||||
gradient.extend = GradientExtend::Pad; changed = true;
|
||||
}
|
||||
if ui.selectable_label(gradient.extend == GradientExtend::Reflect, "Reflect").clicked() {
|
||||
gradient.extend = GradientExtend::Reflect; changed = true;
|
||||
}
|
||||
if ui.selectable_label(gradient.extend == GradientExtend::Repeat, "Repeat").clicked() {
|
||||
gradient.extend = GradientExtend::Repeat; changed = true;
|
||||
}
|
||||
});
|
||||
|
||||
changed
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
fn ShapeColor_to_Color32(c: ShapeColor) -> Color32 {
|
||||
Color32::from_rgba_unmultiplied(c.r, c.g, c.b, c.a)
|
||||
}
|
||||
|
||||
/// Draw a small grey/white checkerboard inside `rect`.
|
||||
fn draw_checker(painter: &Painter, rect: Rect) {
|
||||
let cell = 6.0_f32;
|
||||
let cols = ((rect.width() / cell).ceil() as u32).max(1);
|
||||
let rows = ((rect.height() / cell).ceil() as u32).max(1);
|
||||
for row in 0..rows {
|
||||
for col in 0..cols {
|
||||
let light = (row + col) % 2 == 0;
|
||||
let col32 = if light { Color32::from_gray(200) } else { Color32::from_gray(140) };
|
||||
let x = rect.min.x + col as f32 * cell;
|
||||
let y = rect.min.y + row as f32 * cell;
|
||||
let r = Rect::from_min_size(
|
||||
egui::pos2(x, y),
|
||||
Vec2::splat(cell),
|
||||
).intersect(rect);
|
||||
painter.rect_filled(r, 0.0, col32);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -11,12 +11,15 @@
|
|||
/// - Document settings (when nothing is focused)
|
||||
|
||||
use eframe::egui::{self, DragValue, Ui};
|
||||
use lightningbeam_core::actions::{SetDocumentPropertiesAction, SetShapePropertiesAction};
|
||||
use lightningbeam_core::brush_settings::{bundled_brushes, BrushSettings};
|
||||
use lightningbeam_core::actions::{SetDocumentPropertiesAction, SetShapePropertiesAction, SetFillPaintAction};
|
||||
use lightningbeam_core::gradient::ShapeGradient;
|
||||
use lightningbeam_core::layer::{AnyLayer, LayerTrait};
|
||||
use lightningbeam_core::selection::FocusSelection;
|
||||
use lightningbeam_core::shape::ShapeColor;
|
||||
use lightningbeam_core::tool::{SimplifyMode, Tool};
|
||||
use super::{NodePath, PaneRenderer, SharedPaneState};
|
||||
use super::gradient_editor::gradient_stop_editor;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Info panel pane state
|
||||
|
|
@ -25,13 +28,36 @@ pub struct InfopanelPane {
|
|||
tool_section_open: bool,
|
||||
/// Whether the shape properties section is expanded
|
||||
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 {
|
||||
pub fn new() -> Self {
|
||||
let presets = bundled_brushes();
|
||||
let default_eraser_idx = presets.iter().position(|p| p.name == "Brush");
|
||||
Self {
|
||||
tool_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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -47,6 +73,8 @@ struct SelectionInfo {
|
|||
|
||||
// Shape property values (None = mixed)
|
||||
fill_color: Option<Option<ShapeColor>>,
|
||||
/// None = mixed across selection; Some(None) = no gradient; Some(Some(g)) = all same gradient
|
||||
fill_gradient: Option<Option<ShapeGradient>>,
|
||||
stroke_color: Option<Option<ShapeColor>>,
|
||||
stroke_width: Option<f64>,
|
||||
}
|
||||
|
|
@ -58,6 +86,7 @@ impl Default for SelectionInfo {
|
|||
dcel_count: 0,
|
||||
layer_id: None,
|
||||
fill_color: None,
|
||||
fill_gradient: None,
|
||||
stroke_color: None,
|
||||
stroke_width: None,
|
||||
}
|
||||
|
|
@ -120,21 +149,32 @@ impl InfopanelPane {
|
|||
// Gather fill properties from selected faces
|
||||
let mut first_fill_color: Option<Option<ShapeColor>> = None;
|
||||
let mut fill_color_mixed = false;
|
||||
let mut first_fill_gradient: Option<Option<ShapeGradient>> = None;
|
||||
let mut fill_gradient_mixed = false;
|
||||
|
||||
for &fid in shared.selection.selected_faces() {
|
||||
let face = dcel.face(fid);
|
||||
let fc = face.fill_color;
|
||||
let fg = face.gradient_fill.clone();
|
||||
|
||||
match first_fill_color {
|
||||
None => first_fill_color = Some(fc),
|
||||
Some(prev) if prev != fc => fill_color_mixed = true,
|
||||
_ => {}
|
||||
}
|
||||
match &first_fill_gradient {
|
||||
None => first_fill_gradient = Some(fg),
|
||||
Some(prev) if *prev != fg => fill_gradient_mixed = true,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
if !fill_color_mixed {
|
||||
info.fill_color = first_fill_color;
|
||||
}
|
||||
if !fill_gradient_mixed {
|
||||
info.fill_gradient = first_fill_gradient;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -151,7 +191,8 @@ impl InfopanelPane {
|
|||
.and_then(|id| shared.action_executor.document().get_layer(&id))
|
||||
.map_or(false, |l| matches!(l, AnyLayer::Raster(_)));
|
||||
|
||||
let is_raster_paint_tool = active_is_raster && matches!(tool, Tool::Draw | Tool::Erase | Tool::Smudge);
|
||||
let raster_tool_def = active_is_raster.then(|| crate::tools::raster_tool_def(&tool)).flatten();
|
||||
let is_raster_paint_tool = raster_tool_def.is_some();
|
||||
|
||||
// Only show tool options for tools that have options
|
||||
let is_vector_tool = !active_is_raster && matches!(
|
||||
|
|
@ -159,23 +200,30 @@ impl InfopanelPane {
|
|||
Tool::Select | Tool::BezierEdit | Tool::Draw | Tool::Rectangle
|
||||
| Tool::Ellipse | Tool::Line | Tool::Polygon
|
||||
);
|
||||
let has_options = is_vector_tool || is_raster_paint_tool || matches!(
|
||||
let is_raster_transform = active_is_raster
|
||||
&& 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::PaintBucket | Tool::RegionSelect
|
||||
Tool::Rectangle | Tool::Ellipse | Tool::Line | Tool::Polygon
|
||||
);
|
||||
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 {
|
||||
return;
|
||||
}
|
||||
|
||||
let header_label = if is_raster_paint_tool {
|
||||
match tool {
|
||||
Tool::Erase => "Eraser",
|
||||
Tool::Smudge => "Smudge",
|
||||
_ => "Brush",
|
||||
}
|
||||
let header_label = if is_raster_transform {
|
||||
"Raster Transform"
|
||||
} else {
|
||||
"Tool Options"
|
||||
raster_tool_def.map(|d| d.header_label()).unwrap_or("Tool Options")
|
||||
};
|
||||
|
||||
egui::CollapsingHeader::new(header_label)
|
||||
|
|
@ -190,6 +238,23 @@ impl InfopanelPane {
|
|||
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 {
|
||||
Tool::Draw if !is_raster_paint_tool => {
|
||||
// Stroke width
|
||||
|
|
@ -242,7 +307,43 @@ impl InfopanelPane {
|
|||
}
|
||||
|
||||
Tool::PaintBucket => {
|
||||
// Gap tolerance
|
||||
if active_is_raster {
|
||||
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.label("Gap Tolerance:");
|
||||
ui.add(
|
||||
|
|
@ -252,6 +353,145 @@ 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 => {
|
||||
// Number of sides
|
||||
|
|
@ -300,48 +540,20 @@ impl InfopanelPane {
|
|||
});
|
||||
}
|
||||
|
||||
// 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) {
|
||||
Tool::Gradient if active_is_raster => {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Opacity:");
|
||||
ui.add(
|
||||
egui::Slider::new(shared.brush_opacity, 0.0_f32..=1.0)
|
||||
.custom_formatter(|v, _| format!("{:.0}%", v * 100.0)),
|
||||
);
|
||||
ui.add(egui::Slider::new(
|
||||
&mut shared.raster_settings.gradient_opacity,
|
||||
0.0_f32..=1.0,
|
||||
).custom_formatter(|v, _| format!("{:.0}%", v * 100.0)));
|
||||
});
|
||||
}
|
||||
ui.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.add_space(4.0);
|
||||
gradient_stop_editor(
|
||||
ui,
|
||||
&mut shared.raster_settings.gradient,
|
||||
&mut self.selected_tool_gradient_stop,
|
||||
);
|
||||
});
|
||||
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)),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
_ => {}
|
||||
|
|
@ -351,6 +563,192 @@ 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)
|
||||
|
||||
/// Render shape properties section (fill/stroke)
|
||||
|
|
@ -377,28 +775,72 @@ impl InfopanelPane {
|
|||
self.shape_section_open = true;
|
||||
ui.add_space(4.0);
|
||||
|
||||
// Fill color
|
||||
// Fill — determine current fill type
|
||||
let has_gradient = matches!(&info.fill_gradient, Some(Some(_)));
|
||||
let has_solid = matches!(&info.fill_color, Some(Some(_)));
|
||||
let fill_is_none = matches!(&info.fill_color, Some(None))
|
||||
&& matches!(&info.fill_gradient, Some(None));
|
||||
let fill_mixed = info.fill_color.is_none() && info.fill_gradient.is_none();
|
||||
|
||||
// Fill type toggle row
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Fill:");
|
||||
match info.fill_color {
|
||||
Some(Some(color)) => {
|
||||
let mut rgba = [color.r, color.g, color.b, color.a];
|
||||
if ui.color_edit_button_srgba_unmultiplied(&mut rgba).changed() {
|
||||
let new_color = ShapeColor::rgba(rgba[0], rgba[1], rgba[2], rgba[3]);
|
||||
let action = SetShapePropertiesAction::set_fill_color(
|
||||
layer_id, time, face_ids.clone(), Some(new_color),
|
||||
if fill_mixed {
|
||||
ui.label("--");
|
||||
} else {
|
||||
if ui.selectable_label(fill_is_none, "None").clicked() && !fill_is_none {
|
||||
let action = SetFillPaintAction::solid(
|
||||
layer_id, time, face_ids.clone(), None,
|
||||
);
|
||||
shared.pending_actions.push(Box::new(action));
|
||||
}
|
||||
if ui.selectable_label(has_solid || (!has_gradient && !fill_is_none), "Solid").clicked() && !has_solid {
|
||||
// Switch to solid: use existing color or default to black
|
||||
let color = info.fill_color.flatten()
|
||||
.unwrap_or(ShapeColor::rgba(0, 0, 0, 255));
|
||||
let action = SetFillPaintAction::solid(
|
||||
layer_id, time, face_ids.clone(), Some(color),
|
||||
);
|
||||
shared.pending_actions.push(Box::new(action));
|
||||
}
|
||||
if ui.selectable_label(has_gradient, "Gradient").clicked() && !has_gradient {
|
||||
let grad = info.fill_gradient.clone().flatten()
|
||||
.unwrap_or_default();
|
||||
let action = SetFillPaintAction::gradient(
|
||||
layer_id, time, face_ids.clone(), Some(grad),
|
||||
);
|
||||
shared.pending_actions.push(Box::new(action));
|
||||
}
|
||||
}
|
||||
Some(None) => {
|
||||
ui.label("None");
|
||||
}
|
||||
None => {
|
||||
ui.label("--");
|
||||
}
|
||||
});
|
||||
|
||||
// Solid fill color editor
|
||||
if !fill_mixed && has_solid {
|
||||
if let Some(Some(color)) = info.fill_color {
|
||||
ui.horizontal(|ui| {
|
||||
let mut rgba = [color.r, color.g, color.b, color.a];
|
||||
if ui.color_edit_button_srgba_unmultiplied(&mut rgba).changed() {
|
||||
let new_color = ShapeColor::rgba(rgba[0], rgba[1], rgba[2], rgba[3]);
|
||||
let action = SetFillPaintAction::solid(
|
||||
layer_id, time, face_ids.clone(), Some(new_color),
|
||||
);
|
||||
shared.pending_actions.push(Box::new(action));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Gradient fill editor
|
||||
if !fill_mixed && has_gradient {
|
||||
if let Some(Some(mut grad)) = info.fill_gradient.clone() {
|
||||
if gradient_stop_editor(ui, &mut grad, &mut self.selected_shape_gradient_stop) {
|
||||
let action = SetFillPaintAction::gradient(
|
||||
layer_id, time, face_ids.clone(), Some(grad),
|
||||
);
|
||||
shared.pending_actions.push(Box::new(action));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Stroke color
|
||||
ui.horizontal(|ui| {
|
||||
|
|
@ -864,6 +1306,40 @@ 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")
|
||||
fn midi_note_name(note: u8) -> String {
|
||||
const NAMES: [&str; 12] = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"];
|
||||
|
|
|
|||
|
|
@ -68,6 +68,7 @@ pub enum WebcamRecordCommand {
|
|||
|
||||
pub mod toolbar;
|
||||
pub mod stage;
|
||||
pub mod gradient_editor;
|
||||
pub mod timeline;
|
||||
pub mod infopanel;
|
||||
pub mod outliner;
|
||||
|
|
@ -187,13 +188,8 @@ pub struct SharedPaneState<'a> {
|
|||
pub draw_simplify_mode: &'a mut lightningbeam_core::tool::SimplifyMode,
|
||||
pub rdp_tolerance: &'a mut f64,
|
||||
pub schneider_max_error: &'a mut f64,
|
||||
/// Raster brush settings
|
||||
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,
|
||||
/// All per-tool raster paint settings (replaces 20+ individual fields).
|
||||
pub raster_settings: &'a mut crate::tools::RasterToolSettings,
|
||||
/// 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>>>,
|
||||
/// Video manager for video decoding and frame caching
|
||||
|
|
@ -269,6 +265,8 @@ pub struct SharedPaneState<'a> {
|
|||
pub region_selection: &'a mut Option<lightningbeam_core::selection::RegionSelection>,
|
||||
/// Region select mode (Rectangle or Lasso)
|
||||
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
|
||||
/// GraphLoadPreset command so the repaint loop stays alive until the
|
||||
/// audio thread sends GraphPresetLoaded back
|
||||
|
|
@ -291,6 +289,10 @@ pub struct SharedPaneState<'a> {
|
|||
/// Synthetic input from test mode replay (debug builds only)
|
||||
#[cfg(debug_assertions)]
|
||||
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
|
||||
|
|
|
|||
|
|
@ -0,0 +1,27 @@
|
|||
// 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));
|
||||
}
|
||||
|
|
@ -20,7 +20,7 @@ struct GpuDab {
|
|||
x: f32, y: f32, radius: f32, hardness: f32, // bytes 0–15
|
||||
opacity: f32, color_r: f32, color_g: f32, color_b: f32, // bytes 16–31
|
||||
color_a: f32, ndx: f32, ndy: f32, smudge_dist: f32, // bytes 32–47
|
||||
blend_mode: u32, _pad0: u32, _pad1: u32, _pad2: u32, // bytes 48–63
|
||||
blend_mode: u32, elliptical_dab_ratio: f32, elliptical_dab_angle: f32, lock_alpha: f32, // bytes 48–63
|
||||
}
|
||||
|
||||
struct Params {
|
||||
|
|
@ -76,7 +76,20 @@ fn bilinear_sample(px: f32, py: f32) -> 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 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; }
|
||||
|
||||
// Quadratic falloff: flat inner core, smooth quadratic outer zone.
|
||||
|
|
@ -94,15 +107,17 @@ fn apply_dab(current: vec4<f32>, dab: GpuDab, px: i32, py: i32) -> vec4<f32> {
|
|||
}
|
||||
|
||||
if dab.blend_mode == 0u {
|
||||
// Normal: "over" operator
|
||||
// Normal: "over" operator on premultiplied RGBA.
|
||||
// If lock_alpha > 0.5, preserve the destination alpha unchanged.
|
||||
let dab_a = opa_weight * dab.opacity * dab.color_a;
|
||||
if dab_a <= 0.0 { return current; }
|
||||
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>(
|
||||
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,
|
||||
out_a,
|
||||
);
|
||||
} else if dab.blend_mode == 1u {
|
||||
// Erase: multiplicative alpha reduction
|
||||
|
|
@ -111,7 +126,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 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);
|
||||
} else {
|
||||
} else if dab.blend_mode == 2u {
|
||||
// Smudge: directional warp — sample from position behind the stroke direction
|
||||
let alpha = opa_weight * dab.opacity;
|
||||
if alpha <= 0.0 { return current; }
|
||||
|
|
@ -125,6 +140,192 @@ fn apply_dab(current: vec4<f32>, dab: GpuDab, px: i32, py: i32) -> vec4<f32> {
|
|||
alpha * src.b + da * current.b,
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,30 +1,31 @@
|
|||
// Canvas blit shader.
|
||||
//
|
||||
// Renders a GPU raster canvas (at document resolution) into an Rgba16Float HDR
|
||||
// buffer (at viewport resolution), applying the camera transform (pan + zoom)
|
||||
// to map document-space pixels to viewport-space pixels.
|
||||
// buffer (at viewport resolution), applying a general affine transform that maps
|
||||
// viewport UV [0,1]² directly to canvas UV [0,1]².
|
||||
//
|
||||
// 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
|
||||
// compositor sees the same premultiplied-linear format it always works with,
|
||||
// bypassing the sRGB intermediate used for Vello layers.
|
||||
//
|
||||
// Any viewport pixel whose corresponding document coordinate falls outside
|
||||
// [0, canvas_w) × [0, canvas_h) outputs transparent black.
|
||||
// Any viewport pixel whose corresponding canvas coordinate falls outside [0,1)²
|
||||
// outputs transparent black.
|
||||
|
||||
struct CameraParams {
|
||||
pan_x: f32,
|
||||
pan_y: f32,
|
||||
zoom: f32,
|
||||
canvas_w: f32,
|
||||
canvas_h: f32,
|
||||
viewport_w: f32,
|
||||
viewport_h: f32,
|
||||
_pad: f32,
|
||||
struct BlitTransform {
|
||||
/// Column 0 of the viewport_uv → canvas_uv affine matrix (+ padding).
|
||||
col0: vec4<f32>,
|
||||
/// Column 1 (+ padding).
|
||||
col1: vec4<f32>,
|
||||
/// Column 2: translation column — col2.xy = translation, col2.z = 1 (+ padding).
|
||||
col2: vec4<f32>,
|
||||
}
|
||||
|
||||
@group(0) @binding(0) var canvas_tex: texture_2d<f32>;
|
||||
@group(0) @binding(1) var canvas_sampler: sampler;
|
||||
@group(0) @binding(2) var<uniform> camera: CameraParams;
|
||||
@group(0) @binding(2) var<uniform> transform: BlitTransform;
|
||||
/// Selection mask: R8Unorm, 255 = inside selection (keep), 0 = outside (discard).
|
||||
/// A 1×1 all-white texture is bound when no selection is active.
|
||||
@group(0) @binding(3) var mask_tex: texture_2d<f32>;
|
||||
|
|
@ -48,14 +49,10 @@ fn vs_main(@builtin(vertex_index) vertex_index: u32) -> VertexOutput {
|
|||
|
||||
@fragment
|
||||
fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> {
|
||||
// Map viewport UV [0,1] → viewport pixel
|
||||
let vp = in.uv * vec2<f32>(camera.viewport_w, camera.viewport_h);
|
||||
|
||||
// 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);
|
||||
// Apply the combined inverse transform: viewport UV → canvas UV.
|
||||
let m = mat3x3<f32>(transform.col0.xyz, transform.col1.xyz, transform.col2.xyz);
|
||||
let canvas_uv_h = m * vec3<f32>(in.uv.x, in.uv.y, 1.0);
|
||||
let canvas_uv = canvas_uv_h.xy;
|
||||
|
||||
// Out-of-bounds → transparent
|
||||
if canvas_uv.x < 0.0 || canvas_uv.x > 1.0
|
||||
|
|
|
|||
|
|
@ -0,0 +1,137 @@
|
|||
// GPU gradient fill shader.
|
||||
//
|
||||
// Reads the anchor canvas (before_pixels), composites a gradient over it, and
|
||||
// writes the result to the display canvas. All color values in the canvas are
|
||||
// linear premultiplied RGBA. The stop colors passed via `stops` are linear
|
||||
// straight-alpha [0..1] (sRGB→linear conversion is done on the CPU).
|
||||
//
|
||||
// Dispatch: ceil(canvas_w / 8) × ceil(canvas_h / 8) × 1
|
||||
|
||||
struct Params {
|
||||
canvas_w: u32,
|
||||
canvas_h: u32,
|
||||
start_x: f32,
|
||||
start_y: f32,
|
||||
end_x: f32,
|
||||
end_y: f32,
|
||||
opacity: f32,
|
||||
extend_mode: u32, // 0 = Pad, 1 = Reflect, 2 = Repeat
|
||||
num_stops: u32,
|
||||
kind: u32, // 0 = Linear, 1 = Radial
|
||||
_pad1: u32,
|
||||
_pad2: u32,
|
||||
}
|
||||
|
||||
// 32 bytes per stop (8 × f32), matching `GpuGradientStop` on the Rust side.
|
||||
struct GradientStop {
|
||||
position: f32,
|
||||
r: f32, // linear [0..1], straight-alpha
|
||||
g: f32,
|
||||
b: f32,
|
||||
a: f32,
|
||||
_pad0: f32,
|
||||
_pad1: f32,
|
||||
_pad2: f32,
|
||||
}
|
||||
|
||||
@group(0) @binding(0) var<uniform> params: Params;
|
||||
@group(0) @binding(1) var src: texture_2d<f32>;
|
||||
@group(0) @binding(2) var<storage, read> stops: array<GradientStop>;
|
||||
@group(0) @binding(3) var dst: texture_storage_2d<rgba8unorm, write>;
|
||||
|
||||
fn apply_extend(t: f32) -> f32 {
|
||||
if params.extend_mode == 0u {
|
||||
// Pad: clamp to [0, 1]
|
||||
return clamp(t, 0.0, 1.0);
|
||||
} else if params.extend_mode == 1u {
|
||||
// Reflect: 0→1→0→1→...
|
||||
let t_abs = abs(t);
|
||||
let period = floor(t_abs);
|
||||
let frac = t_abs - period;
|
||||
if (u32(period) & 1u) == 0u {
|
||||
return frac;
|
||||
} else {
|
||||
return 1.0 - frac;
|
||||
}
|
||||
} else {
|
||||
// Repeat: tile [0, 1)
|
||||
return t - floor(t);
|
||||
}
|
||||
}
|
||||
|
||||
fn eval_gradient(t: f32) -> vec4<f32> {
|
||||
let n = params.num_stops;
|
||||
if n == 0u { return vec4<f32>(0.0); }
|
||||
|
||||
let s0 = stops[0];
|
||||
if t <= s0.position {
|
||||
return vec4<f32>(s0.r, s0.g, s0.b, s0.a);
|
||||
}
|
||||
|
||||
let sn = stops[n - 1u];
|
||||
if t >= sn.position {
|
||||
return vec4<f32>(sn.r, sn.g, sn.b, sn.a);
|
||||
}
|
||||
|
||||
for (var i = 0u; i < n - 1u; i++) {
|
||||
let sa = stops[i];
|
||||
let sb = stops[i + 1u];
|
||||
if t >= sa.position && t <= sb.position {
|
||||
let span = sb.position - sa.position;
|
||||
let f = select(0.0, (t - sa.position) / span, span > 0.0001);
|
||||
return mix(
|
||||
vec4<f32>(sa.r, sa.g, sa.b, sa.a),
|
||||
vec4<f32>(sb.r, sb.g, sb.b, sb.a),
|
||||
f,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return vec4<f32>(sn.r, sn.g, sn.b, sn.a);
|
||||
}
|
||||
|
||||
@compute @workgroup_size(8, 8)
|
||||
fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
if gid.x >= params.canvas_w || gid.y >= params.canvas_h { return; }
|
||||
|
||||
// Anchor pixel (linear premultiplied RGBA).
|
||||
let src_px = textureLoad(src, vec2<i32>(i32(gid.x), i32(gid.y)), 0);
|
||||
|
||||
let dx = params.end_x - params.start_x;
|
||||
let dy = params.end_y - params.start_y;
|
||||
let px = f32(gid.x) + 0.5;
|
||||
let py = f32(gid.y) + 0.5;
|
||||
|
||||
var t_raw: f32 = 0.0;
|
||||
if params.kind == 1u {
|
||||
// Radial: center at start point, radius = |end-start|.
|
||||
let radius = sqrt(dx * dx + dy * dy);
|
||||
if radius >= 0.5 {
|
||||
let pdx = px - params.start_x;
|
||||
let pdy = py - params.start_y;
|
||||
t_raw = sqrt(pdx * pdx + pdy * pdy) / radius;
|
||||
}
|
||||
} else {
|
||||
// Linear: project pixel centre onto gradient axis (start → end).
|
||||
let len2 = dx * dx + dy * dy;
|
||||
if len2 >= 1.0 {
|
||||
let fx = px - params.start_x;
|
||||
let fy = py - params.start_y;
|
||||
t_raw = (fx * dx + fy * dy) / len2;
|
||||
}
|
||||
}
|
||||
|
||||
let t = apply_extend(t_raw);
|
||||
let grad = eval_gradient(t); // straight-alpha linear RGBA
|
||||
|
||||
// Effective alpha: gradient alpha × tool opacity.
|
||||
let a = grad.a * params.opacity;
|
||||
|
||||
// Alpha-over composite.
|
||||
// src_px.rgb is premultiplied (= straight_rgb * src_a).
|
||||
// Output is also premultiplied.
|
||||
let out_a = a + src_px.a * (1.0 - a);
|
||||
let out_rgb = grad.rgb * a + src_px.rgb * (1.0 - a);
|
||||
|
||||
textureStore(dst, vec2<i32>(i32(gid.x), i32(gid.y)), vec4<f32>(out_rgb, out_a));
|
||||
}
|
||||
|
|
@ -0,0 +1,92 @@
|
|||
// GPU liquify-brush shader.
|
||||
//
|
||||
// Updates a per-pixel displacement map (array of vec2f) for one brush step.
|
||||
// Each pixel within the brush radius receives a displacement contribution
|
||||
// weighted by a Gaussian falloff.
|
||||
//
|
||||
// Modes:
|
||||
// 0 = Push — displace in brush-drag direction (dx, dy)
|
||||
// 1 = Pucker — pull toward brush center
|
||||
// 2 = Bloat — push away from brush center
|
||||
// 3 = Smooth — blend toward average of 4 cardinal neighbours
|
||||
// 4 = Reconstruct — blend toward zero (gradually undo)
|
||||
//
|
||||
// Dispatch: ceil((2*radius+1) / 8) × ceil((2*radius+1) / 8) × 1
|
||||
// The CPU clips invocation IDs to the valid map range.
|
||||
|
||||
struct Params {
|
||||
cx: f32, // brush center x (canvas pixels)
|
||||
cy: f32, // brush center y
|
||||
radius: f32, // brush radius (canvas pixels)
|
||||
strength: f32, // effect strength [0..1]
|
||||
dx: f32, // push direction x (normalised by caller, Push mode only)
|
||||
dy: f32, // push direction y
|
||||
mode: u32, // 0=Push 1=Pucker 2=Bloat 3=Smooth 4=Reconstruct
|
||||
map_w: u32,
|
||||
map_h: u32,
|
||||
_pad0: u32,
|
||||
_pad1: u32,
|
||||
}
|
||||
|
||||
@group(0) @binding(0) var<uniform> params: Params;
|
||||
@group(0) @binding(1) var<storage, read_write> disp: array<vec2f>;
|
||||
|
||||
@compute @workgroup_size(8, 8)
|
||||
fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
// Offset invocation into the brush bounding box so gid(0,0) = (cx-r, cy-r).
|
||||
let base_x = floor(params.cx - params.radius);
|
||||
let base_y = floor(params.cy - params.radius);
|
||||
let px = base_x + f32(gid.x);
|
||||
let py = base_y + f32(gid.y);
|
||||
|
||||
// Clip to displacement map bounds.
|
||||
if px < 0.0 || py < 0.0 { return; }
|
||||
let map_x = u32(px);
|
||||
let map_y = u32(py);
|
||||
if map_x >= params.map_w || map_y >= params.map_h { return; }
|
||||
|
||||
let ddx = px - params.cx;
|
||||
let ddy = py - params.cy;
|
||||
let dist2 = ddx * ddx + ddy * ddy;
|
||||
let r2 = params.radius * params.radius;
|
||||
|
||||
if dist2 > r2 { return; }
|
||||
|
||||
// Gaussian influence: 1 at center, ~0.01 at edge (sigma = radius/2.15)
|
||||
let influence = params.strength * exp(-dist2 / (r2 * 0.2));
|
||||
|
||||
let idx = map_y * params.map_w + map_x;
|
||||
var d = disp[idx];
|
||||
|
||||
switch params.mode {
|
||||
case 0u: { // Push
|
||||
d = d + vec2f(params.dx, params.dy) * influence * params.radius;
|
||||
}
|
||||
case 1u: { // Pucker — toward center
|
||||
let len = sqrt(dist2) + 0.0001;
|
||||
d = d + vec2f(-ddx / len, -ddy / len) * influence * params.radius;
|
||||
}
|
||||
case 2u: { // Bloat — away from center
|
||||
let len = sqrt(dist2) + 0.0001;
|
||||
d = d + vec2f(ddx / len, ddy / len) * influence * params.radius;
|
||||
}
|
||||
case 3u: { // Smooth — blend toward average of 4 neighbours
|
||||
let xi = i32(map_x);
|
||||
let yi = i32(map_y);
|
||||
let w = i32(params.map_w);
|
||||
let h = i32(params.map_h);
|
||||
let l = disp[u32(clamp(yi, 0, h-1)) * params.map_w + u32(clamp(xi - 1, 0, w-1))];
|
||||
let r = disp[u32(clamp(yi, 0, h-1)) * params.map_w + u32(clamp(xi + 1, 0, w-1))];
|
||||
let u = disp[u32(clamp(yi - 1, 0, h-1)) * params.map_w + u32(clamp(xi, 0, w-1))];
|
||||
let dn = disp[u32(clamp(yi + 1, 0, h-1)) * params.map_w + u32(clamp(xi, 0, w-1))];
|
||||
let avg = (l + r + u + dn) * 0.25;
|
||||
d = mix(d, avg, influence * 0.5);
|
||||
}
|
||||
case 4u: { // Reconstruct — blend toward zero
|
||||
d = mix(d, vec2f(0.0), influence * 0.5);
|
||||
}
|
||||
default: {}
|
||||
}
|
||||
|
||||
disp[idx] = d;
|
||||
}
|
||||
|
|
@ -0,0 +1,78 @@
|
|||
// 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);
|
||||
}
|
||||
|
|
@ -0,0 +1,103 @@
|
|||
// GPU warp-apply shader.
|
||||
//
|
||||
// Two modes selected by grid_cols / grid_rows:
|
||||
//
|
||||
// grid_cols == 0 (Liquify / per-pixel mode)
|
||||
// disp[] is a full canvas-sized array<vec2f>. Each pixel reads its own entry.
|
||||
//
|
||||
// grid_cols > 0 (Warp control-point mode)
|
||||
// disp[] contains only grid_cols * grid_rows vec2f displacements (one per
|
||||
// control point). The shader bilinearly interpolates them so the CPU never
|
||||
// needs to build or upload the full per-pixel buffer.
|
||||
//
|
||||
// Dispatch: ceil(dst_w / 8) × ceil(dst_h / 8) × 1
|
||||
|
||||
struct Params {
|
||||
src_w: u32,
|
||||
src_h: u32,
|
||||
dst_w: u32,
|
||||
dst_h: u32,
|
||||
grid_cols: u32, // 0 = per-pixel mode
|
||||
grid_rows: u32,
|
||||
_pad0: u32,
|
||||
_pad1: u32,
|
||||
}
|
||||
|
||||
@group(0) @binding(0) var<uniform> params: Params;
|
||||
@group(0) @binding(1) var src: texture_2d<f32>;
|
||||
@group(0) @binding(2) var<storage, read> disp: array<vec2f>;
|
||||
@group(0) @binding(3) var dst: texture_storage_2d<rgba8unorm, write>;
|
||||
|
||||
// Manual bilinear sample with clamp-to-edge (textureSample forbidden in compute shaders).
|
||||
fn bilinear_sample(px: f32, py: f32) -> vec4<f32> {
|
||||
let sw = i32(params.src_w);
|
||||
let sh = i32(params.src_h);
|
||||
|
||||
let ix = i32(floor(px - 0.5));
|
||||
let iy = i32(floor(py - 0.5));
|
||||
let fx = fract(px - 0.5);
|
||||
let fy = fract(py - 0.5);
|
||||
|
||||
let x0 = clamp(ix, 0, sw - 1);
|
||||
let x1 = clamp(ix + 1, 0, sw - 1);
|
||||
let y0 = clamp(iy, 0, sh - 1);
|
||||
let y1 = clamp(iy + 1, 0, sh - 1);
|
||||
|
||||
let s00 = textureLoad(src, vec2<i32>(x0, y0), 0);
|
||||
let s10 = textureLoad(src, vec2<i32>(x1, y0), 0);
|
||||
let s01 = textureLoad(src, vec2<i32>(x0, y1), 0);
|
||||
let s11 = textureLoad(src, vec2<i32>(x1, y1), 0);
|
||||
|
||||
return mix(mix(s00, s10, fx), mix(s01, s11, fx), fy);
|
||||
}
|
||||
|
||||
// Bilinearly interpolate the control-point displacement grid.
|
||||
fn grid_displacement(px: u32, py: u32) -> vec2f {
|
||||
let cols = params.grid_cols;
|
||||
let rows = params.grid_rows;
|
||||
|
||||
// Normalised position in grid space [0 .. cols-1] × [0 .. rows-1].
|
||||
let gx = f32(px) / f32(params.dst_w - 1u) * f32(cols - 1u);
|
||||
let gy = f32(py) / f32(params.dst_h - 1u) * f32(rows - 1u);
|
||||
|
||||
let col0 = u32(floor(gx));
|
||||
let row0 = u32(floor(gy));
|
||||
let col1 = min(col0 + 1u, cols - 1u);
|
||||
let row1 = min(row0 + 1u, rows - 1u);
|
||||
let fx = gx - floor(gx);
|
||||
let fy = gy - floor(gy);
|
||||
|
||||
let d00 = disp[row0 * cols + col0];
|
||||
let d10 = disp[row0 * cols + col1];
|
||||
let d01 = disp[row1 * cols + col0];
|
||||
let d11 = disp[row1 * cols + col1];
|
||||
|
||||
return d00 * (1.0 - fx) * (1.0 - fy)
|
||||
+ d10 * fx * (1.0 - fy)
|
||||
+ d01 * (1.0 - fx) * fy
|
||||
+ d11 * fx * fy;
|
||||
}
|
||||
|
||||
@compute @workgroup_size(8, 8)
|
||||
fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
if gid.x >= params.dst_w || gid.y >= params.dst_h { return; }
|
||||
|
||||
var d: vec2f;
|
||||
if params.grid_cols > 0u {
|
||||
d = grid_displacement(gid.x, gid.y);
|
||||
} else {
|
||||
d = disp[gid.y * params.dst_w + gid.x];
|
||||
}
|
||||
|
||||
let sx = f32(gid.x) + d.x;
|
||||
let sy = f32(gid.y) + d.y;
|
||||
|
||||
var color: vec4<f32>;
|
||||
if sx < 0.0 || sy < 0.0 || sx >= f32(params.src_w) || sy >= f32(params.src_h) {
|
||||
color = vec4<f32>(0.0);
|
||||
} else {
|
||||
color = bilinear_sample(sx + 0.5, sy + 0.5);
|
||||
}
|
||||
|
||||
textureStore(dst, vec2<i32>(i32(gid.x), i32(gid.y)), color);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -5,7 +5,8 @@
|
|||
|
||||
use eframe::egui;
|
||||
use lightningbeam_core::layer::{AnyLayer, LayerType};
|
||||
use lightningbeam_core::tool::{Tool, RegionSelectMode};
|
||||
use lightningbeam_core::tool::{Tool, RegionSelectMode, LassoMode};
|
||||
use lightningbeam_core::brush_settings::bundled_brushes;
|
||||
use crate::keymap::tool_app_action;
|
||||
use super::{NodePath, PaneRenderer, SharedPaneState};
|
||||
|
||||
|
|
@ -101,7 +102,7 @@ impl PaneRenderer for ToolbarPane {
|
|||
}
|
||||
|
||||
// Draw sub-tool arrow indicator for tools with modes
|
||||
let has_sub_tools = matches!(tool, Tool::RegionSelect);
|
||||
let has_sub_tools = matches!(tool, Tool::RegionSelect | Tool::SelectLasso);
|
||||
if has_sub_tools {
|
||||
let arrow_size = 6.0;
|
||||
let margin = 4.0;
|
||||
|
|
@ -125,6 +126,22 @@ impl PaneRenderer for ToolbarPane {
|
|||
// Check for click first
|
||||
if response.clicked() {
|
||||
*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
|
||||
|
|
@ -150,6 +167,33 @@ impl PaneRenderer for ToolbarPane {
|
|||
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();
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
});
|
||||
|
|
@ -176,6 +220,13 @@ impl PaneRenderer for ToolbarPane {
|
|||
RegionSelectMode::Lasso => "Lasso",
|
||||
};
|
||||
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 {
|
||||
format!("{}{}", tool.display_name(), hint)
|
||||
};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,758 @@
|
|||
//! 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, 0–255).
|
||||
/// 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.
|
||||
}
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
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)));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
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)));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
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) {}
|
||||
}
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,268 @@
|
|||
/// 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 (1–20)
|
||||
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, 0–510). 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,
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
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) {}
|
||||
}
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
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)));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
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)));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -13,6 +13,19 @@ fn main() {
|
|||
let wrapper_dir = Path::new(&manifest_dir).join("cmake");
|
||||
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);
|
||||
// Force single-config generator on Unix to avoid libraries landing in Release/ subdirs
|
||||
if !cfg!(target_os = "windows") {
|
||||
|
|
@ -50,6 +63,4 @@ fn main() {
|
|||
_ => {}
|
||||
}
|
||||
|
||||
println!("cargo:rerun-if-changed=../vendor/NeuralAudio/NeuralAudioCAPI/NeuralAudioCApi.h");
|
||||
println!("cargo:rerun-if-changed=../vendor/NeuralAudio/NeuralAudioCAPI/NeuralAudioCApi.cpp");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,83 @@
|
|||
#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);
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
#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
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
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
|
||||
|
|
@ -0,0 +1,204 @@
|
|||
{
|
||||
"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
|
||||
}
|
||||
|
|
@ -0,0 +1,203 @@
|
|||
{
|
||||
"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
|
||||
}
|
||||
|
|
@ -0,0 +1,215 @@
|
|||
{
|
||||
"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
|
||||
}
|
||||
|
|
@ -0,0 +1,219 @@
|
|||
{
|
||||
"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
|
||||
}
|
||||
|
|
@ -0,0 +1,195 @@
|
|||
{
|
||||
"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
|
||||
}
|
||||
|
|
@ -0,0 +1,210 @@
|
|||
{
|
||||
"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
|
||||
}
|
||||
|
|
@ -0,0 +1,200 @@
|
|||
{
|
||||
"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
|
||||
}
|
||||
|
|
@ -0,0 +1,189 @@
|
|||
{
|
||||
"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
|
||||
}
|
||||
|
|
@ -0,0 +1,215 @@
|
|||
{
|
||||
"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
|
||||
}
|
||||
|
|
@ -0,0 +1,195 @@
|
|||
{
|
||||
"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
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
<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>
|
||||
|
After Width: | Height: | Size: 297 B |
Loading…
Reference in New Issue