Compare commits
No commits in common. "89721d4c0efe91c9f03bc63ca03c8e91de0196db" and "bc7d997cff972c677d1b3d0dbafba0d415f3f281" have entirely different histories.
89721d4c0e
...
bc7d997cff
24
Changelog.md
24
Changelog.md
|
|
@ -1,27 +1,3 @@
|
||||||
# 1.0.2-alpha:
|
|
||||||
Changes:
|
|
||||||
- All vector shapes on a layer go into a unified shape rather than separate shapes
|
|
||||||
- Keyboard shortcuts are now user-configurable
|
|
||||||
- Added webcam support in video editor
|
|
||||||
- Background can now be transparent
|
|
||||||
- Video thumbnails are now displayed on the clip
|
|
||||||
- Virtual keyboard, piano roll and node editor now have a quick switcher
|
|
||||||
- Add electric guitar preset
|
|
||||||
- Layers can now be grouped
|
|
||||||
- Layers can be reordered by dragging
|
|
||||||
- Added VU meters to audio layers and mix
|
|
||||||
- Added raster image editing
|
|
||||||
- Added brush, airbrush, dodge/burn, sponge, pattern stamp, healing brush, clone stamp, blur/sharpen, magic wand and quick select tools
|
|
||||||
- Added support for MyPaint .myb brushes
|
|
||||||
- UI now uses CSS styling to support future user styles
|
|
||||||
- Added image export
|
|
||||||
|
|
||||||
Bugfixes:
|
|
||||||
- Toolbar now only shows tools that can be used on the current layer
|
|
||||||
- Fix NAM model loading
|
|
||||||
- Fix menu width and mouse following
|
|
||||||
- Export dialog now remembers the previous export filename
|
|
||||||
|
|
||||||
# 1.0.1-alpha:
|
# 1.0.1-alpha:
|
||||||
Changes:
|
Changes:
|
||||||
- Added real-time amp simulation via NAM
|
- Added real-time amp simulation via NAM
|
||||||
|
|
|
||||||
|
|
@ -32,9 +32,7 @@ pub mod region_split;
|
||||||
pub mod toggle_group_expansion;
|
pub mod toggle_group_expansion;
|
||||||
pub mod group_layers;
|
pub mod group_layers;
|
||||||
pub mod raster_stroke;
|
pub mod raster_stroke;
|
||||||
pub mod raster_fill;
|
|
||||||
pub mod move_layer;
|
pub mod move_layer;
|
||||||
pub mod set_fill_paint;
|
|
||||||
|
|
||||||
pub use add_clip_instance::AddClipInstanceAction;
|
pub use add_clip_instance::AddClipInstanceAction;
|
||||||
pub use add_effect::AddEffectAction;
|
pub use add_effect::AddEffectAction;
|
||||||
|
|
@ -65,6 +63,4 @@ pub use region_split::RegionSplitAction;
|
||||||
pub use toggle_group_expansion::ToggleGroupExpansionAction;
|
pub use toggle_group_expansion::ToggleGroupExpansionAction;
|
||||||
pub use group_layers::GroupLayersAction;
|
pub use group_layers::GroupLayersAction;
|
||||||
pub use raster_stroke::RasterStrokeAction;
|
pub use raster_stroke::RasterStrokeAction;
|
||||||
pub use raster_fill::RasterFillAction;
|
|
||||||
pub use move_layer::MoveLayerAction;
|
pub use move_layer::MoveLayerAction;
|
||||||
pub use set_fill_paint::SetFillPaintAction;
|
|
||||||
|
|
|
||||||
|
|
@ -1,66 +0,0 @@
|
||||||
//! Raster flood-fill action — records and undoes a paint bucket fill on a RasterLayer.
|
|
||||||
|
|
||||||
use crate::action::Action;
|
|
||||||
use crate::document::Document;
|
|
||||||
use crate::layer::AnyLayer;
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
pub struct RasterFillAction {
|
|
||||||
layer_id: Uuid,
|
|
||||||
time: f64,
|
|
||||||
buffer_before: Vec<u8>,
|
|
||||||
buffer_after: Vec<u8>,
|
|
||||||
width: u32,
|
|
||||||
height: u32,
|
|
||||||
name: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl RasterFillAction {
|
|
||||||
pub fn new(
|
|
||||||
layer_id: Uuid,
|
|
||||||
time: f64,
|
|
||||||
buffer_before: Vec<u8>,
|
|
||||||
buffer_after: Vec<u8>,
|
|
||||||
width: u32,
|
|
||||||
height: u32,
|
|
||||||
) -> Self {
|
|
||||||
Self { layer_id, time, buffer_before, buffer_after, width, height, name: "Flood fill".to_string() }
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn with_description(mut self, name: &str) -> Self {
|
|
||||||
self.name = name.to_string();
|
|
||||||
self
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Action for RasterFillAction {
|
|
||||||
fn execute(&mut self, document: &mut Document) -> Result<(), String> {
|
|
||||||
let layer = document.get_layer_mut(&self.layer_id)
|
|
||||||
.ok_or_else(|| format!("Layer {} not found", self.layer_id))?;
|
|
||||||
let raster = match layer {
|
|
||||||
AnyLayer::Raster(rl) => rl,
|
|
||||||
_ => return Err("Not a raster layer".to_string()),
|
|
||||||
};
|
|
||||||
let kf = raster.ensure_keyframe_at(self.time, self.width, self.height);
|
|
||||||
kf.raw_pixels = self.buffer_after.clone();
|
|
||||||
kf.texture_dirty = true;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn rollback(&mut self, document: &mut Document) -> Result<(), String> {
|
|
||||||
let layer = document.get_layer_mut(&self.layer_id)
|
|
||||||
.ok_or_else(|| format!("Layer {} not found", self.layer_id))?;
|
|
||||||
let raster = match layer {
|
|
||||||
AnyLayer::Raster(rl) => rl,
|
|
||||||
_ => return Err("Not a raster layer".to_string()),
|
|
||||||
};
|
|
||||||
let kf = raster.ensure_keyframe_at(self.time, self.width, self.height);
|
|
||||||
kf.raw_pixels = self.buffer_before.clone();
|
|
||||||
kf.texture_dirty = true;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn description(&self) -> String {
|
|
||||||
self.name.clone()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -49,14 +49,12 @@ impl Action for RasterStrokeAction {
|
||||||
fn execute(&mut self, document: &mut Document) -> Result<(), String> {
|
fn execute(&mut self, document: &mut Document) -> Result<(), String> {
|
||||||
let kf = get_keyframe_mut(document, &self.layer_id, self.time, self.width, self.height)?;
|
let kf = get_keyframe_mut(document, &self.layer_id, self.time, self.width, self.height)?;
|
||||||
kf.raw_pixels = self.buffer_after.clone();
|
kf.raw_pixels = self.buffer_after.clone();
|
||||||
kf.texture_dirty = true;
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn rollback(&mut self, document: &mut Document) -> Result<(), String> {
|
fn rollback(&mut self, document: &mut Document) -> Result<(), String> {
|
||||||
let kf = get_keyframe_mut(document, &self.layer_id, self.time, self.width, self.height)?;
|
let kf = get_keyframe_mut(document, &self.layer_id, self.time, self.width, self.height)?;
|
||||||
kf.raw_pixels = self.buffer_before.clone();
|
kf.raw_pixels = self.buffer_before.clone();
|
||||||
kf.texture_dirty = true;
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,127 +0,0 @@
|
||||||
//! Action that changes the fill of one or more DCEL faces.
|
|
||||||
//!
|
|
||||||
//! Handles both solid-colour and gradient fills, clearing the other type so they
|
|
||||||
//! don't coexist on a face.
|
|
||||||
|
|
||||||
use crate::action::Action;
|
|
||||||
use crate::dcel::FaceId;
|
|
||||||
use crate::document::Document;
|
|
||||||
use crate::gradient::ShapeGradient;
|
|
||||||
use crate::layer::AnyLayer;
|
|
||||||
use crate::shape::ShapeColor;
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
/// Snapshot of one face's fill state (both types) for undo.
|
|
||||||
#[derive(Clone)]
|
|
||||||
struct OldFill {
|
|
||||||
face_id: FaceId,
|
|
||||||
color: Option<ShapeColor>,
|
|
||||||
gradient: Option<ShapeGradient>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Action that sets a solid-colour *or* gradient fill on a set of faces,
|
|
||||||
/// clearing the other fill type.
|
|
||||||
pub struct SetFillPaintAction {
|
|
||||||
layer_id: Uuid,
|
|
||||||
time: f64,
|
|
||||||
face_ids: Vec<FaceId>,
|
|
||||||
new_color: Option<ShapeColor>,
|
|
||||||
new_gradient: Option<ShapeGradient>,
|
|
||||||
old_fills: Vec<OldFill>,
|
|
||||||
description: &'static str,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl SetFillPaintAction {
|
|
||||||
/// Set a solid fill (clears any gradient on the same faces).
|
|
||||||
pub fn solid(
|
|
||||||
layer_id: Uuid,
|
|
||||||
time: f64,
|
|
||||||
face_ids: Vec<FaceId>,
|
|
||||||
color: Option<ShapeColor>,
|
|
||||||
) -> Self {
|
|
||||||
Self {
|
|
||||||
layer_id,
|
|
||||||
time,
|
|
||||||
face_ids,
|
|
||||||
new_color: color,
|
|
||||||
new_gradient: None,
|
|
||||||
old_fills: Vec::new(),
|
|
||||||
description: "Set fill colour",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Set a gradient fill (clears any solid colour on the same faces).
|
|
||||||
pub fn gradient(
|
|
||||||
layer_id: Uuid,
|
|
||||||
time: f64,
|
|
||||||
face_ids: Vec<FaceId>,
|
|
||||||
gradient: Option<ShapeGradient>,
|
|
||||||
) -> Self {
|
|
||||||
Self {
|
|
||||||
layer_id,
|
|
||||||
time,
|
|
||||||
face_ids,
|
|
||||||
new_color: None,
|
|
||||||
new_gradient: gradient,
|
|
||||||
old_fills: Vec::new(),
|
|
||||||
description: "Set gradient fill",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn get_dcel_mut<'a>(
|
|
||||||
document: &'a mut Document,
|
|
||||||
layer_id: &Uuid,
|
|
||||||
time: f64,
|
|
||||||
) -> Result<&'a mut crate::dcel::Dcel, String> {
|
|
||||||
let layer = document
|
|
||||||
.get_layer_mut(layer_id)
|
|
||||||
.ok_or_else(|| format!("Layer {} not found", layer_id))?;
|
|
||||||
match layer {
|
|
||||||
AnyLayer::Vector(vl) => vl
|
|
||||||
.dcel_at_time_mut(time)
|
|
||||||
.ok_or_else(|| format!("No keyframe at time {}", time)),
|
|
||||||
_ => Err("Not a vector layer".to_string()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Action for SetFillPaintAction {
|
|
||||||
fn execute(&mut self, document: &mut Document) -> Result<(), String> {
|
|
||||||
let dcel = Self::get_dcel_mut(document, &self.layer_id, self.time)?;
|
|
||||||
self.old_fills.clear();
|
|
||||||
|
|
||||||
for &fid in &self.face_ids {
|
|
||||||
let face = dcel.face(fid);
|
|
||||||
self.old_fills.push(OldFill {
|
|
||||||
face_id: fid,
|
|
||||||
color: face.fill_color,
|
|
||||||
gradient: face.gradient_fill.clone(),
|
|
||||||
});
|
|
||||||
|
|
||||||
let face_mut = dcel.face_mut(fid);
|
|
||||||
// Setting a gradient clears solid colour and vice-versa.
|
|
||||||
if self.new_gradient.is_some() || self.new_color.is_none() {
|
|
||||||
face_mut.fill_color = self.new_color;
|
|
||||||
face_mut.gradient_fill = self.new_gradient.clone();
|
|
||||||
} else {
|
|
||||||
face_mut.fill_color = self.new_color;
|
|
||||||
face_mut.gradient_fill = None;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn rollback(&mut self, document: &mut Document) -> Result<(), String> {
|
|
||||||
let dcel = Self::get_dcel_mut(document, &self.layer_id, self.time)?;
|
|
||||||
for old in &self.old_fills {
|
|
||||||
let face = dcel.face_mut(old.face_id);
|
|
||||||
face.fill_color = old.color;
|
|
||||||
face.gradient_fill = old.gradient.clone();
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn description(&self) -> String {
|
|
||||||
self.description.to_string()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -15,26 +15,17 @@
|
||||||
//! The GPU compute shader (`brush_dab.wgsl`) is the authoritative implementation.
|
//! The GPU compute shader (`brush_dab.wgsl`) is the authoritative implementation.
|
||||||
//!
|
//!
|
||||||
//! ### Dab placement
|
//! ### Dab placement
|
||||||
//! Follows the libmypaint model: distance-based and time-based contributions are
|
//! Dabs are placed along the stroke polyline at intervals of
|
||||||
//! **summed** into a single `partial_dabs` accumulator. A dab fires whenever the
|
//! `spacing = radius * dabs_per_radius`. Fractional remainder is tracked across
|
||||||
//! accumulator reaches 1.0.
|
//! consecutive calls via `StrokeState`.
|
||||||
//!
|
|
||||||
//! Rate (dabs per pixel) = dabs_per_actual_radius / actual_radius
|
|
||||||
//! + dabs_per_basic_radius / base_radius
|
|
||||||
//! Time contribution added per call = dt × dabs_per_second
|
|
||||||
//!
|
|
||||||
//! ### Opacity
|
|
||||||
//! Matches libmypaint's `opaque_linearize` formula. `dabs_per_pixel` is a fixed
|
|
||||||
//! brush-level estimate of how many dabs overlap at any pixel:
|
|
||||||
//!
|
|
||||||
//! `dabs_per_pixel = 1 + opaque_linearize × ((dabs_per_actual + dabs_per_basic) × 2 - 1)`
|
|
||||||
//! `per_dab_alpha = 1 - (1 - raw_opacity) ^ (1 / dabs_per_pixel)`
|
|
||||||
//!
|
|
||||||
//! With `opaque_linearize = 0` the raw opacity is used directly per dab.
|
|
||||||
//!
|
//!
|
||||||
//! ### Blending
|
//! ### Blending
|
||||||
//! Normal mode uses the standard "over" operator on premultiplied RGBA.
|
//! Normal mode uses the standard "over" operator on premultiplied RGBA:
|
||||||
//! Erase mode subtracts from destination alpha.
|
//! ```text
|
||||||
|
//! result_a = opa_a + (1 - opa_a) * bottom_a
|
||||||
|
//! result_rgb = opa_a * top_rgb + (1 - opa_a) * bottom_rgb
|
||||||
|
//! ```
|
||||||
|
//! Erase mode: subtract `opa_a` from the destination alpha and premultiply.
|
||||||
|
|
||||||
use image::RgbaImage;
|
use image::RgbaImage;
|
||||||
use crate::raster_layer::{RasterBlendMode, StrokeRecord};
|
use crate::raster_layer::{RasterBlendMode, StrokeRecord};
|
||||||
|
|
@ -74,48 +65,20 @@ pub struct GpuDab {
|
||||||
|
|
||||||
/// Blend mode: 0 = Normal, 1 = Erase, 2 = Smudge
|
/// Blend mode: 0 = Normal, 1 = Erase, 2 = Smudge
|
||||||
pub blend_mode: u32,
|
pub blend_mode: u32,
|
||||||
/// Elliptical dab aspect ratio (1.0 = circle)
|
pub _pad0: u32,
|
||||||
pub elliptical_dab_ratio: f32,
|
pub _pad1: u32,
|
||||||
/// Elliptical dab rotation angle in radians
|
pub _pad2: u32,
|
||||||
pub elliptical_dab_angle: f32,
|
|
||||||
/// Lock alpha: 0.0 = modify alpha normally, 1.0 = don't modify destination alpha
|
|
||||||
pub lock_alpha: f32,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Transient brush stroke state (tracks position and randomness between segments)
|
/// Transient brush stroke state (tracks partial dab position between segments)
|
||||||
pub struct StrokeState {
|
pub struct StrokeState {
|
||||||
/// Fractional dab accumulator — reaches 1.0 when the next dab should fire.
|
/// Distance along the path already "consumed" toward the next dab (in pixels)
|
||||||
/// Initialised to 1.0 so the very first call always emits at least one dab.
|
pub distance_since_last_dab: f32,
|
||||||
pub partial_dabs: f32,
|
|
||||||
/// Exponentially-smoothed cursor X for slow_tracking
|
|
||||||
pub smooth_x: f32,
|
|
||||||
/// Exponentially-smoothed cursor Y for slow_tracking
|
|
||||||
pub smooth_y: f32,
|
|
||||||
/// Whether smooth_x/y have been initialised yet
|
|
||||||
pub smooth_initialized: bool,
|
|
||||||
/// xorshift32 seed for jitter and radius variation
|
|
||||||
pub rng_seed: u32,
|
|
||||||
/// Accumulated per-dab hue shift
|
|
||||||
pub color_h_phase: f32,
|
|
||||||
/// Accumulated per-dab value shift
|
|
||||||
pub color_v_phase: f32,
|
|
||||||
/// Accumulated per-dab saturation shift
|
|
||||||
pub color_s_phase: f32,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl StrokeState {
|
impl StrokeState {
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
Self {
|
Self { distance_since_last_dab: 0.0 }
|
||||||
// Start at 1.0 so the first call always emits the stroke-start dab.
|
|
||||||
partial_dabs: 1.0,
|
|
||||||
smooth_x: 0.0,
|
|
||||||
smooth_y: 0.0,
|
|
||||||
smooth_initialized: false,
|
|
||||||
rng_seed: 0xDEAD_BEEF,
|
|
||||||
color_h_phase: 0.0,
|
|
||||||
color_v_phase: 0.0,
|
|
||||||
color_s_phase: 0.0,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -123,199 +86,37 @@ impl Default for StrokeState {
|
||||||
fn default() -> Self { Self::new() }
|
fn default() -> Self { Self::new() }
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Helpers
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/// xorshift32 — fast, no-alloc PRNG. Returns a value in [0, 1).
|
|
||||||
#[inline]
|
|
||||||
fn xorshift(seed: &mut u32) -> f32 {
|
|
||||||
let mut s = *seed;
|
|
||||||
s ^= s << 13;
|
|
||||||
s ^= s >> 17;
|
|
||||||
s ^= s << 5;
|
|
||||||
*seed = s;
|
|
||||||
(s as f32) / (u32::MAX as f32)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Box-Muller Gaussian sample with mean 0 and std-dev 1.
|
|
||||||
/// Consumes two xorshift samples; the second half of the pair is discarded
|
|
||||||
/// (acceptable for brush jitter which doesn't need correlated pairs).
|
|
||||||
#[inline]
|
|
||||||
fn gaussian(seed: &mut u32) -> f32 {
|
|
||||||
let u1 = xorshift(seed).max(1e-7); // avoid ln(0)
|
|
||||||
let u2 = xorshift(seed);
|
|
||||||
(-2.0 * u1.ln()).sqrt() * (2.0 * std::f32::consts::PI * u2).cos()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Convert linear RGB (not premultiplied) to HSV.
|
|
||||||
fn rgb_to_hsv(r: f32, g: f32, b: f32) -> (f32, f32, f32) {
|
|
||||||
let max = r.max(g).max(b);
|
|
||||||
let min = r.min(g).min(b);
|
|
||||||
let delta = max - min;
|
|
||||||
let v = max;
|
|
||||||
let s = if max > 1e-6 { delta / max } else { 0.0 };
|
|
||||||
let h = if delta < 1e-6 {
|
|
||||||
0.0
|
|
||||||
} else if max == r {
|
|
||||||
((g - b) / delta).rem_euclid(6.0) / 6.0
|
|
||||||
} else if max == g {
|
|
||||||
((b - r) / delta + 2.0) / 6.0
|
|
||||||
} else {
|
|
||||||
((r - g) / delta + 4.0) / 6.0
|
|
||||||
};
|
|
||||||
(h, s, v)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Convert HSV to linear RGB.
|
|
||||||
fn hsv_to_rgb(h: f32, s: f32, v: f32) -> (f32, f32, f32) {
|
|
||||||
let h6 = h.rem_euclid(1.0) * 6.0;
|
|
||||||
let i = h6.floor() as i32;
|
|
||||||
let f = h6 - i as f32;
|
|
||||||
let p = v * (1.0 - s);
|
|
||||||
let q = v * (1.0 - s * f);
|
|
||||||
let t = v * (1.0 - s * (1.0 - f));
|
|
||||||
match i % 6 {
|
|
||||||
0 => (v, t, p),
|
|
||||||
1 => (q, v, p),
|
|
||||||
2 => (p, v, t),
|
|
||||||
3 => (p, q, v),
|
|
||||||
4 => (t, p, v),
|
|
||||||
_ => (v, p, q),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Per-dab effects helper
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/// Apply per-dab randomness and color-shift effects, matching libmypaint.
|
|
||||||
///
|
|
||||||
/// Returns `(ex, ey, radius, opacity, cr, cg, cb)` ready for the dab emitter.
|
|
||||||
///
|
|
||||||
/// Opacity uses the `opaque_linearize` formula (same fixed brush-level estimate
|
|
||||||
/// whether called from the single-point path or the drag path).
|
|
||||||
///
|
|
||||||
/// Jitter uses Gaussian noise (matching libmypaint), not uniform.
|
|
||||||
///
|
|
||||||
/// Radius jitter applies an opacity correction `× (base_r / jittered_r)²` to
|
|
||||||
/// keep perceived ink-amount constant as radius varies (matches libmypaint).
|
|
||||||
fn apply_dab_effects(
|
|
||||||
state: &mut StrokeState,
|
|
||||||
bs: &crate::brush_settings::BrushSettings,
|
|
||||||
x: f32, y: f32,
|
|
||||||
base_radius: f32, // radius_at_pressure(pressure), before jitter
|
|
||||||
pressure: f32,
|
|
||||||
color: [f32; 4],
|
|
||||||
) -> (f32, f32, f32, f32, f32, f32, f32) {
|
|
||||||
// ---- Opacity (libmypaint opaque_linearize formula) --------------------
|
|
||||||
// Estimate average dab overlap per pixel from brush settings (fixed, not
|
|
||||||
// speed-dependent), then convert stroke-level opacity to per-dab alpha.
|
|
||||||
let raw_dpp = ((bs.dabs_per_actual_radius + bs.dabs_per_radius) * 2.0).max(1.0);
|
|
||||||
let dabs_per_pixel = (1.0 + bs.opaque_linearize * (raw_dpp - 1.0)).max(1.0);
|
|
||||||
let raw_o = bs.opacity_at_pressure(pressure);
|
|
||||||
let mut opacity = 1.0 - (1.0 - raw_o).powf(1.0 / dabs_per_pixel);
|
|
||||||
|
|
||||||
// ---- Radius jitter (Gaussian in log-space, matching libmypaint) -------
|
|
||||||
let mut radius = base_radius;
|
|
||||||
if bs.radius_by_random != 0.0 {
|
|
||||||
let noise = gaussian(&mut state.rng_seed) * bs.radius_by_random;
|
|
||||||
let jittered_log = bs.radius_log + noise;
|
|
||||||
radius = jittered_log.exp().clamp(0.5, 500.0);
|
|
||||||
// Opacity correction: keep ink-amount constant as radius varies.
|
|
||||||
let alpha_correction = (base_radius / radius).powi(2);
|
|
||||||
opacity = (opacity * alpha_correction).clamp(0.0, 1.0);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- Position jitter + fixed offset (Gaussian, matching libmypaint) ---
|
|
||||||
let mut ex = x;
|
|
||||||
let mut ey = y;
|
|
||||||
if bs.offset_by_random != 0.0 || bs.offset_x != 0.0 || bs.offset_y != 0.0 {
|
|
||||||
// libmypaint uses base_radius (no-pressure) for the jitter scale.
|
|
||||||
let base_r_fixed = bs.radius_log.exp();
|
|
||||||
ex += gaussian(&mut state.rng_seed) * bs.offset_by_random * base_r_fixed
|
|
||||||
+ bs.offset_x * base_r_fixed;
|
|
||||||
ey += gaussian(&mut state.rng_seed) * bs.offset_by_random * base_r_fixed
|
|
||||||
+ bs.offset_y * base_r_fixed;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- Per-dab color phase shifts ---------------------------------------
|
|
||||||
state.color_h_phase += bs.change_color_h;
|
|
||||||
state.color_v_phase += bs.change_color_v;
|
|
||||||
state.color_s_phase += bs.change_color_hsv_s;
|
|
||||||
|
|
||||||
let (mut cr, mut cg, mut cb) = (color[0], color[1], color[2]);
|
|
||||||
let ca = color[3];
|
|
||||||
if ca > 1e-6
|
|
||||||
&& (bs.change_color_h != 0.0
|
|
||||||
|| bs.change_color_v != 0.0
|
|
||||||
|| bs.change_color_hsv_s != 0.0)
|
|
||||||
{
|
|
||||||
let (ur, ug, ub) = (cr / ca, cg / ca, cb / ca);
|
|
||||||
let (mut h, mut s, mut v) = rgb_to_hsv(ur, ug, ub);
|
|
||||||
h = (h + state.color_h_phase).rem_euclid(1.0);
|
|
||||||
v = (v + state.color_v_phase).clamp(0.0, 1.0);
|
|
||||||
s = (s + state.color_s_phase).clamp(0.0, 1.0);
|
|
||||||
let (r2, g2, b2) = hsv_to_rgb(h, s, v);
|
|
||||||
cr = r2 * ca;
|
|
||||||
cg = g2 * ca;
|
|
||||||
cb = b2 * ca;
|
|
||||||
}
|
|
||||||
|
|
||||||
(ex, ey, radius, opacity.clamp(0.0, 1.0), cr, cg, cb)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Brush engine
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/// Pure-Rust MyPaint-style Gaussian dab brush engine
|
/// Pure-Rust MyPaint-style Gaussian dab brush engine
|
||||||
pub struct BrushEngine;
|
pub struct BrushEngine;
|
||||||
|
|
||||||
impl BrushEngine {
|
impl BrushEngine {
|
||||||
/// Compute the list of GPU dabs for a stroke segment.
|
/// Compute the list of GPU dabs for a stroke segment.
|
||||||
///
|
///
|
||||||
/// `dt` is the elapsed time in seconds since the previous call for this
|
/// Uses the same dab-spacing logic as [`apply_stroke_with_state`] but produces
|
||||||
/// stroke. Pass `0.0` on the very first call (stroke start).
|
/// [`GpuDab`] structs for upload to the GPU compute pipeline instead of painting
|
||||||
|
/// into a pixel buffer.
|
||||||
///
|
///
|
||||||
/// Follows the libmypaint spacing model: distance-based and time-based
|
/// Also returns the union bounding box of all dabs as `(x0, y0, x1, y1)` in
|
||||||
/// contributions are **summed** in a single `partial_dabs` accumulator.
|
/// integer canvas pixel coordinates (clamped to non-negative values; `x0==i32::MAX`
|
||||||
/// A dab is emitted whenever `partial_dabs` reaches 1.0.
|
/// when the returned Vec is empty).
|
||||||
///
|
|
||||||
/// Also returns the union bounding box of all dabs as `(x0, y0, x1, y1)`.
|
|
||||||
pub fn compute_dabs(
|
pub fn compute_dabs(
|
||||||
stroke: &StrokeRecord,
|
stroke: &StrokeRecord,
|
||||||
state: &mut StrokeState,
|
state: &mut StrokeState,
|
||||||
dt: f32,
|
|
||||||
) -> (Vec<GpuDab>, (i32, i32, i32, i32)) {
|
) -> (Vec<GpuDab>, (i32, i32, i32, i32)) {
|
||||||
let mut dabs: Vec<GpuDab> = Vec::new();
|
let mut dabs: Vec<GpuDab> = Vec::new();
|
||||||
let mut bbox = (i32::MAX, i32::MAX, i32::MIN, i32::MIN);
|
let mut bbox = (i32::MAX, i32::MAX, i32::MIN, i32::MIN);
|
||||||
let bs = &stroke.brush_settings;
|
|
||||||
|
|
||||||
// Determine blend mode, allowing brush settings to override Normal
|
let blend_mode_u = match stroke.blend_mode {
|
||||||
let base_blend = match stroke.blend_mode {
|
RasterBlendMode::Normal => 0u32,
|
||||||
RasterBlendMode::Normal if bs.eraser > 0.5 => RasterBlendMode::Erase,
|
RasterBlendMode::Erase => 1u32,
|
||||||
RasterBlendMode::Normal if bs.smudge > 0.5 => RasterBlendMode::Smudge,
|
RasterBlendMode::Smudge => 2u32,
|
||||||
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>,
|
let push_dab = |dabs: &mut Vec<GpuDab>,
|
||||||
bbox: &mut (i32, i32, i32, i32),
|
bbox: &mut (i32, i32, i32, i32),
|
||||||
x: f32, y: f32,
|
x: f32, y: f32,
|
||||||
radius: f32, opacity: f32,
|
radius: f32, opacity: f32,
|
||||||
cr: f32, cg: f32, cb: f32,
|
ndx: f32, ndy: f32, smudge_dist: f32| {
|
||||||
ndx: f32, ndy: f32, smudge_dist: f32| {
|
|
||||||
let r_fringe = radius + 1.0;
|
let r_fringe = radius + 1.0;
|
||||||
bbox.0 = bbox.0.min((x - r_fringe).floor() as i32);
|
bbox.0 = bbox.0.min((x - r_fringe).floor() as i32);
|
||||||
bbox.1 = bbox.1.min((y - r_fringe).floor() as i32);
|
bbox.1 = bbox.1.min((y - r_fringe).floor() as i32);
|
||||||
|
|
@ -323,83 +124,32 @@ impl BrushEngine {
|
||||||
bbox.3 = bbox.3.max((y + r_fringe).ceil() as i32);
|
bbox.3 = bbox.3.max((y + r_fringe).ceil() as i32);
|
||||||
dabs.push(GpuDab {
|
dabs.push(GpuDab {
|
||||||
x, y, radius,
|
x, y, radius,
|
||||||
hardness: bs.hardness,
|
hardness: stroke.brush_settings.hardness,
|
||||||
opacity,
|
opacity,
|
||||||
color_r: cr,
|
color_r: stroke.color[0],
|
||||||
color_g: cg,
|
color_g: stroke.color[1],
|
||||||
color_b: cb,
|
color_b: stroke.color[2],
|
||||||
// Clone stamp: color_r/color_g hold source canvas X/Y, so color_a = 1.0
|
color_a: stroke.color[3],
|
||||||
// (blend strength is opa_weight × opacity × 1.0 in the shader).
|
|
||||||
color_a: if base_blend.uses_brush_color() { stroke.color[3] } else { 1.0 },
|
|
||||||
ndx, ndy, smudge_dist,
|
ndx, ndy, smudge_dist,
|
||||||
blend_mode: blend_mode_u,
|
blend_mode: blend_mode_u,
|
||||||
elliptical_dab_ratio: bs.elliptical_dab_ratio.max(1.0),
|
_pad0: 0, _pad1: 0, _pad2: 0,
|
||||||
elliptical_dab_angle: bs.elliptical_dab_angle.to_radians(),
|
|
||||||
lock_alpha: bs.lock_alpha,
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// Time-based accumulation: dt × dabs_per_second contributes to partial_dabs
|
|
||||||
// regardless of whether the cursor moved.
|
|
||||||
// Cap dt to 0.1 s to avoid a burst of dabs after a long pause.
|
|
||||||
let dt_capped = dt.min(0.1);
|
|
||||||
state.partial_dabs += dt_capped * bs.dabs_per_second;
|
|
||||||
|
|
||||||
// ----------------------------------------------------------------
|
|
||||||
// Single-point path: emit time-based (and stroke-start) dabs.
|
|
||||||
// The caller is responsible for timing; we just fire whenever
|
|
||||||
// partial_dabs ≥ 1.0.
|
|
||||||
// ----------------------------------------------------------------
|
|
||||||
if stroke.points.len() < 2 {
|
if stroke.points.len() < 2 {
|
||||||
if let Some(pt) = stroke.points.first() {
|
if let Some(pt) = stroke.points.first() {
|
||||||
if !state.smooth_initialized {
|
let r = stroke.brush_settings.radius_at_pressure(pt.pressure);
|
||||||
state.smooth_x = pt.x;
|
let raw_o = stroke.brush_settings.opacity_at_pressure(pt.pressure);
|
||||||
state.smooth_y = pt.y;
|
let o = 1.0 - (1.0 - raw_o).powf(stroke.brush_settings.dabs_per_radius * 0.5);
|
||||||
state.smooth_initialized = true;
|
// Single-tap smudge has no direction — skip (same as CPU engine)
|
||||||
}
|
if !matches!(stroke.blend_mode, RasterBlendMode::Smudge) {
|
||||||
while state.partial_dabs >= 1.0 {
|
push_dab(&mut dabs, &mut bbox, pt.x, pt.y, r, o, 0.0, 0.0, 0.0);
|
||||||
state.partial_dabs -= 1.0;
|
|
||||||
let base_r = bs.radius_at_pressure(pt.pressure);
|
|
||||||
let (ex, ey, r, o, cr, cg, cb) = apply_dab_effects(
|
|
||||||
state, bs, pt.x, pt.y, base_r, pt.pressure, stroke.color,
|
|
||||||
);
|
|
||||||
if !matches!(base_blend, RasterBlendMode::Smudge) {
|
|
||||||
let tp = &stroke.tool_params;
|
|
||||||
let (cr2, cg2, cb2, ndx2, ndy2) = match base_blend {
|
|
||||||
RasterBlendMode::CloneStamp | RasterBlendMode::Healing =>
|
|
||||||
(tp[0], tp[1], 0.0, 0.0, 0.0),
|
|
||||||
RasterBlendMode::PatternStamp =>
|
|
||||||
(cr, cg, cb, tp[0], tp[1]),
|
|
||||||
RasterBlendMode::DodgeBurn | RasterBlendMode::Sponge =>
|
|
||||||
(tp[0], 0.0, 0.0, 0.0, 0.0),
|
|
||||||
RasterBlendMode::BlurSharpen =>
|
|
||||||
(tp[0], 0.0, 0.0, tp[1], 0.0),
|
|
||||||
_ => (cr, cg, cb, 0.0, 0.0),
|
|
||||||
};
|
|
||||||
push_dab(&mut dabs, &mut bbox, ex, ey, r, o, cr2, cg2, cb2,
|
|
||||||
ndx2, ndy2, 0.0);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
state.distance_since_last_dab = 0.0;
|
||||||
}
|
}
|
||||||
return (dabs, bbox);
|
return (dabs, bbox);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ----------------------------------------------------------------
|
|
||||||
// Drag path: walk the polyline, accumulating partial_dabs from
|
|
||||||
// both distance-based and time-based contributions.
|
|
||||||
// ----------------------------------------------------------------
|
|
||||||
|
|
||||||
// Track the last smoothed position so that any residual time-based
|
|
||||||
// dabs can be emitted at the end of the segment walk.
|
|
||||||
let mut last_smooth_x = state.smooth_x;
|
|
||||||
let mut last_smooth_y = state.smooth_y;
|
|
||||||
let mut last_pressure = stroke.points.last()
|
|
||||||
.map(|p| p.pressure)
|
|
||||||
.unwrap_or(1.0);
|
|
||||||
|
|
||||||
// Fixed base radius (no pressure) used for the basic-radius spacing rate.
|
|
||||||
let base_radius_fixed = bs.radius_log.exp();
|
|
||||||
|
|
||||||
for window in stroke.points.windows(2) {
|
for window in stroke.points.windows(2) {
|
||||||
let p0 = &window[0];
|
let p0 = &window[0];
|
||||||
let p1 = &window[1];
|
let p1 = &window[1];
|
||||||
|
|
@ -409,143 +159,45 @@ impl BrushEngine {
|
||||||
let seg_len = (dx * dx + dy * dy).sqrt();
|
let seg_len = (dx * dx + dy * dy).sqrt();
|
||||||
if seg_len < 1e-4 { continue; }
|
if seg_len < 1e-4 { continue; }
|
||||||
|
|
||||||
last_pressure = p1.pressure;
|
|
||||||
|
|
||||||
let mut t = 0.0f32;
|
let mut t = 0.0f32;
|
||||||
while t < 1.0 {
|
while t < 1.0 {
|
||||||
let pressure = p0.pressure + t * (p1.pressure - p0.pressure);
|
let pressure = p0.pressure + t * (p1.pressure - p0.pressure);
|
||||||
let radius_for_rate = bs.radius_at_pressure(pressure);
|
let radius = stroke.brush_settings.radius_at_pressure(pressure);
|
||||||
|
let spacing = (radius * stroke.brush_settings.dabs_per_radius).max(0.5);
|
||||||
|
|
||||||
// Dab rate = sum of distance-based contributions (dabs per pixel).
|
let dist_to_next = spacing - state.distance_since_last_dab;
|
||||||
// Matches libmypaint: dabs_per_actual/actual_r + dabs_per_basic/base_r.
|
let seg_t_to_next = (dist_to_next / seg_len).max(0.0);
|
||||||
// For elliptical brushes use the minor-axis radius so dabs connect
|
|
||||||
// when moving perpendicular to the major axis.
|
|
||||||
let eff_radius = if bs.elliptical_dab_ratio > 1.001 {
|
|
||||||
radius_for_rate / bs.elliptical_dab_ratio
|
|
||||||
} else {
|
|
||||||
radius_for_rate
|
|
||||||
};
|
|
||||||
let rate_actual = if bs.dabs_per_actual_radius > 0.0 {
|
|
||||||
bs.dabs_per_actual_radius / eff_radius
|
|
||||||
} else { 0.0 };
|
|
||||||
let rate_basic = if bs.dabs_per_radius > 0.0 {
|
|
||||||
bs.dabs_per_radius / base_radius_fixed
|
|
||||||
} else { 0.0 };
|
|
||||||
let rate = rate_actual + rate_basic; // dabs per pixel
|
|
||||||
|
|
||||||
let remaining = 1.0 - state.partial_dabs;
|
|
||||||
let pixels_to_next = if rate > 1e-8 { remaining / rate } else { f32::MAX };
|
|
||||||
let seg_t_to_next = (pixels_to_next / seg_len).max(0.0);
|
|
||||||
|
|
||||||
if seg_t_to_next > 1.0 - t {
|
if seg_t_to_next > 1.0 - t {
|
||||||
// Won't reach the next dab within this segment.
|
state.distance_since_last_dab += seg_len * (1.0 - t);
|
||||||
if rate > 1e-8 {
|
|
||||||
state.partial_dabs += (1.0 - t) * seg_len * rate;
|
|
||||||
}
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
t += seg_t_to_next;
|
t += seg_t_to_next;
|
||||||
let pressure2 = p0.pressure + t * (p1.pressure - p0.pressure);
|
|
||||||
|
|
||||||
// Stroke threshold gating
|
|
||||||
if pressure2 < bs.stroke_threshold {
|
|
||||||
state.partial_dabs = 0.0;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
let base_r2 = bs.radius_at_pressure(pressure2);
|
|
||||||
|
|
||||||
// Slow tracking: exponential position smoothing
|
|
||||||
let x2 = p0.x + t * dx;
|
let x2 = p0.x + t * dx;
|
||||||
let y2 = p0.y + t * dy;
|
let y2 = p0.y + t * dy;
|
||||||
if !state.smooth_initialized {
|
let pressure2 = p0.pressure + t * (p1.pressure - p0.pressure);
|
||||||
state.smooth_x = x2; state.smooth_y = y2;
|
let radius2 = stroke.brush_settings.radius_at_pressure(pressure2);
|
||||||
state.smooth_initialized = true;
|
let raw_opacity = stroke.brush_settings.opacity_at_pressure(pressure2);
|
||||||
}
|
// Normalize per-dab opacity so dense dabs don't saturate faster than sparse ones.
|
||||||
// spacing_px ≈ 1 / rate (pixels per dab), used as time-constant scale
|
// Formula: per_dab = 1 − (1 − raw)^(dabs_per_radius / 2)
|
||||||
let spacing_px = if rate > 1e-8 { 1.0 / rate } else { 1.0 };
|
// Derivation: N = 2/dabs_per_radius dabs cover one full diameter at the centre;
|
||||||
let k = if bs.slow_tracking > 0.0 {
|
// accumulated = 1 − (1 − per_dab)^N = raw → per_dab = 1 − (1−raw)^(dabs_per_radius/2)
|
||||||
(-spacing_px / bs.slow_tracking.max(0.1)).exp()
|
let opacity2 = 1.0 - (1.0 - raw_opacity).powf(stroke.brush_settings.dabs_per_radius * 0.5);
|
||||||
} else { 0.0 };
|
|
||||||
state.smooth_x = state.smooth_x * k + x2 * (1.0 - k);
|
|
||||||
state.smooth_y = state.smooth_y * k + y2 * (1.0 - k);
|
|
||||||
last_smooth_x = state.smooth_x;
|
|
||||||
last_smooth_y = state.smooth_y;
|
|
||||||
|
|
||||||
let (sx, sy) = (state.smooth_x, state.smooth_y);
|
if matches!(stroke.blend_mode, RasterBlendMode::Smudge) {
|
||||||
let (ex, ey, radius2, opacity2, cr, cg, cb) = apply_dab_effects(
|
|
||||||
state, bs, sx, sy, base_r2, pressure2, stroke.color,
|
|
||||||
);
|
|
||||||
|
|
||||||
if matches!(base_blend, RasterBlendMode::Smudge) {
|
|
||||||
let ndx = dx / seg_len;
|
let ndx = dx / seg_len;
|
||||||
let ndy = dy / seg_len;
|
let ndy = dy / seg_len;
|
||||||
// strength=1.0 → sample from 1 dab back (drag pixels with us).
|
let smudge_dist =
|
||||||
// strength=0.0 → sample from current position (no change).
|
(radius2 * stroke.brush_settings.dabs_per_radius).max(1.0);
|
||||||
// smudge_radius_log is repurposed as a linear [0,1] strength value here.
|
|
||||||
let smudge_dist = spacing_px * bs.smudge_radius_log.clamp(0.0, 1.0);
|
|
||||||
push_dab(&mut dabs, &mut bbox,
|
push_dab(&mut dabs, &mut bbox,
|
||||||
ex, ey, radius2, opacity2, cr, cg, cb,
|
x2, y2, radius2, opacity2, ndx, ndy, smudge_dist);
|
||||||
ndx, ndy, smudge_dist);
|
|
||||||
} else {
|
} else {
|
||||||
let tp = &stroke.tool_params;
|
|
||||||
let (cr2, cg2, cb2, ndx2, ndy2) = match base_blend {
|
|
||||||
RasterBlendMode::CloneStamp | RasterBlendMode::Healing =>
|
|
||||||
(tp[0], tp[1], 0.0, 0.0, 0.0),
|
|
||||||
RasterBlendMode::PatternStamp =>
|
|
||||||
(cr, cg, cb, tp[0], tp[1]),
|
|
||||||
RasterBlendMode::DodgeBurn | RasterBlendMode::Sponge =>
|
|
||||||
(tp[0], 0.0, 0.0, 0.0, 0.0),
|
|
||||||
RasterBlendMode::BlurSharpen =>
|
|
||||||
(tp[0], 0.0, 0.0, tp[1], 0.0),
|
|
||||||
_ => (cr, cg, cb, 0.0, 0.0),
|
|
||||||
};
|
|
||||||
push_dab(&mut dabs, &mut bbox,
|
push_dab(&mut dabs, &mut bbox,
|
||||||
ex, ey, radius2, opacity2, cr2, cg2, cb2,
|
x2, y2, radius2, opacity2, 0.0, 0.0, 0.0);
|
||||||
ndx2, ndy2, 0.0);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
state.partial_dabs = 0.0;
|
state.distance_since_last_dab = 0.0;
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Emit any residual time-based dabs (partial_dabs ≥ 1.0 from the dt
|
|
||||||
// contribution not consumed by distance-based movement) at the last
|
|
||||||
// known cursor position.
|
|
||||||
if state.partial_dabs >= 1.0 && !matches!(base_blend, RasterBlendMode::Smudge) {
|
|
||||||
// Initialise smooth position if we never entered the segment loop.
|
|
||||||
if !state.smooth_initialized {
|
|
||||||
if let Some(pt) = stroke.points.last() {
|
|
||||||
state.smooth_x = pt.x;
|
|
||||||
state.smooth_y = pt.y;
|
|
||||||
state.smooth_initialized = true;
|
|
||||||
last_smooth_x = state.smooth_x;
|
|
||||||
last_smooth_y = state.smooth_y;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
while state.partial_dabs >= 1.0 {
|
|
||||||
state.partial_dabs -= 1.0;
|
|
||||||
let base_r = bs.radius_at_pressure(last_pressure);
|
|
||||||
let (ex, ey, r, o, cr, cg, cb) = apply_dab_effects(
|
|
||||||
state, bs,
|
|
||||||
last_smooth_x, last_smooth_y,
|
|
||||||
base_r, last_pressure, stroke.color,
|
|
||||||
);
|
|
||||||
let tp = &stroke.tool_params;
|
|
||||||
let (cr2, cg2, cb2, ndx2, ndy2) = match base_blend {
|
|
||||||
RasterBlendMode::CloneStamp | RasterBlendMode::Healing =>
|
|
||||||
(tp[0], tp[1], 0.0, 0.0, 0.0),
|
|
||||||
RasterBlendMode::PatternStamp =>
|
|
||||||
(cr, cg, cb, tp[0], tp[1]),
|
|
||||||
RasterBlendMode::DodgeBurn | RasterBlendMode::Sponge =>
|
|
||||||
(tp[0], 0.0, 0.0, 0.0, 0.0),
|
|
||||||
RasterBlendMode::BlurSharpen =>
|
|
||||||
(tp[0], 0.0, 0.0, tp[1], 0.0),
|
|
||||||
_ => (cr, cg, cb, 0.0, 0.0),
|
|
||||||
};
|
|
||||||
push_dab(&mut dabs, &mut bbox, ex, ey, r, o, cr2, cg2, cb2,
|
|
||||||
ndx2, ndy2, 0.0);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,215 +1,63 @@
|
||||||
//! Brush settings for the raster paint engine
|
//! Brush settings for the raster paint engine
|
||||||
//!
|
//!
|
||||||
//! Settings that describe the appearance and behavior of a paint brush.
|
//! Settings that describe the appearance and behavior of a paint brush.
|
||||||
//! Compatible with MyPaint .myb brush file format.
|
//! Compatible with MyPaint .myb brush file format (subset).
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::sync::OnceLock;
|
|
||||||
|
|
||||||
/// Settings for a paint brush — mirrors the MyPaint .myb settings schema.
|
/// Settings for a paint brush
|
||||||
///
|
|
||||||
/// All fields correspond directly to MyPaint JSON keys. Fields marked
|
|
||||||
/// "parse-only" are stored so that .myb files round-trip cleanly; they will
|
|
||||||
/// be used when the dynamic-input system is wired up in a future task.
|
|
||||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
pub struct BrushSettings {
|
pub struct BrushSettings {
|
||||||
// ── Core shape ──────────────────────────────────────────────────────────
|
|
||||||
/// log(radius) base value; actual radius = exp(radius_log)
|
/// log(radius) base value; actual radius = exp(radius_log)
|
||||||
pub radius_log: f32,
|
pub radius_log: f32,
|
||||||
/// Edge hardness 0.0 (fully soft/gaussian) to 1.0 (hard edge)
|
/// Edge hardness 0.0 (fully soft/gaussian) to 1.0 (hard edge)
|
||||||
pub hardness: f32,
|
pub hardness: f32,
|
||||||
/// Base opacity 0.0–1.0
|
/// Base opacity 0.0–1.0
|
||||||
pub opaque: f32,
|
pub opaque: f32,
|
||||||
/// Additional opacity multiplier (opaque_multiply)
|
/// Dab spacing as fraction of radius (smaller = denser strokes)
|
||||||
pub opaque_multiply: f32,
|
|
||||||
/// Dabs per basic_radius distance (MyPaint: dabs_per_basic_radius)
|
|
||||||
pub dabs_per_radius: f32,
|
pub dabs_per_radius: f32,
|
||||||
/// Dabs per actual (pressure-modified) radius distance
|
|
||||||
pub dabs_per_actual_radius: f32,
|
|
||||||
|
|
||||||
// ── Elliptical dab ──────────────────────────────────────────────────────
|
|
||||||
/// Dab aspect ratio ≥ 1.0 (1.0 = circle, 3.0 = 3:1 ellipse)
|
|
||||||
pub elliptical_dab_ratio: f32,
|
|
||||||
/// Elliptical dab rotation angle in degrees (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
|
/// HSV hue (0.0–1.0); usually overridden by stroke color
|
||||||
pub color_h: f32,
|
pub color_h: f32,
|
||||||
/// HSV saturation (0.0–1.0)
|
/// HSV saturation (0.0–1.0)
|
||||||
pub color_s: f32,
|
pub color_s: f32,
|
||||||
/// HSV value (0.0–1.0)
|
/// HSV value (0.0–1.0)
|
||||||
pub color_v: f32,
|
pub color_v: f32,
|
||||||
/// Per-dab hue shift (accumulates over the stroke)
|
|
||||||
pub change_color_h: f32,
|
|
||||||
/// Per-dab HSV value shift
|
|
||||||
pub change_color_v: f32,
|
|
||||||
/// Per-dab HSV saturation shift
|
|
||||||
pub change_color_hsv_s: f32,
|
|
||||||
/// Per-dab HSL lightness shift
|
|
||||||
pub change_color_l: f32,
|
|
||||||
/// Per-dab HSL saturation shift
|
|
||||||
pub change_color_hsl_s: f32,
|
|
||||||
|
|
||||||
// ── Blend ───────────────────────────────────────────────────────────────
|
|
||||||
/// Lock alpha channel (0 = off, 1 = on — don't modify destination alpha)
|
|
||||||
pub lock_alpha: f32,
|
|
||||||
/// Eraser strength (>0.5 activates erase blend when tool mode is Normal)
|
|
||||||
pub eraser: f32,
|
|
||||||
|
|
||||||
// ── Smudge ──────────────────────────────────────────────────────────────
|
|
||||||
/// Smudge amount (>0.5 activates smudge blend when tool mode is Normal)
|
|
||||||
pub smudge: f32,
|
|
||||||
/// How quickly the smudge color updates (0 = instant, 1 = slow)
|
|
||||||
pub smudge_length: f32,
|
|
||||||
/// Smudge pickup radius offset (log-scale added to radius_log)
|
|
||||||
pub smudge_radius_log: f32,
|
|
||||||
|
|
||||||
// ── Stroke gating ───────────────────────────────────────────────────────
|
|
||||||
/// Minimum pressure required to emit dabs (0 = always emit)
|
|
||||||
pub stroke_threshold: f32,
|
|
||||||
|
|
||||||
// ── Pressure dynamics ───────────────────────────────────────────────────
|
|
||||||
/// How much pressure increases/decreases radius
|
/// How much pressure increases/decreases radius
|
||||||
|
/// Final radius = exp(radius_log + pressure_radius_gain * pressure)
|
||||||
pub pressure_radius_gain: f32,
|
pub pressure_radius_gain: f32,
|
||||||
/// How much pressure increases/decreases opacity
|
/// How much pressure increases/decreases opacity
|
||||||
|
/// Final opacity = opaque * (1 + pressure_opacity_gain * (pressure - 0.5))
|
||||||
pub pressure_opacity_gain: f32,
|
pub pressure_opacity_gain: f32,
|
||||||
|
|
||||||
// ── Parse-only: future input curve system ───────────────────────────────
|
|
||||||
pub opaque_linearize: f32,
|
|
||||||
pub anti_aliasing: f32,
|
|
||||||
pub dabs_per_second: f32,
|
|
||||||
pub offset_by_speed: f32,
|
|
||||||
pub offset_by_speed_slowness: f32,
|
|
||||||
pub speed1_slowness: f32,
|
|
||||||
pub speed2_slowness: f32,
|
|
||||||
pub speed1_gamma: f32,
|
|
||||||
pub speed2_gamma: f32,
|
|
||||||
pub direction_filter: f32,
|
|
||||||
pub stroke_duration_log: f32,
|
|
||||||
pub stroke_holdtime: f32,
|
|
||||||
pub pressure_gain_log: f32,
|
|
||||||
pub smudge_transparency: f32,
|
|
||||||
pub smudge_length_log: f32,
|
|
||||||
pub smudge_bucket: f32,
|
|
||||||
pub paint_mode: f32,
|
|
||||||
pub colorize: f32,
|
|
||||||
pub posterize: f32,
|
|
||||||
pub posterize_num: f32,
|
|
||||||
pub snap_to_pixel: f32,
|
|
||||||
pub custom_input: f32,
|
|
||||||
pub custom_input_slowness: f32,
|
|
||||||
pub gridmap_scale: f32,
|
|
||||||
pub gridmap_scale_x: f32,
|
|
||||||
pub gridmap_scale_y: f32,
|
|
||||||
pub restore_color: f32,
|
|
||||||
pub offset_angle: f32,
|
|
||||||
pub offset_angle_asc: f32,
|
|
||||||
pub offset_angle_view: f32,
|
|
||||||
pub offset_angle_2: f32,
|
|
||||||
pub offset_angle_2_asc: f32,
|
|
||||||
pub offset_angle_2_view: f32,
|
|
||||||
pub offset_angle_adj: f32,
|
|
||||||
pub offset_multiplier: f32,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl BrushSettings {
|
impl BrushSettings {
|
||||||
/// Default soft round brush (smooth Gaussian falloff)
|
/// Default soft round brush (smooth Gaussian falloff)
|
||||||
pub fn default_round_soft() -> Self {
|
pub fn default_round_soft() -> Self {
|
||||||
Self {
|
Self {
|
||||||
radius_log: 2.0,
|
radius_log: 2.0, // radius ≈ 7.4 px
|
||||||
hardness: 0.1,
|
hardness: 0.1,
|
||||||
opaque: 0.8,
|
opaque: 0.8,
|
||||||
opaque_multiply: 0.0,
|
dabs_per_radius: 0.25,
|
||||||
dabs_per_radius: 2.0,
|
|
||||||
dabs_per_actual_radius: 2.0,
|
|
||||||
elliptical_dab_ratio: 1.0,
|
|
||||||
elliptical_dab_angle: 90.0,
|
|
||||||
radius_by_random: 0.0,
|
|
||||||
offset_by_random: 0.0,
|
|
||||||
offset_x: 0.0,
|
|
||||||
offset_y: 0.0,
|
|
||||||
slow_tracking: 0.0,
|
|
||||||
slow_tracking_per_dab: 0.0,
|
|
||||||
color_h: 0.0,
|
color_h: 0.0,
|
||||||
color_s: 0.0,
|
color_s: 0.0,
|
||||||
color_v: 0.0,
|
color_v: 0.0,
|
||||||
change_color_h: 0.0,
|
|
||||||
change_color_v: 0.0,
|
|
||||||
change_color_hsv_s: 0.0,
|
|
||||||
change_color_l: 0.0,
|
|
||||||
change_color_hsl_s: 0.0,
|
|
||||||
lock_alpha: 0.0,
|
|
||||||
eraser: 0.0,
|
|
||||||
smudge: 0.0,
|
|
||||||
smudge_length: 0.5,
|
|
||||||
smudge_radius_log: 0.0,
|
|
||||||
stroke_threshold: 0.0,
|
|
||||||
pressure_radius_gain: 0.5,
|
pressure_radius_gain: 0.5,
|
||||||
pressure_opacity_gain: 1.0,
|
pressure_opacity_gain: 1.0,
|
||||||
opaque_linearize: 0.9,
|
|
||||||
anti_aliasing: 1.0,
|
|
||||||
dabs_per_second: 0.0,
|
|
||||||
offset_by_speed: 0.0,
|
|
||||||
offset_by_speed_slowness: 1.0,
|
|
||||||
speed1_slowness: 0.04,
|
|
||||||
speed2_slowness: 0.8,
|
|
||||||
speed1_gamma: 4.0,
|
|
||||||
speed2_gamma: 4.0,
|
|
||||||
direction_filter: 2.0,
|
|
||||||
stroke_duration_log: 4.0,
|
|
||||||
stroke_holdtime: 0.0,
|
|
||||||
pressure_gain_log: 0.0,
|
|
||||||
smudge_transparency: 0.0,
|
|
||||||
smudge_length_log: 0.0,
|
|
||||||
smudge_bucket: 0.0,
|
|
||||||
paint_mode: 1.0,
|
|
||||||
colorize: 0.0,
|
|
||||||
posterize: 0.0,
|
|
||||||
posterize_num: 0.05,
|
|
||||||
snap_to_pixel: 0.0,
|
|
||||||
custom_input: 0.0,
|
|
||||||
custom_input_slowness: 0.0,
|
|
||||||
gridmap_scale: 0.0,
|
|
||||||
gridmap_scale_x: 1.0,
|
|
||||||
gridmap_scale_y: 1.0,
|
|
||||||
restore_color: 0.0,
|
|
||||||
offset_angle: 0.0,
|
|
||||||
offset_angle_asc: 0.0,
|
|
||||||
offset_angle_view: 0.0,
|
|
||||||
offset_angle_2: 0.0,
|
|
||||||
offset_angle_2_asc: 0.0,
|
|
||||||
offset_angle_2_view: 0.0,
|
|
||||||
offset_angle_adj: 0.0,
|
|
||||||
offset_multiplier: 0.0,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Default hard round brush (sharp edge)
|
/// Default hard round brush (sharp edge)
|
||||||
pub fn default_round_hard() -> Self {
|
pub fn default_round_hard() -> Self {
|
||||||
Self {
|
Self {
|
||||||
|
radius_log: 2.0,
|
||||||
hardness: 0.9,
|
hardness: 0.9,
|
||||||
opaque: 1.0,
|
opaque: 1.0,
|
||||||
dabs_per_radius: 2.0,
|
dabs_per_radius: 0.2,
|
||||||
|
color_h: 0.0,
|
||||||
|
color_s: 0.0,
|
||||||
|
color_v: 0.0,
|
||||||
pressure_radius_gain: 0.3,
|
pressure_radius_gain: 0.3,
|
||||||
pressure_opacity_gain: 0.8,
|
pressure_opacity_gain: 0.8,
|
||||||
..Self::default_round_soft()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -225,10 +73,10 @@ impl BrushSettings {
|
||||||
o.clamp(0.0, 1.0)
|
o.clamp(0.0, 1.0)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parse a MyPaint .myb JSON brush file.
|
/// Parse a MyPaint .myb JSON brush file (subset).
|
||||||
///
|
///
|
||||||
/// Reads all known settings from `settings[key].base_value`.
|
/// Reads `radius_logarithmic`, `hardness`, `opaque`, `dabs_per_basic_radius`,
|
||||||
/// Unknown keys are silently ignored for forward compatibility.
|
/// `color_h`, `color_s`, `color_v` from the `settings` key's `base_value` fields.
|
||||||
pub fn from_myb(json: &str) -> Result<Self, String> {
|
pub fn from_myb(json: &str) -> Result<Self, String> {
|
||||||
let v: serde_json::Value =
|
let v: serde_json::Value =
|
||||||
serde_json::from_str(json).map_err(|e| format!("JSON parse error: {e}"))?;
|
serde_json::from_str(json).map_err(|e| format!("JSON parse error: {e}"))?;
|
||||||
|
|
@ -244,13 +92,15 @@ impl BrushSettings {
|
||||||
.unwrap_or(default)
|
.unwrap_or(default)
|
||||||
};
|
};
|
||||||
|
|
||||||
// Pressure dynamics: approximate from the pressure input curve endpoints
|
// Pressure dynamics: read from the "inputs" mapping of radius/opacity
|
||||||
|
// For simplicity, look for the pressure input point in radius_logarithmic
|
||||||
let pressure_radius_gain = settings
|
let pressure_radius_gain = settings
|
||||||
.get("radius_logarithmic")
|
.get("radius_logarithmic")
|
||||||
.and_then(|s| s.get("inputs"))
|
.and_then(|s| s.get("inputs"))
|
||||||
.and_then(|inp| inp.get("pressure"))
|
.and_then(|inp| inp.get("pressure"))
|
||||||
.and_then(|pts| pts.as_array())
|
.and_then(|pts| pts.as_array())
|
||||||
.and_then(|arr| {
|
.and_then(|arr| {
|
||||||
|
// arr = [[x0,y0],[x1,y1],...] – approximate as linear gain at x=1.0
|
||||||
if arr.len() >= 2 {
|
if arr.len() >= 2 {
|
||||||
let y0 = arr[0].get(1)?.as_f64()? as f32;
|
let y0 = arr[0].get(1)?.as_f64()? as f32;
|
||||||
let y1 = arr[arr.len() - 1].get(1)?.as_f64()? as f32;
|
let y1 = arr[arr.len() - 1].get(1)?.as_f64()? as f32;
|
||||||
|
|
@ -278,81 +128,15 @@ impl BrushSettings {
|
||||||
.unwrap_or(1.0);
|
.unwrap_or(1.0);
|
||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
// Core shape
|
radius_log: read_base("radius_logarithmic", 2.0),
|
||||||
radius_log: read_base("radius_logarithmic", 2.0),
|
hardness: read_base("hardness", 0.5).clamp(0.0, 1.0),
|
||||||
hardness: read_base("hardness", 0.8).clamp(0.0, 1.0),
|
opaque: read_base("opaque", 1.0).clamp(0.0, 1.0),
|
||||||
opaque: read_base("opaque", 1.0).clamp(0.0, 2.0),
|
dabs_per_radius: read_base("dabs_per_basic_radius", 0.25).clamp(0.01, 10.0),
|
||||||
opaque_multiply: read_base("opaque_multiply", 0.0),
|
color_h: read_base("color_h", 0.0),
|
||||||
dabs_per_radius: read_base("dabs_per_basic_radius", 0.0).max(0.0),
|
color_s: read_base("color_s", 0.0),
|
||||||
dabs_per_actual_radius: read_base("dabs_per_actual_radius", 2.0).max(0.0),
|
color_v: read_base("color_v", 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_radius_gain,
|
||||||
pressure_opacity_gain,
|
pressure_opacity_gain,
|
||||||
// Parse-only
|
|
||||||
opaque_linearize: read_base("opaque_linearize", 0.9),
|
|
||||||
anti_aliasing: read_base("anti_aliasing", 1.0),
|
|
||||||
dabs_per_second: read_base("dabs_per_second", 0.0),
|
|
||||||
offset_by_speed: read_base("offset_by_speed", 0.0),
|
|
||||||
offset_by_speed_slowness: read_base("offset_by_speed_slowness", 1.0),
|
|
||||||
speed1_slowness: read_base("speed1_slowness", 0.04),
|
|
||||||
speed2_slowness: read_base("speed2_slowness", 0.8),
|
|
||||||
speed1_gamma: read_base("speed1_gamma", 4.0),
|
|
||||||
speed2_gamma: read_base("speed2_gamma", 4.0),
|
|
||||||
direction_filter: read_base("direction_filter", 2.0),
|
|
||||||
stroke_duration_log: read_base("stroke_duration_logarithmic", 4.0),
|
|
||||||
stroke_holdtime: read_base("stroke_holdtime", 0.0),
|
|
||||||
pressure_gain_log: read_base("pressure_gain_log", 0.0),
|
|
||||||
smudge_transparency: read_base("smudge_transparency", 0.0),
|
|
||||||
smudge_length_log: read_base("smudge_length_log", 0.0),
|
|
||||||
smudge_bucket: read_base("smudge_bucket", 0.0),
|
|
||||||
paint_mode: read_base("paint_mode", 1.0),
|
|
||||||
colorize: read_base("colorize", 0.0),
|
|
||||||
posterize: read_base("posterize", 0.0),
|
|
||||||
posterize_num: read_base("posterize_num", 0.05),
|
|
||||||
snap_to_pixel: read_base("snap_to_pixel", 0.0),
|
|
||||||
custom_input: read_base("custom_input", 0.0),
|
|
||||||
custom_input_slowness: read_base("custom_input_slowness", 0.0),
|
|
||||||
gridmap_scale: read_base("gridmap_scale", 0.0),
|
|
||||||
gridmap_scale_x: read_base("gridmap_scale_x", 1.0),
|
|
||||||
gridmap_scale_y: read_base("gridmap_scale_y", 1.0),
|
|
||||||
restore_color: read_base("restore_color", 0.0),
|
|
||||||
offset_angle: read_base("offset_angle", 0.0),
|
|
||||||
offset_angle_asc: read_base("offset_angle_asc", 0.0),
|
|
||||||
offset_angle_view: read_base("offset_angle_view", 0.0),
|
|
||||||
offset_angle_2: read_base("offset_angle_2", 0.0),
|
|
||||||
offset_angle_2_asc: read_base("offset_angle_2_asc", 0.0),
|
|
||||||
offset_angle_2_view: read_base("offset_angle_2_view", 0.0),
|
|
||||||
offset_angle_adj: read_base("offset_angle_adj", 0.0),
|
|
||||||
offset_multiplier: read_base("offset_multiplier", 0.0),
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -362,41 +146,3 @@ impl Default for BrushSettings {
|
||||||
Self::default_round_soft()
|
Self::default_round_soft()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Bundled brush presets
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/// A named brush preset backed by a bundled .myb file.
|
|
||||||
pub struct BrushPreset {
|
|
||||||
pub name: &'static str,
|
|
||||||
pub settings: BrushSettings,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns the list of bundled brush presets (parsed once from embedded .myb files).
|
|
||||||
///
|
|
||||||
/// Sources: mypaint/mypaint-brushes — CC0 1.0 Universal (Public Domain)
|
|
||||||
pub fn bundled_brushes() -> &'static [BrushPreset] {
|
|
||||||
static CACHE: OnceLock<Vec<BrushPreset>> = OnceLock::new();
|
|
||||||
CACHE.get_or_init(|| {
|
|
||||||
let mut v = Vec::new();
|
|
||||||
macro_rules! brush {
|
|
||||||
($name:literal, $path:literal) => {
|
|
||||||
if let Ok(s) = BrushSettings::from_myb(include_str!($path)) {
|
|
||||||
v.push(BrushPreset { name: $name, settings: s });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
brush!("Pencil", "../../../src/assets/brushes/pencil.myb");
|
|
||||||
brush!("Pen", "../../../src/assets/brushes/pen.myb");
|
|
||||||
brush!("Charcoal", "../../../src/assets/brushes/charcoal.myb");
|
|
||||||
brush!("Brush", "../../../src/assets/brushes/brush.myb");
|
|
||||||
brush!("Dry Brush", "../../../src/assets/brushes/dry_brush.myb");
|
|
||||||
brush!("Ink", "../../../src/assets/brushes/ink_blot.myb");
|
|
||||||
brush!("Calligraphy", "../../../src/assets/brushes/calligraphy.myb");
|
|
||||||
brush!("Airbrush", "../../../src/assets/brushes/airbrush.myb");
|
|
||||||
brush!("Chalk", "../../../src/assets/brushes/chalk.myb");
|
|
||||||
brush!("Liner", "../../../src/assets/brushes/liner.myb");
|
|
||||||
v
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -114,8 +114,6 @@ pub struct Face {
|
||||||
pub image_fill: Option<uuid::Uuid>,
|
pub image_fill: Option<uuid::Uuid>,
|
||||||
pub fill_rule: FillRule,
|
pub fill_rule: FillRule,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub gradient_fill: Option<crate::gradient::ShapeGradient>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub deleted: bool,
|
pub deleted: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -243,7 +241,6 @@ impl Dcel {
|
||||||
fill_color: None,
|
fill_color: None,
|
||||||
image_fill: None,
|
image_fill: None,
|
||||||
fill_rule: FillRule::NonZero,
|
fill_rule: FillRule::NonZero,
|
||||||
gradient_fill: None,
|
|
||||||
deleted: false,
|
deleted: false,
|
||||||
};
|
};
|
||||||
let debug_recorder = if std::env::var("DAW_DCEL_RECORD").is_ok() {
|
let debug_recorder = if std::env::var("DAW_DCEL_RECORD").is_ok() {
|
||||||
|
|
@ -375,7 +372,6 @@ impl Dcel {
|
||||||
fill_color: None,
|
fill_color: None,
|
||||||
image_fill: None,
|
image_fill: None,
|
||||||
fill_rule: FillRule::NonZero,
|
fill_rule: FillRule::NonZero,
|
||||||
gradient_fill: None,
|
|
||||||
deleted: false,
|
deleted: false,
|
||||||
};
|
};
|
||||||
if let Some(idx) = self.free_faces.pop() {
|
if let Some(idx) = self.free_faces.pop() {
|
||||||
|
|
|
||||||
|
|
@ -390,59 +390,6 @@ impl VideoExportSettings {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Image export ─────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/// Image export formats (single-frame still image)
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
pub enum ImageFormat {
|
|
||||||
Png,
|
|
||||||
Jpeg,
|
|
||||||
WebP,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ImageFormat {
|
|
||||||
pub fn name(self) -> &'static str {
|
|
||||||
match self { Self::Png => "PNG", Self::Jpeg => "JPEG", Self::WebP => "WebP" }
|
|
||||||
}
|
|
||||||
pub fn extension(self) -> &'static str {
|
|
||||||
match self { Self::Png => "png", Self::Jpeg => "jpg", Self::WebP => "webp" }
|
|
||||||
}
|
|
||||||
/// Whether quality (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
|
/// Progress updates during export
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub enum ExportProgress {
|
pub enum ExportProgress {
|
||||||
|
|
|
||||||
|
|
@ -1,198 +1,8 @@
|
||||||
//! Flood fill algorithms for paint bucket tool
|
//! Flood fill algorithm for paint bucket tool
|
||||||
//!
|
//!
|
||||||
//! This module contains two fill implementations:
|
//! This module implements a flood fill that tracks which curves each point
|
||||||
//! - `flood_fill` — vector curve-boundary fill (used by vector paint bucket)
|
//! touches. Instead of filling with pixels, it returns boundary points that
|
||||||
//! - `raster_flood_fill` — pixel BFS fill with configurable threshold, soft
|
//! can be used to construct a filled shape from exact curve geometry.
|
||||||
//! edge, and optional selection clipping (used by raster paint bucket)
|
|
||||||
|
|
||||||
// ── Raster flood fill ─────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/// Which pixel to compare against when deciding if a neighbor should be filled.
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
||||||
pub enum FillThresholdMode {
|
|
||||||
/// Compare each candidate pixel to the original seed pixel (Photoshop default).
|
|
||||||
Absolute,
|
|
||||||
/// Compare each candidate pixel to the pixel it was reached from (spreads
|
|
||||||
/// through gradients without a global seed-color reference).
|
|
||||||
Relative,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// BFS / global scan flood fill mask.
|
|
||||||
///
|
|
||||||
/// Returns a `Vec<Option<f32>>` of length `width × height`:
|
|
||||||
/// - `Some(d)` — pixel is within the fill region; `d` is the color distance
|
|
||||||
/// from its comparison color (0.0 at seed, up to `threshold` at the edge).
|
|
||||||
/// - `None` — pixel is outside the fill region.
|
|
||||||
///
|
|
||||||
/// # Parameters
|
|
||||||
/// - `pixels` – raw RGBA buffer (read-only)
|
|
||||||
/// - `width/height` – canvas dimensions
|
|
||||||
/// - `seed_x/y` – click coordinates (canvas pixel indices, 0-based)
|
|
||||||
/// - `threshold` – max color distance to include
|
|
||||||
/// - `mode` – Absolute = compare to seed; Relative = compare to BFS parent
|
|
||||||
/// - `contiguous` – true = BFS from seed (connected region only);
|
|
||||||
/// false = scan every pixel against seed color globally
|
|
||||||
/// - `selection` – optional clip mask; pixels outside are never included
|
|
||||||
pub fn raster_fill_mask(
|
|
||||||
pixels: &[u8],
|
|
||||||
width: u32,
|
|
||||||
height: u32,
|
|
||||||
seed_x: i32,
|
|
||||||
seed_y: i32,
|
|
||||||
threshold: f32,
|
|
||||||
mode: FillThresholdMode,
|
|
||||||
contiguous: bool,
|
|
||||||
selection: Option<&crate::selection::RasterSelection>,
|
|
||||||
) -> Vec<Option<f32>> {
|
|
||||||
use std::collections::VecDeque;
|
|
||||||
|
|
||||||
let w = width as i32;
|
|
||||||
let h = height as i32;
|
|
||||||
let n = (width * height) as usize;
|
|
||||||
|
|
||||||
let mut dist_map: Vec<Option<f32>> = vec![None; n];
|
|
||||||
|
|
||||||
if seed_x < 0 || seed_y < 0 || seed_x >= w || seed_y >= h {
|
|
||||||
return dist_map;
|
|
||||||
}
|
|
||||||
|
|
||||||
let seed_idx = (seed_y * w + seed_x) as usize;
|
|
||||||
let seed_color = [
|
|
||||||
pixels[seed_idx * 4],
|
|
||||||
pixels[seed_idx * 4 + 1],
|
|
||||||
pixels[seed_idx * 4 + 2],
|
|
||||||
pixels[seed_idx * 4 + 3],
|
|
||||||
];
|
|
||||||
|
|
||||||
if contiguous {
|
|
||||||
// BFS: only connected pixels within threshold.
|
|
||||||
let mut parent_color: Vec<[u8; 4]> = vec![[0; 4]; n];
|
|
||||||
let mut queue: VecDeque<(i32, i32)> = VecDeque::new();
|
|
||||||
|
|
||||||
dist_map[seed_idx] = Some(0.0);
|
|
||||||
parent_color[seed_idx] = seed_color;
|
|
||||||
queue.push_back((seed_x, seed_y));
|
|
||||||
|
|
||||||
let dirs: [(i32, i32); 4] = [(0, -1), (0, 1), (-1, 0), (1, 0)];
|
|
||||||
|
|
||||||
while let Some((cx, cy)) = queue.pop_front() {
|
|
||||||
let ci = (cy * w + cx) as usize;
|
|
||||||
let compare_color = match mode {
|
|
||||||
FillThresholdMode::Absolute => seed_color,
|
|
||||||
FillThresholdMode::Relative => parent_color[ci],
|
|
||||||
};
|
|
||||||
for (dx, dy) in dirs {
|
|
||||||
let nx = cx + dx;
|
|
||||||
let ny = cy + dy;
|
|
||||||
if nx < 0 || ny < 0 || nx >= w || ny >= h { continue; }
|
|
||||||
let ni = (ny * w + nx) as usize;
|
|
||||||
if dist_map[ni].is_some() { continue; }
|
|
||||||
if let Some(sel) = selection {
|
|
||||||
if !sel.contains_pixel(nx, ny) { continue; }
|
|
||||||
}
|
|
||||||
let npx = [pixels[ni*4], pixels[ni*4+1], pixels[ni*4+2], pixels[ni*4+3]];
|
|
||||||
let d = color_distance(npx, compare_color);
|
|
||||||
if d <= threshold {
|
|
||||||
dist_map[ni] = Some(d);
|
|
||||||
parent_color[ni] = npx;
|
|
||||||
queue.push_back((nx, ny));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Global scan: every pixel compared against seed color (Absolute mode).
|
|
||||||
for row in 0..h {
|
|
||||||
for col in 0..w {
|
|
||||||
if let Some(sel) = selection {
|
|
||||||
if !sel.contains_pixel(col, row) { continue; }
|
|
||||||
}
|
|
||||||
let ni = (row * w + col) as usize;
|
|
||||||
let npx = [pixels[ni*4], pixels[ni*4+1], pixels[ni*4+2], pixels[ni*4+3]];
|
|
||||||
let d = color_distance(npx, seed_color);
|
|
||||||
if d <= threshold {
|
|
||||||
dist_map[ni] = Some(d);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
dist_map
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Pixel flood fill for the raster paint bucket tool.
|
|
||||||
///
|
|
||||||
/// Calls [`raster_fill_mask`] then alpha-composites `fill_color` over each
|
|
||||||
/// matched pixel. `softness` controls a fade zone near the fill boundary.
|
|
||||||
pub fn raster_flood_fill(
|
|
||||||
pixels: &mut Vec<u8>,
|
|
||||||
width: u32,
|
|
||||||
height: u32,
|
|
||||||
seed_x: i32,
|
|
||||||
seed_y: i32,
|
|
||||||
fill_color: [u8; 4],
|
|
||||||
threshold: f32,
|
|
||||||
softness: f32,
|
|
||||||
mode: FillThresholdMode,
|
|
||||||
contiguous: bool,
|
|
||||||
selection: Option<&crate::selection::RasterSelection>,
|
|
||||||
) {
|
|
||||||
let dist_map = raster_fill_mask(pixels, width, height, seed_x, seed_y,
|
|
||||||
threshold, mode, contiguous, selection);
|
|
||||||
let n = (width * height) as usize;
|
|
||||||
|
|
||||||
let fr = fill_color[0] as f32 / 255.0;
|
|
||||||
let fg = fill_color[1] as f32 / 255.0;
|
|
||||||
let fb = fill_color[2] as f32 / 255.0;
|
|
||||||
let fa_base = fill_color[3] as f32 / 255.0;
|
|
||||||
|
|
||||||
let falloff_start = if softness <= 0.0 || threshold <= 0.0 {
|
|
||||||
1.0_f32
|
|
||||||
} else {
|
|
||||||
1.0 - softness / 100.0
|
|
||||||
};
|
|
||||||
|
|
||||||
for i in 0..n {
|
|
||||||
if let Some(d) = dist_map[i] {
|
|
||||||
let alpha = if threshold <= 0.0 {
|
|
||||||
fa_base
|
|
||||||
} else {
|
|
||||||
let t = d / threshold;
|
|
||||||
if t <= falloff_start {
|
|
||||||
fa_base
|
|
||||||
} else {
|
|
||||||
let frac = (t - falloff_start) / (1.0 - falloff_start).max(1e-6);
|
|
||||||
fa_base * (1.0 - frac)
|
|
||||||
}
|
|
||||||
};
|
|
||||||
if alpha <= 0.0 { continue; }
|
|
||||||
|
|
||||||
let dst_r = pixels[i * 4 ] as f32 / 255.0;
|
|
||||||
let dst_g = pixels[i * 4 + 1] as f32 / 255.0;
|
|
||||||
let dst_b = pixels[i * 4 + 2] as f32 / 255.0;
|
|
||||||
let dst_a = pixels[i * 4 + 3] as f32 / 255.0;
|
|
||||||
let inv_a = 1.0 - alpha;
|
|
||||||
let out_a = alpha + dst_a * inv_a;
|
|
||||||
if out_a > 0.0 {
|
|
||||||
pixels[i*4 ] = ((fr * alpha + dst_r * dst_a * inv_a) / out_a * 255.0).round() as u8;
|
|
||||||
pixels[i*4+1] = ((fg * alpha + dst_g * dst_a * inv_a) / out_a * 255.0).round() as u8;
|
|
||||||
pixels[i*4+2] = ((fb * alpha + dst_b * dst_a * inv_a) / out_a * 255.0).round() as u8;
|
|
||||||
pixels[i*4+3] = (out_a * 255.0).round() as u8;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn color_distance(a: [u8; 4], b: [u8; 4]) -> f32 {
|
|
||||||
let dr = a[0] as f32 - b[0] as f32;
|
|
||||||
let dg = a[1] as f32 - b[1] as f32;
|
|
||||||
let db = a[2] as f32 - b[2] as f32;
|
|
||||||
let da = a[3] as f32 - b[3] as f32;
|
|
||||||
(dr * dr + dg * dg + db * db + da * da).sqrt()
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Vector (curve-boundary) flood fill ───────────────────────────────────────
|
|
||||||
// The following is the original vector-layer flood fill, kept for the vector
|
|
||||||
// paint bucket tool.
|
|
||||||
|
|
||||||
use crate::curve_segment::CurveSegment;
|
use crate::curve_segment::CurveSegment;
|
||||||
use crate::quadtree::{BoundingBox, Quadtree};
|
use crate::quadtree::{BoundingBox, Quadtree};
|
||||||
|
|
|
||||||
|
|
@ -1,178 +0,0 @@
|
||||||
//! Gradient types for vector and raster fills.
|
|
||||||
|
|
||||||
use crate::shape::ShapeColor;
|
|
||||||
use kurbo::Point;
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use vello::peniko::{self, Brush, Extend, Gradient};
|
|
||||||
|
|
||||||
// ── Stop ────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/// One colour stop in a gradient.
|
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
|
||||||
pub struct GradientStop {
|
|
||||||
/// Normalised position in [0.0, 1.0].
|
|
||||||
pub position: f32,
|
|
||||||
pub color: ShapeColor,
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Kind / Extend ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/// Whether the gradient transitions along a line or radiates from a point.
|
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
|
|
||||||
pub enum GradientType {
|
|
||||||
#[default]
|
|
||||||
Linear,
|
|
||||||
Radial,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Behaviour outside the gradient's natural [0, 1] range.
|
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
|
|
||||||
pub enum GradientExtend {
|
|
||||||
/// Clamp to edge colour (default).
|
|
||||||
#[default]
|
|
||||||
Pad,
|
|
||||||
/// Mirror the gradient.
|
|
||||||
Reflect,
|
|
||||||
/// Repeat the gradient.
|
|
||||||
Repeat,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<GradientExtend> for Extend {
|
|
||||||
fn from(e: GradientExtend) -> Self {
|
|
||||||
match e {
|
|
||||||
GradientExtend::Pad => Extend::Pad,
|
|
||||||
GradientExtend::Reflect => Extend::Reflect,
|
|
||||||
GradientExtend::Repeat => Extend::Repeat,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── ShapeGradient ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/// A serialisable gradient description.
|
|
||||||
///
|
|
||||||
/// Stops are kept sorted by position (ascending). There are always ≥ 2 stops.
|
|
||||||
///
|
|
||||||
/// *Rendering*: call [`to_peniko_brush`](ShapeGradient::to_peniko_brush) with
|
|
||||||
/// explicit start/end canvas-space points. For vector faces the caller derives
|
|
||||||
/// the points from the bounding box + `angle`; for the raster tool the caller
|
|
||||||
/// uses the drag start/end directly.
|
|
||||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
|
||||||
pub struct ShapeGradient {
|
|
||||||
pub kind: GradientType,
|
|
||||||
/// Colour stops, sorted by position.
|
|
||||||
pub stops: Vec<GradientStop>,
|
|
||||||
/// Angle in degrees for Linear (0 = left→right, 90 = top→bottom).
|
|
||||||
/// Ignored for Radial.
|
|
||||||
pub angle: f32,
|
|
||||||
pub extend: GradientExtend,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for ShapeGradient {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self {
|
|
||||||
kind: GradientType::Linear,
|
|
||||||
stops: vec![
|
|
||||||
GradientStop { position: 0.0, color: ShapeColor::rgba(0, 0, 0, 255) },
|
|
||||||
GradientStop { position: 1.0, color: ShapeColor::rgba(0, 0, 0, 0) },
|
|
||||||
],
|
|
||||||
angle: 0.0,
|
|
||||||
extend: GradientExtend::Pad,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ShapeGradient {
|
|
||||||
// ── CPU evaluation ───────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/// Sample RGBA at `t ∈ [0,1]` by linear interpolation between adjacent stops.
|
|
||||||
/// Stops must be sorted ascending by position.
|
|
||||||
pub fn eval(&self, t: f32) -> [u8; 4] {
|
|
||||||
let t = t.clamp(0.0, 1.0);
|
|
||||||
if self.stops.is_empty() {
|
|
||||||
return [0, 0, 0, 0];
|
|
||||||
}
|
|
||||||
if self.stops.len() == 1 {
|
|
||||||
let c = self.stops[0].color;
|
|
||||||
return [c.r, c.g, c.b, c.a];
|
|
||||||
}
|
|
||||||
// Find first stop with position > t
|
|
||||||
let i = self.stops.partition_point(|s| s.position <= t);
|
|
||||||
if i == 0 {
|
|
||||||
let c = self.stops[0].color;
|
|
||||||
return [c.r, c.g, c.b, c.a];
|
|
||||||
}
|
|
||||||
if i >= self.stops.len() {
|
|
||||||
let c = self.stops.last().unwrap().color;
|
|
||||||
return [c.r, c.g, c.b, c.a];
|
|
||||||
}
|
|
||||||
let s0 = self.stops[i - 1];
|
|
||||||
let s1 = self.stops[i];
|
|
||||||
let span = s1.position - s0.position;
|
|
||||||
let f = if span <= 0.0 { 0.0 } else { (t - s0.position) / span };
|
|
||||||
fn lerp(a: u8, b: u8, f: f32) -> u8 {
|
|
||||||
(a as f32 + (b as f32 - a as f32) * f).round().clamp(0.0, 255.0) as u8
|
|
||||||
}
|
|
||||||
[
|
|
||||||
lerp(s0.color.r, s1.color.r, f),
|
|
||||||
lerp(s0.color.g, s1.color.g, f),
|
|
||||||
lerp(s0.color.b, s1.color.b, f),
|
|
||||||
lerp(s0.color.a, s1.color.a, f),
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Apply `extend` mode to a raw t value, returning t ∈ [0,1].
|
|
||||||
pub fn apply_extend(&self, t_raw: f32) -> f32 {
|
|
||||||
match self.extend {
|
|
||||||
GradientExtend::Pad => t_raw.clamp(0.0, 1.0),
|
|
||||||
GradientExtend::Repeat => {
|
|
||||||
let t = t_raw.rem_euclid(1.0);
|
|
||||||
if t < 0.0 { t + 1.0 } else { t }
|
|
||||||
}
|
|
||||||
GradientExtend::Reflect => {
|
|
||||||
let t = t_raw.rem_euclid(2.0).abs();
|
|
||||||
if t > 1.0 { 2.0 - t } else { t }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── GPU / peniko rendering ───────────────────────────────────────────────
|
|
||||||
|
|
||||||
/// Build a `peniko::Brush` from explicit start/end canvas-coordinate points.
|
|
||||||
///
|
|
||||||
/// `opacity` in [0,1] is multiplied into all stop alphas.
|
|
||||||
pub fn to_peniko_brush(&self, start: Point, end: Point, opacity: f32) -> Brush {
|
|
||||||
// Convert stops to peniko tuples.
|
|
||||||
let peniko_stops: Vec<(f32, peniko::Color)> = self.stops.iter().map(|s| {
|
|
||||||
let a_scaled = (s.color.a as f32 * opacity).round().clamp(0.0, 255.0) as u8;
|
|
||||||
let col = peniko::Color::from_rgba8(s.color.r, s.color.g, s.color.b, a_scaled);
|
|
||||||
(s.position, col)
|
|
||||||
}).collect();
|
|
||||||
|
|
||||||
let extend: Extend = self.extend.into();
|
|
||||||
|
|
||||||
match self.kind {
|
|
||||||
GradientType::Linear => {
|
|
||||||
Brush::Gradient(
|
|
||||||
Gradient::new_linear(start, end)
|
|
||||||
.with_extend(extend)
|
|
||||||
.with_stops(peniko_stops.as_slice()),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
GradientType::Radial => {
|
|
||||||
let mid = Point::new(
|
|
||||||
(start.x + end.x) * 0.5,
|
|
||||||
(start.y + end.y) * 0.5,
|
|
||||||
);
|
|
||||||
let dx = end.x - start.x;
|
|
||||||
let dy = end.y - start.y;
|
|
||||||
let radius = ((dx * dx + dy * dy).sqrt() * 0.5) as f32;
|
|
||||||
Brush::Gradient(
|
|
||||||
Gradient::new_radial(mid, radius)
|
|
||||||
.with_extend(extend)
|
|
||||||
.with_stops(peniko_stops.as_slice()),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -52,8 +52,6 @@ pub mod webcam;
|
||||||
pub mod raster_layer;
|
pub mod raster_layer;
|
||||||
pub mod brush_settings;
|
pub mod brush_settings;
|
||||||
pub mod brush_engine;
|
pub mod brush_engine;
|
||||||
pub mod raster_draw;
|
|
||||||
pub mod gradient;
|
|
||||||
|
|
||||||
#[cfg(debug_assertions)]
|
#[cfg(debug_assertions)]
|
||||||
pub mod test_mode;
|
pub mod test_mode;
|
||||||
|
|
|
||||||
|
|
@ -1,194 +0,0 @@
|
||||||
//! CPU-side raster drawing primitives for geometric shapes on raster layers.
|
|
||||||
//!
|
|
||||||
//! All coordinates are in canvas pixels (f32). The pixel buffer is RGBA u8,
|
|
||||||
//! 4 bytes per pixel, row-major, top-left origin.
|
|
||||||
|
|
||||||
/// RGBA color as `[R, G, B, A]` bytes.
|
|
||||||
pub type Rgba = [u8; 4];
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Internal helpers
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/// Alpha-composite `color` (RGBA) onto `pixels[idx..idx+4]` with an extra
|
|
||||||
/// `coverage` factor (0.0 = transparent, 1.0 = full color alpha).
|
|
||||||
#[inline]
|
|
||||||
fn blend_at(pixels: &mut [u8], idx: usize, color: Rgba, coverage: f32) {
|
|
||||||
let a = (color[3] as f32 / 255.0) * coverage;
|
|
||||||
if a <= 0.0 { return; }
|
|
||||||
let inv = 1.0 - a;
|
|
||||||
pixels[idx] = (color[0] as f32 * a + pixels[idx] as f32 * inv) as u8;
|
|
||||||
pixels[idx + 1] = (color[1] as f32 * a + pixels[idx + 1] as f32 * inv) as u8;
|
|
||||||
pixels[idx + 2] = (color[2] as f32 * a + pixels[idx + 2] as f32 * inv) as u8;
|
|
||||||
pixels[idx + 3] = ((a + pixels[idx + 3] as f32 / 255.0 * inv) * 255.0).min(255.0) as u8;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Write a pixel at integer canvas coordinates, clipped to canvas bounds.
|
|
||||||
#[inline]
|
|
||||||
fn put(pixels: &mut [u8], w: u32, h: u32, x: i32, y: i32, color: Rgba, coverage: f32) {
|
|
||||||
if x < 0 || y < 0 || x >= w as i32 || y >= h as i32 { return; }
|
|
||||||
let idx = (y as u32 * w + x as u32) as usize * 4;
|
|
||||||
blend_at(pixels, idx, color, coverage);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Draw an anti-aliased filled disk at (`cx`, `cy`) with the given `radius`.
|
|
||||||
fn draw_disk(pixels: &mut [u8], w: u32, h: u32, cx: f32, cy: f32, radius: f32, color: Rgba) {
|
|
||||||
let r = (radius + 1.0) as i32;
|
|
||||||
let ix = cx as i32;
|
|
||||||
let iy = cy as i32;
|
|
||||||
for dy in -r..=r {
|
|
||||||
for dx in -r..=r {
|
|
||||||
let px = ix + dx;
|
|
||||||
let py = iy + dy;
|
|
||||||
let dist = ((px as f32 - cx).powi(2) + (py as f32 - cy).powi(2)).sqrt();
|
|
||||||
let cov = (radius + 0.5 - dist).clamp(0.0, 1.0);
|
|
||||||
if cov > 0.0 {
|
|
||||||
put(pixels, w, h, px, py, color, cov);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Public API
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/// Draw a thick line from (`x0`, `y0`) to (`x1`, `y1`) by stamping
|
|
||||||
/// anti-aliased disks of radius `thickness / 2` at every half-pixel step.
|
|
||||||
pub fn draw_line(
|
|
||||||
pixels: &mut [u8], w: u32, h: u32,
|
|
||||||
x0: f32, y0: f32, x1: f32, y1: f32,
|
|
||||||
color: Rgba, thickness: f32,
|
|
||||||
) {
|
|
||||||
let radius = (thickness / 2.0).max(0.5);
|
|
||||||
let dx = x1 - x0;
|
|
||||||
let dy = y1 - y0;
|
|
||||||
let len = (dx * dx + dy * dy).sqrt();
|
|
||||||
if len < 0.5 {
|
|
||||||
draw_disk(pixels, w, h, x0, y0, radius, color);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let steps = ((len * 2.0).ceil() as i32).max(1);
|
|
||||||
for i in 0..=steps {
|
|
||||||
let t = i as f32 / steps as f32;
|
|
||||||
draw_disk(pixels, w, h, x0 + dx * t, y0 + dy * t, radius, color);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Draw a rectangle with corners (`x0`, `y0`) and (`x1`, `y1`).
|
|
||||||
///
|
|
||||||
/// `stroke` draws the four edges; `fill` fills the interior. Either may be
|
|
||||||
/// `None` to skip that part.
|
|
||||||
pub fn draw_rect(
|
|
||||||
pixels: &mut [u8], w: u32, h: u32,
|
|
||||||
x0: f32, y0: f32, x1: f32, y1: f32,
|
|
||||||
stroke: Option<Rgba>, fill: Option<Rgba>, thickness: f32,
|
|
||||||
) {
|
|
||||||
let (lx, rx) = if x0 <= x1 { (x0, x1) } else { (x1, x0) };
|
|
||||||
let (ty, by) = if y0 <= y1 { (y0, y1) } else { (y1, y0) };
|
|
||||||
|
|
||||||
if let Some(fc) = fill {
|
|
||||||
let px0 = lx.ceil() as i32;
|
|
||||||
let py0 = ty.ceil() as i32;
|
|
||||||
let px1 = rx.floor() as i32;
|
|
||||||
let py1 = by.floor() as i32;
|
|
||||||
for py in py0..=py1 {
|
|
||||||
for px in px0..=px1 {
|
|
||||||
put(pixels, w, h, px, py, fc, 1.0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(sc) = stroke {
|
|
||||||
draw_line(pixels, w, h, lx, ty, rx, ty, sc, thickness); // top
|
|
||||||
draw_line(pixels, w, h, rx, ty, rx, by, sc, thickness); // right
|
|
||||||
draw_line(pixels, w, h, rx, by, lx, by, sc, thickness); // bottom
|
|
||||||
draw_line(pixels, w, h, lx, by, lx, ty, sc, thickness); // left
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Draw an ellipse centred at (`cx`, `cy`) with semi-axes `rx` and `ry`.
|
|
||||||
///
|
|
||||||
/// `stroke` draws the outline; `fill` fills the interior via scanline.
|
|
||||||
pub fn draw_ellipse(
|
|
||||||
pixels: &mut [u8], w: u32, h: u32,
|
|
||||||
cx: f32, cy: f32, rx: f32, ry: f32,
|
|
||||||
stroke: Option<Rgba>, fill: Option<Rgba>, thickness: f32,
|
|
||||||
) {
|
|
||||||
if rx <= 0.0 || ry <= 0.0 { return; }
|
|
||||||
|
|
||||||
if let Some(fc) = fill {
|
|
||||||
let py0 = (cy - ry).ceil() as i32;
|
|
||||||
let py1 = (cy + ry).floor() as i32;
|
|
||||||
for py in py0..=py1 {
|
|
||||||
let dy = py as f32 - cy;
|
|
||||||
let t = 1.0 - (dy / ry).powi(2);
|
|
||||||
if t <= 0.0 { continue; }
|
|
||||||
let x_ext = rx * t.sqrt();
|
|
||||||
let px0 = (cx - x_ext).ceil() as i32;
|
|
||||||
let px1 = (cx + x_ext).floor() as i32;
|
|
||||||
for px in px0..=px1 {
|
|
||||||
put(pixels, w, h, px, py, fc, 1.0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(sc) = stroke {
|
|
||||||
let radius = (thickness / 2.0).max(0.5);
|
|
||||||
// Ramanujan's perimeter approximation for step count.
|
|
||||||
let perim = std::f32::consts::PI
|
|
||||||
* (3.0 * (rx + ry) - ((3.0 * rx + ry) * (rx + 3.0 * ry)).sqrt());
|
|
||||||
let steps = ((perim * 2.0).ceil() as i32).max(16);
|
|
||||||
for i in 0..steps {
|
|
||||||
let t = i as f32 / steps as f32 * std::f32::consts::TAU;
|
|
||||||
draw_disk(pixels, w, h, cx + rx * t.cos(), cy + ry * t.sin(), radius, sc);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Draw a closed polygon given world-space `vertices` (at least 2).
|
|
||||||
///
|
|
||||||
/// `stroke` draws the outline; `fill` fills the interior via scanline.
|
|
||||||
pub fn draw_polygon(
|
|
||||||
pixels: &mut [u8], w: u32, h: u32,
|
|
||||||
vertices: &[(f32, f32)],
|
|
||||||
stroke: Option<Rgba>, fill: Option<Rgba>, thickness: f32,
|
|
||||||
) {
|
|
||||||
let n = vertices.len();
|
|
||||||
if n < 2 { return; }
|
|
||||||
|
|
||||||
if let Some(fc) = fill {
|
|
||||||
let min_y = vertices.iter().map(|v| v.1).fold(f32::MAX, f32::min).ceil() as i32;
|
|
||||||
let max_y = vertices.iter().map(|v| v.1).fold(f32::MIN, f32::max).floor() as i32;
|
|
||||||
let mut xs: Vec<f32> = Vec::with_capacity(n);
|
|
||||||
for py in min_y..=max_y {
|
|
||||||
xs.clear();
|
|
||||||
let scan_y = py as f32 + 0.5;
|
|
||||||
for i in 0..n {
|
|
||||||
let (x0, y0) = vertices[i];
|
|
||||||
let (x1, y1) = vertices[(i + 1) % n];
|
|
||||||
if (y0 <= scan_y && scan_y < y1) || (y1 <= scan_y && scan_y < y0) {
|
|
||||||
xs.push(x0 + (scan_y - y0) / (y1 - y0) * (x1 - x0));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
xs.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
|
|
||||||
let mut j = 0;
|
|
||||||
while j + 1 < xs.len() {
|
|
||||||
let px0 = xs[j].ceil() as i32;
|
|
||||||
let px1 = xs[j + 1].floor() as i32;
|
|
||||||
for px in px0..=px1 {
|
|
||||||
put(pixels, w, h, px, py, fc, 1.0);
|
|
||||||
}
|
|
||||||
j += 2;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(sc) = stroke {
|
|
||||||
for i in 0..n {
|
|
||||||
let (x0, y0) = vertices[i];
|
|
||||||
let (x1, y1) = vertices[(i + 1) % n];
|
|
||||||
draw_line(pixels, w, h, x0, y0, x1, y1, sc, thickness);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -18,18 +18,6 @@ pub enum RasterBlendMode {
|
||||||
Erase,
|
Erase,
|
||||||
/// Smudge / blend surrounding pixels
|
/// Smudge / blend surrounding pixels
|
||||||
Smudge,
|
Smudge,
|
||||||
/// Clone stamp: copy pixels from a source region
|
|
||||||
CloneStamp,
|
|
||||||
/// Healing brush: color-corrected clone stamp (preserves source texture, shifts color to match destination)
|
|
||||||
Healing,
|
|
||||||
/// Pattern stamp: paint with a repeating procedural tile pattern
|
|
||||||
PatternStamp,
|
|
||||||
/// Dodge / Burn: lighten (dodge) or darken (burn) existing pixels
|
|
||||||
DodgeBurn,
|
|
||||||
/// Sponge: saturate or desaturate existing pixels
|
|
||||||
Sponge,
|
|
||||||
/// Blur / Sharpen: soften or crisp up existing pixels
|
|
||||||
BlurSharpen,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for RasterBlendMode {
|
impl Default for RasterBlendMode {
|
||||||
|
|
@ -38,15 +26,6 @@ impl Default for RasterBlendMode {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl RasterBlendMode {
|
|
||||||
/// Returns false for blend modes that operate on existing pixels and don't
|
|
||||||
/// use the brush color at all (clone, heal, dodge/burn, sponge).
|
|
||||||
/// Used by brush_engine.rs to decide whether color_a should be 1.0 or stroke.color[3].
|
|
||||||
pub fn uses_brush_color(self) -> bool {
|
|
||||||
!matches!(self, Self::CloneStamp | Self::Healing | Self::DodgeBurn | Self::Sponge | Self::BlurSharpen)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A single point along a stroke
|
/// A single point along a stroke
|
||||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
pub struct StrokePoint {
|
pub struct StrokePoint {
|
||||||
|
|
@ -69,13 +48,6 @@ pub struct StrokeRecord {
|
||||||
/// RGBA linear color [r, g, b, a]
|
/// RGBA linear color [r, g, b, a]
|
||||||
pub color: [f32; 4],
|
pub color: [f32; 4],
|
||||||
pub blend_mode: RasterBlendMode,
|
pub blend_mode: RasterBlendMode,
|
||||||
/// Generic tool parameters — encoding depends on blend_mode:
|
|
||||||
/// - CloneStamp / Healing: [offset_x, offset_y, 0, 0]
|
|
||||||
/// - PatternStamp: [pattern_type, pattern_scale, 0, 0]
|
|
||||||
/// - DodgeBurn / Sponge: [mode, 0, 0, 0]
|
|
||||||
/// - all others: [0, 0, 0, 0]
|
|
||||||
#[serde(default)]
|
|
||||||
pub tool_params: [f32; 4],
|
|
||||||
pub points: Vec<StrokePoint>,
|
pub points: Vec<StrokePoint>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -113,14 +85,8 @@ pub struct RasterKeyframe {
|
||||||
/// and encoded back to PNG on save. An empty Vec means the canvas is blank (transparent).
|
/// and encoded back to PNG on save. An empty Vec means the canvas is blank (transparent).
|
||||||
#[serde(skip)]
|
#[serde(skip)]
|
||||||
pub raw_pixels: Vec<u8>,
|
pub raw_pixels: Vec<u8>,
|
||||||
/// Set to `true` whenever `raw_pixels` changes so the GPU texture cache can re-upload.
|
|
||||||
/// Always `true` after load; cleared by the renderer after uploading.
|
|
||||||
#[serde(skip, default = "default_true")]
|
|
||||||
pub texture_dirty: bool,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_true() -> bool { true }
|
|
||||||
|
|
||||||
impl RasterKeyframe {
|
impl RasterKeyframe {
|
||||||
/// Returns true when the pixel buffer has been initialised (non-blank).
|
/// Returns true when the pixel buffer has been initialised (non-blank).
|
||||||
pub fn has_pixels(&self) -> bool {
|
pub fn has_pixels(&self) -> bool {
|
||||||
|
|
@ -139,7 +105,6 @@ impl RasterKeyframe {
|
||||||
stroke_log: Vec::new(),
|
stroke_log: Vec::new(),
|
||||||
tween_after: TweenType::Hold,
|
tween_after: TweenType::Hold,
|
||||||
raw_pixels: Vec::new(),
|
raw_pixels: Vec::new(),
|
||||||
texture_dirty: true,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ use std::collections::HashMap;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
use vello::kurbo::Rect;
|
use vello::kurbo::Rect;
|
||||||
use vello::peniko::{Blob, Fill, ImageAlphaType, ImageBrush, ImageData, ImageFormat, ImageQuality};
|
use vello::peniko::{Blob, Fill, ImageAlphaType, ImageBrush, ImageData, ImageFormat};
|
||||||
use vello::Scene;
|
use vello::Scene;
|
||||||
|
|
||||||
/// Cache for decoded image data to avoid re-decoding every frame
|
/// Cache for decoded image data to avoid re-decoding every frame
|
||||||
|
|
@ -88,53 +88,14 @@ fn decode_image_asset(asset: &ImageAsset) -> Option<ImageBrush> {
|
||||||
// Per-Layer Rendering for HDR Compositing Pipeline
|
// Per-Layer Rendering for HDR Compositing Pipeline
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
/// A single decoded video frame ready for GPU upload, with its document-space transform.
|
|
||||||
pub struct VideoRenderInstance {
|
|
||||||
/// sRGB RGBA8 pixel data (straight alpha — as decoded by ffmpeg).
|
|
||||||
pub rgba_data: Arc<Vec<u8>>,
|
|
||||||
pub width: u32,
|
|
||||||
pub height: u32,
|
|
||||||
/// Affine transform that maps from video-pixel space to document space.
|
|
||||||
/// Composed from the clip's animated position/rotation/scale properties.
|
|
||||||
pub transform: Affine,
|
|
||||||
/// Final opacity [0,1] after cascading layer and instance opacity.
|
|
||||||
pub opacity: f32,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Type of rendered layer for compositor handling
|
/// Type of rendered layer for compositor handling
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
pub enum RenderedLayerType {
|
pub enum RenderedLayerType {
|
||||||
/// Vector / group layer — Vello scene in `RenderedLayer::scene` is used.
|
/// Regular content layer (vector, video) - composite its scene
|
||||||
Vector,
|
Content,
|
||||||
/// Raster keyframe — bypass Vello; compositor uploads pixels via GPU texture cache.
|
/// Effect layer - apply effects to current composite state
|
||||||
Raster {
|
|
||||||
kf_id: Uuid,
|
|
||||||
width: u32,
|
|
||||||
height: u32,
|
|
||||||
/// True when `raw_pixels` changed since the last upload; forces a cache re-upload.
|
|
||||||
dirty: bool,
|
|
||||||
/// Accumulated parent-clip affine (IDENTITY for top-level layers).
|
|
||||||
/// Compositor composes this with the camera into the blit matrix.
|
|
||||||
transform: Affine,
|
|
||||||
},
|
|
||||||
/// Video layer — bypass Vello; each active clip instance carries decoded frame data.
|
|
||||||
Video {
|
|
||||||
instances: Vec<VideoRenderInstance>,
|
|
||||||
},
|
|
||||||
/// Floating raster selection — blitted immediately above its parent layer.
|
|
||||||
Float {
|
|
||||||
canvas_id: Uuid,
|
|
||||||
x: i32,
|
|
||||||
y: i32,
|
|
||||||
width: u32,
|
|
||||||
height: u32,
|
|
||||||
/// Accumulated parent-clip affine (IDENTITY for top-level layers).
|
|
||||||
transform: Affine,
|
|
||||||
/// CPU pixel data (sRGB-premultiplied RGBA8). Arc so the per-frame clone is O(1).
|
|
||||||
/// Used by the export compositor; the live compositor reads the GPU canvas directly.
|
|
||||||
pixels: std::sync::Arc<Vec<u8>>,
|
|
||||||
},
|
|
||||||
/// Effect layer — applied as a post-process pass on the HDR accumulator.
|
|
||||||
Effect {
|
Effect {
|
||||||
|
/// Active effect instances at the current time
|
||||||
effect_instances: Vec<ClipInstance>,
|
effect_instances: Vec<ClipInstance>,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
@ -143,7 +104,7 @@ pub enum RenderedLayerType {
|
||||||
pub struct RenderedLayer {
|
pub struct RenderedLayer {
|
||||||
/// The layer's unique identifier
|
/// The layer's unique identifier
|
||||||
pub layer_id: Uuid,
|
pub layer_id: Uuid,
|
||||||
/// Vello scene — only populated for `RenderedLayerType::Vector`.
|
/// The Vello scene containing the layer's rendered content
|
||||||
pub scene: Scene,
|
pub scene: Scene,
|
||||||
/// Layer opacity (0.0 to 1.0)
|
/// Layer opacity (0.0 to 1.0)
|
||||||
pub opacity: f32,
|
pub opacity: f32,
|
||||||
|
|
@ -151,12 +112,12 @@ pub struct RenderedLayer {
|
||||||
pub blend_mode: BlendMode,
|
pub blend_mode: BlendMode,
|
||||||
/// Whether this layer has any visible content
|
/// Whether this layer has any visible content
|
||||||
pub has_content: bool,
|
pub has_content: bool,
|
||||||
/// Layer variant — determines how the compositor renders this entry.
|
/// Type of layer for compositor (content vs effect)
|
||||||
pub layer_type: RenderedLayerType,
|
pub layer_type: RenderedLayerType,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl RenderedLayer {
|
impl RenderedLayer {
|
||||||
/// Create a new vector layer with default settings.
|
/// Create a new rendered layer with default settings
|
||||||
pub fn new(layer_id: Uuid) -> Self {
|
pub fn new(layer_id: Uuid) -> Self {
|
||||||
Self {
|
Self {
|
||||||
layer_id,
|
layer_id,
|
||||||
|
|
@ -164,11 +125,11 @@ impl RenderedLayer {
|
||||||
opacity: 1.0,
|
opacity: 1.0,
|
||||||
blend_mode: BlendMode::Normal,
|
blend_mode: BlendMode::Normal,
|
||||||
has_content: false,
|
has_content: false,
|
||||||
layer_type: RenderedLayerType::Vector,
|
layer_type: RenderedLayerType::Content,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create a vector layer with specific opacity and blend mode.
|
/// Create with specific opacity and blend mode
|
||||||
pub fn with_settings(layer_id: Uuid, opacity: f32, blend_mode: BlendMode) -> Self {
|
pub fn with_settings(layer_id: Uuid, opacity: f32, blend_mode: BlendMode) -> Self {
|
||||||
Self {
|
Self {
|
||||||
layer_id,
|
layer_id,
|
||||||
|
|
@ -176,11 +137,11 @@ impl RenderedLayer {
|
||||||
opacity,
|
opacity,
|
||||||
blend_mode,
|
blend_mode,
|
||||||
has_content: false,
|
has_content: false,
|
||||||
layer_type: RenderedLayerType::Vector,
|
layer_type: RenderedLayerType::Content,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create an effect layer with active effect instances.
|
/// Create an effect layer with active effect instances
|
||||||
pub fn effect_layer(layer_id: Uuid, opacity: f32, effect_instances: Vec<ClipInstance>) -> Self {
|
pub fn effect_layer(layer_id: Uuid, opacity: f32, effect_instances: Vec<ClipInstance>) -> Self {
|
||||||
let has_content = !effect_instances.is_empty();
|
let has_content = !effect_instances.is_empty();
|
||||||
Self {
|
Self {
|
||||||
|
|
@ -218,14 +179,12 @@ pub fn render_document_for_compositing(
|
||||||
image_cache: &mut ImageCache,
|
image_cache: &mut ImageCache,
|
||||||
video_manager: &std::sync::Arc<std::sync::Mutex<crate::video::VideoManager>>,
|
video_manager: &std::sync::Arc<std::sync::Mutex<crate::video::VideoManager>>,
|
||||||
camera_frame: Option<&crate::webcam::CaptureFrame>,
|
camera_frame: Option<&crate::webcam::CaptureFrame>,
|
||||||
floating_selection: Option<&crate::selection::RasterFloatingSelection>,
|
|
||||||
draw_checkerboard: bool,
|
|
||||||
) -> CompositeRenderResult {
|
) -> CompositeRenderResult {
|
||||||
let time = document.current_time;
|
let time = document.current_time;
|
||||||
|
|
||||||
// Render background to its own scene
|
// Render background to its own scene
|
||||||
let mut background = Scene::new();
|
let mut background = Scene::new();
|
||||||
render_background(document, &mut background, base_transform, draw_checkerboard);
|
render_background(document, &mut background, base_transform);
|
||||||
|
|
||||||
// Check if any layers are soloed
|
// Check if any layers are soloed
|
||||||
let any_soloed = document.visible_layers().any(|layer| layer.soloed());
|
let any_soloed = document.visible_layers().any(|layer| layer.soloed());
|
||||||
|
|
@ -258,36 +217,6 @@ pub fn render_document_for_compositing(
|
||||||
rendered_layers.push(rendered);
|
rendered_layers.push(rendered);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Insert the floating raster selection immediately above its parent layer.
|
|
||||||
// This ensures it composites at the correct z-position in both edit and export.
|
|
||||||
if let Some(float_sel) = floating_selection {
|
|
||||||
if let Some(pos) = rendered_layers.iter().position(|l| l.layer_id == float_sel.layer_id) {
|
|
||||||
// Inherit the parent layer's transform so the float follows it into
|
|
||||||
// any transformed clip context.
|
|
||||||
let parent_transform = match &rendered_layers[pos].layer_type {
|
|
||||||
RenderedLayerType::Raster { transform, .. } => *transform,
|
|
||||||
_ => Affine::IDENTITY,
|
|
||||||
};
|
|
||||||
let float_entry = RenderedLayer {
|
|
||||||
layer_id: Uuid::nil(), // sentinel — not a real document layer
|
|
||||||
scene: Scene::new(),
|
|
||||||
opacity: 1.0,
|
|
||||||
blend_mode: crate::gpu::BlendMode::Normal,
|
|
||||||
has_content: !float_sel.pixels.is_empty(),
|
|
||||||
layer_type: RenderedLayerType::Float {
|
|
||||||
canvas_id: float_sel.canvas_id,
|
|
||||||
x: float_sel.x,
|
|
||||||
y: float_sel.y,
|
|
||||||
width: float_sel.width,
|
|
||||||
height: float_sel.height,
|
|
||||||
transform: parent_transform,
|
|
||||||
pixels: std::sync::Arc::clone(&float_sel.pixels),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
rendered_layers.insert(pos + 1, float_entry);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
CompositeRenderResult {
|
CompositeRenderResult {
|
||||||
background,
|
background,
|
||||||
layers: rendered_layers,
|
layers: rendered_layers,
|
||||||
|
|
@ -340,74 +269,21 @@ pub fn render_layer_isolated(
|
||||||
rendered.has_content = false;
|
rendered.has_content = false;
|
||||||
}
|
}
|
||||||
AnyLayer::Video(video_layer) => {
|
AnyLayer::Video(video_layer) => {
|
||||||
use crate::animation::TransformProperty;
|
|
||||||
let layer_opacity = layer.opacity();
|
|
||||||
let mut video_mgr = video_manager.lock().unwrap();
|
let mut video_mgr = video_manager.lock().unwrap();
|
||||||
let mut instances = Vec::new();
|
// Only pass camera_frame for the layer that has camera enabled
|
||||||
|
let layer_camera_frame = if video_layer.camera_enabled { camera_frame } else { None };
|
||||||
for clip_instance in &video_layer.clip_instances {
|
render_video_layer_to_scene(
|
||||||
let Some(video_clip) = document.video_clips.get(&clip_instance.clip_id) else { continue };
|
document,
|
||||||
let Some(clip_time) = clip_instance.remap_time(time, video_clip.duration) else { continue };
|
time,
|
||||||
let Some(frame) = video_mgr.get_frame(&clip_instance.clip_id, clip_time) else { continue };
|
video_layer,
|
||||||
|
&mut rendered.scene,
|
||||||
// Evaluate animated transform properties.
|
base_transform,
|
||||||
let anim = &video_layer.layer.animation_data;
|
1.0, // Full opacity - layer opacity handled in compositing
|
||||||
let id = clip_instance.id;
|
&mut video_mgr,
|
||||||
let t = &clip_instance.transform;
|
layer_camera_frame,
|
||||||
let x = anim.eval(&crate::animation::AnimationTarget::Object { id, property: TransformProperty::X }, time, t.x);
|
);
|
||||||
let y = anim.eval(&crate::animation::AnimationTarget::Object { id, property: TransformProperty::Y }, time, t.y);
|
rendered.has_content = !video_layer.clip_instances.is_empty()
|
||||||
let rotation = anim.eval(&crate::animation::AnimationTarget::Object { id, property: TransformProperty::Rotation }, time, t.rotation);
|
|| (video_layer.camera_enabled && camera_frame.is_some());
|
||||||
let scale_x = anim.eval(&crate::animation::AnimationTarget::Object { id, property: TransformProperty::ScaleX }, time, t.scale_x);
|
|
||||||
let scale_y = anim.eval(&crate::animation::AnimationTarget::Object { id, property: TransformProperty::ScaleY }, time, t.scale_y);
|
|
||||||
let skew_x = anim.eval(&crate::animation::AnimationTarget::Object { id, property: TransformProperty::SkewX }, time, t.skew_x);
|
|
||||||
let skew_y = anim.eval(&crate::animation::AnimationTarget::Object { id, property: TransformProperty::SkewY }, time, t.skew_y);
|
|
||||||
let inst_opacity = anim.eval(&crate::animation::AnimationTarget::Object { id, property: TransformProperty::Opacity }, time, clip_instance.opacity);
|
|
||||||
|
|
||||||
let cx = video_clip.width / 2.0;
|
|
||||||
let cy = video_clip.height / 2.0;
|
|
||||||
let skew_transform = if skew_x != 0.0 || skew_y != 0.0 {
|
|
||||||
let sx = if skew_x != 0.0 { Affine::new([1.0, 0.0, skew_x.to_radians().tan(), 1.0, 0.0, 0.0]) } else { Affine::IDENTITY };
|
|
||||||
let sy = if skew_y != 0.0 { Affine::new([1.0, skew_y.to_radians().tan(), 0.0, 1.0, 0.0, 0.0]) } else { Affine::IDENTITY };
|
|
||||||
Affine::translate((cx, cy)) * sx * sy * Affine::translate((-cx, -cy))
|
|
||||||
} else { Affine::IDENTITY };
|
|
||||||
|
|
||||||
let clip_transform = Affine::translate((x, y))
|
|
||||||
* Affine::rotate(rotation.to_radians())
|
|
||||||
* Affine::scale_non_uniform(scale_x, scale_y)
|
|
||||||
* skew_transform;
|
|
||||||
|
|
||||||
instances.push(VideoRenderInstance {
|
|
||||||
rgba_data: frame.rgba_data.clone(),
|
|
||||||
width: frame.width,
|
|
||||||
height: frame.height,
|
|
||||||
transform: base_transform * clip_transform,
|
|
||||||
opacity: (layer_opacity * inst_opacity) as f32,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Camera / webcam frame.
|
|
||||||
if instances.is_empty() && video_layer.camera_enabled {
|
|
||||||
if let Some(frame) = camera_frame {
|
|
||||||
let vw = frame.width as f64;
|
|
||||||
let vh = frame.height as f64;
|
|
||||||
let scale = (document.width / vw).min(document.height / vh);
|
|
||||||
let ox = (document.width - vw * scale) / 2.0;
|
|
||||||
let oy = (document.height - vh * scale) / 2.0;
|
|
||||||
let cam_transform = base_transform
|
|
||||||
* Affine::translate((ox, oy))
|
|
||||||
* Affine::scale(scale);
|
|
||||||
instances.push(VideoRenderInstance {
|
|
||||||
rgba_data: frame.rgba_data.clone(),
|
|
||||||
width: frame.width,
|
|
||||||
height: frame.height,
|
|
||||||
transform: cam_transform,
|
|
||||||
opacity: layer_opacity as f32,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
rendered.has_content = !instances.is_empty();
|
|
||||||
rendered.layer_type = RenderedLayerType::Video { instances };
|
|
||||||
}
|
}
|
||||||
AnyLayer::Effect(effect_layer) => {
|
AnyLayer::Effect(effect_layer) => {
|
||||||
// Effect layers are processed during compositing, not rendered to scene
|
// Effect layers are processed during compositing, not rendered to scene
|
||||||
|
|
@ -431,16 +307,9 @@ pub fn render_layer_isolated(
|
||||||
rendered.has_content = !group_layer.children.is_empty();
|
rendered.has_content = !group_layer.children.is_empty();
|
||||||
}
|
}
|
||||||
AnyLayer::Raster(raster_layer) => {
|
AnyLayer::Raster(raster_layer) => {
|
||||||
if let Some(kf) = raster_layer.keyframe_at(time) {
|
render_raster_layer_to_scene(raster_layer, time, &mut rendered.scene, base_transform);
|
||||||
rendered.has_content = kf.has_pixels();
|
rendered.has_content = raster_layer.keyframe_at(time)
|
||||||
rendered.layer_type = RenderedLayerType::Raster {
|
.map_or(false, |kf| kf.has_pixels());
|
||||||
kf_id: kf.id,
|
|
||||||
width: kf.width,
|
|
||||||
height: kf.height,
|
|
||||||
dirty: kf.texture_dirty,
|
|
||||||
transform: base_transform,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -494,11 +363,35 @@ fn render_raster_layer_to_scene(
|
||||||
// decode the sRGB channels without premultiplying again.
|
// decode the sRGB channels without premultiplying again.
|
||||||
alpha_type: ImageAlphaType::AlphaPremultiplied,
|
alpha_type: ImageAlphaType::AlphaPremultiplied,
|
||||||
};
|
};
|
||||||
let brush = ImageBrush::new(image_data).with_quality(ImageQuality::Low);
|
let brush = ImageBrush::new(image_data);
|
||||||
let canvas_rect = Rect::new(0.0, 0.0, kf.width as f64, kf.height as f64);
|
let canvas_rect = Rect::new(0.0, 0.0, kf.width as f64, kf.height as f64);
|
||||||
scene.fill(Fill::NonZero, base_transform, &brush, None, &canvas_rect);
|
scene.fill(Fill::NonZero, base_transform, &brush, None, &canvas_rect);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Render a video layer to an isolated scene (for compositing pipeline)
|
||||||
|
fn render_video_layer_to_scene(
|
||||||
|
document: &Document,
|
||||||
|
time: f64,
|
||||||
|
layer: &crate::layer::VideoLayer,
|
||||||
|
scene: &mut Scene,
|
||||||
|
base_transform: Affine,
|
||||||
|
parent_opacity: f64,
|
||||||
|
video_manager: &mut crate::video::VideoManager,
|
||||||
|
camera_frame: Option<&crate::webcam::CaptureFrame>,
|
||||||
|
) {
|
||||||
|
// Render using the existing function but to this isolated scene
|
||||||
|
render_video_layer(
|
||||||
|
document,
|
||||||
|
time,
|
||||||
|
layer,
|
||||||
|
scene,
|
||||||
|
base_transform,
|
||||||
|
parent_opacity,
|
||||||
|
video_manager,
|
||||||
|
camera_frame,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Legacy Single-Scene Rendering (kept for backwards compatibility)
|
// Legacy Single-Scene Rendering (kept for backwards compatibility)
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
@ -522,8 +415,8 @@ pub fn render_document_with_transform(
|
||||||
image_cache: &mut ImageCache,
|
image_cache: &mut ImageCache,
|
||||||
video_manager: &std::sync::Arc<std::sync::Mutex<crate::video::VideoManager>>,
|
video_manager: &std::sync::Arc<std::sync::Mutex<crate::video::VideoManager>>,
|
||||||
) {
|
) {
|
||||||
// 1. Draw background (with checkerboard for transparent backgrounds — UI path)
|
// 1. Draw background
|
||||||
render_background(document, scene, base_transform, true);
|
render_background(document, scene, base_transform);
|
||||||
|
|
||||||
// 2. Recursively render the root graphics object at current time
|
// 2. Recursively render the root graphics object at current time
|
||||||
let time = document.current_time;
|
let time = document.current_time;
|
||||||
|
|
@ -543,12 +436,12 @@ pub fn render_document_with_transform(
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Draw the document background
|
/// Draw the document background
|
||||||
fn render_background(document: &Document, scene: &mut Scene, base_transform: Affine, draw_checkerboard: bool) {
|
fn render_background(document: &Document, scene: &mut Scene, base_transform: Affine) {
|
||||||
let background_rect = Rect::new(0.0, 0.0, document.width, document.height);
|
let background_rect = Rect::new(0.0, 0.0, document.width, document.height);
|
||||||
let bg = &document.background_color;
|
let bg = &document.background_color;
|
||||||
|
|
||||||
// Draw checkerboard behind transparent backgrounds (UI-only; skip in export)
|
// Draw checkerboard behind transparent backgrounds
|
||||||
if draw_checkerboard && bg.a < 255 {
|
if bg.a < 255 {
|
||||||
use vello::peniko::{Blob, Color, Extend, ImageAlphaType, ImageData, ImageQuality};
|
use vello::peniko::{Blob, Color, Extend, ImageAlphaType, ImageData, ImageQuality};
|
||||||
// 2x2 pixel checkerboard pattern: light/dark alternating
|
// 2x2 pixel checkerboard pattern: light/dark alternating
|
||||||
let light: [u8; 4] = [204, 204, 204, 255];
|
let light: [u8; 4] = [204, 204, 204, 255];
|
||||||
|
|
@ -1030,25 +923,7 @@ fn render_video_layer(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Compute start/end canvas points for a linear gradient across a bounding box.
|
/// Render a vector layer with all its clip instances and shape instances
|
||||||
///
|
|
||||||
/// The axis is centred on the bbox midpoint and oriented at `angle_deg` degrees
|
|
||||||
/// (0 = left→right, 90 = top→bottom). The axis extends ± half the bbox diagonal
|
|
||||||
/// so the gradient covers the entire shape regardless of angle.
|
|
||||||
fn gradient_bbox_endpoints(angle_deg: f32, bbox: kurbo::Rect) -> (kurbo::Point, kurbo::Point) {
|
|
||||||
let cx = bbox.center().x;
|
|
||||||
let cy = bbox.center().y;
|
|
||||||
let dx = bbox.width();
|
|
||||||
let dy = bbox.height();
|
|
||||||
// Use half the diagonal so the full gradient fits at any angle.
|
|
||||||
let half_len = (dx * dx + dy * dy).sqrt() * 0.5;
|
|
||||||
let rad = (angle_deg as f64).to_radians();
|
|
||||||
let (sin, cos) = (rad.sin(), rad.cos());
|
|
||||||
let start = kurbo::Point::new(cx - cos * half_len, cy - sin * half_len);
|
|
||||||
let end = kurbo::Point::new(cx + cos * half_len, cy + sin * half_len);
|
|
||||||
(start, end)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Render a DCEL to a Vello scene.
|
/// Render a DCEL to a Vello scene.
|
||||||
///
|
///
|
||||||
/// Walks faces for fills and edges for strokes.
|
/// Walks faces for fills and edges for strokes.
|
||||||
|
|
@ -1067,7 +942,7 @@ pub fn render_dcel(
|
||||||
if face.deleted || i == 0 {
|
if face.deleted || i == 0 {
|
||||||
continue; // Skip unbounded face and deleted faces
|
continue; // Skip unbounded face and deleted faces
|
||||||
}
|
}
|
||||||
if face.fill_color.is_none() && face.image_fill.is_none() && face.gradient_fill.is_none() {
|
if face.fill_color.is_none() && face.image_fill.is_none() {
|
||||||
continue; // No fill to render
|
continue; // No fill to render
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1088,19 +963,7 @@ pub fn render_dcel(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Gradient fill (takes priority over solid colour fill)
|
// Color fill
|
||||||
if !filled {
|
|
||||||
if let Some(ref grad) = face.gradient_fill {
|
|
||||||
use kurbo::{Point, Rect};
|
|
||||||
let bbox: Rect = vello::kurbo::Shape::bounding_box(&path);
|
|
||||||
let (start, end) = gradient_bbox_endpoints(grad.angle, bbox);
|
|
||||||
let brush = grad.to_peniko_brush(start, end, opacity_f32);
|
|
||||||
scene.fill(fill_rule, base_transform, &brush, None, &path);
|
|
||||||
filled = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Solid colour fill
|
|
||||||
if !filled {
|
if !filled {
|
||||||
if let Some(fill_color) = &face.fill_color {
|
if let Some(fill_color) = &face.fill_color {
|
||||||
let alpha = ((fill_color.a as f32 / 255.0) * opacity_f32 * 255.0) as u8;
|
let alpha = ((fill_color.a as f32 / 255.0) * opacity_f32 * 255.0) as u8;
|
||||||
|
|
|
||||||
|
|
@ -15,16 +15,6 @@ pub enum RasterSelection {
|
||||||
Rect(i32, i32, i32, i32),
|
Rect(i32, i32, i32, i32),
|
||||||
/// Closed freehand lasso polygon.
|
/// Closed freehand lasso polygon.
|
||||||
Lasso(Vec<(i32, i32)>),
|
Lasso(Vec<(i32, i32)>),
|
||||||
/// Per-pixel boolean mask (e.g. from magic wand flood fill).
|
|
||||||
/// `data` is row-major, length = width × height.
|
|
||||||
Mask {
|
|
||||||
data: Vec<bool>,
|
|
||||||
width: u32,
|
|
||||||
height: u32,
|
|
||||||
/// Top-left canvas pixel of the mask's bounding canvas region.
|
|
||||||
origin_x: i32,
|
|
||||||
origin_y: i32,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl RasterSelection {
|
impl RasterSelection {
|
||||||
|
|
@ -39,23 +29,6 @@ impl RasterSelection {
|
||||||
let y1 = pts.iter().map(|p| p.1).max().unwrap_or(0);
|
let y1 = pts.iter().map(|p| p.1).max().unwrap_or(0);
|
||||||
(x0, y0, x1, y1)
|
(x0, y0, x1, y1)
|
||||||
}
|
}
|
||||||
Self::Mask { data, width, height, origin_x, origin_y } => {
|
|
||||||
let w = *width as i32;
|
|
||||||
let mut bx0 = i32::MAX; let mut by0 = i32::MAX;
|
|
||||||
let mut bx1 = i32::MIN; let mut by1 = i32::MIN;
|
|
||||||
for row in 0..*height as i32 {
|
|
||||||
for col in 0..w {
|
|
||||||
if data[(row * w + col) as usize] {
|
|
||||||
bx0 = bx0.min(origin_x + col);
|
|
||||||
by0 = by0.min(origin_y + row);
|
|
||||||
bx1 = bx1.max(origin_x + col + 1);
|
|
||||||
by1 = by1.max(origin_y + row + 1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if bx0 == i32::MAX { (*origin_x, *origin_y, *origin_x, *origin_y) }
|
|
||||||
else { (bx0, by0, bx1, by1) }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -64,14 +37,6 @@ impl RasterSelection {
|
||||||
match self {
|
match self {
|
||||||
Self::Rect(x0, y0, x1, y1) => px >= *x0 && px < *x1 && py >= *y0 && py < *y1,
|
Self::Rect(x0, y0, x1, y1) => px >= *x0 && px < *x1 && py >= *y0 && py < *y1,
|
||||||
Self::Lasso(pts) => point_in_polygon(px, py, pts),
|
Self::Lasso(pts) => point_in_polygon(px, py, pts),
|
||||||
Self::Mask { data, width, height, origin_x, origin_y } => {
|
|
||||||
let lx = px - origin_x;
|
|
||||||
let ly = py - origin_y;
|
|
||||||
if lx < 0 || ly < 0 || lx >= *width as i32 || ly >= *height as i32 {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
data[(ly * *width as i32 + lx) as usize]
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -104,9 +69,7 @@ fn point_in_polygon(px: i32, py: i32, polygon: &[(i32, i32)]) -> bool {
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub struct RasterFloatingSelection {
|
pub struct RasterFloatingSelection {
|
||||||
/// sRGB-encoded premultiplied RGBA, width × height × 4 bytes.
|
/// sRGB-encoded premultiplied RGBA, width × height × 4 bytes.
|
||||||
/// Wrapped in Arc so the renderer can clone a reference each frame (O(1))
|
pub pixels: Vec<u8>,
|
||||||
/// instead of copying megabytes of pixel data.
|
|
||||||
pub pixels: std::sync::Arc<Vec<u8>>,
|
|
||||||
pub width: u32,
|
pub width: u32,
|
||||||
pub height: u32,
|
pub height: u32,
|
||||||
/// Top-left position in canvas pixel coordinates.
|
/// Top-left position in canvas pixel coordinates.
|
||||||
|
|
@ -118,7 +81,7 @@ pub struct RasterFloatingSelection {
|
||||||
/// Snapshot of `raw_pixels` before the cut/paste was initiated, used for
|
/// Snapshot of `raw_pixels` before the cut/paste was initiated, used for
|
||||||
/// undo (via `RasterStrokeAction`) when the float is committed, and for
|
/// undo (via `RasterStrokeAction`) when the float is committed, and for
|
||||||
/// Cancel (Escape) to restore the canvas without creating an undo entry.
|
/// Cancel (Escape) to restore the canvas without creating an undo entry.
|
||||||
pub canvas_before: std::sync::Arc<Vec<u8>>,
|
pub canvas_before: Vec<u8>,
|
||||||
/// Key for this float's GPU canvas in `GpuBrushEngine::canvases`.
|
/// Key for this float's GPU canvas in `GpuBrushEngine::canvases`.
|
||||||
/// Allows painting strokes directly onto the float buffer (B) without
|
/// Allows painting strokes directly onto the float buffer (B) without
|
||||||
/// touching the layer canvas (A).
|
/// touching the layer canvas (A).
|
||||||
|
|
|
||||||
|
|
@ -11,10 +11,9 @@ use vello::kurbo::Point;
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub enum Tool {
|
pub enum Tool {
|
||||||
// ── Vector / shared tools ──────────────────────────────────────────────
|
|
||||||
/// Selection tool - select and move objects
|
/// Selection tool - select and move objects
|
||||||
Select,
|
Select,
|
||||||
/// Draw/Brush tool - freehand drawing (vector) / paintbrush (raster)
|
/// Draw/Pen tool - freehand drawing
|
||||||
Draw,
|
Draw,
|
||||||
/// Transform tool - scale, rotate, skew
|
/// Transform tool - scale, rotate, skew
|
||||||
Transform,
|
Transform,
|
||||||
|
|
@ -38,48 +37,12 @@ pub enum Tool {
|
||||||
RegionSelect,
|
RegionSelect,
|
||||||
/// Split tool - split audio/video clips at a point
|
/// Split tool - split audio/video clips at a point
|
||||||
Split,
|
Split,
|
||||||
// ── Raster brush tools ────────────────────────────────────────────────
|
|
||||||
/// Pencil tool - hard-edged raster brush
|
|
||||||
Pencil,
|
|
||||||
/// Pen tool - pressure-sensitive raster pen
|
|
||||||
Pen,
|
|
||||||
/// Airbrush tool - soft spray raster brush
|
|
||||||
Airbrush,
|
|
||||||
/// Erase tool - erase raster pixels
|
/// Erase tool - erase raster pixels
|
||||||
Erase,
|
Erase,
|
||||||
/// Smudge tool - smudge/blend raster pixels
|
/// Smudge tool - smudge/blend raster pixels
|
||||||
Smudge,
|
Smudge,
|
||||||
/// Clone Stamp - copy pixels from a source point
|
/// Lasso select tool - freehand selection on raster layers
|
||||||
CloneStamp,
|
|
||||||
/// Healing Brush - content-aware pixel repair
|
|
||||||
HealingBrush,
|
|
||||||
/// Pattern Stamp - paint with a repeating pattern
|
|
||||||
PatternStamp,
|
|
||||||
/// Dodge/Burn - lighten or darken pixels
|
|
||||||
DodgeBurn,
|
|
||||||
/// Sponge - saturate or desaturate pixels
|
|
||||||
Sponge,
|
|
||||||
/// Blur/Sharpen - blur or sharpen pixel regions
|
|
||||||
BlurSharpen,
|
|
||||||
// ── Raster fill / shape ───────────────────────────────────────────────
|
|
||||||
/// Gradient tool - fill with a gradient
|
|
||||||
Gradient,
|
|
||||||
/// Custom Shape tool - draw from a shape library
|
|
||||||
CustomShape,
|
|
||||||
// ── Raster selection tools ────────────────────────────────────────────
|
|
||||||
/// Elliptical marquee selection
|
|
||||||
SelectEllipse,
|
|
||||||
/// Lasso select tool - freehand / polygonal / magnetic selection
|
|
||||||
SelectLasso,
|
SelectLasso,
|
||||||
/// Magic Wand - select by colour similarity
|
|
||||||
MagicWand,
|
|
||||||
/// Quick Select - brush-based smart selection
|
|
||||||
QuickSelect,
|
|
||||||
// ── Raster transform tools ────────────────────────────────────────────
|
|
||||||
/// Warp / perspective transform
|
|
||||||
Warp,
|
|
||||||
/// Liquify - freeform pixel warping
|
|
||||||
Liquify,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Region select mode
|
/// Region select mode
|
||||||
|
|
@ -97,23 +60,6 @@ impl Default for RegionSelectMode {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Lasso selection sub-mode
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
|
||||||
pub enum LassoMode {
|
|
||||||
/// Freehand lasso (existing, implemented)
|
|
||||||
Freehand,
|
|
||||||
/// Click-to-place polygonal lasso
|
|
||||||
Polygonal,
|
|
||||||
/// Magnetically snaps to edges
|
|
||||||
Magnetic,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for LassoMode {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self::Freehand
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Tool state tracking for interactive operations
|
/// Tool state tracking for interactive operations
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub enum ToolState {
|
pub enum ToolState {
|
||||||
|
|
@ -283,77 +229,44 @@ impl Tool {
|
||||||
/// Get display name for the tool
|
/// Get display name for the tool
|
||||||
pub fn display_name(self) -> &'static str {
|
pub fn display_name(self) -> &'static str {
|
||||||
match self {
|
match self {
|
||||||
Tool::Select => "Select",
|
Tool::Select => "Select",
|
||||||
Tool::Draw => "Brush",
|
Tool::Draw => "Draw",
|
||||||
Tool::Transform => "Transform",
|
Tool::Transform => "Transform",
|
||||||
Tool::Rectangle => "Rectangle",
|
Tool::Rectangle => "Rectangle",
|
||||||
Tool::Ellipse => "Ellipse",
|
Tool::Ellipse => "Ellipse",
|
||||||
Tool::PaintBucket => "Paint Bucket",
|
Tool::PaintBucket => "Paint Bucket",
|
||||||
Tool::Eyedropper => "Eyedropper",
|
Tool::Eyedropper => "Eyedropper",
|
||||||
Tool::Line => "Line",
|
Tool::Line => "Line",
|
||||||
Tool::Polygon => "Polygon",
|
Tool::Polygon => "Polygon",
|
||||||
Tool::BezierEdit => "Bezier Edit",
|
Tool::BezierEdit => "Bezier Edit",
|
||||||
Tool::Text => "Text",
|
Tool::Text => "Text",
|
||||||
Tool::RegionSelect => "Region Select",
|
Tool::RegionSelect => "Region Select",
|
||||||
Tool::Split => "Split",
|
Tool::Split => "Split",
|
||||||
Tool::Pencil => "Pencil",
|
Tool::Erase => "Erase",
|
||||||
Tool::Pen => "Pen",
|
Tool::Smudge => "Smudge",
|
||||||
Tool::Airbrush => "Airbrush",
|
Tool::SelectLasso => "Lasso Select",
|
||||||
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",
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get SVG icon file name for the tool
|
/// Get SVG icon file name for the tool
|
||||||
pub fn icon_file(self) -> &'static str {
|
pub fn icon_file(self) -> &'static str {
|
||||||
match self {
|
match self {
|
||||||
Tool::Select => "select.svg",
|
Tool::Select => "select.svg",
|
||||||
Tool::Draw => "draw.svg",
|
Tool::Draw => "draw.svg",
|
||||||
Tool::Transform => "transform.svg",
|
Tool::Transform => "transform.svg",
|
||||||
Tool::Rectangle => "rectangle.svg",
|
Tool::Rectangle => "rectangle.svg",
|
||||||
Tool::Ellipse => "ellipse.svg",
|
Tool::Ellipse => "ellipse.svg",
|
||||||
Tool::PaintBucket => "paint_bucket.svg",
|
Tool::PaintBucket => "paint_bucket.svg",
|
||||||
Tool::Eyedropper => "eyedropper.svg",
|
Tool::Eyedropper => "eyedropper.svg",
|
||||||
Tool::Line => "line.svg",
|
Tool::Line => "line.svg",
|
||||||
Tool::Polygon => "polygon.svg",
|
Tool::Polygon => "polygon.svg",
|
||||||
Tool::BezierEdit => "bezier_edit.svg",
|
Tool::BezierEdit => "bezier_edit.svg",
|
||||||
Tool::Text => "text.svg",
|
Tool::Text => "text.svg",
|
||||||
Tool::RegionSelect => "region_select.svg",
|
Tool::RegionSelect => "region_select.svg",
|
||||||
Tool::Split => "split.svg",
|
Tool::Split => "split.svg",
|
||||||
Tool::Erase => "erase.svg",
|
Tool::Erase => "erase.svg",
|
||||||
Tool::Smudge => "smudge.svg",
|
Tool::Smudge => "smudge.svg",
|
||||||
Tool::SelectLasso => "lasso.svg",
|
Tool::SelectLasso => "lasso.svg",
|
||||||
// Not yet implemented — use the placeholder icon
|
|
||||||
Tool::Pencil
|
|
||||||
| Tool::Pen
|
|
||||||
| Tool::Airbrush
|
|
||||||
| Tool::CloneStamp
|
|
||||||
| Tool::HealingBrush
|
|
||||||
| Tool::PatternStamp
|
|
||||||
| Tool::DodgeBurn
|
|
||||||
| Tool::Sponge
|
|
||||||
| Tool::BlurSharpen
|
|
||||||
| Tool::Gradient
|
|
||||||
| Tool::CustomShape
|
|
||||||
| Tool::SelectEllipse
|
|
||||||
| Tool::MagicWand
|
|
||||||
| Tool::QuickSelect
|
|
||||||
| Tool::Warp
|
|
||||||
| Tool::Liquify => "todo.svg",
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -381,23 +294,7 @@ impl Tool {
|
||||||
match layer_type {
|
match layer_type {
|
||||||
None | Some(LayerType::Vector) => Tool::all(),
|
None | Some(LayerType::Vector) => Tool::all(),
|
||||||
Some(LayerType::Audio) | Some(LayerType::Video) => &[Tool::Select, Tool::Split],
|
Some(LayerType::Audio) | Some(LayerType::Video) => &[Tool::Select, Tool::Split],
|
||||||
Some(LayerType::Raster) => &[
|
Some(LayerType::Raster) => &[Tool::Select, Tool::SelectLasso, Tool::Draw, Tool::Erase, Tool::Smudge, Tool::Eyedropper],
|
||||||
// Brush tools
|
|
||||||
Tool::Draw, Tool::Pencil, Tool::Pen, Tool::Airbrush,
|
|
||||||
Tool::Erase, Tool::Smudge,
|
|
||||||
Tool::CloneStamp, Tool::HealingBrush, Tool::PatternStamp,
|
|
||||||
Tool::DodgeBurn, Tool::Sponge, Tool::BlurSharpen,
|
|
||||||
// Fill / shape
|
|
||||||
Tool::PaintBucket, Tool::Gradient,
|
|
||||||
Tool::Rectangle, Tool::Ellipse, Tool::Polygon, Tool::Line, Tool::CustomShape,
|
|
||||||
// Selection
|
|
||||||
Tool::Select, Tool::SelectLasso,
|
|
||||||
Tool::MagicWand, Tool::QuickSelect,
|
|
||||||
// Transform
|
|
||||||
Tool::Transform, Tool::Warp, Tool::Liquify,
|
|
||||||
// Utility
|
|
||||||
Tool::Eyedropper,
|
|
||||||
],
|
|
||||||
_ => &[Tool::Select],
|
_ => &[Tool::Select],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
[package]
|
[package]
|
||||||
name = "lightningbeam-editor"
|
name = "lightningbeam-editor"
|
||||||
version = "1.0.2-alpha"
|
version = "1.0.1-alpha"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
description = "Multimedia editor for audio, video and 2D animation"
|
description = "Multimedia editor for audio, video and 2D animation"
|
||||||
license = "GPL-3.0-or-later"
|
license = "GPL-3.0-or-later"
|
||||||
|
|
|
||||||
|
|
@ -124,7 +124,7 @@
|
||||||
"children": [
|
"children": [
|
||||||
{
|
{
|
||||||
"type": "vertical-grid",
|
"type": "vertical-grid",
|
||||||
"percent": 67,
|
"percent": 30,
|
||||||
"children": [
|
"children": [
|
||||||
{ "type": "pane", "name": "toolbar" },
|
{ "type": "pane", "name": "toolbar" },
|
||||||
{ "type": "pane", "name": "infopanel" }
|
{ "type": "pane", "name": "infopanel" }
|
||||||
|
|
|
||||||
|
|
@ -32,41 +32,22 @@ impl CustomCursor {
|
||||||
/// Convert a Tool enum to the corresponding custom cursor
|
/// Convert a Tool enum to the corresponding custom cursor
|
||||||
pub fn from_tool(tool: Tool) -> Self {
|
pub fn from_tool(tool: Tool) -> Self {
|
||||||
match tool {
|
match tool {
|
||||||
Tool::Select => CustomCursor::Select,
|
Tool::Select => CustomCursor::Select,
|
||||||
Tool::Draw => CustomCursor::Draw,
|
Tool::Draw => CustomCursor::Draw,
|
||||||
Tool::Transform => CustomCursor::Transform,
|
Tool::Transform => CustomCursor::Transform,
|
||||||
Tool::Rectangle => CustomCursor::Rectangle,
|
Tool::Rectangle => CustomCursor::Rectangle,
|
||||||
Tool::Ellipse => CustomCursor::Ellipse,
|
Tool::Ellipse => CustomCursor::Ellipse,
|
||||||
Tool::PaintBucket => CustomCursor::PaintBucket,
|
Tool::PaintBucket => CustomCursor::PaintBucket,
|
||||||
Tool::Eyedropper => CustomCursor::Eyedropper,
|
Tool::Eyedropper => CustomCursor::Eyedropper,
|
||||||
Tool::Line => CustomCursor::Line,
|
Tool::Line => CustomCursor::Line,
|
||||||
Tool::Polygon => CustomCursor::Polygon,
|
Tool::Polygon => CustomCursor::Polygon,
|
||||||
Tool::BezierEdit => CustomCursor::BezierEdit,
|
Tool::BezierEdit => CustomCursor::BezierEdit,
|
||||||
Tool::Text => CustomCursor::Text,
|
Tool::Text => CustomCursor::Text,
|
||||||
Tool::RegionSelect => CustomCursor::Select,
|
Tool::RegionSelect => CustomCursor::Select, // Reuse select cursor for now
|
||||||
Tool::Split => CustomCursor::Select,
|
Tool::Split => CustomCursor::Select, // Reuse select cursor for now
|
||||||
Tool::Erase => CustomCursor::Draw,
|
Tool::Erase => CustomCursor::Draw, // Reuse draw cursor for raster erase
|
||||||
Tool::Smudge => CustomCursor::Draw,
|
Tool::Smudge => CustomCursor::Draw, // Reuse draw cursor for raster smudge
|
||||||
Tool::SelectLasso => CustomCursor::Select,
|
Tool::SelectLasso => CustomCursor::Select, // Reuse select cursor for lasso
|
||||||
// Raster brush tools — use draw cursor until implemented
|
|
||||||
Tool::Pencil
|
|
||||||
| Tool::Pen
|
|
||||||
| Tool::Airbrush
|
|
||||||
| Tool::CloneStamp
|
|
||||||
| Tool::HealingBrush
|
|
||||||
| Tool::PatternStamp
|
|
||||||
| Tool::DodgeBurn
|
|
||||||
| Tool::Sponge
|
|
||||||
| Tool::BlurSharpen => CustomCursor::Draw,
|
|
||||||
// Selection tools — use select cursor until implemented
|
|
||||||
Tool::SelectEllipse
|
|
||||||
| Tool::MagicWand
|
|
||||||
| Tool::QuickSelect => CustomCursor::Select,
|
|
||||||
// Other tools — use select cursor until implemented
|
|
||||||
Tool::Gradient
|
|
||||||
| Tool::CustomShape
|
|
||||||
| Tool::Warp
|
|
||||||
| Tool::Liquify => CustomCursor::Select,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,40 +5,9 @@
|
||||||
|
|
||||||
use eframe::egui;
|
use eframe::egui;
|
||||||
use std::collections::VecDeque;
|
use std::collections::VecDeque;
|
||||||
use std::sync::{Mutex, OnceLock};
|
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
const FRAME_HISTORY_SIZE: usize = 60; // Track last 60 frames for FPS stats
|
const FRAME_HISTORY_SIZE: usize = 60; // Track last 60 frames for FPS stats
|
||||||
|
|
||||||
/// Timing breakdown for the GPU prepare() pass, written by the render thread.
|
|
||||||
#[derive(Debug, Clone, Default)]
|
|
||||||
pub struct PrepareTiming {
|
|
||||||
pub total_ms: f64,
|
|
||||||
pub removals_ms: f64,
|
|
||||||
pub gpu_dispatches_ms: f64,
|
|
||||||
pub scene_build_ms: f64,
|
|
||||||
pub composite_ms: f64,
|
|
||||||
}
|
|
||||||
|
|
||||||
static LAST_PREPARE_TIMING: OnceLock<Mutex<PrepareTiming>> = OnceLock::new();
|
|
||||||
|
|
||||||
/// Called from `VelloCallback::prepare()` every frame to update the timing snapshot.
|
|
||||||
pub fn update_prepare_timing(
|
|
||||||
total_ms: f64,
|
|
||||||
removals_ms: f64,
|
|
||||||
gpu_dispatches_ms: f64,
|
|
||||||
scene_build_ms: f64,
|
|
||||||
composite_ms: f64,
|
|
||||||
) {
|
|
||||||
let cell = LAST_PREPARE_TIMING.get_or_init(|| Mutex::new(PrepareTiming::default()));
|
|
||||||
if let Ok(mut t) = cell.lock() {
|
|
||||||
t.total_ms = total_ms;
|
|
||||||
t.removals_ms = removals_ms;
|
|
||||||
t.gpu_dispatches_ms = gpu_dispatches_ms;
|
|
||||||
t.scene_build_ms = scene_build_ms;
|
|
||||||
t.composite_ms = composite_ms;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const DEVICE_REFRESH_INTERVAL: Duration = Duration::from_secs(2); // Refresh devices every 2 seconds
|
const DEVICE_REFRESH_INTERVAL: Duration = Duration::from_secs(2); // Refresh devices every 2 seconds
|
||||||
const MEMORY_REFRESH_INTERVAL: Duration = Duration::from_millis(500); // Refresh memory every 500ms
|
const MEMORY_REFRESH_INTERVAL: Duration = Duration::from_millis(500); // Refresh memory every 500ms
|
||||||
|
|
||||||
|
|
@ -59,9 +28,6 @@ pub struct DebugStats {
|
||||||
pub audio_input_devices: Vec<String>,
|
pub audio_input_devices: Vec<String>,
|
||||||
pub has_pointer: bool,
|
pub has_pointer: bool,
|
||||||
|
|
||||||
// GPU prepare() timing breakdown (from render thread)
|
|
||||||
pub prepare_timing: PrepareTiming,
|
|
||||||
|
|
||||||
// Performance metrics for each section
|
// Performance metrics for each section
|
||||||
pub timing_memory_us: u64,
|
pub timing_memory_us: u64,
|
||||||
pub timing_gpu_us: u64,
|
pub timing_gpu_us: u64,
|
||||||
|
|
@ -204,12 +170,6 @@ impl DebugStatsCollector {
|
||||||
|
|
||||||
let timing_total_us = collection_start.elapsed().as_micros() as u64;
|
let timing_total_us = collection_start.elapsed().as_micros() as u64;
|
||||||
|
|
||||||
let prepare_timing = LAST_PREPARE_TIMING
|
|
||||||
.get()
|
|
||||||
.and_then(|m| m.lock().ok())
|
|
||||||
.map(|t| t.clone())
|
|
||||||
.unwrap_or_default();
|
|
||||||
|
|
||||||
DebugStats {
|
DebugStats {
|
||||||
fps_current,
|
fps_current,
|
||||||
fps_min,
|
fps_min,
|
||||||
|
|
@ -224,7 +184,6 @@ impl DebugStatsCollector {
|
||||||
midi_devices,
|
midi_devices,
|
||||||
audio_input_devices,
|
audio_input_devices,
|
||||||
has_pointer,
|
has_pointer,
|
||||||
prepare_timing,
|
|
||||||
timing_memory_us,
|
timing_memory_us,
|
||||||
timing_gpu_us,
|
timing_gpu_us,
|
||||||
timing_midi_us,
|
timing_midi_us,
|
||||||
|
|
@ -272,16 +231,6 @@ pub fn render_debug_overlay(ctx: &egui::Context, stats: &DebugStats) {
|
||||||
|
|
||||||
ui.add_space(8.0);
|
ui.add_space(8.0);
|
||||||
|
|
||||||
// GPU prepare() timing section
|
|
||||||
let pt = &stats.prepare_timing;
|
|
||||||
ui.colored_label(egui::Color32::YELLOW, format!("GPU prepare: {:.2} ms", pt.total_ms));
|
|
||||||
ui.label(format!(" removals: {:.2} ms", pt.removals_ms));
|
|
||||||
ui.label(format!(" gpu_dispatch: {:.2} ms", pt.gpu_dispatches_ms));
|
|
||||||
ui.label(format!(" scene_build: {:.2} ms", pt.scene_build_ms));
|
|
||||||
ui.label(format!(" composite: {:.2} ms", pt.composite_ms));
|
|
||||||
|
|
||||||
ui.add_space(8.0);
|
|
||||||
|
|
||||||
// Memory section with timing
|
// Memory section with timing
|
||||||
ui.colored_label(egui::Color32::YELLOW, format!("Memory: ({}µs)", stats.timing_memory_us));
|
ui.colored_label(egui::Color32::YELLOW, format!("Memory: ({}µs)", stats.timing_memory_us));
|
||||||
ui.label(format!("Physical: {} MB", stats.memory_physical_mb));
|
ui.label(format!("Physical: {} MB", stats.memory_physical_mb));
|
||||||
|
|
|
||||||
|
|
@ -3,29 +3,13 @@
|
||||||
//! Provides a user interface for configuring and starting audio/video exports.
|
//! Provides a user interface for configuring and starting audio/video exports.
|
||||||
|
|
||||||
use eframe::egui;
|
use eframe::egui;
|
||||||
use lightningbeam_core::export::{
|
use lightningbeam_core::export::{AudioExportSettings, AudioFormat, VideoExportSettings, VideoCodec, VideoQuality};
|
||||||
AudioExportSettings, AudioFormat,
|
|
||||||
ImageExportSettings, ImageFormat,
|
|
||||||
VideoExportSettings, VideoCodec, VideoQuality,
|
|
||||||
};
|
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
/// Hint about document content, used to pick a smart default export type.
|
|
||||||
pub struct DocumentHint {
|
|
||||||
pub has_video: bool,
|
|
||||||
pub has_audio: bool,
|
|
||||||
pub has_raster: bool,
|
|
||||||
pub has_vector: bool,
|
|
||||||
pub current_time: f64,
|
|
||||||
pub doc_width: u32,
|
|
||||||
pub doc_height: u32,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Export type selection
|
/// Export type selection
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub enum ExportType {
|
pub enum ExportType {
|
||||||
Audio,
|
Audio,
|
||||||
Image,
|
|
||||||
Video,
|
Video,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -33,7 +17,6 @@ pub enum ExportType {
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub enum ExportResult {
|
pub enum ExportResult {
|
||||||
AudioOnly(AudioExportSettings, PathBuf),
|
AudioOnly(AudioExportSettings, PathBuf),
|
||||||
Image(ImageExportSettings, PathBuf),
|
|
||||||
VideoOnly(VideoExportSettings, PathBuf),
|
VideoOnly(VideoExportSettings, PathBuf),
|
||||||
VideoWithAudio(VideoExportSettings, AudioExportSettings, PathBuf),
|
VideoWithAudio(VideoExportSettings, AudioExportSettings, PathBuf),
|
||||||
}
|
}
|
||||||
|
|
@ -49,9 +32,6 @@ pub struct ExportDialog {
|
||||||
/// Audio export settings
|
/// Audio export settings
|
||||||
pub audio_settings: AudioExportSettings,
|
pub audio_settings: AudioExportSettings,
|
||||||
|
|
||||||
/// Image export settings
|
|
||||||
pub image_settings: ImageExportSettings,
|
|
||||||
|
|
||||||
/// Video export settings
|
/// Video export settings
|
||||||
pub video_settings: VideoExportSettings,
|
pub video_settings: VideoExportSettings,
|
||||||
|
|
||||||
|
|
@ -75,15 +55,6 @@ pub struct ExportDialog {
|
||||||
|
|
||||||
/// Output directory
|
/// Output directory
|
||||||
pub output_dir: PathBuf,
|
pub output_dir: PathBuf,
|
||||||
|
|
||||||
/// Project name from the last `open()` call — used to detect file switches.
|
|
||||||
current_project: String,
|
|
||||||
|
|
||||||
/// Export type used the last time the user actually clicked Export for `current_project`.
|
|
||||||
last_export_type: Option<ExportType>,
|
|
||||||
|
|
||||||
/// Full path of the most recent successful export. Restored as the default on next open.
|
|
||||||
last_exported_path: Option<PathBuf>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for ExportDialog {
|
impl Default for ExportDialog {
|
||||||
|
|
@ -100,7 +71,6 @@ impl Default for ExportDialog {
|
||||||
open: false,
|
open: false,
|
||||||
export_type: ExportType::Audio,
|
export_type: ExportType::Audio,
|
||||||
audio_settings: AudioExportSettings::standard_mp3(),
|
audio_settings: AudioExportSettings::standard_mp3(),
|
||||||
image_settings: ImageExportSettings::default(),
|
|
||||||
video_settings: VideoExportSettings::default(),
|
video_settings: VideoExportSettings::default(),
|
||||||
include_audio: true,
|
include_audio: true,
|
||||||
output_path: None,
|
output_path: None,
|
||||||
|
|
@ -108,56 +78,23 @@ impl Default for ExportDialog {
|
||||||
show_advanced: false,
|
show_advanced: false,
|
||||||
selected_video_preset: 0,
|
selected_video_preset: 0,
|
||||||
output_filename: String::new(),
|
output_filename: String::new(),
|
||||||
current_project: String::new(),
|
|
||||||
last_export_type: None,
|
|
||||||
last_exported_path: None,
|
|
||||||
output_dir: music_dir,
|
output_dir: music_dir,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ExportDialog {
|
impl ExportDialog {
|
||||||
/// Open the dialog with default settings, using `hint` to pick a smart default tab.
|
/// Open the dialog with default settings
|
||||||
pub fn open(&mut self, timeline_duration: f64, project_name: &str, hint: &DocumentHint) {
|
pub fn open(&mut self, timeline_duration: f64, project_name: &str) {
|
||||||
self.open = true;
|
self.open = true;
|
||||||
self.audio_settings.end_time = timeline_duration;
|
self.audio_settings.end_time = timeline_duration;
|
||||||
self.video_settings.end_time = timeline_duration;
|
self.video_settings.end_time = timeline_duration;
|
||||||
self.image_settings.time = hint.current_time;
|
|
||||||
// Propagate document dimensions as defaults (None means "use doc size").
|
|
||||||
self.image_settings.width = None;
|
|
||||||
self.image_settings.height = None;
|
|
||||||
self.error_message = None;
|
self.error_message = None;
|
||||||
|
|
||||||
// Determine export type: prefer the type used last time for this file,
|
// Pre-populate filename from project name if not already set
|
||||||
// then fall back to document-content hints.
|
if self.output_filename.is_empty() || !self.output_filename.contains(project_name) {
|
||||||
let same_project = self.current_project == project_name;
|
let ext = self.audio_settings.format.extension();
|
||||||
self.export_type = if same_project && self.last_export_type.is_some() {
|
self.output_filename = format!("{}.{}", project_name, ext);
|
||||||
self.last_export_type.unwrap()
|
|
||||||
} else {
|
|
||||||
let only_audio = hint.has_audio && !hint.has_video && !hint.has_raster && !hint.has_vector;
|
|
||||||
let only_raster = hint.has_raster && !hint.has_video && !hint.has_audio && !hint.has_vector;
|
|
||||||
if hint.has_video { ExportType::Video }
|
|
||||||
else if only_audio { ExportType::Audio }
|
|
||||||
else if only_raster { ExportType::Image }
|
|
||||||
else { self.export_type } // keep current as fallback
|
|
||||||
};
|
|
||||||
self.current_project = project_name.to_owned();
|
|
||||||
|
|
||||||
// Restore the last exported path if available; otherwise default to project name.
|
|
||||||
if let Some(ref last) = self.last_exported_path.clone() {
|
|
||||||
if let Some(dir) = last.parent() { self.output_dir = dir.to_path_buf(); }
|
|
||||||
if let Some(name) = last.file_name() { self.output_filename = name.to_string_lossy().into_owned(); }
|
|
||||||
} else if self.output_filename.is_empty() || !self.output_filename.contains(project_name) {
|
|
||||||
self.output_filename = format!("{}.{}", project_name, self.current_extension());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Extension for the currently selected export type.
|
|
||||||
fn current_extension(&self) -> &'static str {
|
|
||||||
match self.export_type {
|
|
||||||
ExportType::Audio => self.audio_settings.format.extension(),
|
|
||||||
ExportType::Image => self.image_settings.format.extension(),
|
|
||||||
ExportType::Video => self.video_settings.codec.container_format(),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -169,7 +106,10 @@ impl ExportDialog {
|
||||||
|
|
||||||
/// Update the filename extension to match the current format
|
/// Update the filename extension to match the current format
|
||||||
fn update_filename_extension(&mut self) {
|
fn update_filename_extension(&mut self) {
|
||||||
let ext = self.current_extension();
|
let ext = match self.export_type {
|
||||||
|
ExportType::Audio => self.audio_settings.format.extension(),
|
||||||
|
ExportType::Video => self.video_settings.codec.container_format(),
|
||||||
|
};
|
||||||
// Replace extension in filename
|
// Replace extension in filename
|
||||||
if let Some(dot_pos) = self.output_filename.rfind('.') {
|
if let Some(dot_pos) = self.output_filename.rfind('.') {
|
||||||
self.output_filename.truncate(dot_pos + 1);
|
self.output_filename.truncate(dot_pos + 1);
|
||||||
|
|
@ -198,7 +138,6 @@ impl ExportDialog {
|
||||||
|
|
||||||
let window_title = match self.export_type {
|
let window_title = match self.export_type {
|
||||||
ExportType::Audio => "Export Audio",
|
ExportType::Audio => "Export Audio",
|
||||||
ExportType::Image => "Export Image",
|
|
||||||
ExportType::Video => "Export Video",
|
ExportType::Video => "Export Video",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -217,14 +156,11 @@ impl ExportDialog {
|
||||||
|
|
||||||
// Export type selection (tabs)
|
// Export type selection (tabs)
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
for (variant, label) in [
|
if ui.selectable_value(&mut self.export_type, ExportType::Audio, "Audio").clicked() {
|
||||||
(ExportType::Audio, "Audio"),
|
self.update_filename_extension();
|
||||||
(ExportType::Image, "Image"),
|
}
|
||||||
(ExportType::Video, "Video"),
|
if ui.selectable_value(&mut self.export_type, ExportType::Video, "Video").clicked() {
|
||||||
] {
|
self.update_filename_extension();
|
||||||
if ui.selectable_value(&mut self.export_type, variant, label).clicked() {
|
|
||||||
self.update_filename_extension();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -235,7 +171,6 @@ impl ExportDialog {
|
||||||
// Basic settings
|
// Basic settings
|
||||||
match self.export_type {
|
match self.export_type {
|
||||||
ExportType::Audio => self.render_audio_basic(ui),
|
ExportType::Audio => self.render_audio_basic(ui),
|
||||||
ExportType::Image => self.render_image_settings(ui),
|
|
||||||
ExportType::Video => self.render_video_basic(ui),
|
ExportType::Video => self.render_video_basic(ui),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -253,7 +188,6 @@ impl ExportDialog {
|
||||||
ui.add_space(8.0);
|
ui.add_space(8.0);
|
||||||
match self.export_type {
|
match self.export_type {
|
||||||
ExportType::Audio => self.render_audio_advanced(ui),
|
ExportType::Audio => self.render_audio_advanced(ui),
|
||||||
ExportType::Image => self.render_image_advanced(ui),
|
|
||||||
ExportType::Video => self.render_video_advanced(ui),
|
ExportType::Video => self.render_video_advanced(ui),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -326,62 +260,6 @@ impl ExportDialog {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Render basic image export settings (format, quality, transparency).
|
|
||||||
fn render_image_settings(&mut self, ui: &mut egui::Ui) {
|
|
||||||
// Format
|
|
||||||
ui.horizontal(|ui| {
|
|
||||||
ui.label("Format:");
|
|
||||||
let prev = self.image_settings.format;
|
|
||||||
egui::ComboBox::from_id_salt("image_format")
|
|
||||||
.selected_text(self.image_settings.format.name())
|
|
||||||
.show_ui(ui, |ui| {
|
|
||||||
ui.selectable_value(&mut self.image_settings.format, ImageFormat::Png, "PNG");
|
|
||||||
ui.selectable_value(&mut self.image_settings.format, ImageFormat::Jpeg, "JPEG");
|
|
||||||
ui.selectable_value(&mut self.image_settings.format, ImageFormat::WebP, "WebP");
|
|
||||||
});
|
|
||||||
if self.image_settings.format != prev {
|
|
||||||
self.update_filename_extension();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Quality (JPEG / WebP only)
|
|
||||||
if self.image_settings.format.has_quality() {
|
|
||||||
ui.horizontal(|ui| {
|
|
||||||
ui.label("Quality:");
|
|
||||||
ui.add(egui::Slider::new(&mut self.image_settings.quality, 1..=100));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Transparency (PNG / WebP only — JPEG has no alpha)
|
|
||||||
if self.image_settings.format != ImageFormat::Jpeg {
|
|
||||||
ui.checkbox(&mut self.image_settings.allow_transparency, "Allow transparency");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Render advanced image export settings (time, resolution override).
|
|
||||||
fn render_image_advanced(&mut self, ui: &mut egui::Ui) {
|
|
||||||
// Time (which frame to export)
|
|
||||||
ui.horizontal(|ui| {
|
|
||||||
ui.label("Time:");
|
|
||||||
ui.add(egui::DragValue::new(&mut self.image_settings.time)
|
|
||||||
.speed(0.01)
|
|
||||||
.range(0.0..=f64::MAX)
|
|
||||||
.suffix(" s"));
|
|
||||||
});
|
|
||||||
|
|
||||||
// Resolution override (None = use document size; 0 means "use doc size")
|
|
||||||
ui.horizontal(|ui| {
|
|
||||||
ui.label("Size:");
|
|
||||||
let mut w = self.image_settings.width.unwrap_or(0);
|
|
||||||
let mut h = self.image_settings.height.unwrap_or(0);
|
|
||||||
let changed_w = ui.add(egui::DragValue::new(&mut w).range(0..=u32::MAX).prefix("W ")).changed();
|
|
||||||
let changed_h = ui.add(egui::DragValue::new(&mut h).range(0..=u32::MAX).prefix("H ")).changed();
|
|
||||||
if changed_w { self.image_settings.width = if w == 0 { None } else { Some(w) }; }
|
|
||||||
if changed_h { self.image_settings.height = if h == 0 { None } else { Some(h) }; }
|
|
||||||
ui.weak("(0 = document size)");
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Render advanced audio settings (sample rate, channels, bit depth, bitrate, time range)
|
/// Render advanced audio settings (sample rate, channels, bit depth, bitrate, time range)
|
||||||
fn render_audio_advanced(&mut self, ui: &mut egui::Ui) {
|
fn render_audio_advanced(&mut self, ui: &mut egui::Ui) {
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
|
|
@ -541,7 +419,6 @@ impl ExportDialog {
|
||||||
fn render_time_range(&mut self, ui: &mut egui::Ui) {
|
fn render_time_range(&mut self, ui: &mut egui::Ui) {
|
||||||
let (start_time, end_time) = match self.export_type {
|
let (start_time, end_time) = match self.export_type {
|
||||||
ExportType::Audio => (&mut self.audio_settings.start_time, &mut self.audio_settings.end_time),
|
ExportType::Audio => (&mut self.audio_settings.start_time, &mut self.audio_settings.end_time),
|
||||||
ExportType::Image => return, // image uses a single time field, not a range
|
|
||||||
ExportType::Video => (&mut self.video_settings.start_time, &mut self.video_settings.end_time),
|
ExportType::Video => (&mut self.video_settings.start_time, &mut self.video_settings.end_time),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -563,35 +440,26 @@ impl ExportDialog {
|
||||||
ui.label(format!("Duration: {:.2} seconds", duration));
|
ui.label(format!("Duration: {:.2} seconds", duration));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Render output file selection UI — single OS save-file dialog.
|
/// Render output file selection UI
|
||||||
fn render_output_selection(&mut self, ui: &mut egui::Ui) {
|
fn render_output_selection(&mut self, ui: &mut egui::Ui) {
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
// Show the current path (truncated if long).
|
|
||||||
let full_path = self.build_output_path();
|
|
||||||
let path_str = full_path.display().to_string();
|
|
||||||
ui.label("Save to:");
|
ui.label("Save to:");
|
||||||
ui.add(egui::Label::new(
|
let dir_text = self.output_dir.display().to_string();
|
||||||
egui::RichText::new(&path_str).weak()
|
ui.label(&dir_text);
|
||||||
).truncate());
|
if ui.button("Change...").clicked() {
|
||||||
});
|
if let Some(dir) = rfd::FileDialog::new()
|
||||||
|
.set_directory(&self.output_dir)
|
||||||
if ui.button("Choose location...").clicked() {
|
.pick_folder()
|
||||||
let ext = self.current_extension();
|
{
|
||||||
let mut dialog = rfd::FileDialog::new()
|
self.output_dir = dir;
|
||||||
.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();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
|
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
ui.label("Filename:");
|
||||||
|
ui.text_edit_singleline(&mut self.output_filename);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Handle export button click
|
/// Handle export button click
|
||||||
|
|
@ -603,18 +471,7 @@ impl ExportDialog {
|
||||||
|
|
||||||
let output_path = self.output_path.clone().unwrap();
|
let output_path = self.output_path.clone().unwrap();
|
||||||
|
|
||||||
// Remember this export type and path for next time the dialog is opened.
|
|
||||||
self.last_export_type = Some(self.export_type);
|
|
||||||
self.last_exported_path = Some(output_path.clone());
|
|
||||||
|
|
||||||
let result = match self.export_type {
|
let result = match self.export_type {
|
||||||
ExportType::Image => {
|
|
||||||
if let Err(err) = self.image_settings.validate() {
|
|
||||||
self.error_message = Some(err);
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
Some(ExportResult::Image(self.image_settings.clone(), output_path))
|
|
||||||
}
|
|
||||||
ExportType::Audio => {
|
ExportType::Audio => {
|
||||||
// Validate audio settings
|
// Validate audio settings
|
||||||
if let Err(err) = self.audio_settings.validate() {
|
if let Err(err) = self.audio_settings.validate() {
|
||||||
|
|
|
||||||
|
|
@ -1,70 +0,0 @@
|
||||||
//! Image encoding — save raw RGBA bytes as PNG / JPEG / WebP.
|
|
||||||
|
|
||||||
use lightningbeam_core::export::ImageFormat;
|
|
||||||
use std::path::Path;
|
|
||||||
|
|
||||||
/// Encode `pixels` (raw RGBA8, top-left origin) and write to `path`.
|
|
||||||
///
|
|
||||||
/// * `allow_transparency` — when true the alpha channel is preserved (PNG/WebP);
|
|
||||||
/// when false each pixel is composited onto black before encoding.
|
|
||||||
pub fn save_rgba_image(
|
|
||||||
pixels: &[u8],
|
|
||||||
width: u32,
|
|
||||||
height: u32,
|
|
||||||
format: ImageFormat,
|
|
||||||
quality: u8,
|
|
||||||
allow_transparency: bool,
|
|
||||||
path: &Path,
|
|
||||||
) -> Result<(), String> {
|
|
||||||
use image::{ImageBuffer, Rgba};
|
|
||||||
|
|
||||||
let img = ImageBuffer::<Rgba<u8>, _>::from_raw(width, height, pixels.to_vec())
|
|
||||||
.ok_or_else(|| "Pixel buffer size mismatch".to_string())?;
|
|
||||||
|
|
||||||
match format {
|
|
||||||
ImageFormat::Png => {
|
|
||||||
if allow_transparency {
|
|
||||||
img.save(path).map_err(|e| format!("PNG save failed: {e}"))
|
|
||||||
} else {
|
|
||||||
let flat = flatten_alpha(img);
|
|
||||||
flat.save(path).map_err(|e| format!("PNG save failed: {e}"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ImageFormat::Jpeg => {
|
|
||||||
use image::codecs::jpeg::JpegEncoder;
|
|
||||||
use image::DynamicImage;
|
|
||||||
use std::fs::File;
|
|
||||||
use std::io::BufWriter;
|
|
||||||
|
|
||||||
// Flatten alpha onto black before JPEG encoding (JPEG has no alpha).
|
|
||||||
let flat = flatten_alpha(img);
|
|
||||||
let rgb_img = DynamicImage::ImageRgb8(flat).to_rgb8();
|
|
||||||
let file = File::create(path).map_err(|e| format!("Cannot create file: {e}"))?;
|
|
||||||
let writer = BufWriter::new(file);
|
|
||||||
let mut encoder = JpegEncoder::new_with_quality(writer, quality);
|
|
||||||
encoder.encode_image(&rgb_img).map_err(|e| format!("JPEG encode failed: {e}"))
|
|
||||||
}
|
|
||||||
ImageFormat::WebP => {
|
|
||||||
if allow_transparency {
|
|
||||||
img.save(path).map_err(|e| format!("WebP save failed: {e}"))
|
|
||||||
} else {
|
|
||||||
let flat = flatten_alpha(img);
|
|
||||||
flat.save(path).map_err(|e| format!("WebP save failed: {e}"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Composite RGBA pixels onto an opaque black background, returning an RGB image.
|
|
||||||
fn flatten_alpha(img: image::ImageBuffer<image::Rgba<u8>, Vec<u8>>) -> image::ImageBuffer<image::Rgb<u8>, Vec<u8>> {
|
|
||||||
use image::{ImageBuffer, Rgb};
|
|
||||||
ImageBuffer::from_fn(img.width(), img.height(), |x, y| {
|
|
||||||
let p = img.get_pixel(x, y);
|
|
||||||
let a = p[3] as f32 / 255.0;
|
|
||||||
Rgb([
|
|
||||||
(p[0] as f32 * a) as u8,
|
|
||||||
(p[1] as f32 * a) as u8,
|
|
||||||
(p[2] as f32 * a) as u8,
|
|
||||||
])
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
@ -5,13 +5,12 @@
|
||||||
|
|
||||||
pub mod audio_exporter;
|
pub mod audio_exporter;
|
||||||
pub mod dialog;
|
pub mod dialog;
|
||||||
pub mod image_exporter;
|
|
||||||
pub mod video_exporter;
|
pub mod video_exporter;
|
||||||
pub mod readback_pipeline;
|
pub mod readback_pipeline;
|
||||||
pub mod perf_metrics;
|
pub mod perf_metrics;
|
||||||
pub mod cpu_yuv_converter;
|
pub mod cpu_yuv_converter;
|
||||||
|
|
||||||
use lightningbeam_core::export::{AudioExportSettings, ImageExportSettings, VideoExportSettings, ExportProgress};
|
use lightningbeam_core::export::{AudioExportSettings, VideoExportSettings, ExportProgress};
|
||||||
use lightningbeam_core::document::Document;
|
use lightningbeam_core::document::Document;
|
||||||
use lightningbeam_core::renderer::ImageCache;
|
use lightningbeam_core::renderer::ImageCache;
|
||||||
use lightningbeam_core::video::VideoManager;
|
use lightningbeam_core::video::VideoManager;
|
||||||
|
|
@ -67,25 +66,6 @@ pub struct VideoExportState {
|
||||||
perf_metrics: Option<perf_metrics::ExportMetrics>,
|
perf_metrics: Option<perf_metrics::ExportMetrics>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// State for a single-frame image export (runs on the GPU render thread, one frame per update).
|
|
||||||
pub struct ImageExportState {
|
|
||||||
pub settings: ImageExportSettings,
|
|
||||||
pub output_path: PathBuf,
|
|
||||||
/// Resolved pixel dimensions (after applying any width/height overrides).
|
|
||||||
pub width: u32,
|
|
||||||
pub height: u32,
|
|
||||||
/// True once rendering has been submitted; the next call reads back and encodes.
|
|
||||||
pub rendered: bool,
|
|
||||||
/// GPU resources allocated on the first render call.
|
|
||||||
pub gpu_resources: Option<video_exporter::ExportGpuResources>,
|
|
||||||
/// Output RGBA texture — kept separate from gpu_resources to avoid split-borrow issues.
|
|
||||||
pub output_texture: Option<wgpu::Texture>,
|
|
||||||
/// View for output_texture.
|
|
||||||
pub output_texture_view: Option<wgpu::TextureView>,
|
|
||||||
/// Staging buffer for synchronous GPU→CPU readback.
|
|
||||||
pub staging_buffer: Option<wgpu::Buffer>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Export orchestrator that manages the export process
|
/// Export orchestrator that manages the export process
|
||||||
pub struct ExportOrchestrator {
|
pub struct ExportOrchestrator {
|
||||||
/// Channel for receiving progress updates (video or audio-only export)
|
/// Channel for receiving progress updates (video or audio-only export)
|
||||||
|
|
@ -102,9 +82,6 @@ pub struct ExportOrchestrator {
|
||||||
|
|
||||||
/// Parallel audio+video export state
|
/// Parallel audio+video export state
|
||||||
parallel_export: Option<ParallelExportState>,
|
parallel_export: Option<ParallelExportState>,
|
||||||
|
|
||||||
/// Single-frame image export state
|
|
||||||
image_state: Option<ImageExportState>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// State for parallel audio+video export
|
/// State for parallel audio+video export
|
||||||
|
|
@ -138,7 +115,6 @@ impl ExportOrchestrator {
|
||||||
cancel_flag: Arc::new(AtomicBool::new(false)),
|
cancel_flag: Arc::new(AtomicBool::new(false)),
|
||||||
video_state: None,
|
video_state: None,
|
||||||
parallel_export: None,
|
parallel_export: None,
|
||||||
image_state: None,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -470,8 +446,12 @@ impl ExportOrchestrator {
|
||||||
|
|
||||||
/// Check if an export is in progress
|
/// Check if an export is in progress
|
||||||
pub fn is_exporting(&self) -> bool {
|
pub fn is_exporting(&self) -> bool {
|
||||||
if self.parallel_export.is_some() { return true; }
|
// Check parallel export first
|
||||||
if self.image_state.is_some() { return true; }
|
if self.parallel_export.is_some() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check single export
|
||||||
if let Some(handle) = &self.thread_handle {
|
if let Some(handle) = &self.thread_handle {
|
||||||
!handle.is_finished()
|
!handle.is_finished()
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -479,171 +459,6 @@ impl ExportOrchestrator {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Enqueue a single-frame image export. Call `render_image_frame()` from the
|
|
||||||
/// egui update loop (where the wgpu device/queue are available) to complete it.
|
|
||||||
pub fn start_image_export(
|
|
||||||
&mut self,
|
|
||||||
settings: ImageExportSettings,
|
|
||||||
output_path: PathBuf,
|
|
||||||
doc_width: u32,
|
|
||||||
doc_height: u32,
|
|
||||||
) {
|
|
||||||
self.cancel_flag.store(false, Ordering::Relaxed);
|
|
||||||
let width = settings.width.unwrap_or(doc_width).max(1);
|
|
||||||
let height = settings.height.unwrap_or(doc_height).max(1);
|
|
||||||
self.image_state = Some(ImageExportState {
|
|
||||||
settings,
|
|
||||||
output_path,
|
|
||||||
width,
|
|
||||||
height,
|
|
||||||
rendered: false,
|
|
||||||
gpu_resources: None,
|
|
||||||
output_texture: None,
|
|
||||||
output_texture_view: None,
|
|
||||||
staging_buffer: None,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Drive the single-frame image export. Returns `Ok(true)` when done (success or
|
|
||||||
/// cancelled), `Ok(false)` if another call is needed next frame.
|
|
||||||
pub fn render_image_frame(
|
|
||||||
&mut self,
|
|
||||||
document: &mut Document,
|
|
||||||
device: &wgpu::Device,
|
|
||||||
queue: &wgpu::Queue,
|
|
||||||
renderer: &mut vello::Renderer,
|
|
||||||
image_cache: &mut ImageCache,
|
|
||||||
video_manager: &Arc<std::sync::Mutex<VideoManager>>,
|
|
||||||
floating_selection: Option<&lightningbeam_core::selection::RasterFloatingSelection>,
|
|
||||||
) -> Result<bool, String> {
|
|
||||||
if self.cancel_flag.load(Ordering::Relaxed) {
|
|
||||||
self.image_state = None;
|
|
||||||
return Ok(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
let state = match self.image_state.as_mut() {
|
|
||||||
Some(s) => s,
|
|
||||||
None => return Ok(true),
|
|
||||||
};
|
|
||||||
|
|
||||||
if !state.rendered {
|
|
||||||
// ── First call: render the frame to the GPU output texture ────────
|
|
||||||
let w = state.width;
|
|
||||||
let h = state.height;
|
|
||||||
|
|
||||||
if state.gpu_resources.is_none() {
|
|
||||||
state.gpu_resources = Some(video_exporter::ExportGpuResources::new(device, w, h));
|
|
||||||
}
|
|
||||||
if state.output_texture.is_none() {
|
|
||||||
let tex = device.create_texture(&wgpu::TextureDescriptor {
|
|
||||||
label: Some("image_export_output"),
|
|
||||||
size: wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 },
|
|
||||||
mip_level_count: 1,
|
|
||||||
sample_count: 1,
|
|
||||||
dimension: wgpu::TextureDimension::D2,
|
|
||||||
format: wgpu::TextureFormat::Rgba8Unorm,
|
|
||||||
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
|
|
||||||
view_formats: &[],
|
|
||||||
});
|
|
||||||
state.output_texture_view = Some(tex.create_view(&wgpu::TextureViewDescriptor::default()));
|
|
||||||
state.output_texture = Some(tex);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Borrow separately to avoid a split-borrow conflict (gpu mutably, view immutably).
|
|
||||||
let gpu = state.gpu_resources.as_mut().unwrap();
|
|
||||||
let output_view = state.output_texture_view.as_ref().unwrap();
|
|
||||||
|
|
||||||
let mut encoder = video_exporter::render_frame_to_gpu_rgba(
|
|
||||||
document,
|
|
||||||
state.settings.time,
|
|
||||||
w, h,
|
|
||||||
device, queue, renderer, image_cache, video_manager,
|
|
||||||
gpu,
|
|
||||||
output_view,
|
|
||||||
floating_selection,
|
|
||||||
state.settings.allow_transparency,
|
|
||||||
)?;
|
|
||||||
queue.submit(Some(encoder.finish()));
|
|
||||||
|
|
||||||
// Create a staging buffer for synchronous readback.
|
|
||||||
// wgpu requires bytes_per_row to be a multiple of 256.
|
|
||||||
let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT;
|
|
||||||
let bytes_per_row = (w * 4 + align - 1) / align * align;
|
|
||||||
let staging = device.create_buffer(&wgpu::BufferDescriptor {
|
|
||||||
label: Some("image_export_staging"),
|
|
||||||
size: (bytes_per_row * h) as u64,
|
|
||||||
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
|
|
||||||
mapped_at_creation: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
let mut copy_enc = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
|
|
||||||
label: Some("image_export_copy"),
|
|
||||||
});
|
|
||||||
let output_tex = state.output_texture.as_ref().unwrap();
|
|
||||||
copy_enc.copy_texture_to_buffer(
|
|
||||||
wgpu::TexelCopyTextureInfo {
|
|
||||||
texture: output_tex,
|
|
||||||
mip_level: 0,
|
|
||||||
origin: wgpu::Origin3d::ZERO,
|
|
||||||
aspect: wgpu::TextureAspect::All,
|
|
||||||
},
|
|
||||||
wgpu::TexelCopyBufferInfo {
|
|
||||||
buffer: &staging,
|
|
||||||
layout: wgpu::TexelCopyBufferLayout {
|
|
||||||
offset: 0,
|
|
||||||
bytes_per_row: Some(bytes_per_row),
|
|
||||||
rows_per_image: Some(h),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 },
|
|
||||||
);
|
|
||||||
queue.submit(Some(copy_enc.finish()));
|
|
||||||
|
|
||||||
state.staging_buffer = Some(staging);
|
|
||||||
state.rendered = true;
|
|
||||||
return Ok(false); // Come back next frame to read the result.
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Second call: map the staging buffer, encode, and save ─────────────
|
|
||||||
let staging = match state.staging_buffer.as_ref() {
|
|
||||||
Some(b) => b,
|
|
||||||
None => { self.image_state = None; return Ok(true); }
|
|
||||||
};
|
|
||||||
|
|
||||||
// Map synchronously.
|
|
||||||
let slice = staging.slice(..);
|
|
||||||
slice.map_async(wgpu::MapMode::Read, |_| {});
|
|
||||||
let _ = device.poll(wgpu::PollType::wait_indefinitely());
|
|
||||||
|
|
||||||
let w = state.width;
|
|
||||||
let h = state.height;
|
|
||||||
let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT;
|
|
||||||
let bytes_per_row = (w * 4 + align - 1) / align * align;
|
|
||||||
|
|
||||||
let pixels: Vec<u8> = {
|
|
||||||
let mapped = slice.get_mapped_range();
|
|
||||||
// Strip row padding: copy only w*4 bytes from each bytes_per_row-wide row.
|
|
||||||
let mut out = Vec::with_capacity((w * h * 4) as usize);
|
|
||||||
for row in 0..h {
|
|
||||||
let start = (row * bytes_per_row) as usize;
|
|
||||||
out.extend_from_slice(&mapped[start..start + (w * 4) as usize]);
|
|
||||||
}
|
|
||||||
out
|
|
||||||
};
|
|
||||||
staging.unmap();
|
|
||||||
|
|
||||||
let result = image_exporter::save_rgba_image(
|
|
||||||
&pixels, w, h,
|
|
||||||
state.settings.format,
|
|
||||||
state.settings.quality,
|
|
||||||
state.settings.allow_transparency,
|
|
||||||
&state.output_path,
|
|
||||||
);
|
|
||||||
|
|
||||||
self.image_state = None;
|
|
||||||
result.map(|_| true)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Wait for the export to complete
|
/// Wait for the export to complete
|
||||||
///
|
///
|
||||||
/// This blocks until the export thread finishes.
|
/// This blocks until the export thread finishes.
|
||||||
|
|
@ -1109,8 +924,6 @@ impl ExportOrchestrator {
|
||||||
document, timestamp, width, height,
|
document, timestamp, width, height,
|
||||||
device, queue, renderer, image_cache, video_manager,
|
device, queue, renderer, image_cache, video_manager,
|
||||||
gpu_resources, &acquired.rgba_texture_view,
|
gpu_resources, &acquired.rgba_texture_view,
|
||||||
None, // No floating selection during video export
|
|
||||||
false, // Video export is never transparent
|
|
||||||
)?;
|
)?;
|
||||||
let render_end = Instant::now();
|
let render_end = Instant::now();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -79,10 +79,6 @@ pub struct ExportGpuResources {
|
||||||
pub linear_to_srgb_bind_group_layout: wgpu::BindGroupLayout,
|
pub linear_to_srgb_bind_group_layout: wgpu::BindGroupLayout,
|
||||||
/// Sampler for linear to sRGB conversion
|
/// Sampler for linear to sRGB conversion
|
||||||
pub linear_to_srgb_sampler: wgpu::Sampler,
|
pub linear_to_srgb_sampler: wgpu::Sampler,
|
||||||
/// Canvas blit pipeline for raster/video/float layers (bypasses Vello).
|
|
||||||
pub canvas_blit: crate::gpu_brush::CanvasBlitPipeline,
|
|
||||||
/// Per-keyframe GPU texture cache for raster layers during export.
|
|
||||||
pub raster_cache: std::collections::HashMap<uuid::Uuid, crate::gpu_brush::CanvasPair>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ExportGpuResources {
|
impl ExportGpuResources {
|
||||||
|
|
@ -239,8 +235,6 @@ impl ExportGpuResources {
|
||||||
..Default::default()
|
..Default::default()
|
||||||
});
|
});
|
||||||
|
|
||||||
let canvas_blit = crate::gpu_brush::CanvasBlitPipeline::new(device);
|
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
buffer_pool,
|
buffer_pool,
|
||||||
compositor,
|
compositor,
|
||||||
|
|
@ -257,8 +251,6 @@ impl ExportGpuResources {
|
||||||
linear_to_srgb_pipeline,
|
linear_to_srgb_pipeline,
|
||||||
linear_to_srgb_bind_group_layout,
|
linear_to_srgb_bind_group_layout,
|
||||||
linear_to_srgb_sampler,
|
linear_to_srgb_sampler,
|
||||||
canvas_blit,
|
|
||||||
raster_cache: std::collections::HashMap::new(),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -710,233 +702,6 @@ pub fn render_frame_to_rgba(
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Composite all layers from `composite_result` into `gpu_resources.hdr_texture_view`.
|
|
||||||
///
|
|
||||||
/// Shared by both export functions. Handles every layer type:
|
|
||||||
/// - Vector/Group: Vello scene → sRGB → linear → composite
|
|
||||||
/// - Raster: upload pixels to `raster_cache` (if needed) → GPU blit → composite
|
|
||||||
/// - Video: sRGB straight-alpha → linear premultiplied → transient GPU texture → blit → composite
|
|
||||||
/// - Float: sRGB-premultiplied → linear → transient GPU texture → blit → composite
|
|
||||||
/// - Effect: apply post-process on the HDR accumulator
|
|
||||||
fn composite_document_to_hdr(
|
|
||||||
composite_result: &lightningbeam_core::renderer::CompositeRenderResult,
|
|
||||||
document: &Document,
|
|
||||||
device: &wgpu::Device,
|
|
||||||
queue: &wgpu::Queue,
|
|
||||||
renderer: &mut vello::Renderer,
|
|
||||||
gpu_resources: &mut ExportGpuResources,
|
|
||||||
width: u32,
|
|
||||||
height: u32,
|
|
||||||
allow_transparency: bool,
|
|
||||||
) -> Result<(), String> {
|
|
||||||
use vello::kurbo::Affine;
|
|
||||||
|
|
||||||
let layer_spec = BufferSpec::new(width, height, BufferFormat::Rgba8Srgb);
|
|
||||||
let hdr_spec = BufferSpec::new(width, height, BufferFormat::Rgba16Float);
|
|
||||||
let layer_render_params = vello::RenderParams {
|
|
||||||
base_color: vello::peniko::Color::TRANSPARENT,
|
|
||||||
width, height,
|
|
||||||
antialiasing_method: vello::AaConfig::Area,
|
|
||||||
};
|
|
||||||
|
|
||||||
// --- Background ---
|
|
||||||
let bg_srgb = gpu_resources.buffer_pool.acquire(device, layer_spec);
|
|
||||||
let bg_hdr = gpu_resources.buffer_pool.acquire(device, hdr_spec);
|
|
||||||
if let (Some(bg_srgb_view), Some(bg_hdr_view)) = (
|
|
||||||
gpu_resources.buffer_pool.get_view(bg_srgb),
|
|
||||||
gpu_resources.buffer_pool.get_view(bg_hdr),
|
|
||||||
) {
|
|
||||||
renderer.render_to_texture(device, queue, &composite_result.background, bg_srgb_view, &layer_render_params)
|
|
||||||
.map_err(|e| format!("Failed to render background: {e}"))?;
|
|
||||||
let mut enc = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("export_bg_srgb_to_linear") });
|
|
||||||
gpu_resources.srgb_to_linear.convert(device, &mut enc, bg_srgb_view, bg_hdr_view);
|
|
||||||
queue.submit(Some(enc.finish()));
|
|
||||||
let bg_layer = CompositorLayer::normal(bg_hdr, 1.0);
|
|
||||||
let mut enc = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("export_bg_composite") });
|
|
||||||
// When transparency is allowed, start from transparent black so the background's
|
|
||||||
// native alpha is preserved. Otherwise force an opaque black underlay.
|
|
||||||
let clear = if allow_transparency { [0.0, 0.0, 0.0, 0.0] } else { [0.0, 0.0, 0.0, 1.0] };
|
|
||||||
gpu_resources.compositor.composite(device, queue, &mut enc, &[bg_layer],
|
|
||||||
&gpu_resources.buffer_pool, &gpu_resources.hdr_texture_view, Some(clear));
|
|
||||||
queue.submit(Some(enc.finish()));
|
|
||||||
}
|
|
||||||
gpu_resources.buffer_pool.release(bg_srgb);
|
|
||||||
gpu_resources.buffer_pool.release(bg_hdr);
|
|
||||||
|
|
||||||
// --- Layers ---
|
|
||||||
for rendered_layer in &composite_result.layers {
|
|
||||||
if !rendered_layer.has_content { continue; }
|
|
||||||
|
|
||||||
match &rendered_layer.layer_type {
|
|
||||||
RenderedLayerType::Vector => {
|
|
||||||
let srgb_handle = gpu_resources.buffer_pool.acquire(device, layer_spec);
|
|
||||||
let hdr_layer_handle = gpu_resources.buffer_pool.acquire(device, hdr_spec);
|
|
||||||
if let (Some(srgb_view), Some(hdr_layer_view)) = (
|
|
||||||
gpu_resources.buffer_pool.get_view(srgb_handle),
|
|
||||||
gpu_resources.buffer_pool.get_view(hdr_layer_handle),
|
|
||||||
) {
|
|
||||||
renderer.render_to_texture(device, queue, &rendered_layer.scene, srgb_view, &layer_render_params)
|
|
||||||
.map_err(|e| format!("Failed to render layer: {e}"))?;
|
|
||||||
let mut enc = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("export_layer_srgb_to_linear") });
|
|
||||||
gpu_resources.srgb_to_linear.convert(device, &mut enc, srgb_view, hdr_layer_view);
|
|
||||||
queue.submit(Some(enc.finish()));
|
|
||||||
let compositor_layer = CompositorLayer::new(hdr_layer_handle, rendered_layer.opacity, rendered_layer.blend_mode);
|
|
||||||
let mut enc = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("export_layer_composite") });
|
|
||||||
gpu_resources.compositor.composite(device, queue, &mut enc, &[compositor_layer], &gpu_resources.buffer_pool, &gpu_resources.hdr_texture_view, None);
|
|
||||||
queue.submit(Some(enc.finish()));
|
|
||||||
}
|
|
||||||
gpu_resources.buffer_pool.release(srgb_handle);
|
|
||||||
gpu_resources.buffer_pool.release(hdr_layer_handle);
|
|
||||||
}
|
|
||||||
RenderedLayerType::Raster { kf_id, width: cw, height: ch, transform: layer_transform, dirty: _ } => {
|
|
||||||
let raw_pixels = document.get_layer(&rendered_layer.layer_id)
|
|
||||||
.and_then(|l| match l {
|
|
||||||
lightningbeam_core::layer::AnyLayer::Raster(rl) => rl.keyframe_at(document.current_time),
|
|
||||||
_ => None,
|
|
||||||
})
|
|
||||||
.filter(|kf| !kf.raw_pixels.is_empty())
|
|
||||||
.map(|kf| kf.raw_pixels.clone());
|
|
||||||
if let Some(pixels) = raw_pixels {
|
|
||||||
if !gpu_resources.raster_cache.contains_key(kf_id) {
|
|
||||||
let canvas = crate::gpu_brush::CanvasPair::new(device, *cw, *ch);
|
|
||||||
canvas.upload(queue, &pixels);
|
|
||||||
gpu_resources.raster_cache.insert(*kf_id, canvas);
|
|
||||||
}
|
|
||||||
if let Some(canvas) = gpu_resources.raster_cache.get(kf_id) {
|
|
||||||
let hdr_layer_handle = gpu_resources.buffer_pool.acquire(device, hdr_spec);
|
|
||||||
if let Some(hdr_layer_view) = gpu_resources.buffer_pool.get_view(hdr_layer_handle) {
|
|
||||||
let bt = crate::gpu_brush::BlitTransform::new(*layer_transform, *cw, *ch, width, height);
|
|
||||||
gpu_resources.canvas_blit.blit(device, queue, canvas.src_view(), hdr_layer_view, &bt, None);
|
|
||||||
let compositor_layer = CompositorLayer::new(hdr_layer_handle, rendered_layer.opacity, rendered_layer.blend_mode);
|
|
||||||
let mut enc = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("export_raster_composite") });
|
|
||||||
gpu_resources.compositor.composite(device, queue, &mut enc, &[compositor_layer], &gpu_resources.buffer_pool, &gpu_resources.hdr_texture_view, None);
|
|
||||||
queue.submit(Some(enc.finish()));
|
|
||||||
}
|
|
||||||
gpu_resources.buffer_pool.release(hdr_layer_handle);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
RenderedLayerType::Video { instances } => {
|
|
||||||
for inst in instances {
|
|
||||||
if inst.rgba_data.is_empty() { continue; }
|
|
||||||
let hdr_layer_handle = gpu_resources.buffer_pool.acquire(device, hdr_spec);
|
|
||||||
if let Some(hdr_layer_view) = gpu_resources.buffer_pool.get_view(hdr_layer_handle) {
|
|
||||||
// sRGB straight-alpha → linear premultiplied
|
|
||||||
let linear: Vec<u8> = inst.rgba_data.chunks_exact(4).flat_map(|p| {
|
|
||||||
let a = p[3] as f32 / 255.0;
|
|
||||||
let lin = |c: u8| -> f32 {
|
|
||||||
let f = c as f32 / 255.0;
|
|
||||||
if f <= 0.04045 { f / 12.92 } else { ((f + 0.055) / 1.055).powf(2.4) }
|
|
||||||
};
|
|
||||||
let r = (lin(p[0]) * a * 255.0 + 0.5) as u8;
|
|
||||||
let g = (lin(p[1]) * a * 255.0 + 0.5) as u8;
|
|
||||||
let b = (lin(p[2]) * a * 255.0 + 0.5) as u8;
|
|
||||||
[r, g, b, p[3]]
|
|
||||||
}).collect();
|
|
||||||
let tex = upload_transient_texture(device, queue, &linear, inst.width, inst.height, Some("export_video_frame_tex"));
|
|
||||||
let tex_view = tex.create_view(&Default::default());
|
|
||||||
let bt = crate::gpu_brush::BlitTransform::new(inst.transform, inst.width, inst.height, width, height);
|
|
||||||
gpu_resources.canvas_blit.blit(device, queue, &tex_view, hdr_layer_view, &bt, None);
|
|
||||||
let compositor_layer = CompositorLayer::new(hdr_layer_handle, inst.opacity, lightningbeam_core::gpu::BlendMode::Normal);
|
|
||||||
let mut enc = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("export_video_composite") });
|
|
||||||
gpu_resources.compositor.composite(device, queue, &mut enc, &[compositor_layer], &gpu_resources.buffer_pool, &gpu_resources.hdr_texture_view, None);
|
|
||||||
queue.submit(Some(enc.finish()));
|
|
||||||
}
|
|
||||||
gpu_resources.buffer_pool.release(hdr_layer_handle);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
RenderedLayerType::Float { x: float_x, y: float_y, width: fw, height: fh, transform: layer_transform, pixels, .. } => {
|
|
||||||
if !pixels.is_empty() {
|
|
||||||
// sRGB-premultiplied → linear-premultiplied
|
|
||||||
let linear: Vec<u8> = pixels.chunks_exact(4).flat_map(|p| {
|
|
||||||
let lin = |c: u8| -> u8 {
|
|
||||||
let f = c as f32 / 255.0;
|
|
||||||
let l = if f <= 0.04045 { f / 12.92 } else { ((f + 0.055) / 1.055).powf(2.4) };
|
|
||||||
(l * 255.0 + 0.5) as u8
|
|
||||||
};
|
|
||||||
[lin(p[0]), lin(p[1]), lin(p[2]), p[3]]
|
|
||||||
}).collect();
|
|
||||||
let tex = upload_transient_texture(device, queue, &linear, *fw, *fh, Some("export_float_tex"));
|
|
||||||
let tex_view = tex.create_view(&Default::default());
|
|
||||||
let hdr_layer_handle = gpu_resources.buffer_pool.acquire(device, hdr_spec);
|
|
||||||
if let Some(hdr_layer_view) = gpu_resources.buffer_pool.get_view(hdr_layer_handle) {
|
|
||||||
let float_to_vp = *layer_transform * Affine::translate((*float_x as f64, *float_y as f64));
|
|
||||||
let bt = crate::gpu_brush::BlitTransform::new(float_to_vp, *fw, *fh, width, height);
|
|
||||||
gpu_resources.canvas_blit.blit(device, queue, &tex_view, hdr_layer_view, &bt, None);
|
|
||||||
let compositor_layer = CompositorLayer::normal(hdr_layer_handle, 1.0);
|
|
||||||
let mut enc = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("export_float_composite") });
|
|
||||||
gpu_resources.compositor.composite(device, queue, &mut enc, &[compositor_layer], &gpu_resources.buffer_pool, &gpu_resources.hdr_texture_view, None);
|
|
||||||
queue.submit(Some(enc.finish()));
|
|
||||||
}
|
|
||||||
gpu_resources.buffer_pool.release(hdr_layer_handle);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
RenderedLayerType::Effect { effect_instances } => {
|
|
||||||
let current_time = document.current_time;
|
|
||||||
for effect_instance in effect_instances {
|
|
||||||
let Some(effect_def) = document.get_effect_definition(&effect_instance.clip_id) else { continue; };
|
|
||||||
if !gpu_resources.effect_processor.is_compiled(&effect_def.id) {
|
|
||||||
let success = gpu_resources.effect_processor.compile_effect(device, effect_def);
|
|
||||||
if !success { eprintln!("Failed to compile effect: {}", effect_def.name); continue; }
|
|
||||||
}
|
|
||||||
let effect_inst = lightningbeam_core::effect::EffectInstance::new(
|
|
||||||
effect_def,
|
|
||||||
effect_instance.timeline_start,
|
|
||||||
effect_instance.timeline_start + effect_instance.effective_duration(lightningbeam_core::effect::EFFECT_DURATION),
|
|
||||||
);
|
|
||||||
let effect_output_handle = gpu_resources.buffer_pool.acquire(device, hdr_spec);
|
|
||||||
if let Some(effect_output_view) = gpu_resources.buffer_pool.get_view(effect_output_handle) {
|
|
||||||
let mut enc = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("export_effect") });
|
|
||||||
let applied = gpu_resources.effect_processor.apply_effect(
|
|
||||||
device, queue, &mut enc, effect_def, &effect_inst,
|
|
||||||
&gpu_resources.hdr_texture_view, effect_output_view, width, height, current_time,
|
|
||||||
);
|
|
||||||
if applied {
|
|
||||||
queue.submit(Some(enc.finish()));
|
|
||||||
let effect_layer = CompositorLayer::normal(effect_output_handle, rendered_layer.opacity);
|
|
||||||
let mut copy_enc = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("export_effect_copy") });
|
|
||||||
// Replace the accumulator with the processed result.
|
|
||||||
gpu_resources.compositor.composite(device, queue, &mut copy_enc, &[effect_layer], &gpu_resources.buffer_pool, &gpu_resources.hdr_texture_view, Some([0.0, 0.0, 0.0, 0.0]));
|
|
||||||
queue.submit(Some(copy_enc.finish()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
gpu_resources.buffer_pool.release(effect_output_handle);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
gpu_resources.buffer_pool.next_frame();
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Upload `pixels` to a transient `Rgba8Unorm` GPU texture (TEXTURE_BINDING | COPY_DST).
|
|
||||||
fn upload_transient_texture(
|
|
||||||
device: &wgpu::Device,
|
|
||||||
queue: &wgpu::Queue,
|
|
||||||
pixels: &[u8],
|
|
||||||
width: u32,
|
|
||||||
height: u32,
|
|
||||||
label: Option<&'static str>,
|
|
||||||
) -> wgpu::Texture {
|
|
||||||
let tex = device.create_texture(&wgpu::TextureDescriptor {
|
|
||||||
label,
|
|
||||||
size: wgpu::Extent3d { width, height, depth_or_array_layers: 1 },
|
|
||||||
mip_level_count: 1, sample_count: 1,
|
|
||||||
dimension: wgpu::TextureDimension::D2,
|
|
||||||
format: wgpu::TextureFormat::Rgba8Unorm,
|
|
||||||
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
|
|
||||||
view_formats: &[],
|
|
||||||
});
|
|
||||||
queue.write_texture(
|
|
||||||
wgpu::TexelCopyTextureInfo { texture: &tex, mip_level: 0, origin: wgpu::Origin3d::ZERO, aspect: wgpu::TextureAspect::All },
|
|
||||||
pixels,
|
|
||||||
wgpu::TexelCopyBufferLayout { offset: 0, bytes_per_row: Some(width * 4), rows_per_image: Some(height) },
|
|
||||||
wgpu::Extent3d { width, height, depth_or_array_layers: 1 },
|
|
||||||
);
|
|
||||||
tex
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Render a document frame using the HDR compositing pipeline with effects
|
/// Render a document frame using the HDR compositing pipeline with effects
|
||||||
///
|
///
|
||||||
/// This function uses the same rendering pipeline as the stage preview,
|
/// This function uses the same rendering pipeline as the stage preview,
|
||||||
|
|
@ -983,12 +748,193 @@ pub fn render_frame_to_rgba_hdr(
|
||||||
image_cache,
|
image_cache,
|
||||||
video_manager,
|
video_manager,
|
||||||
None, // No webcam during export
|
None, // No webcam during export
|
||||||
None, // No floating selection during export
|
|
||||||
false, // No checkerboard in export
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// Video export is never transparent.
|
// Buffer specs for layer rendering
|
||||||
composite_document_to_hdr(&composite_result, document, device, queue, renderer, gpu_resources, width, height, false)?;
|
let layer_spec = BufferSpec::new(width, height, BufferFormat::Rgba8Srgb);
|
||||||
|
let hdr_spec = BufferSpec::new(width, height, BufferFormat::Rgba16Float);
|
||||||
|
|
||||||
|
// Render parameters for Vello (transparent background for layers)
|
||||||
|
let layer_render_params = vello::RenderParams {
|
||||||
|
base_color: vello::peniko::Color::TRANSPARENT,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
antialiasing_method: vello::AaConfig::Area,
|
||||||
|
};
|
||||||
|
|
||||||
|
// First, render background and composite it
|
||||||
|
let bg_srgb_handle = gpu_resources.buffer_pool.acquire(device, layer_spec);
|
||||||
|
let bg_hdr_handle = gpu_resources.buffer_pool.acquire(device, hdr_spec);
|
||||||
|
|
||||||
|
if let (Some(bg_srgb_view), Some(bg_hdr_view)) = (
|
||||||
|
gpu_resources.buffer_pool.get_view(bg_srgb_handle),
|
||||||
|
gpu_resources.buffer_pool.get_view(bg_hdr_handle),
|
||||||
|
) {
|
||||||
|
// Render background scene
|
||||||
|
renderer.render_to_texture(device, queue, &composite_result.background, bg_srgb_view, &layer_render_params)
|
||||||
|
.map_err(|e| format!("Failed to render background: {}", e))?;
|
||||||
|
|
||||||
|
// Convert sRGB to linear HDR
|
||||||
|
let mut convert_encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
|
||||||
|
label: Some("export_bg_srgb_to_linear_encoder"),
|
||||||
|
});
|
||||||
|
gpu_resources.srgb_to_linear.convert(device, &mut convert_encoder, bg_srgb_view, bg_hdr_view);
|
||||||
|
queue.submit(Some(convert_encoder.finish()));
|
||||||
|
|
||||||
|
// Composite background onto HDR texture (first layer, clears to black for export)
|
||||||
|
let bg_compositor_layer = CompositorLayer::normal(bg_hdr_handle, 1.0);
|
||||||
|
let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
|
||||||
|
label: Some("export_bg_composite_encoder"),
|
||||||
|
});
|
||||||
|
// Clear to black for export (unlike stage preview which has gray background)
|
||||||
|
gpu_resources.compositor.composite(
|
||||||
|
device,
|
||||||
|
queue,
|
||||||
|
&mut encoder,
|
||||||
|
&[bg_compositor_layer],
|
||||||
|
&gpu_resources.buffer_pool,
|
||||||
|
&gpu_resources.hdr_texture_view,
|
||||||
|
Some([0.0, 0.0, 0.0, 1.0]),
|
||||||
|
);
|
||||||
|
queue.submit(Some(encoder.finish()));
|
||||||
|
}
|
||||||
|
gpu_resources.buffer_pool.release(bg_srgb_handle);
|
||||||
|
gpu_resources.buffer_pool.release(bg_hdr_handle);
|
||||||
|
|
||||||
|
// Now render and composite each layer incrementally
|
||||||
|
for rendered_layer in &composite_result.layers {
|
||||||
|
if !rendered_layer.has_content {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
match &rendered_layer.layer_type {
|
||||||
|
RenderedLayerType::Content => {
|
||||||
|
// Regular content layer - render to sRGB, convert to linear, then composite
|
||||||
|
let srgb_handle = gpu_resources.buffer_pool.acquire(device, layer_spec);
|
||||||
|
let hdr_layer_handle = gpu_resources.buffer_pool.acquire(device, hdr_spec);
|
||||||
|
|
||||||
|
if let (Some(srgb_view), Some(hdr_layer_view)) = (
|
||||||
|
gpu_resources.buffer_pool.get_view(srgb_handle),
|
||||||
|
gpu_resources.buffer_pool.get_view(hdr_layer_handle),
|
||||||
|
) {
|
||||||
|
// Render layer scene to sRGB buffer
|
||||||
|
renderer.render_to_texture(device, queue, &rendered_layer.scene, srgb_view, &layer_render_params)
|
||||||
|
.map_err(|e| format!("Failed to render layer: {}", e))?;
|
||||||
|
|
||||||
|
// Convert sRGB to linear HDR
|
||||||
|
let mut convert_encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
|
||||||
|
label: Some("export_layer_srgb_to_linear_encoder"),
|
||||||
|
});
|
||||||
|
gpu_resources.srgb_to_linear.convert(device, &mut convert_encoder, srgb_view, hdr_layer_view);
|
||||||
|
queue.submit(Some(convert_encoder.finish()));
|
||||||
|
|
||||||
|
// Composite this layer onto the HDR accumulator with its opacity
|
||||||
|
let compositor_layer = CompositorLayer::new(
|
||||||
|
hdr_layer_handle,
|
||||||
|
rendered_layer.opacity,
|
||||||
|
rendered_layer.blend_mode,
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
|
||||||
|
label: Some("export_layer_composite_encoder"),
|
||||||
|
});
|
||||||
|
gpu_resources.compositor.composite(
|
||||||
|
device,
|
||||||
|
queue,
|
||||||
|
&mut encoder,
|
||||||
|
&[compositor_layer],
|
||||||
|
&gpu_resources.buffer_pool,
|
||||||
|
&gpu_resources.hdr_texture_view,
|
||||||
|
None, // Don't clear - blend onto existing content
|
||||||
|
);
|
||||||
|
queue.submit(Some(encoder.finish()));
|
||||||
|
}
|
||||||
|
|
||||||
|
gpu_resources.buffer_pool.release(srgb_handle);
|
||||||
|
gpu_resources.buffer_pool.release(hdr_layer_handle);
|
||||||
|
}
|
||||||
|
RenderedLayerType::Effect { effect_instances } => {
|
||||||
|
// Effect layer - apply effects to the current HDR accumulator
|
||||||
|
let current_time = document.current_time;
|
||||||
|
|
||||||
|
for effect_instance in effect_instances {
|
||||||
|
// Get effect definition from document
|
||||||
|
let Some(effect_def) = document.get_effect_definition(&effect_instance.clip_id) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Compile effect if needed
|
||||||
|
if !gpu_resources.effect_processor.is_compiled(&effect_def.id) {
|
||||||
|
let success = gpu_resources.effect_processor.compile_effect(device, effect_def);
|
||||||
|
if !success {
|
||||||
|
eprintln!("Failed to compile effect: {}", effect_def.name);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create EffectInstance from ClipInstance for the processor
|
||||||
|
let effect_inst = lightningbeam_core::effect::EffectInstance::new(
|
||||||
|
effect_def,
|
||||||
|
effect_instance.timeline_start,
|
||||||
|
effect_instance.timeline_start + effect_instance.effective_duration(lightningbeam_core::effect::EFFECT_DURATION),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Acquire temp buffer for effect output (HDR format)
|
||||||
|
let effect_output_handle = gpu_resources.buffer_pool.acquire(device, hdr_spec);
|
||||||
|
|
||||||
|
if let Some(effect_output_view) = gpu_resources.buffer_pool.get_view(effect_output_handle) {
|
||||||
|
let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
|
||||||
|
label: Some("export_effect_encoder"),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Apply effect: HDR accumulator → effect output buffer
|
||||||
|
let applied = gpu_resources.effect_processor.apply_effect(
|
||||||
|
device,
|
||||||
|
queue,
|
||||||
|
&mut encoder,
|
||||||
|
effect_def,
|
||||||
|
&effect_inst,
|
||||||
|
&gpu_resources.hdr_texture_view,
|
||||||
|
effect_output_view,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
current_time,
|
||||||
|
);
|
||||||
|
|
||||||
|
if applied {
|
||||||
|
queue.submit(Some(encoder.finish()));
|
||||||
|
|
||||||
|
// Copy effect output back to HDR accumulator
|
||||||
|
let mut copy_encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
|
||||||
|
label: Some("export_effect_copy_encoder"),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Use compositor to copy (replacing content)
|
||||||
|
let effect_layer = CompositorLayer::normal(
|
||||||
|
effect_output_handle,
|
||||||
|
rendered_layer.opacity, // Apply effect layer opacity
|
||||||
|
);
|
||||||
|
gpu_resources.compositor.composite(
|
||||||
|
device,
|
||||||
|
queue,
|
||||||
|
&mut copy_encoder,
|
||||||
|
&[effect_layer],
|
||||||
|
&gpu_resources.buffer_pool,
|
||||||
|
&gpu_resources.hdr_texture_view,
|
||||||
|
Some([0.0, 0.0, 0.0, 0.0]), // Clear with transparent (we're replacing)
|
||||||
|
);
|
||||||
|
queue.submit(Some(copy_encoder.finish()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
gpu_resources.buffer_pool.release(effect_output_handle);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Advance frame counter for buffer cleanup
|
||||||
|
gpu_resources.buffer_pool.next_frame();
|
||||||
|
|
||||||
// Use persistent output texture (already created in ExportGpuResources)
|
// Use persistent output texture (already created in ExportGpuResources)
|
||||||
let output_view = &gpu_resources.output_texture_view;
|
let output_view = &gpu_resources.output_texture_view;
|
||||||
|
|
@ -1172,8 +1118,6 @@ pub fn render_frame_to_gpu_rgba(
|
||||||
video_manager: &Arc<std::sync::Mutex<VideoManager>>,
|
video_manager: &Arc<std::sync::Mutex<VideoManager>>,
|
||||||
gpu_resources: &mut ExportGpuResources,
|
gpu_resources: &mut ExportGpuResources,
|
||||||
rgba_texture_view: &wgpu::TextureView,
|
rgba_texture_view: &wgpu::TextureView,
|
||||||
floating_selection: Option<&lightningbeam_core::selection::RasterFloatingSelection>,
|
|
||||||
allow_transparency: bool,
|
|
||||||
) -> Result<wgpu::CommandEncoder, String> {
|
) -> Result<wgpu::CommandEncoder, String> {
|
||||||
use vello::kurbo::Affine;
|
use vello::kurbo::Affine;
|
||||||
|
|
||||||
|
|
@ -1190,11 +1134,176 @@ pub fn render_frame_to_gpu_rgba(
|
||||||
image_cache,
|
image_cache,
|
||||||
video_manager,
|
video_manager,
|
||||||
None, // No webcam during export
|
None, // No webcam during export
|
||||||
floating_selection,
|
|
||||||
false, // No checkerboard in export
|
|
||||||
);
|
);
|
||||||
|
|
||||||
composite_document_to_hdr(&composite_result, document, device, queue, renderer, gpu_resources, width, height, allow_transparency)?;
|
// Buffer specs for layer rendering
|
||||||
|
let layer_spec = BufferSpec::new(width, height, BufferFormat::Rgba8Srgb);
|
||||||
|
let hdr_spec = BufferSpec::new(width, height, BufferFormat::Rgba16Float);
|
||||||
|
|
||||||
|
// Render parameters for Vello (transparent background for layers)
|
||||||
|
let layer_render_params = vello::RenderParams {
|
||||||
|
base_color: vello::peniko::Color::TRANSPARENT,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
antialiasing_method: vello::AaConfig::Area,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Render background and composite it
|
||||||
|
let bg_srgb_handle = gpu_resources.buffer_pool.acquire(device, layer_spec);
|
||||||
|
let bg_hdr_handle = gpu_resources.buffer_pool.acquire(device, hdr_spec);
|
||||||
|
|
||||||
|
if let (Some(bg_srgb_view), Some(bg_hdr_view)) = (
|
||||||
|
gpu_resources.buffer_pool.get_view(bg_srgb_handle),
|
||||||
|
gpu_resources.buffer_pool.get_view(bg_hdr_handle),
|
||||||
|
) {
|
||||||
|
renderer.render_to_texture(device, queue, &composite_result.background, bg_srgb_view, &layer_render_params)
|
||||||
|
.map_err(|e| format!("Failed to render background: {}", e))?;
|
||||||
|
|
||||||
|
let mut convert_encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
|
||||||
|
label: Some("export_bg_srgb_to_linear_encoder"),
|
||||||
|
});
|
||||||
|
gpu_resources.srgb_to_linear.convert(device, &mut convert_encoder, bg_srgb_view, bg_hdr_view);
|
||||||
|
queue.submit(Some(convert_encoder.finish()));
|
||||||
|
|
||||||
|
let bg_compositor_layer = CompositorLayer::normal(bg_hdr_handle, 1.0);
|
||||||
|
let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
|
||||||
|
label: Some("export_bg_composite_encoder"),
|
||||||
|
});
|
||||||
|
gpu_resources.compositor.composite(
|
||||||
|
device,
|
||||||
|
queue,
|
||||||
|
&mut encoder,
|
||||||
|
&[bg_compositor_layer],
|
||||||
|
&gpu_resources.buffer_pool,
|
||||||
|
&gpu_resources.hdr_texture_view,
|
||||||
|
Some([0.0, 0.0, 0.0, 1.0]),
|
||||||
|
);
|
||||||
|
queue.submit(Some(encoder.finish()));
|
||||||
|
}
|
||||||
|
gpu_resources.buffer_pool.release(bg_srgb_handle);
|
||||||
|
gpu_resources.buffer_pool.release(bg_hdr_handle);
|
||||||
|
|
||||||
|
// Render and composite each layer incrementally
|
||||||
|
for rendered_layer in &composite_result.layers {
|
||||||
|
if !rendered_layer.has_content {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
match &rendered_layer.layer_type {
|
||||||
|
RenderedLayerType::Content => {
|
||||||
|
let srgb_handle = gpu_resources.buffer_pool.acquire(device, layer_spec);
|
||||||
|
let hdr_layer_handle = gpu_resources.buffer_pool.acquire(device, hdr_spec);
|
||||||
|
|
||||||
|
if let (Some(srgb_view), Some(hdr_layer_view)) = (
|
||||||
|
gpu_resources.buffer_pool.get_view(srgb_handle),
|
||||||
|
gpu_resources.buffer_pool.get_view(hdr_layer_handle),
|
||||||
|
) {
|
||||||
|
renderer.render_to_texture(device, queue, &rendered_layer.scene, srgb_view, &layer_render_params)
|
||||||
|
.map_err(|e| format!("Failed to render layer: {}", e))?;
|
||||||
|
|
||||||
|
let mut convert_encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
|
||||||
|
label: Some("export_layer_srgb_to_linear_encoder"),
|
||||||
|
});
|
||||||
|
gpu_resources.srgb_to_linear.convert(device, &mut convert_encoder, srgb_view, hdr_layer_view);
|
||||||
|
queue.submit(Some(convert_encoder.finish()));
|
||||||
|
|
||||||
|
let compositor_layer = CompositorLayer::normal(hdr_layer_handle, rendered_layer.opacity);
|
||||||
|
let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
|
||||||
|
label: Some("export_layer_composite_encoder"),
|
||||||
|
});
|
||||||
|
gpu_resources.compositor.composite(
|
||||||
|
device,
|
||||||
|
queue,
|
||||||
|
&mut encoder,
|
||||||
|
&[compositor_layer],
|
||||||
|
&gpu_resources.buffer_pool,
|
||||||
|
&gpu_resources.hdr_texture_view,
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
queue.submit(Some(encoder.finish()));
|
||||||
|
}
|
||||||
|
gpu_resources.buffer_pool.release(srgb_handle);
|
||||||
|
gpu_resources.buffer_pool.release(hdr_layer_handle);
|
||||||
|
}
|
||||||
|
RenderedLayerType::Effect { effect_instances } => {
|
||||||
|
// Effect layer - apply effects to the current HDR accumulator
|
||||||
|
let current_time = document.current_time;
|
||||||
|
|
||||||
|
for effect_instance in effect_instances {
|
||||||
|
// Get effect definition from document
|
||||||
|
let Some(effect_def) = document.get_effect_definition(&effect_instance.clip_id) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Compile effect if needed
|
||||||
|
if !gpu_resources.effect_processor.is_compiled(&effect_def.id) {
|
||||||
|
let success = gpu_resources.effect_processor.compile_effect(device, effect_def);
|
||||||
|
if !success {
|
||||||
|
eprintln!("Failed to compile effect: {}", effect_def.name);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create EffectInstance from ClipInstance for the processor
|
||||||
|
let effect_inst = lightningbeam_core::effect::EffectInstance::new(
|
||||||
|
effect_def,
|
||||||
|
effect_instance.timeline_start,
|
||||||
|
effect_instance.timeline_start + effect_instance.effective_duration(lightningbeam_core::effect::EFFECT_DURATION),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Acquire temp buffer for effect output (HDR format)
|
||||||
|
let effect_output_handle = gpu_resources.buffer_pool.acquire(device, hdr_spec);
|
||||||
|
|
||||||
|
if let Some(effect_output_view) = gpu_resources.buffer_pool.get_view(effect_output_handle) {
|
||||||
|
let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
|
||||||
|
label: Some("export_effect_encoder"),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Apply effect: HDR accumulator → effect output buffer
|
||||||
|
let applied = gpu_resources.effect_processor.apply_effect(
|
||||||
|
device,
|
||||||
|
queue,
|
||||||
|
&mut encoder,
|
||||||
|
effect_def,
|
||||||
|
&effect_inst,
|
||||||
|
&gpu_resources.hdr_texture_view,
|
||||||
|
effect_output_view,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
current_time,
|
||||||
|
);
|
||||||
|
|
||||||
|
if applied {
|
||||||
|
// Copy effect output back to HDR accumulator
|
||||||
|
encoder.copy_texture_to_texture(
|
||||||
|
wgpu::TexelCopyTextureInfo {
|
||||||
|
texture: gpu_resources.buffer_pool.get_texture(effect_output_handle).unwrap(),
|
||||||
|
mip_level: 0,
|
||||||
|
origin: wgpu::Origin3d::ZERO,
|
||||||
|
aspect: wgpu::TextureAspect::All,
|
||||||
|
},
|
||||||
|
wgpu::TexelCopyTextureInfo {
|
||||||
|
texture: &gpu_resources.hdr_texture,
|
||||||
|
mip_level: 0,
|
||||||
|
origin: wgpu::Origin3d::ZERO,
|
||||||
|
aspect: wgpu::TextureAspect::All,
|
||||||
|
},
|
||||||
|
wgpu::Extent3d {
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
depth_or_array_layers: 1,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
queue.submit(Some(encoder.finish()));
|
||||||
|
}
|
||||||
|
|
||||||
|
gpu_resources.buffer_pool.release(effect_output_handle);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Convert HDR to sRGB (linear → sRGB), render directly to external RGBA texture
|
// Convert HDR to sRGB (linear → sRGB), render directly to external RGBA texture
|
||||||
let output_view = rgba_texture_view;
|
let output_view = rgba_texture_view;
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -429,26 +429,24 @@ impl AppAction {
|
||||||
/// `Tool::Split` has no tool-shortcut action (it's triggered via the menu).
|
/// `Tool::Split` has no tool-shortcut action (it's triggered via the menu).
|
||||||
pub fn tool_app_action(tool: lightningbeam_core::tool::Tool) -> Option<AppAction> {
|
pub fn tool_app_action(tool: lightningbeam_core::tool::Tool) -> Option<AppAction> {
|
||||||
use lightningbeam_core::tool::Tool;
|
use lightningbeam_core::tool::Tool;
|
||||||
match tool {
|
Some(match tool {
|
||||||
Tool::Select => Some(AppAction::ToolSelect),
|
Tool::Select => AppAction::ToolSelect,
|
||||||
Tool::Draw => Some(AppAction::ToolDraw),
|
Tool::Draw => AppAction::ToolDraw,
|
||||||
Tool::Transform => Some(AppAction::ToolTransform),
|
Tool::Transform => AppAction::ToolTransform,
|
||||||
Tool::Rectangle => Some(AppAction::ToolRectangle),
|
Tool::Rectangle => AppAction::ToolRectangle,
|
||||||
Tool::Ellipse => Some(AppAction::ToolEllipse),
|
Tool::Ellipse => AppAction::ToolEllipse,
|
||||||
Tool::PaintBucket => Some(AppAction::ToolPaintBucket),
|
Tool::PaintBucket => AppAction::ToolPaintBucket,
|
||||||
Tool::Eyedropper => Some(AppAction::ToolEyedropper),
|
Tool::Eyedropper => AppAction::ToolEyedropper,
|
||||||
Tool::Line => Some(AppAction::ToolLine),
|
Tool::Line => AppAction::ToolLine,
|
||||||
Tool::Polygon => Some(AppAction::ToolPolygon),
|
Tool::Polygon => AppAction::ToolPolygon,
|
||||||
Tool::BezierEdit => Some(AppAction::ToolBezierEdit),
|
Tool::BezierEdit => AppAction::ToolBezierEdit,
|
||||||
Tool::Text => Some(AppAction::ToolText),
|
Tool::Text => AppAction::ToolText,
|
||||||
Tool::RegionSelect => Some(AppAction::ToolRegionSelect),
|
Tool::RegionSelect => AppAction::ToolRegionSelect,
|
||||||
Tool::Erase => Some(AppAction::ToolErase),
|
Tool::Erase => AppAction::ToolErase,
|
||||||
Tool::Smudge => Some(AppAction::ToolSmudge),
|
Tool::Smudge => AppAction::ToolSmudge,
|
||||||
Tool::SelectLasso => Some(AppAction::ToolSelectLasso),
|
Tool::SelectLasso => AppAction::ToolSelectLasso,
|
||||||
Tool::Split => Some(AppAction::ToolSplit),
|
Tool::Split => AppAction::ToolSplit,
|
||||||
// New tools have no keybinding yet
|
})
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// === Default bindings ===
|
// === Default bindings ===
|
||||||
|
|
|
||||||
|
|
@ -13,8 +13,6 @@ use uuid::Uuid;
|
||||||
mod panes;
|
mod panes;
|
||||||
use panes::{PaneInstance, PaneRenderer};
|
use panes::{PaneInstance, PaneRenderer};
|
||||||
|
|
||||||
mod tools;
|
|
||||||
|
|
||||||
mod widgets;
|
mod widgets;
|
||||||
|
|
||||||
mod menu;
|
mod menu;
|
||||||
|
|
@ -28,8 +26,6 @@ mod waveform_gpu;
|
||||||
mod cqt_gpu;
|
mod cqt_gpu;
|
||||||
mod gpu_brush;
|
mod gpu_brush;
|
||||||
|
|
||||||
mod raster_tool;
|
|
||||||
|
|
||||||
mod config;
|
mod config;
|
||||||
use config::AppConfig;
|
use config::AppConfig;
|
||||||
|
|
||||||
|
|
@ -336,7 +332,6 @@ mod tool_icons {
|
||||||
pub static ERASE: &[u8] = include_bytes!("../../../src/assets/erase.svg");
|
pub static ERASE: &[u8] = include_bytes!("../../../src/assets/erase.svg");
|
||||||
pub static SMUDGE: &[u8] = include_bytes!("../../../src/assets/smudge.svg");
|
pub static SMUDGE: &[u8] = include_bytes!("../../../src/assets/smudge.svg");
|
||||||
pub static LASSO: &[u8] = include_bytes!("../../../src/assets/lasso.svg");
|
pub static LASSO: &[u8] = include_bytes!("../../../src/assets/lasso.svg");
|
||||||
pub static TODO: &[u8] = include_bytes!("../../../src/assets/todo.svg");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Embedded focus icon SVGs
|
/// Embedded focus icon SVGs
|
||||||
|
|
@ -404,28 +399,11 @@ impl ToolIconCache {
|
||||||
Tool::Polygon => tool_icons::POLYGON,
|
Tool::Polygon => tool_icons::POLYGON,
|
||||||
Tool::BezierEdit => tool_icons::BEZIER_EDIT,
|
Tool::BezierEdit => tool_icons::BEZIER_EDIT,
|
||||||
Tool::Text => tool_icons::TEXT,
|
Tool::Text => tool_icons::TEXT,
|
||||||
Tool::RegionSelect => tool_icons::SELECT,
|
Tool::RegionSelect => tool_icons::SELECT, // Reuse select icon for now
|
||||||
Tool::Split => tool_icons::SPLIT,
|
Tool::Split => tool_icons::SPLIT,
|
||||||
Tool::Erase => tool_icons::ERASE,
|
Tool::Erase => tool_icons::ERASE,
|
||||||
Tool::Smudge => tool_icons::SMUDGE,
|
Tool::Smudge => tool_icons::SMUDGE,
|
||||||
Tool::SelectLasso => tool_icons::LASSO,
|
Tool::SelectLasso => tool_icons::LASSO,
|
||||||
// Not yet implemented — use placeholder icon
|
|
||||||
Tool::Pencil
|
|
||||||
| Tool::Pen
|
|
||||||
| Tool::Airbrush
|
|
||||||
| Tool::CloneStamp
|
|
||||||
| Tool::HealingBrush
|
|
||||||
| Tool::PatternStamp
|
|
||||||
| Tool::DodgeBurn
|
|
||||||
| Tool::Sponge
|
|
||||||
| Tool::BlurSharpen
|
|
||||||
| Tool::Gradient
|
|
||||||
| Tool::CustomShape
|
|
||||||
| Tool::SelectEllipse
|
|
||||||
| Tool::MagicWand
|
|
||||||
| Tool::QuickSelect
|
|
||||||
| Tool::Warp
|
|
||||||
| Tool::Liquify => tool_icons::TODO,
|
|
||||||
};
|
};
|
||||||
if let Some(texture) = rasterize_svg(svg_data, tool.icon_file(), 180, ctx) {
|
if let Some(texture) = rasterize_svg(svg_data, tool.icon_file(), 180, ctx) {
|
||||||
self.icons.insert(tool, texture);
|
self.icons.insert(tool, texture);
|
||||||
|
|
@ -788,10 +766,12 @@ struct EditorApp {
|
||||||
draw_simplify_mode: lightningbeam_core::tool::SimplifyMode, // Current simplification mode for draw tool
|
draw_simplify_mode: lightningbeam_core::tool::SimplifyMode, // Current simplification mode for draw tool
|
||||||
rdp_tolerance: f64, // RDP simplification tolerance (default: 10.0)
|
rdp_tolerance: f64, // RDP simplification tolerance (default: 10.0)
|
||||||
schneider_max_error: f64, // Schneider curve fitting max error (default: 30.0)
|
schneider_max_error: f64, // Schneider curve fitting max error (default: 30.0)
|
||||||
/// All per-tool raster paint settings (brush, eraser, smudge, clone, pattern, dodge/burn, sponge).
|
// Raster brush settings
|
||||||
raster_settings: tools::RasterToolSettings,
|
brush_radius: f32, // brush radius in pixels
|
||||||
/// GPU-rendered brush preview pixel buffers, shared with VelloCallback::prepare().
|
brush_opacity: f32, // brush opacity 0.0–1.0
|
||||||
brush_preview_pixels: std::sync::Arc<std::sync::Mutex<Vec<(u32, u32, Vec<u8>)>>>,
|
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
|
||||||
// Audio engine integration
|
// Audio engine integration
|
||||||
#[allow(dead_code)] // Must be kept alive to maintain audio output
|
#[allow(dead_code)] // Must be kept alive to maintain audio output
|
||||||
audio_stream: Option<cpal::Stream>,
|
audio_stream: Option<cpal::Stream>,
|
||||||
|
|
@ -861,7 +841,6 @@ struct EditorApp {
|
||||||
// Region select state
|
// Region select state
|
||||||
region_selection: Option<lightningbeam_core::selection::RegionSelection>,
|
region_selection: Option<lightningbeam_core::selection::RegionSelection>,
|
||||||
region_select_mode: lightningbeam_core::tool::RegionSelectMode,
|
region_select_mode: lightningbeam_core::tool::RegionSelectMode,
|
||||||
lasso_mode: lightningbeam_core::tool::LassoMode,
|
|
||||||
|
|
||||||
// VU meter levels
|
// VU meter levels
|
||||||
input_level: f32,
|
input_level: f32,
|
||||||
|
|
@ -956,9 +935,6 @@ impl EditorApp {
|
||||||
#[cfg(debug_assertions)]
|
#[cfg(debug_assertions)]
|
||||||
cc.egui_ctx.style_mut(|style| style.debug.show_unaligned = false);
|
cc.egui_ctx.style_mut(|style| style.debug.show_unaligned = false);
|
||||||
|
|
||||||
// Disable egui's built-in Ctrl+Plus/Minus zoom — we handle zoom ourselves.
|
|
||||||
cc.egui_ctx.options_mut(|o| o.zoom_with_keyboard = false);
|
|
||||||
|
|
||||||
// Load application config
|
// Load application config
|
||||||
let config = AppConfig::load();
|
let config = AppConfig::load();
|
||||||
|
|
||||||
|
|
@ -1073,8 +1049,11 @@ impl EditorApp {
|
||||||
draw_simplify_mode: lightningbeam_core::tool::SimplifyMode::Smooth, // Default to smooth curves
|
draw_simplify_mode: lightningbeam_core::tool::SimplifyMode::Smooth, // Default to smooth curves
|
||||||
rdp_tolerance: 10.0, // Default RDP tolerance
|
rdp_tolerance: 10.0, // Default RDP tolerance
|
||||||
schneider_max_error: 30.0, // Default Schneider max error
|
schneider_max_error: 30.0, // Default Schneider max error
|
||||||
raster_settings: tools::RasterToolSettings::default(),
|
brush_radius: 10.0,
|
||||||
brush_preview_pixels: std::sync::Arc::new(std::sync::Mutex::new(Vec::new())),
|
brush_opacity: 1.0,
|
||||||
|
brush_hardness: 0.5,
|
||||||
|
brush_spacing: 0.1,
|
||||||
|
brush_use_fg: true,
|
||||||
audio_stream,
|
audio_stream,
|
||||||
audio_controller,
|
audio_controller,
|
||||||
audio_event_rx,
|
audio_event_rx,
|
||||||
|
|
@ -1118,7 +1097,6 @@ impl EditorApp {
|
||||||
polygon_sides: 5, // Default to pentagon
|
polygon_sides: 5, // Default to pentagon
|
||||||
region_selection: None,
|
region_selection: None,
|
||||||
region_select_mode: lightningbeam_core::tool::RegionSelectMode::default(),
|
region_select_mode: lightningbeam_core::tool::RegionSelectMode::default(),
|
||||||
lasso_mode: lightningbeam_core::tool::LassoMode::default(),
|
|
||||||
input_level: 0.0,
|
input_level: 0.0,
|
||||||
output_level: (0.0, 0.0),
|
output_level: (0.0, 0.0),
|
||||||
track_levels: HashMap::new(),
|
track_levels: HashMap::new(),
|
||||||
|
|
@ -1874,8 +1852,7 @@ impl EditorApp {
|
||||||
let cy = y0 + row;
|
let cy = y0 + row;
|
||||||
let inside = match sel {
|
let inside = match sel {
|
||||||
RasterSelection::Rect(..) => true,
|
RasterSelection::Rect(..) => true,
|
||||||
RasterSelection::Lasso(_) | RasterSelection::Mask { .. } =>
|
RasterSelection::Lasso(_) => sel.contains_pixel(cx as i32, cy as i32),
|
||||||
sel.contains_pixel(cx as i32, cy as i32),
|
|
||||||
};
|
};
|
||||||
if inside {
|
if inside {
|
||||||
let src = ((cy * canvas_w + cx) * 4) as usize;
|
let src = ((cy * canvas_w + cx) * 4) as usize;
|
||||||
|
|
@ -1999,8 +1976,7 @@ impl EditorApp {
|
||||||
|
|
||||||
let action = RasterStrokeAction::new(
|
let action = RasterStrokeAction::new(
|
||||||
float.layer_id, float.time,
|
float.layer_id, float.time,
|
||||||
std::sync::Arc::try_unwrap(float.canvas_before).unwrap_or_else(|a| (*a).clone()),
|
float.canvas_before, canvas_after,
|
||||||
canvas_after,
|
|
||||||
w, h,
|
w, h,
|
||||||
);
|
);
|
||||||
if let Err(e) = self.action_executor.execute(Box::new(action)) {
|
if let Err(e) = self.action_executor.execute(Box::new(action)) {
|
||||||
|
|
@ -2019,7 +1995,7 @@ impl EditorApp {
|
||||||
let document = self.action_executor.document_mut();
|
let document = self.action_executor.document_mut();
|
||||||
let Some(AnyLayer::Raster(rl)) = document.get_layer_mut(&float.layer_id) else { return };
|
let Some(AnyLayer::Raster(rl)) = document.get_layer_mut(&float.layer_id) else { return };
|
||||||
let Some(kf) = rl.keyframe_at_mut(float.time) else { return };
|
let Some(kf) = rl.keyframe_at_mut(float.time) else { return };
|
||||||
kf.raw_pixels = std::sync::Arc::try_unwrap(float.canvas_before).unwrap_or_else(|a| (*a).clone());
|
kf.raw_pixels = float.canvas_before;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Drop (discard) the floating selection keeping the hole punched in the
|
/// Drop (discard) the floating selection keeping the hole punched in the
|
||||||
|
|
@ -2039,8 +2015,7 @@ impl EditorApp {
|
||||||
let (w, h) = (kf.width, kf.height);
|
let (w, h) = (kf.width, kf.height);
|
||||||
let action = RasterStrokeAction::new(
|
let action = RasterStrokeAction::new(
|
||||||
float.layer_id, float.time,
|
float.layer_id, float.time,
|
||||||
std::sync::Arc::try_unwrap(float.canvas_before).unwrap_or_else(|a| (*a).clone()),
|
float.canvas_before, canvas_after,
|
||||||
canvas_after,
|
|
||||||
w, h,
|
w, h,
|
||||||
);
|
);
|
||||||
if let Err(e) = self.action_executor.execute(Box::new(action)) {
|
if let Err(e) = self.action_executor.execute(Box::new(action)) {
|
||||||
|
|
@ -2061,7 +2036,7 @@ impl EditorApp {
|
||||||
if matches!(document.get_layer(&layer_id), Some(AnyLayer::Raster(_))) {
|
if matches!(document.get_layer(&layer_id), Some(AnyLayer::Raster(_))) {
|
||||||
if let Some(float) = &self.selection.raster_floating {
|
if let Some(float) = &self.selection.raster_floating {
|
||||||
self.clipboard_manager.copy(ClipboardContent::RasterPixels {
|
self.clipboard_manager.copy(ClipboardContent::RasterPixels {
|
||||||
pixels: (*float.pixels).clone(),
|
pixels: float.pixels.clone(),
|
||||||
width: float.width,
|
width: float.width,
|
||||||
height: float.height,
|
height: float.height,
|
||||||
});
|
});
|
||||||
|
|
@ -2445,14 +2420,14 @@ impl EditorApp {
|
||||||
|
|
||||||
use lightningbeam_core::selection::{RasterFloatingSelection, RasterSelection};
|
use lightningbeam_core::selection::{RasterFloatingSelection, RasterSelection};
|
||||||
self.selection.raster_floating = Some(RasterFloatingSelection {
|
self.selection.raster_floating = Some(RasterFloatingSelection {
|
||||||
pixels: std::sync::Arc::new(pixels),
|
pixels,
|
||||||
width,
|
width,
|
||||||
height,
|
height,
|
||||||
x: paste_x,
|
x: paste_x,
|
||||||
y: paste_y,
|
y: paste_y,
|
||||||
layer_id,
|
layer_id,
|
||||||
time: self.playback_time,
|
time: self.playback_time,
|
||||||
canvas_before: std::sync::Arc::new(canvas_before),
|
canvas_before,
|
||||||
canvas_id: uuid::Uuid::new_v4(),
|
canvas_id: uuid::Uuid::new_v4(),
|
||||||
});
|
});
|
||||||
// Update the marquee to show the floating selection bounds.
|
// Update the marquee to show the floating selection bounds.
|
||||||
|
|
@ -2871,42 +2846,14 @@ impl EditorApp {
|
||||||
}
|
}
|
||||||
MenuAction::Export => {
|
MenuAction::Export => {
|
||||||
println!("Menu: Export");
|
println!("Menu: Export");
|
||||||
|
// Open export dialog with calculated timeline endpoint
|
||||||
let timeline_endpoint = self.action_executor.document().calculate_timeline_endpoint();
|
let timeline_endpoint = self.action_executor.document().calculate_timeline_endpoint();
|
||||||
|
// Derive project name from the .beam file path, falling back to document name
|
||||||
let project_name = self.current_file_path.as_ref()
|
let project_name = self.current_file_path.as_ref()
|
||||||
.and_then(|p| p.file_stem())
|
.and_then(|p| p.file_stem())
|
||||||
.map(|s| s.to_string_lossy().into_owned())
|
.map(|s| s.to_string_lossy().into_owned())
|
||||||
.unwrap_or_else(|| self.action_executor.document().name.clone());
|
.unwrap_or_else(|| self.action_executor.document().name.clone());
|
||||||
|
self.export_dialog.open(timeline_endpoint, &project_name);
|
||||||
// Build document hint for smart export-type defaulting.
|
|
||||||
let hint = {
|
|
||||||
use lightningbeam_core::layer::AnyLayer;
|
|
||||||
use export::dialog::DocumentHint;
|
|
||||||
fn scan(layers: &[AnyLayer], hint: &mut DocumentHint) {
|
|
||||||
for l in layers {
|
|
||||||
match l {
|
|
||||||
AnyLayer::Video(_) => hint.has_video = true,
|
|
||||||
AnyLayer::Audio(_) => hint.has_audio = true,
|
|
||||||
AnyLayer::Raster(_) => hint.has_raster = true,
|
|
||||||
AnyLayer::Vector(_) | AnyLayer::Effect(_) => hint.has_vector = true,
|
|
||||||
AnyLayer::Group(g) => scan(&g.children, hint),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let doc = self.action_executor.document();
|
|
||||||
let mut h = DocumentHint {
|
|
||||||
has_video: false,
|
|
||||||
has_audio: false,
|
|
||||||
has_raster: false,
|
|
||||||
has_vector: false,
|
|
||||||
current_time: doc.current_time,
|
|
||||||
doc_width: doc.width as u32,
|
|
||||||
doc_height: doc.height as u32,
|
|
||||||
};
|
|
||||||
scan(&doc.root.children, &mut h);
|
|
||||||
h
|
|
||||||
};
|
|
||||||
|
|
||||||
self.export_dialog.open(timeline_endpoint, &project_name, &hint);
|
|
||||||
}
|
}
|
||||||
MenuAction::Quit => {
|
MenuAction::Quit => {
|
||||||
println!("Menu: Quit");
|
println!("Menu: Quit");
|
||||||
|
|
@ -4488,9 +4435,16 @@ impl eframe::App for EditorApp {
|
||||||
fn update(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) {
|
fn update(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) {
|
||||||
let _frame_start = std::time::Instant::now();
|
let _frame_start = std::time::Instant::now();
|
||||||
|
|
||||||
|
// Disable egui's built-in Ctrl+Plus/Minus zoom behavior
|
||||||
|
// We handle zoom ourselves for the Stage pane
|
||||||
|
ctx.options_mut(|o| {
|
||||||
|
o.zoom_with_keyboard = false;
|
||||||
|
});
|
||||||
|
|
||||||
// Force continuous repaint if we have pending waveform updates
|
// Force continuous repaint if we have pending waveform updates
|
||||||
// This ensures thumbnails update immediately when waveform data arrives
|
// This ensures thumbnails update immediately when waveform data arrives
|
||||||
if !self.audio_pools_with_new_waveforms.is_empty() {
|
if !self.audio_pools_with_new_waveforms.is_empty() {
|
||||||
|
println!("🔄 [UPDATE] Pending waveform updates for pools: {:?}", self.audio_pools_with_new_waveforms);
|
||||||
ctx.request_repaint();
|
ctx.request_repaint();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -5210,17 +5164,6 @@ impl eframe::App for EditorApp {
|
||||||
|
|
||||||
let export_started = if let Some(orchestrator) = &mut self.export_orchestrator {
|
let export_started = if let Some(orchestrator) = &mut self.export_orchestrator {
|
||||||
match export_result {
|
match export_result {
|
||||||
ExportResult::Image(settings, output_path) => {
|
|
||||||
println!("🖼 [MAIN] Starting image export: {}", output_path.display());
|
|
||||||
let doc = self.action_executor.document();
|
|
||||||
orchestrator.start_image_export(
|
|
||||||
settings,
|
|
||||||
output_path,
|
|
||||||
doc.width as u32,
|
|
||||||
doc.height as u32,
|
|
||||||
);
|
|
||||||
false // image export is silent (no progress dialog)
|
|
||||||
}
|
|
||||||
ExportResult::AudioOnly(settings, output_path) => {
|
ExportResult::AudioOnly(settings, output_path) => {
|
||||||
println!("🎵 [MAIN] Starting audio-only export: {}", output_path.display());
|
println!("🎵 [MAIN] Starting audio-only export: {}", output_path.display());
|
||||||
|
|
||||||
|
|
@ -5331,7 +5274,6 @@ impl eframe::App for EditorApp {
|
||||||
let mut temp_image_cache = lightningbeam_core::renderer::ImageCache::new();
|
let mut temp_image_cache = lightningbeam_core::renderer::ImageCache::new();
|
||||||
|
|
||||||
if let Some(renderer) = &mut temp_renderer {
|
if let Some(renderer) = &mut temp_renderer {
|
||||||
// Drive incremental video export.
|
|
||||||
if let Ok(has_more) = orchestrator.render_next_video_frame(
|
if let Ok(has_more) = orchestrator.render_next_video_frame(
|
||||||
self.action_executor.document_mut(),
|
self.action_executor.document_mut(),
|
||||||
device,
|
device,
|
||||||
|
|
@ -5341,24 +5283,10 @@ impl eframe::App for EditorApp {
|
||||||
&self.video_manager,
|
&self.video_manager,
|
||||||
) {
|
) {
|
||||||
if has_more {
|
if has_more {
|
||||||
|
// More frames to render - request repaint for next frame
|
||||||
ctx.request_repaint();
|
ctx.request_repaint();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Drive single-frame image export (two-frame async: render then readback).
|
|
||||||
match orchestrator.render_image_frame(
|
|
||||||
self.action_executor.document_mut(),
|
|
||||||
device,
|
|
||||||
queue,
|
|
||||||
renderer,
|
|
||||||
&mut temp_image_cache,
|
|
||||||
&self.video_manager,
|
|
||||||
self.selection.raster_floating.as_ref(),
|
|
||||||
) {
|
|
||||||
Ok(false) => { ctx.request_repaint(); } // readback pending
|
|
||||||
Ok(true) => {} // done or cancelled
|
|
||||||
Err(e) => { eprintln!("Image export failed: {e}"); }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -5577,7 +5505,11 @@ impl eframe::App for EditorApp {
|
||||||
draw_simplify_mode: &mut self.draw_simplify_mode,
|
draw_simplify_mode: &mut self.draw_simplify_mode,
|
||||||
rdp_tolerance: &mut self.rdp_tolerance,
|
rdp_tolerance: &mut self.rdp_tolerance,
|
||||||
schneider_max_error: &mut self.schneider_max_error,
|
schneider_max_error: &mut self.schneider_max_error,
|
||||||
raster_settings: &mut self.raster_settings,
|
brush_radius: &mut self.brush_radius,
|
||||||
|
brush_opacity: &mut self.brush_opacity,
|
||||||
|
brush_hardness: &mut self.brush_hardness,
|
||||||
|
brush_spacing: &mut self.brush_spacing,
|
||||||
|
brush_use_fg: &mut self.brush_use_fg,
|
||||||
audio_controller: self.audio_controller.as_ref(),
|
audio_controller: self.audio_controller.as_ref(),
|
||||||
video_manager: &self.video_manager,
|
video_manager: &self.video_manager,
|
||||||
playback_time: &mut self.playback_time,
|
playback_time: &mut self.playback_time,
|
||||||
|
|
@ -5618,7 +5550,6 @@ impl eframe::App for EditorApp {
|
||||||
script_saved: &mut self.script_saved,
|
script_saved: &mut self.script_saved,
|
||||||
region_selection: &mut self.region_selection,
|
region_selection: &mut self.region_selection,
|
||||||
region_select_mode: &mut self.region_select_mode,
|
region_select_mode: &mut self.region_select_mode,
|
||||||
lasso_mode: &mut self.lasso_mode,
|
|
||||||
pending_graph_loads: &self.pending_graph_loads,
|
pending_graph_loads: &self.pending_graph_loads,
|
||||||
clipboard_consumed: &mut clipboard_consumed,
|
clipboard_consumed: &mut clipboard_consumed,
|
||||||
keymap: &self.keymap,
|
keymap: &self.keymap,
|
||||||
|
|
@ -5629,7 +5560,6 @@ impl eframe::App for EditorApp {
|
||||||
test_mode: &mut self.test_mode,
|
test_mode: &mut self.test_mode,
|
||||||
#[cfg(debug_assertions)]
|
#[cfg(debug_assertions)]
|
||||||
synthetic_input: &mut synthetic_input_storage,
|
synthetic_input: &mut synthetic_input_storage,
|
||||||
brush_preview_pixels: &self.brush_preview_pixels,
|
|
||||||
},
|
},
|
||||||
pane_instances: &mut self.pane_instances,
|
pane_instances: &mut self.pane_instances,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,261 +0,0 @@
|
||||||
//! Gradient stop editor widget.
|
|
||||||
//!
|
|
||||||
//! Call [`gradient_stop_editor`] inside any egui layout; it returns `true` when
|
|
||||||
//! `gradient` was modified.
|
|
||||||
|
|
||||||
use eframe::egui::{self, Color32, DragValue, Painter, Rect, Sense, Stroke, Vec2};
|
|
||||||
use lightningbeam_core::gradient::{GradientExtend, GradientStop, GradientType, ShapeGradient};
|
|
||||||
use lightningbeam_core::shape::ShapeColor;
|
|
||||||
|
|
||||||
// ── Public entry point ───────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/// Render an inline gradient editor.
|
|
||||||
///
|
|
||||||
/// * `gradient` – the gradient being edited (mutated in place).
|
|
||||||
/// * `selected_stop` – index of the currently selected stop (persisted by caller).
|
|
||||||
///
|
|
||||||
/// Returns `true` if anything changed.
|
|
||||||
pub fn gradient_stop_editor(
|
|
||||||
ui: &mut egui::Ui,
|
|
||||||
gradient: &mut ShapeGradient,
|
|
||||||
selected_stop: &mut Option<usize>,
|
|
||||||
) -> bool {
|
|
||||||
let mut changed = false;
|
|
||||||
|
|
||||||
// ── Row 1: Kind + angle ───────────────────────────────────────────────
|
|
||||||
ui.horizontal(|ui| {
|
|
||||||
let was_linear = gradient.kind == GradientType::Linear;
|
|
||||||
if ui.selectable_label(was_linear, "Linear").clicked() && !was_linear {
|
|
||||||
gradient.kind = GradientType::Linear;
|
|
||||||
changed = true;
|
|
||||||
}
|
|
||||||
if ui.selectable_label(!was_linear, "Radial").clicked() && was_linear {
|
|
||||||
gradient.kind = GradientType::Radial;
|
|
||||||
changed = true;
|
|
||||||
}
|
|
||||||
if gradient.kind == GradientType::Linear {
|
|
||||||
ui.add_space(8.0);
|
|
||||||
ui.label("Angle:");
|
|
||||||
if ui.add(
|
|
||||||
DragValue::new(&mut gradient.angle)
|
|
||||||
.speed(1.0)
|
|
||||||
.range(-360.0..=360.0)
|
|
||||||
.suffix("°"),
|
|
||||||
).changed() {
|
|
||||||
changed = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Gradient bar + handles ────────────────────────────────────────────
|
|
||||||
let bar_height = 22.0_f32;
|
|
||||||
let handle_h = 14.0_f32;
|
|
||||||
let total_height = bar_height + handle_h + 4.0;
|
|
||||||
let avail_w = ui.available_width();
|
|
||||||
|
|
||||||
let (bar_rect, bar_resp) = ui.allocate_exact_size(
|
|
||||||
Vec2::new(avail_w, total_height),
|
|
||||||
Sense::click(),
|
|
||||||
);
|
|
||||||
let painter = ui.painter_at(bar_rect);
|
|
||||||
|
|
||||||
let bar = Rect::from_min_size(bar_rect.min, Vec2::new(avail_w, bar_height));
|
|
||||||
let track = Rect::from_min_size(
|
|
||||||
egui::pos2(bar_rect.min.x, bar_rect.min.y + bar_height + 2.0),
|
|
||||||
Vec2::new(avail_w, handle_h),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Draw checkerboard background (transparent indicator).
|
|
||||||
draw_checker(&painter, bar);
|
|
||||||
|
|
||||||
// Draw gradient bar as N segments.
|
|
||||||
let seg = 128_usize;
|
|
||||||
for i in 0..seg {
|
|
||||||
let t0 = i as f32 / seg as f32;
|
|
||||||
let t1 = (i + 1) as f32 / seg as f32;
|
|
||||||
let t = (t0 + t1) * 0.5;
|
|
||||||
let [r, g, b, a] = gradient.eval(t);
|
|
||||||
let col = Color32::from_rgba_unmultiplied(r, g, b, a);
|
|
||||||
let x0 = bar.min.x + t0 * bar.width();
|
|
||||||
let x1 = bar.min.x + t1 * bar.width();
|
|
||||||
let seg_rect = Rect::from_min_max(
|
|
||||||
egui::pos2(x0, bar.min.y),
|
|
||||||
egui::pos2(x1, bar.max.y),
|
|
||||||
);
|
|
||||||
painter.rect_filled(seg_rect, 0.0, col);
|
|
||||||
}
|
|
||||||
// Outline.
|
|
||||||
painter.rect_stroke(bar, 2.0, Stroke::new(1.0, Color32::from_gray(60)), eframe::egui::StrokeKind::Middle);
|
|
||||||
|
|
||||||
// Click on bar → add stop.
|
|
||||||
if bar_resp.clicked() {
|
|
||||||
if let Some(pos) = bar_resp.interact_pointer_pos() {
|
|
||||||
if bar.contains(pos) {
|
|
||||||
let t = ((pos.x - bar.min.x) / bar.width()).clamp(0.0, 1.0);
|
|
||||||
let [r, g, b, a] = gradient.eval(t);
|
|
||||||
gradient.stops.push(GradientStop {
|
|
||||||
position: t,
|
|
||||||
color: ShapeColor::rgba(r, g, b, a),
|
|
||||||
});
|
|
||||||
gradient.stops.sort_by(|a, b| a.position.partial_cmp(&b.position).unwrap());
|
|
||||||
*selected_stop = gradient.stops.iter().position(|s| s.position == t);
|
|
||||||
changed = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Draw stop handles.
|
|
||||||
// We need to detect drags per-handle, so allocate individual rects with the
|
|
||||||
// regular egui input model. To avoid borrow conflicts we collect interactions
|
|
||||||
// before mutating.
|
|
||||||
let handle_w = 10.0_f32;
|
|
||||||
let n_stops = gradient.stops.len();
|
|
||||||
|
|
||||||
let mut drag_idx: Option<usize> = None;
|
|
||||||
let mut drag_delta: f32 = 0.0;
|
|
||||||
let mut click_idx: Option<usize> = None;
|
|
||||||
|
|
||||||
// To render handles after collecting, remember their rects.
|
|
||||||
let handle_rects: Vec<Rect> = (0..n_stops).map(|i| {
|
|
||||||
let cx = track.min.x + gradient.stops[i].position * track.width();
|
|
||||||
Rect::from_center_size(
|
|
||||||
egui::pos2(cx, track.center().y),
|
|
||||||
Vec2::new(handle_w, handle_h),
|
|
||||||
)
|
|
||||||
}).collect();
|
|
||||||
|
|
||||||
for (i, &h_rect) in handle_rects.iter().enumerate() {
|
|
||||||
let resp = ui.interact(h_rect, ui.id().with(("grad_handle", i)), Sense::click_and_drag());
|
|
||||||
if resp.dragged() {
|
|
||||||
drag_idx = Some(i);
|
|
||||||
drag_delta = resp.drag_delta().x / track.width();
|
|
||||||
}
|
|
||||||
if resp.clicked() {
|
|
||||||
click_idx = Some(i);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply drag.
|
|
||||||
if let (Some(i), delta) = (drag_idx, drag_delta) {
|
|
||||||
if delta != 0.0 {
|
|
||||||
let new_pos = (gradient.stops[i].position + delta).clamp(0.0, 1.0);
|
|
||||||
gradient.stops[i].position = new_pos;
|
|
||||||
// Re-sort and track the moved stop.
|
|
||||||
gradient.stops.sort_by(|a, b| a.position.partial_cmp(&b.position).unwrap());
|
|
||||||
// Find new index of the moved stop (closest position match).
|
|
||||||
if let Some(ref mut sel) = *selected_stop {
|
|
||||||
// Re-find by position proximity.
|
|
||||||
*sel = gradient.stops.iter().enumerate()
|
|
||||||
.min_by(|(_, a), (_, b)| {
|
|
||||||
let pa = (a.position - (gradient.stops.get(i).map_or(0.0, |s| s.position))).abs();
|
|
||||||
let pb = (b.position - (gradient.stops.get(i).map_or(0.0, |s| s.position))).abs();
|
|
||||||
pa.partial_cmp(&pb).unwrap()
|
|
||||||
})
|
|
||||||
.map(|(idx, _)| idx)
|
|
||||||
.unwrap_or(0);
|
|
||||||
}
|
|
||||||
changed = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(i) = click_idx {
|
|
||||||
*selected_stop = Some(i);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Paint handles on top (after interaction so they visually react).
|
|
||||||
for (i, h_rect) in handle_rects.iter().enumerate() {
|
|
||||||
let col = ShapeColor_to_Color32(gradient.stops[i].color);
|
|
||||||
let is_selected = *selected_stop == Some(i);
|
|
||||||
|
|
||||||
// Draw a downward-pointing triangle.
|
|
||||||
let cx = h_rect.center().x;
|
|
||||||
let top = h_rect.min.y;
|
|
||||||
let bot = h_rect.max.y;
|
|
||||||
let hw = h_rect.width() * 0.5;
|
|
||||||
let tri = vec![
|
|
||||||
egui::pos2(cx, bot),
|
|
||||||
egui::pos2(cx - hw, top),
|
|
||||||
egui::pos2(cx + hw, top),
|
|
||||||
];
|
|
||||||
painter.add(egui::Shape::convex_polygon(
|
|
||||||
tri,
|
|
||||||
col,
|
|
||||||
Stroke::new(if is_selected { 2.0 } else { 1.0 },
|
|
||||||
if is_selected { Color32::WHITE } else { Color32::from_gray(100) }),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Selected stop detail ──────────────────────────────────────────────
|
|
||||||
if let Some(i) = *selected_stop {
|
|
||||||
if i < gradient.stops.len() {
|
|
||||||
ui.separator();
|
|
||||||
ui.horizontal(|ui| {
|
|
||||||
let stop = &mut gradient.stops[i];
|
|
||||||
let mut rgba = [stop.color.r, stop.color.g, stop.color.b, stop.color.a];
|
|
||||||
if ui.color_edit_button_srgba_unmultiplied(&mut rgba).changed() {
|
|
||||||
stop.color = ShapeColor::rgba(rgba[0], rgba[1], rgba[2], rgba[3]);
|
|
||||||
changed = true;
|
|
||||||
}
|
|
||||||
ui.label("Position:");
|
|
||||||
if ui.add(
|
|
||||||
DragValue::new(&mut stop.position)
|
|
||||||
.speed(0.005)
|
|
||||||
.range(0.0..=1.0),
|
|
||||||
).changed() {
|
|
||||||
gradient.stops.sort_by(|a, b| a.position.partial_cmp(&b.position).unwrap());
|
|
||||||
changed = true;
|
|
||||||
}
|
|
||||||
let can_remove = gradient.stops.len() > 2;
|
|
||||||
if ui.add_enabled(can_remove, egui::Button::new("− Remove")).clicked() {
|
|
||||||
gradient.stops.remove(i);
|
|
||||||
*selected_stop = None;
|
|
||||||
changed = true;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
*selected_stop = None;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Extend mode ───────────────────────────────────────────────────────
|
|
||||||
ui.horizontal(|ui| {
|
|
||||||
ui.label("Extend:");
|
|
||||||
if ui.selectable_label(gradient.extend == GradientExtend::Pad, "Pad").clicked() {
|
|
||||||
gradient.extend = GradientExtend::Pad; changed = true;
|
|
||||||
}
|
|
||||||
if ui.selectable_label(gradient.extend == GradientExtend::Reflect, "Reflect").clicked() {
|
|
||||||
gradient.extend = GradientExtend::Reflect; changed = true;
|
|
||||||
}
|
|
||||||
if ui.selectable_label(gradient.extend == GradientExtend::Repeat, "Repeat").clicked() {
|
|
||||||
gradient.extend = GradientExtend::Repeat; changed = true;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
changed
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
fn ShapeColor_to_Color32(c: ShapeColor) -> Color32 {
|
|
||||||
Color32::from_rgba_unmultiplied(c.r, c.g, c.b, c.a)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Draw a small grey/white checkerboard inside `rect`.
|
|
||||||
fn draw_checker(painter: &Painter, rect: Rect) {
|
|
||||||
let cell = 6.0_f32;
|
|
||||||
let cols = ((rect.width() / cell).ceil() as u32).max(1);
|
|
||||||
let rows = ((rect.height() / cell).ceil() as u32).max(1);
|
|
||||||
for row in 0..rows {
|
|
||||||
for col in 0..cols {
|
|
||||||
let light = (row + col) % 2 == 0;
|
|
||||||
let col32 = if light { Color32::from_gray(200) } else { Color32::from_gray(140) };
|
|
||||||
let x = rect.min.x + col as f32 * cell;
|
|
||||||
let y = rect.min.y + row as f32 * cell;
|
|
||||||
let r = Rect::from_min_size(
|
|
||||||
egui::pos2(x, y),
|
|
||||||
Vec2::splat(cell),
|
|
||||||
).intersect(rect);
|
|
||||||
painter.rect_filled(r, 0.0, col32);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -11,15 +11,12 @@
|
||||||
/// - Document settings (when nothing is focused)
|
/// - Document settings (when nothing is focused)
|
||||||
|
|
||||||
use eframe::egui::{self, DragValue, Ui};
|
use eframe::egui::{self, DragValue, Ui};
|
||||||
use lightningbeam_core::brush_settings::{bundled_brushes, BrushSettings};
|
use lightningbeam_core::actions::{SetDocumentPropertiesAction, SetShapePropertiesAction};
|
||||||
use lightningbeam_core::actions::{SetDocumentPropertiesAction, SetShapePropertiesAction, SetFillPaintAction};
|
|
||||||
use lightningbeam_core::gradient::ShapeGradient;
|
|
||||||
use lightningbeam_core::layer::{AnyLayer, LayerTrait};
|
use lightningbeam_core::layer::{AnyLayer, LayerTrait};
|
||||||
use lightningbeam_core::selection::FocusSelection;
|
use lightningbeam_core::selection::FocusSelection;
|
||||||
use lightningbeam_core::shape::ShapeColor;
|
use lightningbeam_core::shape::ShapeColor;
|
||||||
use lightningbeam_core::tool::{SimplifyMode, Tool};
|
use lightningbeam_core::tool::{SimplifyMode, Tool};
|
||||||
use super::{NodePath, PaneRenderer, SharedPaneState};
|
use super::{NodePath, PaneRenderer, SharedPaneState};
|
||||||
use super::gradient_editor::gradient_stop_editor;
|
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
/// Info panel pane state
|
/// Info panel pane state
|
||||||
|
|
@ -28,36 +25,13 @@ pub struct InfopanelPane {
|
||||||
tool_section_open: bool,
|
tool_section_open: bool,
|
||||||
/// Whether the shape properties section is expanded
|
/// Whether the shape properties section is expanded
|
||||||
shape_section_open: bool,
|
shape_section_open: bool,
|
||||||
/// Index of the selected paint brush preset (None = custom / unset)
|
|
||||||
selected_brush_preset: Option<usize>,
|
|
||||||
/// Whether the paint brush picker is expanded
|
|
||||||
brush_picker_expanded: bool,
|
|
||||||
/// Index of the selected eraser brush preset
|
|
||||||
selected_eraser_preset: Option<usize>,
|
|
||||||
/// Whether the eraser brush picker is expanded
|
|
||||||
eraser_picker_expanded: bool,
|
|
||||||
/// Cached preview textures, one per preset (populated lazily).
|
|
||||||
brush_preview_textures: Vec<egui::TextureHandle>,
|
|
||||||
/// Selected stop index for gradient editor in shape section.
|
|
||||||
selected_shape_gradient_stop: Option<usize>,
|
|
||||||
/// Selected stop index for gradient editor in tool section (gradient tool).
|
|
||||||
selected_tool_gradient_stop: Option<usize>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl InfopanelPane {
|
impl InfopanelPane {
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
let presets = bundled_brushes();
|
|
||||||
let default_eraser_idx = presets.iter().position(|p| p.name == "Brush");
|
|
||||||
Self {
|
Self {
|
||||||
tool_section_open: true,
|
tool_section_open: true,
|
||||||
shape_section_open: true,
|
shape_section_open: true,
|
||||||
selected_brush_preset: None,
|
|
||||||
brush_picker_expanded: false,
|
|
||||||
selected_eraser_preset: default_eraser_idx,
|
|
||||||
eraser_picker_expanded: false,
|
|
||||||
brush_preview_textures: Vec::new(),
|
|
||||||
selected_shape_gradient_stop: None,
|
|
||||||
selected_tool_gradient_stop: None,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -73,8 +47,6 @@ struct SelectionInfo {
|
||||||
|
|
||||||
// Shape property values (None = mixed)
|
// Shape property values (None = mixed)
|
||||||
fill_color: Option<Option<ShapeColor>>,
|
fill_color: Option<Option<ShapeColor>>,
|
||||||
/// None = mixed across selection; Some(None) = no gradient; Some(Some(g)) = all same gradient
|
|
||||||
fill_gradient: Option<Option<ShapeGradient>>,
|
|
||||||
stroke_color: Option<Option<ShapeColor>>,
|
stroke_color: Option<Option<ShapeColor>>,
|
||||||
stroke_width: Option<f64>,
|
stroke_width: Option<f64>,
|
||||||
}
|
}
|
||||||
|
|
@ -86,7 +58,6 @@ impl Default for SelectionInfo {
|
||||||
dcel_count: 0,
|
dcel_count: 0,
|
||||||
layer_id: None,
|
layer_id: None,
|
||||||
fill_color: None,
|
fill_color: None,
|
||||||
fill_gradient: None,
|
|
||||||
stroke_color: None,
|
stroke_color: None,
|
||||||
stroke_width: None,
|
stroke_width: None,
|
||||||
}
|
}
|
||||||
|
|
@ -149,32 +120,21 @@ impl InfopanelPane {
|
||||||
// Gather fill properties from selected faces
|
// Gather fill properties from selected faces
|
||||||
let mut first_fill_color: Option<Option<ShapeColor>> = None;
|
let mut first_fill_color: Option<Option<ShapeColor>> = None;
|
||||||
let mut fill_color_mixed = false;
|
let mut fill_color_mixed = false;
|
||||||
let mut first_fill_gradient: Option<Option<ShapeGradient>> = None;
|
|
||||||
let mut fill_gradient_mixed = false;
|
|
||||||
|
|
||||||
for &fid in shared.selection.selected_faces() {
|
for &fid in shared.selection.selected_faces() {
|
||||||
let face = dcel.face(fid);
|
let face = dcel.face(fid);
|
||||||
let fc = face.fill_color;
|
let fc = face.fill_color;
|
||||||
let fg = face.gradient_fill.clone();
|
|
||||||
|
|
||||||
match first_fill_color {
|
match first_fill_color {
|
||||||
None => first_fill_color = Some(fc),
|
None => first_fill_color = Some(fc),
|
||||||
Some(prev) if prev != fc => fill_color_mixed = true,
|
Some(prev) if prev != fc => fill_color_mixed = true,
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
match &first_fill_gradient {
|
|
||||||
None => first_fill_gradient = Some(fg),
|
|
||||||
Some(prev) if *prev != fg => fill_gradient_mixed = true,
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if !fill_color_mixed {
|
if !fill_color_mixed {
|
||||||
info.fill_color = first_fill_color;
|
info.fill_color = first_fill_color;
|
||||||
}
|
}
|
||||||
if !fill_gradient_mixed {
|
|
||||||
info.fill_gradient = first_fill_gradient;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -191,8 +151,7 @@ impl InfopanelPane {
|
||||||
.and_then(|id| shared.action_executor.document().get_layer(&id))
|
.and_then(|id| shared.action_executor.document().get_layer(&id))
|
||||||
.map_or(false, |l| matches!(l, AnyLayer::Raster(_)));
|
.map_or(false, |l| matches!(l, AnyLayer::Raster(_)));
|
||||||
|
|
||||||
let raster_tool_def = active_is_raster.then(|| crate::tools::raster_tool_def(&tool)).flatten();
|
let is_raster_paint_tool = active_is_raster && matches!(tool, Tool::Draw | Tool::Erase | Tool::Smudge);
|
||||||
let is_raster_paint_tool = raster_tool_def.is_some();
|
|
||||||
|
|
||||||
// Only show tool options for tools that have options
|
// Only show tool options for tools that have options
|
||||||
let is_vector_tool = !active_is_raster && matches!(
|
let is_vector_tool = !active_is_raster && matches!(
|
||||||
|
|
@ -200,30 +159,23 @@ impl InfopanelPane {
|
||||||
Tool::Select | Tool::BezierEdit | Tool::Draw | Tool::Rectangle
|
Tool::Select | Tool::BezierEdit | Tool::Draw | Tool::Rectangle
|
||||||
| Tool::Ellipse | Tool::Line | Tool::Polygon
|
| Tool::Ellipse | Tool::Line | Tool::Polygon
|
||||||
);
|
);
|
||||||
let is_raster_transform = active_is_raster
|
let has_options = is_vector_tool || is_raster_paint_tool || matches!(
|
||||||
&& matches!(tool, Tool::Transform)
|
|
||||||
&& shared.selection.raster_floating.is_some();
|
|
||||||
|
|
||||||
let is_raster_select = active_is_raster && matches!(tool, Tool::Select);
|
|
||||||
let is_raster_shape = active_is_raster && matches!(
|
|
||||||
tool,
|
tool,
|
||||||
Tool::Rectangle | Tool::Ellipse | Tool::Line | Tool::Polygon
|
Tool::PaintBucket | Tool::RegionSelect
|
||||||
);
|
|
||||||
let has_options = is_vector_tool || is_raster_paint_tool || is_raster_transform
|
|
||||||
|| is_raster_select || is_raster_shape || matches!(
|
|
||||||
tool,
|
|
||||||
Tool::PaintBucket | Tool::RegionSelect | Tool::MagicWand | Tool::QuickSelect
|
|
||||||
| Tool::Warp | Tool::Liquify | Tool::Gradient
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if !has_options {
|
if !has_options {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let header_label = if is_raster_transform {
|
let header_label = if is_raster_paint_tool {
|
||||||
"Raster Transform"
|
match tool {
|
||||||
|
Tool::Erase => "Eraser",
|
||||||
|
Tool::Smudge => "Smudge",
|
||||||
|
_ => "Brush",
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
raster_tool_def.map(|d| d.header_label()).unwrap_or("Tool Options")
|
"Tool Options"
|
||||||
};
|
};
|
||||||
|
|
||||||
egui::CollapsingHeader::new(header_label)
|
egui::CollapsingHeader::new(header_label)
|
||||||
|
|
@ -238,23 +190,6 @@ impl InfopanelPane {
|
||||||
ui.add_space(2.0);
|
ui.add_space(2.0);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Raster transform tool hint.
|
|
||||||
if is_raster_transform {
|
|
||||||
ui.label("Drag handles to move, scale, or rotate.");
|
|
||||||
ui.add_space(4.0);
|
|
||||||
ui.label("Enter — apply Esc — cancel");
|
|
||||||
ui.add_space(4.0);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Raster paint tool: delegate to per-tool impl.
|
|
||||||
if let Some(def) = raster_tool_def {
|
|
||||||
def.render_ui(ui, shared.raster_settings);
|
|
||||||
if def.show_brush_preset_picker() {
|
|
||||||
self.render_raster_tool_options(ui, shared, def.is_eraser());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
match tool {
|
match tool {
|
||||||
Tool::Draw if !is_raster_paint_tool => {
|
Tool::Draw if !is_raster_paint_tool => {
|
||||||
// Stroke width
|
// Stroke width
|
||||||
|
|
@ -307,190 +242,15 @@ impl InfopanelPane {
|
||||||
}
|
}
|
||||||
|
|
||||||
Tool::PaintBucket => {
|
Tool::PaintBucket => {
|
||||||
if active_is_raster {
|
// Gap tolerance
|
||||||
use crate::tools::FillThresholdMode;
|
|
||||||
ui.horizontal(|ui| {
|
|
||||||
ui.label("Threshold:");
|
|
||||||
ui.add(
|
|
||||||
egui::Slider::new(
|
|
||||||
&mut shared.raster_settings.fill_threshold,
|
|
||||||
0.0_f32..=255.0,
|
|
||||||
)
|
|
||||||
.step_by(1.0),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
ui.horizontal(|ui| {
|
|
||||||
ui.label("Softness:");
|
|
||||||
ui.add(
|
|
||||||
egui::Slider::new(
|
|
||||||
&mut shared.raster_settings.fill_softness,
|
|
||||||
0.0_f32..=100.0,
|
|
||||||
)
|
|
||||||
.custom_formatter(|v, _| format!("{:.0}%", v)),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
ui.horizontal(|ui| {
|
|
||||||
ui.label("Mode:");
|
|
||||||
ui.selectable_value(
|
|
||||||
&mut shared.raster_settings.fill_threshold_mode,
|
|
||||||
FillThresholdMode::Absolute,
|
|
||||||
"Absolute",
|
|
||||||
);
|
|
||||||
ui.selectable_value(
|
|
||||||
&mut shared.raster_settings.fill_threshold_mode,
|
|
||||||
FillThresholdMode::Relative,
|
|
||||||
"Relative",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
// Vector: gap tolerance
|
|
||||||
ui.horizontal(|ui| {
|
|
||||||
ui.label("Gap Tolerance:");
|
|
||||||
ui.add(
|
|
||||||
DragValue::new(shared.paint_bucket_gap_tolerance)
|
|
||||||
.speed(0.1)
|
|
||||||
.range(0.0..=50.0),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Tool::Select if is_raster_select => {
|
|
||||||
use crate::tools::SelectionShape;
|
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
ui.label("Shape:");
|
ui.label("Gap Tolerance:");
|
||||||
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(
|
ui.add(
|
||||||
egui::Slider::new(
|
DragValue::new(shared.paint_bucket_gap_tolerance)
|
||||||
&mut shared.raster_settings.wand_threshold,
|
.speed(0.1)
|
||||||
0.0_f32..=255.0,
|
.range(0.0..=50.0),
|
||||||
)
|
|
||||||
.step_by(1.0),
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
ui.horizontal(|ui| {
|
|
||||||
ui.label("Mode:");
|
|
||||||
ui.selectable_value(
|
|
||||||
&mut shared.raster_settings.wand_mode,
|
|
||||||
FillThresholdMode::Absolute,
|
|
||||||
"Absolute",
|
|
||||||
);
|
|
||||||
ui.selectable_value(
|
|
||||||
&mut shared.raster_settings.wand_mode,
|
|
||||||
FillThresholdMode::Relative,
|
|
||||||
"Relative",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
ui.checkbox(&mut shared.raster_settings.wand_contiguous, "Contiguous");
|
|
||||||
}
|
|
||||||
|
|
||||||
Tool::QuickSelect => {
|
|
||||||
use crate::tools::FillThresholdMode;
|
|
||||||
ui.horizontal(|ui| {
|
|
||||||
ui.label("Radius:");
|
|
||||||
ui.add(
|
|
||||||
egui::Slider::new(
|
|
||||||
&mut shared.raster_settings.quick_select_radius,
|
|
||||||
1.0_f32..=200.0,
|
|
||||||
)
|
|
||||||
.step_by(1.0),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
ui.horizontal(|ui| {
|
|
||||||
ui.label("Threshold:");
|
|
||||||
ui.add(
|
|
||||||
egui::Slider::new(
|
|
||||||
&mut shared.raster_settings.wand_threshold,
|
|
||||||
0.0_f32..=255.0,
|
|
||||||
)
|
|
||||||
.step_by(1.0),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
ui.horizontal(|ui| {
|
|
||||||
ui.label("Mode:");
|
|
||||||
ui.selectable_value(
|
|
||||||
&mut shared.raster_settings.wand_mode,
|
|
||||||
FillThresholdMode::Absolute,
|
|
||||||
"Absolute",
|
|
||||||
);
|
|
||||||
ui.selectable_value(
|
|
||||||
&mut shared.raster_settings.wand_mode,
|
|
||||||
FillThresholdMode::Relative,
|
|
||||||
"Relative",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
Tool::Warp => {
|
|
||||||
ui.horizontal(|ui| {
|
|
||||||
ui.label("Grid:");
|
|
||||||
let cols = shared.raster_settings.warp_grid_cols;
|
|
||||||
let rows = shared.raster_settings.warp_grid_rows;
|
|
||||||
for (label, c, r) in [("3×3", 3u32, 3u32), ("4×4", 4, 4), ("5×5", 5, 5), ("8×8", 8, 8)] {
|
|
||||||
let selected = cols == c && rows == r;
|
|
||||||
if ui.selectable_label(selected, label).clicked() {
|
|
||||||
shared.raster_settings.warp_grid_cols = c;
|
|
||||||
shared.raster_settings.warp_grid_rows = r;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
ui.small("Enter to commit · Escape to cancel");
|
|
||||||
}
|
|
||||||
|
|
||||||
Tool::Liquify => {
|
|
||||||
use crate::tools::LiquifyMode;
|
|
||||||
ui.horizontal(|ui| {
|
|
||||||
ui.label("Mode:");
|
|
||||||
for (label, mode) in [
|
|
||||||
("Push", LiquifyMode::Push),
|
|
||||||
("Pucker", LiquifyMode::Pucker),
|
|
||||||
("Bloat", LiquifyMode::Bloat),
|
|
||||||
("Smooth", LiquifyMode::Smooth),
|
|
||||||
("Reconstruct", LiquifyMode::Reconstruct),
|
|
||||||
] {
|
|
||||||
let selected = shared.raster_settings.liquify_mode == mode;
|
|
||||||
if ui.selectable_label(selected, label).clicked() {
|
|
||||||
shared.raster_settings.liquify_mode = mode;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
ui.horizontal(|ui| {
|
|
||||||
ui.label("Radius:");
|
|
||||||
ui.add(
|
|
||||||
egui::Slider::new(
|
|
||||||
&mut shared.raster_settings.liquify_radius,
|
|
||||||
5.0_f32..=500.0,
|
|
||||||
)
|
|
||||||
.step_by(1.0),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
ui.horizontal(|ui| {
|
|
||||||
ui.label("Strength:");
|
|
||||||
ui.add(
|
|
||||||
egui::Slider::new(
|
|
||||||
&mut shared.raster_settings.liquify_strength,
|
|
||||||
0.01_f32..=1.0,
|
|
||||||
)
|
|
||||||
.step_by(0.01),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
ui.small("Enter to commit · Escape to cancel");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Tool::Polygon => {
|
Tool::Polygon => {
|
||||||
|
|
@ -540,20 +300,48 @@ impl InfopanelPane {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
Tool::Gradient if active_is_raster => {
|
// Raster paint tools
|
||||||
|
Tool::Draw | Tool::Erase | Tool::Smudge if is_raster_paint_tool => {
|
||||||
|
// Color source toggle (Draw tool only)
|
||||||
|
if matches!(tool, Tool::Draw) {
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
ui.label("Color:");
|
||||||
|
ui.selectable_value(shared.brush_use_fg, true, "FG");
|
||||||
|
ui.selectable_value(shared.brush_use_fg, false, "BG");
|
||||||
|
});
|
||||||
|
}
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
ui.label("Opacity:");
|
ui.label("Size:");
|
||||||
ui.add(egui::Slider::new(
|
ui.add(
|
||||||
&mut shared.raster_settings.gradient_opacity,
|
egui::Slider::new(shared.brush_radius, 1.0_f32..=200.0)
|
||||||
0.0_f32..=1.0,
|
.logarithmic(true)
|
||||||
).custom_formatter(|v, _| format!("{:.0}%", v * 100.0)));
|
.suffix(" px"),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
if !matches!(tool, Tool::Smudge) {
|
||||||
|
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.horizontal(|ui| {
|
||||||
|
ui.label("Hardness:");
|
||||||
|
ui.add(
|
||||||
|
egui::Slider::new(shared.brush_hardness, 0.0_f32..=1.0)
|
||||||
|
.custom_formatter(|v, _| format!("{:.0}%", v * 100.0)),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
ui.label("Spacing:");
|
||||||
|
ui.add(
|
||||||
|
egui::Slider::new(shared.brush_spacing, 0.01_f32..=1.0)
|
||||||
|
.logarithmic(true)
|
||||||
|
.custom_formatter(|v, _| format!("{:.0}%", v * 100.0)),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
ui.add_space(4.0);
|
|
||||||
gradient_stop_editor(
|
|
||||||
ui,
|
|
||||||
&mut shared.raster_settings.gradient,
|
|
||||||
&mut self.selected_tool_gradient_stop,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_ => {}
|
_ => {}
|
||||||
|
|
@ -563,192 +351,6 @@ impl InfopanelPane {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Render all options for a raster paint tool (brush picker + sliders).
|
|
||||||
/// `is_eraser` drives which shared state is read/written.
|
|
||||||
fn render_raster_tool_options(
|
|
||||||
&mut self,
|
|
||||||
ui: &mut Ui,
|
|
||||||
shared: &mut SharedPaneState,
|
|
||||||
is_eraser: bool,
|
|
||||||
) {
|
|
||||||
self.render_brush_preset_grid(ui, shared, is_eraser);
|
|
||||||
ui.add_space(2.0);
|
|
||||||
|
|
||||||
let rs = &mut shared.raster_settings;
|
|
||||||
|
|
||||||
if !is_eraser {
|
|
||||||
ui.horizontal(|ui| {
|
|
||||||
ui.label("Color:");
|
|
||||||
ui.selectable_value(&mut rs.brush_use_fg, true, "FG");
|
|
||||||
ui.selectable_value(&mut rs.brush_use_fg, false, "BG");
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
macro_rules! field {
|
|
||||||
($eraser:ident, $brush:ident) => {
|
|
||||||
if is_eraser { &mut rs.$eraser } else { &mut rs.$brush }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ui.horizontal(|ui| {
|
|
||||||
ui.label("Size:");
|
|
||||||
ui.add(egui::Slider::new(field!(eraser_radius, brush_radius), 1.0_f32..=200.0).logarithmic(true).suffix(" px"));
|
|
||||||
});
|
|
||||||
ui.horizontal(|ui| {
|
|
||||||
ui.label("Opacity:");
|
|
||||||
ui.add(egui::Slider::new(field!(eraser_opacity, brush_opacity), 0.0_f32..=1.0)
|
|
||||||
.custom_formatter(|v, _| format!("{:.0}%", v * 100.0)));
|
|
||||||
});
|
|
||||||
ui.horizontal(|ui| {
|
|
||||||
ui.label("Hardness:");
|
|
||||||
ui.add(egui::Slider::new(field!(eraser_hardness, brush_hardness), 0.0_f32..=1.0)
|
|
||||||
.custom_formatter(|v, _| format!("{:.0}%", v * 100.0)));
|
|
||||||
});
|
|
||||||
ui.horizontal(|ui| {
|
|
||||||
ui.label("Spacing:");
|
|
||||||
ui.add(egui::Slider::new(field!(eraser_spacing, brush_spacing), 0.01_f32..=1.0)
|
|
||||||
.logarithmic(true)
|
|
||||||
.custom_formatter(|v, _| format!("{:.0}%", v * 100.0)));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Render the brush preset thumbnail grid (collapsible).
|
|
||||||
/// `is_eraser` drives which picker state and which shared settings are updated.
|
|
||||||
fn render_brush_preset_grid(&mut self, ui: &mut Ui, shared: &mut SharedPaneState, is_eraser: bool) {
|
|
||||||
let presets = bundled_brushes();
|
|
||||||
if presets.is_empty() { return; }
|
|
||||||
|
|
||||||
// Build preview TextureHandles from GPU-rendered pixel data when available.
|
|
||||||
if self.brush_preview_textures.len() != presets.len() {
|
|
||||||
if let Ok(previews) = shared.brush_preview_pixels.try_lock() {
|
|
||||||
if previews.len() == presets.len() {
|
|
||||||
self.brush_preview_textures.clear();
|
|
||||||
for (idx, (w, h, pixels)) in previews.iter().enumerate() {
|
|
||||||
let image = egui::ColorImage::from_rgba_premultiplied(
|
|
||||||
[*w as usize, *h as usize],
|
|
||||||
pixels,
|
|
||||||
);
|
|
||||||
let handle = ui.ctx().load_texture(
|
|
||||||
format!("brush_preview_{}", presets[idx].name),
|
|
||||||
image,
|
|
||||||
egui::TextureOptions::LINEAR,
|
|
||||||
);
|
|
||||||
self.brush_preview_textures.push(handle);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Read picker state into locals to avoid multiple &mut self borrows.
|
|
||||||
let mut expanded = if is_eraser { self.eraser_picker_expanded } else { self.brush_picker_expanded };
|
|
||||||
let mut selected = if is_eraser { self.selected_eraser_preset } else { self.selected_brush_preset };
|
|
||||||
|
|
||||||
let gap = 3.0;
|
|
||||||
let cols = 2usize;
|
|
||||||
let avail_w = ui.available_width();
|
|
||||||
let cell_w = ((avail_w - gap * (cols as f32 - 1.0)) / cols as f32).max(50.0);
|
|
||||||
let cell_h = 80.0;
|
|
||||||
|
|
||||||
if !expanded {
|
|
||||||
// Collapsed: show just the currently selected preset as a single wide cell.
|
|
||||||
let show_idx = selected.unwrap_or(0);
|
|
||||||
if let Some(preset) = presets.get(show_idx) {
|
|
||||||
let full_w = avail_w.max(50.0);
|
|
||||||
let (rect, resp) = ui.allocate_exact_size(egui::vec2(full_w, cell_h), egui::Sense::click());
|
|
||||||
let painter = ui.painter();
|
|
||||||
let bg = if resp.hovered() {
|
|
||||||
egui::Color32::from_rgb(50, 56, 70)
|
|
||||||
} else {
|
|
||||||
egui::Color32::from_rgb(45, 65, 95)
|
|
||||||
};
|
|
||||||
painter.rect_filled(rect, 4.0, bg);
|
|
||||||
painter.rect_stroke(rect, 4.0, egui::Stroke::new(1.5, egui::Color32::from_rgb(80, 140, 220)), egui::StrokeKind::Middle);
|
|
||||||
let preview_rect = egui::Rect::from_min_size(
|
|
||||||
rect.min + egui::vec2(4.0, 4.0),
|
|
||||||
egui::vec2(rect.width() - 8.0, cell_h - 22.0),
|
|
||||||
);
|
|
||||||
if let Some(tex) = self.brush_preview_textures.get(show_idx) {
|
|
||||||
painter.image(tex.id(), preview_rect,
|
|
||||||
egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)),
|
|
||||||
egui::Color32::WHITE);
|
|
||||||
}
|
|
||||||
painter.text(egui::pos2(rect.center().x, rect.max.y - 9.0),
|
|
||||||
egui::Align2::CENTER_CENTER, preset.name,
|
|
||||||
egui::FontId::proportional(9.5), egui::Color32::from_rgb(140, 190, 255));
|
|
||||||
if resp.clicked() { expanded = true; }
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Expanded: full grid; clicking a preset selects it and collapses.
|
|
||||||
for (row_idx, chunk) in presets.chunks(cols).enumerate() {
|
|
||||||
ui.horizontal(|ui| {
|
|
||||||
ui.spacing_mut().item_spacing.x = gap;
|
|
||||||
for (col_idx, preset) in chunk.iter().enumerate() {
|
|
||||||
let idx = row_idx * cols + col_idx;
|
|
||||||
let is_sel = selected == Some(idx);
|
|
||||||
let (rect, resp) = ui.allocate_exact_size(egui::vec2(cell_w, cell_h), egui::Sense::click());
|
|
||||||
let painter = ui.painter();
|
|
||||||
let bg = if is_sel {
|
|
||||||
egui::Color32::from_rgb(45, 65, 95)
|
|
||||||
} else if resp.hovered() {
|
|
||||||
egui::Color32::from_rgb(45, 50, 62)
|
|
||||||
} else {
|
|
||||||
egui::Color32::from_rgb(32, 36, 44)
|
|
||||||
};
|
|
||||||
painter.rect_filled(rect, 4.0, bg);
|
|
||||||
if is_sel {
|
|
||||||
painter.rect_stroke(rect, 4.0, egui::Stroke::new(1.5, egui::Color32::from_rgb(80, 140, 220)), egui::StrokeKind::Middle);
|
|
||||||
}
|
|
||||||
let preview_rect = egui::Rect::from_min_size(
|
|
||||||
rect.min + egui::vec2(4.0, 4.0),
|
|
||||||
egui::vec2(cell_w - 8.0, cell_h - 22.0),
|
|
||||||
);
|
|
||||||
if let Some(tex) = self.brush_preview_textures.get(idx) {
|
|
||||||
painter.image(tex.id(), preview_rect,
|
|
||||||
egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)),
|
|
||||||
egui::Color32::WHITE);
|
|
||||||
}
|
|
||||||
painter.text(egui::pos2(rect.center().x, rect.max.y - 9.0),
|
|
||||||
egui::Align2::CENTER_CENTER, preset.name,
|
|
||||||
egui::FontId::proportional(9.5),
|
|
||||||
if is_sel { egui::Color32::from_rgb(140, 190, 255) } else { egui::Color32::from_gray(160) });
|
|
||||||
if resp.clicked() {
|
|
||||||
selected = Some(idx);
|
|
||||||
expanded = false;
|
|
||||||
let s = &preset.settings;
|
|
||||||
let rs = &mut shared.raster_settings;
|
|
||||||
if is_eraser {
|
|
||||||
rs.eraser_opacity = s.opaque.clamp(0.0, 1.0);
|
|
||||||
rs.eraser_hardness = s.hardness.clamp(0.0, 1.0);
|
|
||||||
rs.eraser_spacing = s.dabs_per_radius;
|
|
||||||
rs.active_eraser_settings = s.clone();
|
|
||||||
} else {
|
|
||||||
rs.brush_opacity = s.opaque.clamp(0.0, 1.0);
|
|
||||||
rs.brush_hardness = s.hardness.clamp(0.0, 1.0);
|
|
||||||
rs.brush_spacing = s.dabs_per_radius;
|
|
||||||
rs.active_brush_settings = s.clone();
|
|
||||||
// If the user was on a preset-backed tool (Pencil/Pen/Airbrush)
|
|
||||||
// and manually picked a different brush, revert to the generic tool.
|
|
||||||
if matches!(*shared.selected_tool, Tool::Pencil | Tool::Pen | Tool::Airbrush) {
|
|
||||||
*shared.selected_tool = Tool::Draw;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
ui.add_space(gap);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Write back picker state.
|
|
||||||
if is_eraser {
|
|
||||||
self.eraser_picker_expanded = expanded;
|
|
||||||
self.selected_eraser_preset = selected;
|
|
||||||
} else {
|
|
||||||
self.brush_picker_expanded = expanded;
|
|
||||||
self.selected_brush_preset = selected;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Transform section: deferred to Phase 2 (DCEL elements don't have instance transforms)
|
// Transform section: deferred to Phase 2 (DCEL elements don't have instance transforms)
|
||||||
|
|
||||||
/// Render shape properties section (fill/stroke)
|
/// Render shape properties section (fill/stroke)
|
||||||
|
|
@ -775,72 +377,28 @@ impl InfopanelPane {
|
||||||
self.shape_section_open = true;
|
self.shape_section_open = true;
|
||||||
ui.add_space(4.0);
|
ui.add_space(4.0);
|
||||||
|
|
||||||
// Fill — determine current fill type
|
// Fill color
|
||||||
let has_gradient = matches!(&info.fill_gradient, Some(Some(_)));
|
|
||||||
let has_solid = matches!(&info.fill_color, Some(Some(_)));
|
|
||||||
let fill_is_none = matches!(&info.fill_color, Some(None))
|
|
||||||
&& matches!(&info.fill_gradient, Some(None));
|
|
||||||
let fill_mixed = info.fill_color.is_none() && info.fill_gradient.is_none();
|
|
||||||
|
|
||||||
// Fill type toggle row
|
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
ui.label("Fill:");
|
ui.label("Fill:");
|
||||||
if fill_mixed {
|
match info.fill_color {
|
||||||
ui.label("--");
|
Some(Some(color)) => {
|
||||||
} else {
|
|
||||||
if ui.selectable_label(fill_is_none, "None").clicked() && !fill_is_none {
|
|
||||||
let action = SetFillPaintAction::solid(
|
|
||||||
layer_id, time, face_ids.clone(), None,
|
|
||||||
);
|
|
||||||
shared.pending_actions.push(Box::new(action));
|
|
||||||
}
|
|
||||||
if ui.selectable_label(has_solid || (!has_gradient && !fill_is_none), "Solid").clicked() && !has_solid {
|
|
||||||
// Switch to solid: use existing color or default to black
|
|
||||||
let color = info.fill_color.flatten()
|
|
||||||
.unwrap_or(ShapeColor::rgba(0, 0, 0, 255));
|
|
||||||
let action = SetFillPaintAction::solid(
|
|
||||||
layer_id, time, face_ids.clone(), Some(color),
|
|
||||||
);
|
|
||||||
shared.pending_actions.push(Box::new(action));
|
|
||||||
}
|
|
||||||
if ui.selectable_label(has_gradient, "Gradient").clicked() && !has_gradient {
|
|
||||||
let grad = info.fill_gradient.clone().flatten()
|
|
||||||
.unwrap_or_default();
|
|
||||||
let action = SetFillPaintAction::gradient(
|
|
||||||
layer_id, time, face_ids.clone(), Some(grad),
|
|
||||||
);
|
|
||||||
shared.pending_actions.push(Box::new(action));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Solid fill color editor
|
|
||||||
if !fill_mixed && has_solid {
|
|
||||||
if let Some(Some(color)) = info.fill_color {
|
|
||||||
ui.horizontal(|ui| {
|
|
||||||
let mut rgba = [color.r, color.g, color.b, color.a];
|
let mut rgba = [color.r, color.g, color.b, color.a];
|
||||||
if ui.color_edit_button_srgba_unmultiplied(&mut rgba).changed() {
|
if ui.color_edit_button_srgba_unmultiplied(&mut rgba).changed() {
|
||||||
let new_color = ShapeColor::rgba(rgba[0], rgba[1], rgba[2], rgba[3]);
|
let new_color = ShapeColor::rgba(rgba[0], rgba[1], rgba[2], rgba[3]);
|
||||||
let action = SetFillPaintAction::solid(
|
let action = SetShapePropertiesAction::set_fill_color(
|
||||||
layer_id, time, face_ids.clone(), Some(new_color),
|
layer_id, time, face_ids.clone(), Some(new_color),
|
||||||
);
|
);
|
||||||
shared.pending_actions.push(Box::new(action));
|
shared.pending_actions.push(Box::new(action));
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
}
|
Some(None) => {
|
||||||
}
|
ui.label("None");
|
||||||
|
}
|
||||||
// Gradient fill editor
|
None => {
|
||||||
if !fill_mixed && has_gradient {
|
ui.label("--");
|
||||||
if let Some(Some(mut grad)) = info.fill_gradient.clone() {
|
|
||||||
if gradient_stop_editor(ui, &mut grad, &mut self.selected_shape_gradient_stop) {
|
|
||||||
let action = SetFillPaintAction::gradient(
|
|
||||||
layer_id, time, face_ids.clone(), Some(grad),
|
|
||||||
);
|
|
||||||
shared.pending_actions.push(Box::new(action));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
|
|
||||||
// Stroke color
|
// Stroke color
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
|
|
@ -1306,40 +864,6 @@ impl InfopanelPane {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Draw a brush dab preview into `rect` approximating the brush falloff shape.
|
|
||||||
///
|
|
||||||
/// Renders N concentric filled circles from outermost to innermost. Because each
|
|
||||||
/// inner circle overwrites the pixels of all outer circles beneath it, the visible
|
|
||||||
/// alpha at distance `d` from the centre equals the alpha of the innermost circle
|
|
||||||
/// whose radius ≥ `d`. This step-approximates the actual brush falloff formula:
|
|
||||||
/// `opa = ((1 − r) / (1 − hardness))²` for `r > hardness`, 1 inside the hard core.
|
|
||||||
fn paint_brush_dab(painter: &egui::Painter, rect: egui::Rect, s: &BrushSettings) {
|
|
||||||
let center = rect.center();
|
|
||||||
let max_r = (rect.width().min(rect.height()) / 2.0 - 2.0).max(1.0);
|
|
||||||
let h = s.hardness;
|
|
||||||
let a = s.opaque;
|
|
||||||
|
|
||||||
const N: usize = 12;
|
|
||||||
for i in 0..N {
|
|
||||||
// t: normalized radial position of this ring, 1.0 = outermost edge
|
|
||||||
let t = 1.0 - i as f32 / N as f32;
|
|
||||||
let r = max_r * t;
|
|
||||||
|
|
||||||
let opa_weight = if h >= 1.0 || t <= h {
|
|
||||||
1.0f32
|
|
||||||
} else {
|
|
||||||
let x = (1.0 - t) / (1.0 - h).max(1e-4);
|
|
||||||
(x * x).min(1.0)
|
|
||||||
};
|
|
||||||
|
|
||||||
let alpha = (opa_weight * a * 220.0).min(220.0) as u8;
|
|
||||||
painter.circle_filled(
|
|
||||||
center, r,
|
|
||||||
egui::Color32::from_rgba_unmultiplied(200, 200, 220, alpha),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Convert MIDI note number to note name (e.g. 60 -> "C4")
|
/// Convert MIDI note number to note name (e.g. 60 -> "C4")
|
||||||
fn midi_note_name(note: u8) -> String {
|
fn midi_note_name(note: u8) -> String {
|
||||||
const NAMES: [&str; 12] = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"];
|
const NAMES: [&str; 12] = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"];
|
||||||
|
|
|
||||||
|
|
@ -68,7 +68,6 @@ pub enum WebcamRecordCommand {
|
||||||
|
|
||||||
pub mod toolbar;
|
pub mod toolbar;
|
||||||
pub mod stage;
|
pub mod stage;
|
||||||
pub mod gradient_editor;
|
|
||||||
pub mod timeline;
|
pub mod timeline;
|
||||||
pub mod infopanel;
|
pub mod infopanel;
|
||||||
pub mod outliner;
|
pub mod outliner;
|
||||||
|
|
@ -188,8 +187,13 @@ pub struct SharedPaneState<'a> {
|
||||||
pub draw_simplify_mode: &'a mut lightningbeam_core::tool::SimplifyMode,
|
pub draw_simplify_mode: &'a mut lightningbeam_core::tool::SimplifyMode,
|
||||||
pub rdp_tolerance: &'a mut f64,
|
pub rdp_tolerance: &'a mut f64,
|
||||||
pub schneider_max_error: &'a mut f64,
|
pub schneider_max_error: &'a mut f64,
|
||||||
/// All per-tool raster paint settings (replaces 20+ individual fields).
|
/// Raster brush settings
|
||||||
pub raster_settings: &'a mut crate::tools::RasterToolSettings,
|
pub brush_radius: &'a mut f32,
|
||||||
|
pub brush_opacity: &'a mut f32,
|
||||||
|
pub brush_hardness: &'a mut f32,
|
||||||
|
pub brush_spacing: &'a mut f32,
|
||||||
|
/// Whether the brush paints with the foreground (fill) color (true) or background (stroke) color (false)
|
||||||
|
pub brush_use_fg: &'a mut bool,
|
||||||
/// Audio engine controller for playback control (wrapped in Arc<Mutex<>> for thread safety)
|
/// Audio engine controller for playback control (wrapped in Arc<Mutex<>> for thread safety)
|
||||||
pub audio_controller: Option<&'a std::sync::Arc<std::sync::Mutex<daw_backend::EngineController>>>,
|
pub audio_controller: Option<&'a std::sync::Arc<std::sync::Mutex<daw_backend::EngineController>>>,
|
||||||
/// Video manager for video decoding and frame caching
|
/// Video manager for video decoding and frame caching
|
||||||
|
|
@ -265,8 +269,6 @@ pub struct SharedPaneState<'a> {
|
||||||
pub region_selection: &'a mut Option<lightningbeam_core::selection::RegionSelection>,
|
pub region_selection: &'a mut Option<lightningbeam_core::selection::RegionSelection>,
|
||||||
/// Region select mode (Rectangle or Lasso)
|
/// Region select mode (Rectangle or Lasso)
|
||||||
pub region_select_mode: &'a mut lightningbeam_core::tool::RegionSelectMode,
|
pub region_select_mode: &'a mut lightningbeam_core::tool::RegionSelectMode,
|
||||||
/// Lasso select sub-mode (Freehand / Polygonal / Magnetic)
|
|
||||||
pub lasso_mode: &'a mut lightningbeam_core::tool::LassoMode,
|
|
||||||
/// Counter for in-flight graph preset loads — increment when sending a
|
/// Counter for in-flight graph preset loads — increment when sending a
|
||||||
/// GraphLoadPreset command so the repaint loop stays alive until the
|
/// GraphLoadPreset command so the repaint loop stays alive until the
|
||||||
/// audio thread sends GraphPresetLoaded back
|
/// audio thread sends GraphPresetLoaded back
|
||||||
|
|
@ -289,10 +291,6 @@ pub struct SharedPaneState<'a> {
|
||||||
/// Synthetic input from test mode replay (debug builds only)
|
/// Synthetic input from test mode replay (debug builds only)
|
||||||
#[cfg(debug_assertions)]
|
#[cfg(debug_assertions)]
|
||||||
pub synthetic_input: &'a mut Option<crate::test_mode::SyntheticInput>,
|
pub synthetic_input: &'a mut Option<crate::test_mode::SyntheticInput>,
|
||||||
/// GPU-rendered brush preview thumbnails. Populated by `VelloCallback::prepare()`
|
|
||||||
/// on the first frame; panes (e.g. infopanel) convert the pixel data to egui
|
|
||||||
/// TextureHandles. Each entry is `(width, height, sRGB-premultiplied RGBA bytes)`.
|
|
||||||
pub brush_preview_pixels: &'a std::sync::Arc<std::sync::Mutex<Vec<(u32, u32, Vec<u8>)>>>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Trait for pane rendering
|
/// Trait for pane rendering
|
||||||
|
|
|
||||||
|
|
@ -1,27 +0,0 @@
|
||||||
// Alpha composite compute shader.
|
|
||||||
//
|
|
||||||
// Composites the accumulated-dab scratch buffer C on top of the source buffer A,
|
|
||||||
// writing the result into the output buffer B:
|
|
||||||
//
|
|
||||||
// B[px] = C[px] + A[px] * (1 − C[px].a) (Porter-Duff src-over, C over A)
|
|
||||||
//
|
|
||||||
// All textures are Rgba8Unorm, linear premultiplied RGBA.
|
|
||||||
// Dispatch: ceil(w/8) × ceil(h/8) × 1.
|
|
||||||
|
|
||||||
@group(0) @binding(0) var tex_a: texture_2d<f32>; // source (A)
|
|
||||||
@group(0) @binding(1) var tex_c: texture_2d<f32>; // accumulated dabs (C)
|
|
||||||
@group(0) @binding(2) var tex_b: texture_storage_2d<rgba8unorm, write>; // output (B)
|
|
||||||
|
|
||||||
@compute @workgroup_size(8, 8)
|
|
||||||
fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
|
|
||||||
let dims = textureDimensions(tex_a);
|
|
||||||
if gid.x >= dims.x || gid.y >= dims.y { return; }
|
|
||||||
|
|
||||||
let coord = vec2<i32>(i32(gid.x), i32(gid.y));
|
|
||||||
let a = textureLoad(tex_a, coord, 0);
|
|
||||||
let c = textureLoad(tex_c, coord, 0);
|
|
||||||
|
|
||||||
// Porter-Duff src-over: C is the foreground (dabs), A is the background.
|
|
||||||
// out = c + a * (1 - c.a)
|
|
||||||
textureStore(tex_b, coord, c + a * (1.0 - c.a));
|
|
||||||
}
|
|
||||||
|
|
@ -20,7 +20,7 @@ struct GpuDab {
|
||||||
x: f32, y: f32, radius: f32, hardness: f32, // bytes 0–15
|
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
|
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
|
color_a: f32, ndx: f32, ndy: f32, smudge_dist: f32, // bytes 32–47
|
||||||
blend_mode: u32, elliptical_dab_ratio: f32, elliptical_dab_angle: f32, lock_alpha: f32, // bytes 48–63
|
blend_mode: u32, _pad0: u32, _pad1: u32, _pad2: u32, // bytes 48–63
|
||||||
}
|
}
|
||||||
|
|
||||||
struct Params {
|
struct Params {
|
||||||
|
|
@ -76,20 +76,7 @@ fn bilinear_sample(px: f32, py: f32) -> vec4<f32> {
|
||||||
fn apply_dab(current: vec4<f32>, dab: GpuDab, px: i32, py: i32) -> vec4<f32> {
|
fn apply_dab(current: vec4<f32>, dab: GpuDab, px: i32, py: i32) -> vec4<f32> {
|
||||||
let dx = f32(px) + 0.5 - dab.x;
|
let dx = f32(px) + 0.5 - dab.x;
|
||||||
let dy = f32(py) + 0.5 - dab.y;
|
let dy = f32(py) + 0.5 - dab.y;
|
||||||
|
let rr = (dx * dx + dy * dy) / (dab.radius * dab.radius);
|
||||||
// Normalised squared distance — supports circular and elliptical dabs.
|
|
||||||
var rr: f32;
|
|
||||||
if dab.elliptical_dab_ratio > 1.001 {
|
|
||||||
// Rotate into the dab's local frame.
|
|
||||||
// Major axis is along dab.elliptical_dab_angle; minor axis is compressed by ratio.
|
|
||||||
let c = cos(dab.elliptical_dab_angle);
|
|
||||||
let s = sin(dab.elliptical_dab_angle);
|
|
||||||
let dx_r = dx * c + dy * s; // along major axis
|
|
||||||
let dy_r = (-dx * s + dy * c) * dab.elliptical_dab_ratio; // minor axis compressed
|
|
||||||
rr = (dx_r * dx_r + dy_r * dy_r) / (dab.radius * dab.radius);
|
|
||||||
} else {
|
|
||||||
rr = (dx * dx + dy * dy) / (dab.radius * dab.radius);
|
|
||||||
}
|
|
||||||
if rr > 1.0 { return current; }
|
if rr > 1.0 { return current; }
|
||||||
|
|
||||||
// Quadratic falloff: flat inner core, smooth quadratic outer zone.
|
// Quadratic falloff: flat inner core, smooth quadratic outer zone.
|
||||||
|
|
@ -107,17 +94,15 @@ fn apply_dab(current: vec4<f32>, dab: GpuDab, px: i32, py: i32) -> vec4<f32> {
|
||||||
}
|
}
|
||||||
|
|
||||||
if dab.blend_mode == 0u {
|
if dab.blend_mode == 0u {
|
||||||
// Normal: "over" operator on premultiplied RGBA.
|
// Normal: "over" operator
|
||||||
// If lock_alpha > 0.5, preserve the destination alpha unchanged.
|
|
||||||
let dab_a = opa_weight * dab.opacity * dab.color_a;
|
let dab_a = opa_weight * dab.opacity * dab.color_a;
|
||||||
if dab_a <= 0.0 { return current; }
|
if dab_a <= 0.0 { return current; }
|
||||||
let ba = 1.0 - dab_a;
|
let ba = 1.0 - dab_a;
|
||||||
let out_a = select(dab_a + ba * current.a, current.a, dab.lock_alpha > 0.5);
|
|
||||||
return vec4<f32>(
|
return vec4<f32>(
|
||||||
dab_a * dab.color_r + ba * current.r,
|
dab_a * dab.color_r + ba * current.r,
|
||||||
dab_a * dab.color_g + ba * current.g,
|
dab_a * dab.color_g + ba * current.g,
|
||||||
dab_a * dab.color_b + ba * current.b,
|
dab_a * dab.color_b + ba * current.b,
|
||||||
out_a,
|
dab_a + ba * current.a,
|
||||||
);
|
);
|
||||||
} else if dab.blend_mode == 1u {
|
} else if dab.blend_mode == 1u {
|
||||||
// Erase: multiplicative alpha reduction
|
// Erase: multiplicative alpha reduction
|
||||||
|
|
@ -126,7 +111,7 @@ fn apply_dab(current: vec4<f32>, dab: GpuDab, px: i32, py: i32) -> vec4<f32> {
|
||||||
let new_a = current.a * (1.0 - dab_a);
|
let new_a = current.a * (1.0 - dab_a);
|
||||||
let scale = select(0.0, new_a / current.a, current.a > 1e-6);
|
let scale = select(0.0, new_a / current.a, current.a > 1e-6);
|
||||||
return vec4<f32>(current.r * scale, current.g * scale, current.b * scale, new_a);
|
return vec4<f32>(current.r * scale, current.g * scale, current.b * scale, new_a);
|
||||||
} else if dab.blend_mode == 2u {
|
} else {
|
||||||
// Smudge: directional warp — sample from position behind the stroke direction
|
// Smudge: directional warp — sample from position behind the stroke direction
|
||||||
let alpha = opa_weight * dab.opacity;
|
let alpha = opa_weight * dab.opacity;
|
||||||
if alpha <= 0.0 { return current; }
|
if alpha <= 0.0 { return current; }
|
||||||
|
|
@ -140,192 +125,6 @@ fn apply_dab(current: vec4<f32>, dab: GpuDab, px: i32, py: i32) -> vec4<f32> {
|
||||||
alpha * src.b + da * current.b,
|
alpha * src.b + da * current.b,
|
||||||
alpha * src.a + da * current.a,
|
alpha * src.a + da * current.a,
|
||||||
);
|
);
|
||||||
} else if dab.blend_mode == 3u {
|
|
||||||
// Clone stamp: sample from (this_pixel + offset) in the source canvas.
|
|
||||||
// color_r/color_g store the world-space offset (source_world - drag_start_world)
|
|
||||||
// computed once when the stroke begins. Each pixel samples its own source texel.
|
|
||||||
let alpha = opa_weight * dab.opacity;
|
|
||||||
if alpha <= 0.0 { return current; }
|
|
||||||
let src_x = f32(px) + 0.5 + dab.color_r;
|
|
||||||
let src_y = f32(py) + 0.5 + dab.color_g;
|
|
||||||
let src = bilinear_sample(src_x, src_y);
|
|
||||||
let ba = 1.0 - alpha;
|
|
||||||
return vec4<f32>(
|
|
||||||
alpha * src.r + ba * current.r,
|
|
||||||
alpha * src.g + ba * current.g,
|
|
||||||
alpha * src.b + ba * current.b,
|
|
||||||
alpha * src.a + ba * current.a,
|
|
||||||
);
|
|
||||||
} else if dab.blend_mode == 5u {
|
|
||||||
// Pattern stamp: procedural tiling pattern using brush color.
|
|
||||||
// ndx = pattern_type (0=Checker, 1=Dots, 2=H-Lines, 3=V-Lines, 4=Diagonal, 5=Crosshatch)
|
|
||||||
// ndy = pattern_scale (tile size in pixels, >= 1.0)
|
|
||||||
let scale = max(dab.ndy, 1.0);
|
|
||||||
let pt = u32(dab.ndx);
|
|
||||||
|
|
||||||
// Fractional position within the tile [0.0, 1.0)
|
|
||||||
let tx = fract(f32(px) / scale);
|
|
||||||
let ty = fract(f32(py) / scale);
|
|
||||||
|
|
||||||
var on: bool;
|
|
||||||
if pt == 0u { // Checkerboard
|
|
||||||
let cx = u32(floor(f32(px) / scale));
|
|
||||||
let cy = u32(floor(f32(py) / scale));
|
|
||||||
on = (cx + cy) % 2u == 0u;
|
|
||||||
} else if pt == 1u { // Polka dots (r ≈ 0.35 of cell radius)
|
|
||||||
let ddx = tx - 0.5; let ddy = ty - 0.5;
|
|
||||||
on = ddx * ddx + ddy * ddy < 0.1225;
|
|
||||||
} else if pt == 2u { // Horizontal lines (50% duty)
|
|
||||||
on = ty < 0.5;
|
|
||||||
} else if pt == 3u { // Vertical lines (50% duty)
|
|
||||||
on = tx < 0.5;
|
|
||||||
} else if pt == 4u { // Diagonal \ (top-left → bottom-right)
|
|
||||||
on = fract((f32(px) + f32(py)) / scale) < 0.5;
|
|
||||||
} else if pt == 5u { // Diagonal / (top-right → bottom-left)
|
|
||||||
on = fract((f32(px) - f32(py)) / scale) < 0.5;
|
|
||||||
} else { // Crosshatch (type 6+)
|
|
||||||
on = tx < 0.4 || ty < 0.4;
|
|
||||||
}
|
|
||||||
|
|
||||||
if !on { return current; }
|
|
||||||
|
|
||||||
// Paint with brush color — same compositing as Normal blend
|
|
||||||
let dab_a = opa_weight * dab.opacity * dab.color_a;
|
|
||||||
if dab_a <= 0.0 { return current; }
|
|
||||||
let ba = 1.0 - dab_a;
|
|
||||||
return vec4<f32>(
|
|
||||||
dab_a * dab.color_r + ba * current.r,
|
|
||||||
dab_a * dab.color_g + ba * current.g,
|
|
||||||
dab_a * dab.color_b + ba * current.b,
|
|
||||||
dab_a + ba * current.a,
|
|
||||||
);
|
|
||||||
} else if dab.blend_mode == 4u {
|
|
||||||
// Healing brush: per-pixel color-corrected clone stamp.
|
|
||||||
// color_r/color_g = source offset (ox, oy), same as clone stamp.
|
|
||||||
// For each pixel: result = src_pixel + (local_dest_mean - local_src_mean)
|
|
||||||
// Means are computed from 4 cardinal neighbors at ±half-radius — per-pixel, no banding.
|
|
||||||
let alpha = opa_weight * dab.opacity;
|
|
||||||
if alpha <= 0.0 { return current; }
|
|
||||||
|
|
||||||
let cw = i32(params.canvas_w);
|
|
||||||
let ch = i32(params.canvas_h);
|
|
||||||
let ox = dab.color_r;
|
|
||||||
let oy = dab.color_g;
|
|
||||||
let hr = max(dab.radius * 0.5, 1.0);
|
|
||||||
let ihr = i32(hr);
|
|
||||||
|
|
||||||
// Per-pixel DESTINATION mean: 4 cardinal neighbors from canvas_src (pre-batch state)
|
|
||||||
let d_n = textureLoad(canvas_src, vec2<i32>(px, clamp(py - ihr, 0, ch - 1)), 0);
|
|
||||||
let d_s = textureLoad(canvas_src, vec2<i32>(px, clamp(py + ihr, 0, ch - 1)), 0);
|
|
||||||
let d_w = textureLoad(canvas_src, vec2<i32>(clamp(px - ihr, 0, cw - 1), py ), 0);
|
|
||||||
let d_e = textureLoad(canvas_src, vec2<i32>(clamp(px + ihr, 0, cw - 1), py ), 0);
|
|
||||||
let d_mean = (d_n + d_s + d_w + d_e) * 0.25;
|
|
||||||
|
|
||||||
// Per-pixel SOURCE mean: 4 cardinal neighbors at offset position (bilinear for sub-pixel offsets)
|
|
||||||
let spx = f32(px) + 0.5 + ox;
|
|
||||||
let spy = f32(py) + 0.5 + oy;
|
|
||||||
let s_mean = (bilinear_sample(spx, spy - hr)
|
|
||||||
+ bilinear_sample(spx, spy + hr)
|
|
||||||
+ bilinear_sample(spx - hr, spy )
|
|
||||||
+ bilinear_sample(spx + hr, spy )) * 0.25;
|
|
||||||
|
|
||||||
// Source pixel + color correction
|
|
||||||
let s_pixel = bilinear_sample(spx, spy);
|
|
||||||
let corrected = clamp(s_pixel + (d_mean - s_mean), vec4<f32>(0.0), vec4<f32>(1.0));
|
|
||||||
|
|
||||||
let ba = 1.0 - alpha;
|
|
||||||
return vec4<f32>(
|
|
||||||
alpha * corrected.r + ba * current.r,
|
|
||||||
alpha * corrected.g + ba * current.g,
|
|
||||||
alpha * corrected.b + ba * current.b,
|
|
||||||
alpha * corrected.a + ba * current.a,
|
|
||||||
);
|
|
||||||
} else if dab.blend_mode == 6u {
|
|
||||||
// Dodge / Burn: power-curve exposure adjustment.
|
|
||||||
// color_r: 0.0 = dodge, 1.0 = burn
|
|
||||||
// Uses pow(channel, gamma) which is asymmetric across channels:
|
|
||||||
// burn (gamma > 1): low channels compressed toward 0 faster than high ones → saturation increases
|
|
||||||
// dodge (gamma < 1): low channels lifted faster than high ones → saturation decreases
|
|
||||||
// This matches the behaviour of GIMP / Photoshop dodge-burn tools.
|
|
||||||
let s = opa_weight * dab.opacity;
|
|
||||||
if s <= 0.0 { return current; }
|
|
||||||
|
|
||||||
let rgb = max(current.rgb, vec3<f32>(0.0));
|
|
||||||
var adjusted: vec3<f32>;
|
|
||||||
if dab.color_r < 0.5 {
|
|
||||||
// Dodge: gamma < 1 → brightens
|
|
||||||
adjusted = pow(rgb, vec3<f32>(max(1.0 - s, 0.001)));
|
|
||||||
} else {
|
|
||||||
// Burn: gamma > 1 → darkens and increases saturation
|
|
||||||
adjusted = pow(rgb, vec3<f32>(1.0 + s));
|
|
||||||
}
|
|
||||||
return vec4<f32>(clamp(adjusted, vec3<f32>(0.0), vec3<f32>(1.0)), current.a);
|
|
||||||
} else if dab.blend_mode == 7u {
|
|
||||||
// Sponge: saturate or desaturate existing pixels.
|
|
||||||
// color_r: 0.0 = saturate, 1.0 = desaturate
|
|
||||||
// Computes luminance, then moves RGB toward (desaturate) or away from (saturate) it.
|
|
||||||
let s = opa_weight * dab.opacity;
|
|
||||||
if s <= 0.0 { return current; }
|
|
||||||
|
|
||||||
let luma = dot(current.rgb, vec3<f32>(0.2126, 0.7152, 0.0722));
|
|
||||||
let luma_vec = vec3<f32>(luma);
|
|
||||||
var adjusted: vec3<f32>;
|
|
||||||
if dab.color_r < 0.5 {
|
|
||||||
// Saturate: push RGB away from luma (increase chroma)
|
|
||||||
adjusted = clamp(current.rgb + s * (current.rgb - luma_vec), vec3<f32>(0.0), vec3<f32>(1.0));
|
|
||||||
} else {
|
|
||||||
// Desaturate: blend RGB toward luma
|
|
||||||
adjusted = mix(current.rgb, luma_vec, s);
|
|
||||||
}
|
|
||||||
return vec4<f32>(adjusted, current.a);
|
|
||||||
} else if dab.blend_mode == 8u {
|
|
||||||
// Blur / Sharpen: 5×5 separable Gaussian kernel.
|
|
||||||
// color_r: 0.0 = blur, 1.0 = sharpen
|
|
||||||
// ndx: kernel radius in canvas pixels (> 0)
|
|
||||||
//
|
|
||||||
// Samples are placed on a grid at ±step and ±2*step per axis, where step = kr/2.
|
|
||||||
// Weights are exp(-x²/2σ²) with σ = step, factored as a separable product.
|
|
||||||
// This gives a true Gaussian falloff rather than a flat ring, so edges blend
|
|
||||||
// into a smooth gradient rather than a flat averaged zone.
|
|
||||||
let s = opa_weight * dab.opacity;
|
|
||||||
if s <= 0.0 { return current; }
|
|
||||||
|
|
||||||
let kr = max(dab.ndx, 1.0);
|
|
||||||
let cx2 = f32(px) + 0.5;
|
|
||||||
let cy2 = f32(py) + 0.5;
|
|
||||||
let step = kr * 0.5;
|
|
||||||
|
|
||||||
// 1-D Gaussian weights at distances 0, ±step, ±2*step (σ = step):
|
|
||||||
// exp(0) = 1.0, exp(-0.5) ≈ 0.6065, exp(-2.0) ≈ 0.1353
|
|
||||||
var gauss = array<f32, 5>(0.1353, 0.6065, 1.0, 0.6065, 0.1353);
|
|
||||||
|
|
||||||
var blur_sum = vec4<f32>(0.0);
|
|
||||||
var blur_w = 0.0;
|
|
||||||
for (var iy = 0; iy < 5; iy++) {
|
|
||||||
for (var ix = 0; ix < 5; ix++) {
|
|
||||||
let w = gauss[ix] * gauss[iy];
|
|
||||||
let spx = cx2 + (f32(ix) - 2.0) * step;
|
|
||||||
let spy = cy2 + (f32(iy) - 2.0) * step;
|
|
||||||
blur_sum += bilinear_sample(spx, spy) * w;
|
|
||||||
blur_w += w;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let blurred = blur_sum / blur_w;
|
|
||||||
|
|
||||||
let c = textureLoad(canvas_src, vec2<i32>(px, py), 0);
|
|
||||||
var result: vec4<f32>;
|
|
||||||
if dab.color_r < 0.5 {
|
|
||||||
// Blur: blend current toward the Gaussian-weighted local average.
|
|
||||||
result = mix(current, blurred, s);
|
|
||||||
} else {
|
|
||||||
// Sharpen: unsharp mask — push pixel away from the local average.
|
|
||||||
// sharpened = 2*src - blurred → highlights diverge, shadows diverge.
|
|
||||||
let sharpened = clamp(c * 2.0 - blurred, vec4<f32>(0.0), vec4<f32>(1.0));
|
|
||||||
result = mix(current, sharpened, s);
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
} else {
|
|
||||||
return current;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,31 +1,30 @@
|
||||||
// Canvas blit shader.
|
// Canvas blit shader.
|
||||||
//
|
//
|
||||||
// Renders a GPU raster canvas (at document resolution) into an Rgba16Float HDR
|
// Renders a GPU raster canvas (at document resolution) into an Rgba16Float HDR
|
||||||
// buffer (at viewport resolution), applying a general affine transform that maps
|
// buffer (at viewport resolution), applying the camera transform (pan + zoom)
|
||||||
// viewport UV [0,1]² directly to canvas UV [0,1]².
|
// to map document-space pixels to viewport-space pixels.
|
||||||
//
|
|
||||||
// The combined inverse transform (viewport UV → canvas UV) is pre-computed on the
|
|
||||||
// CPU and uploaded as a column-major 3×3 matrix packed into three vec4 uniforms.
|
|
||||||
//
|
//
|
||||||
// The canvas stores premultiplied linear RGBA. We output it as-is so the HDR
|
// The canvas stores premultiplied linear RGBA. We output it as-is so the HDR
|
||||||
// compositor sees the same premultiplied-linear format it always works with,
|
// compositor sees the same premultiplied-linear format it always works with,
|
||||||
// bypassing the sRGB intermediate used for Vello layers.
|
// bypassing the sRGB intermediate used for Vello layers.
|
||||||
//
|
//
|
||||||
// Any viewport pixel whose corresponding canvas coordinate falls outside [0,1)²
|
// Any viewport pixel whose corresponding document coordinate falls outside
|
||||||
// outputs transparent black.
|
// [0, canvas_w) × [0, canvas_h) outputs transparent black.
|
||||||
|
|
||||||
struct BlitTransform {
|
struct CameraParams {
|
||||||
/// Column 0 of the viewport_uv → canvas_uv affine matrix (+ padding).
|
pan_x: f32,
|
||||||
col0: vec4<f32>,
|
pan_y: f32,
|
||||||
/// Column 1 (+ padding).
|
zoom: f32,
|
||||||
col1: vec4<f32>,
|
canvas_w: f32,
|
||||||
/// Column 2: translation column — col2.xy = translation, col2.z = 1 (+ padding).
|
canvas_h: f32,
|
||||||
col2: vec4<f32>,
|
viewport_w: f32,
|
||||||
|
viewport_h: f32,
|
||||||
|
_pad: f32,
|
||||||
}
|
}
|
||||||
|
|
||||||
@group(0) @binding(0) var canvas_tex: texture_2d<f32>;
|
@group(0) @binding(0) var canvas_tex: texture_2d<f32>;
|
||||||
@group(0) @binding(1) var canvas_sampler: sampler;
|
@group(0) @binding(1) var canvas_sampler: sampler;
|
||||||
@group(0) @binding(2) var<uniform> transform: BlitTransform;
|
@group(0) @binding(2) var<uniform> camera: CameraParams;
|
||||||
/// Selection mask: R8Unorm, 255 = inside selection (keep), 0 = outside (discard).
|
/// Selection mask: R8Unorm, 255 = inside selection (keep), 0 = outside (discard).
|
||||||
/// A 1×1 all-white texture is bound when no selection is active.
|
/// A 1×1 all-white texture is bound when no selection is active.
|
||||||
@group(0) @binding(3) var mask_tex: texture_2d<f32>;
|
@group(0) @binding(3) var mask_tex: texture_2d<f32>;
|
||||||
|
|
@ -49,10 +48,14 @@ fn vs_main(@builtin(vertex_index) vertex_index: u32) -> VertexOutput {
|
||||||
|
|
||||||
@fragment
|
@fragment
|
||||||
fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> {
|
fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> {
|
||||||
// Apply the combined inverse transform: viewport UV → canvas UV.
|
// Map viewport UV [0,1] → viewport pixel
|
||||||
let m = mat3x3<f32>(transform.col0.xyz, transform.col1.xyz, transform.col2.xyz);
|
let vp = in.uv * vec2<f32>(camera.viewport_w, camera.viewport_h);
|
||||||
let canvas_uv_h = m * vec3<f32>(in.uv.x, in.uv.y, 1.0);
|
|
||||||
let canvas_uv = canvas_uv_h.xy;
|
// Map viewport pixel → document pixel (inverse camera transform)
|
||||||
|
let doc = (vp - vec2<f32>(camera.pan_x, camera.pan_y)) / camera.zoom;
|
||||||
|
|
||||||
|
// Map document pixel → canvas UV [0,1]
|
||||||
|
let canvas_uv = doc / vec2<f32>(camera.canvas_w, camera.canvas_h);
|
||||||
|
|
||||||
// Out-of-bounds → transparent
|
// Out-of-bounds → transparent
|
||||||
if canvas_uv.x < 0.0 || canvas_uv.x > 1.0
|
if canvas_uv.x < 0.0 || canvas_uv.x > 1.0
|
||||||
|
|
|
||||||
|
|
@ -1,137 +0,0 @@
|
||||||
// GPU gradient fill shader.
|
|
||||||
//
|
|
||||||
// Reads the anchor canvas (before_pixels), composites a gradient over it, and
|
|
||||||
// writes the result to the display canvas. All color values in the canvas are
|
|
||||||
// linear premultiplied RGBA. The stop colors passed via `stops` are linear
|
|
||||||
// straight-alpha [0..1] (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));
|
|
||||||
}
|
|
||||||
|
|
@ -1,92 +0,0 @@
|
||||||
// GPU liquify-brush shader.
|
|
||||||
//
|
|
||||||
// Updates a per-pixel displacement map (array of vec2f) for one brush step.
|
|
||||||
// Each pixel within the brush radius receives a displacement contribution
|
|
||||||
// weighted by a Gaussian falloff.
|
|
||||||
//
|
|
||||||
// Modes:
|
|
||||||
// 0 = Push — displace in brush-drag direction (dx, dy)
|
|
||||||
// 1 = Pucker — pull toward brush center
|
|
||||||
// 2 = Bloat — push away from brush center
|
|
||||||
// 3 = Smooth — blend toward average of 4 cardinal neighbours
|
|
||||||
// 4 = Reconstruct — blend toward zero (gradually undo)
|
|
||||||
//
|
|
||||||
// Dispatch: ceil((2*radius+1) / 8) × ceil((2*radius+1) / 8) × 1
|
|
||||||
// The CPU clips invocation IDs to the valid map range.
|
|
||||||
|
|
||||||
struct Params {
|
|
||||||
cx: f32, // brush center x (canvas pixels)
|
|
||||||
cy: f32, // brush center y
|
|
||||||
radius: f32, // brush radius (canvas pixels)
|
|
||||||
strength: f32, // effect strength [0..1]
|
|
||||||
dx: f32, // push direction x (normalised by caller, Push mode only)
|
|
||||||
dy: f32, // push direction y
|
|
||||||
mode: u32, // 0=Push 1=Pucker 2=Bloat 3=Smooth 4=Reconstruct
|
|
||||||
map_w: u32,
|
|
||||||
map_h: u32,
|
|
||||||
_pad0: u32,
|
|
||||||
_pad1: u32,
|
|
||||||
}
|
|
||||||
|
|
||||||
@group(0) @binding(0) var<uniform> params: Params;
|
|
||||||
@group(0) @binding(1) var<storage, read_write> disp: array<vec2f>;
|
|
||||||
|
|
||||||
@compute @workgroup_size(8, 8)
|
|
||||||
fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
|
|
||||||
// Offset invocation into the brush bounding box so gid(0,0) = (cx-r, cy-r).
|
|
||||||
let base_x = floor(params.cx - params.radius);
|
|
||||||
let base_y = floor(params.cy - params.radius);
|
|
||||||
let px = base_x + f32(gid.x);
|
|
||||||
let py = base_y + f32(gid.y);
|
|
||||||
|
|
||||||
// Clip to displacement map bounds.
|
|
||||||
if px < 0.0 || py < 0.0 { return; }
|
|
||||||
let map_x = u32(px);
|
|
||||||
let map_y = u32(py);
|
|
||||||
if map_x >= params.map_w || map_y >= params.map_h { return; }
|
|
||||||
|
|
||||||
let ddx = px - params.cx;
|
|
||||||
let ddy = py - params.cy;
|
|
||||||
let dist2 = ddx * ddx + ddy * ddy;
|
|
||||||
let r2 = params.radius * params.radius;
|
|
||||||
|
|
||||||
if dist2 > r2 { return; }
|
|
||||||
|
|
||||||
// Gaussian influence: 1 at center, ~0.01 at edge (sigma = radius/2.15)
|
|
||||||
let influence = params.strength * exp(-dist2 / (r2 * 0.2));
|
|
||||||
|
|
||||||
let idx = map_y * params.map_w + map_x;
|
|
||||||
var d = disp[idx];
|
|
||||||
|
|
||||||
switch params.mode {
|
|
||||||
case 0u: { // Push
|
|
||||||
d = d + vec2f(params.dx, params.dy) * influence * params.radius;
|
|
||||||
}
|
|
||||||
case 1u: { // Pucker — toward center
|
|
||||||
let len = sqrt(dist2) + 0.0001;
|
|
||||||
d = d + vec2f(-ddx / len, -ddy / len) * influence * params.radius;
|
|
||||||
}
|
|
||||||
case 2u: { // Bloat — away from center
|
|
||||||
let len = sqrt(dist2) + 0.0001;
|
|
||||||
d = d + vec2f(ddx / len, ddy / len) * influence * params.radius;
|
|
||||||
}
|
|
||||||
case 3u: { // Smooth — blend toward average of 4 neighbours
|
|
||||||
let xi = i32(map_x);
|
|
||||||
let yi = i32(map_y);
|
|
||||||
let w = i32(params.map_w);
|
|
||||||
let h = i32(params.map_h);
|
|
||||||
let l = disp[u32(clamp(yi, 0, h-1)) * params.map_w + u32(clamp(xi - 1, 0, w-1))];
|
|
||||||
let r = disp[u32(clamp(yi, 0, h-1)) * params.map_w + u32(clamp(xi + 1, 0, w-1))];
|
|
||||||
let u = disp[u32(clamp(yi - 1, 0, h-1)) * params.map_w + u32(clamp(xi, 0, w-1))];
|
|
||||||
let dn = disp[u32(clamp(yi + 1, 0, h-1)) * params.map_w + u32(clamp(xi, 0, w-1))];
|
|
||||||
let avg = (l + r + u + dn) * 0.25;
|
|
||||||
d = mix(d, avg, influence * 0.5);
|
|
||||||
}
|
|
||||||
case 4u: { // Reconstruct — blend toward zero
|
|
||||||
d = mix(d, vec2f(0.0), influence * 0.5);
|
|
||||||
}
|
|
||||||
default: {}
|
|
||||||
}
|
|
||||||
|
|
||||||
disp[idx] = d;
|
|
||||||
}
|
|
||||||
|
|
@ -1,78 +0,0 @@
|
||||||
// GPU affine-transform resample shader.
|
|
||||||
//
|
|
||||||
// For each output pixel, computes the corresponding source pixel via an inverse
|
|
||||||
// 2D affine transform (no perspective) and bilinear-samples from the source texture.
|
|
||||||
//
|
|
||||||
// Used by the raster selection transform tool: the source is the immutable "anchor"
|
|
||||||
// canvas (original float pixels), the destination is the current float canvas.
|
|
||||||
//
|
|
||||||
// CPU precomputes the inverse affine matrix components and the output bounding box.
|
|
||||||
// The shader just does the per-pixel mapping and bilinear interpolation.
|
|
||||||
//
|
|
||||||
// Dispatch: ceil(dst_w / 8) × ceil(dst_h / 8) × 1
|
|
||||||
|
|
||||||
struct Params {
|
|
||||||
// Inverse affine: src_pixel = A * out_pixel + b
|
|
||||||
// For output pixel center (ox, oy), source pixel is:
|
|
||||||
// sx = a00*ox + a01*oy + b0
|
|
||||||
// sy = a10*ox + a11*oy + b1
|
|
||||||
a00: f32, a01: f32,
|
|
||||||
a10: f32, a11: f32,
|
|
||||||
b0: f32, b1: f32,
|
|
||||||
src_w: u32, src_h: u32,
|
|
||||||
dst_w: u32, dst_h: u32,
|
|
||||||
_pad0: u32, _pad1: u32, // pad to 48 bytes (3 × 16)
|
|
||||||
}
|
|
||||||
|
|
||||||
@group(0) @binding(0) var<uniform> params: Params;
|
|
||||||
@group(0) @binding(1) var src: texture_2d<f32>;
|
|
||||||
@group(0) @binding(2) var dst: texture_storage_2d<rgba8unorm, write>;
|
|
||||||
|
|
||||||
// Manual bilinear sample with clamp-to-edge (textureSample forbidden in compute shaders).
|
|
||||||
fn bilinear_sample(px: f32, py: f32) -> vec4<f32> {
|
|
||||||
let sw = i32(params.src_w);
|
|
||||||
let sh = i32(params.src_h);
|
|
||||||
|
|
||||||
let ix = i32(floor(px - 0.5));
|
|
||||||
let iy = i32(floor(py - 0.5));
|
|
||||||
let fx = fract(px - 0.5);
|
|
||||||
let fy = fract(py - 0.5);
|
|
||||||
|
|
||||||
let x0 = clamp(ix, 0, sw - 1);
|
|
||||||
let x1 = clamp(ix + 1, 0, sw - 1);
|
|
||||||
let y0 = clamp(iy, 0, sh - 1);
|
|
||||||
let y1 = clamp(iy + 1, 0, sh - 1);
|
|
||||||
|
|
||||||
let s00 = textureLoad(src, vec2<i32>(x0, y0), 0);
|
|
||||||
let s10 = textureLoad(src, vec2<i32>(x1, y0), 0);
|
|
||||||
let s01 = textureLoad(src, vec2<i32>(x0, y1), 0);
|
|
||||||
let s11 = textureLoad(src, vec2<i32>(x1, y1), 0);
|
|
||||||
|
|
||||||
return mix(mix(s00, s10, fx), mix(s01, s11, fx), fy);
|
|
||||||
}
|
|
||||||
|
|
||||||
@compute @workgroup_size(8, 8)
|
|
||||||
fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
|
|
||||||
if gid.x >= params.dst_w || gid.y >= params.dst_h { return; }
|
|
||||||
|
|
||||||
let ox = f32(gid.x);
|
|
||||||
let oy = f32(gid.y);
|
|
||||||
|
|
||||||
// Map output pixel index → source pixel position via inverse affine.
|
|
||||||
// We use pixel centers (ox + 0.5, oy + 0.5) in the forward transform, but the
|
|
||||||
// b0/b1 precomputation on the CPU already accounts for the +0.5 offset, so ox/oy
|
|
||||||
// are used directly here (the CPU bakes +0.5 into b).
|
|
||||||
let sx = params.a00 * ox + params.a01 * oy + params.b0;
|
|
||||||
let sy = params.a10 * ox + params.a11 * oy + params.b1;
|
|
||||||
|
|
||||||
var color: vec4<f32>;
|
|
||||||
if sx < 0.0 || sy < 0.0 || sx >= f32(params.src_w) || sy >= f32(params.src_h) {
|
|
||||||
// Outside source bounds → transparent
|
|
||||||
color = vec4<f32>(0.0);
|
|
||||||
} else {
|
|
||||||
// Bilinear sample at pixel center
|
|
||||||
color = bilinear_sample(sx + 0.5, sy + 0.5);
|
|
||||||
}
|
|
||||||
|
|
||||||
textureStore(dst, vec2<i32>(i32(gid.x), i32(gid.y)), color);
|
|
||||||
}
|
|
||||||
|
|
@ -1,103 +0,0 @@
|
||||||
// GPU warp-apply shader.
|
|
||||||
//
|
|
||||||
// Two modes selected by grid_cols / grid_rows:
|
|
||||||
//
|
|
||||||
// grid_cols == 0 (Liquify / per-pixel mode)
|
|
||||||
// disp[] is a full canvas-sized array<vec2f>. Each pixel reads its own entry.
|
|
||||||
//
|
|
||||||
// grid_cols > 0 (Warp control-point mode)
|
|
||||||
// disp[] contains only grid_cols * grid_rows vec2f displacements (one per
|
|
||||||
// control point). The shader bilinearly interpolates them so the CPU never
|
|
||||||
// needs to build or upload the full per-pixel buffer.
|
|
||||||
//
|
|
||||||
// Dispatch: ceil(dst_w / 8) × ceil(dst_h / 8) × 1
|
|
||||||
|
|
||||||
struct Params {
|
|
||||||
src_w: u32,
|
|
||||||
src_h: u32,
|
|
||||||
dst_w: u32,
|
|
||||||
dst_h: u32,
|
|
||||||
grid_cols: u32, // 0 = per-pixel mode
|
|
||||||
grid_rows: u32,
|
|
||||||
_pad0: u32,
|
|
||||||
_pad1: u32,
|
|
||||||
}
|
|
||||||
|
|
||||||
@group(0) @binding(0) var<uniform> params: Params;
|
|
||||||
@group(0) @binding(1) var src: texture_2d<f32>;
|
|
||||||
@group(0) @binding(2) var<storage, read> disp: array<vec2f>;
|
|
||||||
@group(0) @binding(3) var dst: texture_storage_2d<rgba8unorm, write>;
|
|
||||||
|
|
||||||
// Manual bilinear sample with clamp-to-edge (textureSample forbidden in compute shaders).
|
|
||||||
fn bilinear_sample(px: f32, py: f32) -> vec4<f32> {
|
|
||||||
let sw = i32(params.src_w);
|
|
||||||
let sh = i32(params.src_h);
|
|
||||||
|
|
||||||
let ix = i32(floor(px - 0.5));
|
|
||||||
let iy = i32(floor(py - 0.5));
|
|
||||||
let fx = fract(px - 0.5);
|
|
||||||
let fy = fract(py - 0.5);
|
|
||||||
|
|
||||||
let x0 = clamp(ix, 0, sw - 1);
|
|
||||||
let x1 = clamp(ix + 1, 0, sw - 1);
|
|
||||||
let y0 = clamp(iy, 0, sh - 1);
|
|
||||||
let y1 = clamp(iy + 1, 0, sh - 1);
|
|
||||||
|
|
||||||
let s00 = textureLoad(src, vec2<i32>(x0, y0), 0);
|
|
||||||
let s10 = textureLoad(src, vec2<i32>(x1, y0), 0);
|
|
||||||
let s01 = textureLoad(src, vec2<i32>(x0, y1), 0);
|
|
||||||
let s11 = textureLoad(src, vec2<i32>(x1, y1), 0);
|
|
||||||
|
|
||||||
return mix(mix(s00, s10, fx), mix(s01, s11, fx), fy);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Bilinearly interpolate the control-point displacement grid.
|
|
||||||
fn grid_displacement(px: u32, py: u32) -> vec2f {
|
|
||||||
let cols = params.grid_cols;
|
|
||||||
let rows = params.grid_rows;
|
|
||||||
|
|
||||||
// Normalised position in grid space [0 .. cols-1] × [0 .. rows-1].
|
|
||||||
let gx = f32(px) / f32(params.dst_w - 1u) * f32(cols - 1u);
|
|
||||||
let gy = f32(py) / f32(params.dst_h - 1u) * f32(rows - 1u);
|
|
||||||
|
|
||||||
let col0 = u32(floor(gx));
|
|
||||||
let row0 = u32(floor(gy));
|
|
||||||
let col1 = min(col0 + 1u, cols - 1u);
|
|
||||||
let row1 = min(row0 + 1u, rows - 1u);
|
|
||||||
let fx = gx - floor(gx);
|
|
||||||
let fy = gy - floor(gy);
|
|
||||||
|
|
||||||
let d00 = disp[row0 * cols + col0];
|
|
||||||
let d10 = disp[row0 * cols + col1];
|
|
||||||
let d01 = disp[row1 * cols + col0];
|
|
||||||
let d11 = disp[row1 * cols + col1];
|
|
||||||
|
|
||||||
return d00 * (1.0 - fx) * (1.0 - fy)
|
|
||||||
+ d10 * fx * (1.0 - fy)
|
|
||||||
+ d01 * (1.0 - fx) * fy
|
|
||||||
+ d11 * fx * fy;
|
|
||||||
}
|
|
||||||
|
|
||||||
@compute @workgroup_size(8, 8)
|
|
||||||
fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
|
|
||||||
if gid.x >= params.dst_w || gid.y >= params.dst_h { return; }
|
|
||||||
|
|
||||||
var d: vec2f;
|
|
||||||
if params.grid_cols > 0u {
|
|
||||||
d = grid_displacement(gid.x, gid.y);
|
|
||||||
} else {
|
|
||||||
d = disp[gid.y * params.dst_w + gid.x];
|
|
||||||
}
|
|
||||||
|
|
||||||
let sx = f32(gid.x) + d.x;
|
|
||||||
let sy = f32(gid.y) + d.y;
|
|
||||||
|
|
||||||
var color: vec4<f32>;
|
|
||||||
if sx < 0.0 || sy < 0.0 || sx >= f32(params.src_w) || sy >= f32(params.src_h) {
|
|
||||||
color = vec4<f32>(0.0);
|
|
||||||
} else {
|
|
||||||
color = bilinear_sample(sx + 0.5, sy + 0.5);
|
|
||||||
}
|
|
||||||
|
|
||||||
textureStore(dst, vec2<i32>(i32(gid.x), i32(gid.y)), color);
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -5,8 +5,7 @@
|
||||||
|
|
||||||
use eframe::egui;
|
use eframe::egui;
|
||||||
use lightningbeam_core::layer::{AnyLayer, LayerType};
|
use lightningbeam_core::layer::{AnyLayer, LayerType};
|
||||||
use lightningbeam_core::tool::{Tool, RegionSelectMode, LassoMode};
|
use lightningbeam_core::tool::{Tool, RegionSelectMode};
|
||||||
use lightningbeam_core::brush_settings::bundled_brushes;
|
|
||||||
use crate::keymap::tool_app_action;
|
use crate::keymap::tool_app_action;
|
||||||
use super::{NodePath, PaneRenderer, SharedPaneState};
|
use super::{NodePath, PaneRenderer, SharedPaneState};
|
||||||
|
|
||||||
|
|
@ -102,7 +101,7 @@ impl PaneRenderer for ToolbarPane {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Draw sub-tool arrow indicator for tools with modes
|
// Draw sub-tool arrow indicator for tools with modes
|
||||||
let has_sub_tools = matches!(tool, Tool::RegionSelect | Tool::SelectLasso);
|
let has_sub_tools = matches!(tool, Tool::RegionSelect);
|
||||||
if has_sub_tools {
|
if has_sub_tools {
|
||||||
let arrow_size = 6.0;
|
let arrow_size = 6.0;
|
||||||
let margin = 4.0;
|
let margin = 4.0;
|
||||||
|
|
@ -126,22 +125,6 @@ impl PaneRenderer for ToolbarPane {
|
||||||
// Check for click first
|
// Check for click first
|
||||||
if response.clicked() {
|
if response.clicked() {
|
||||||
*shared.selected_tool = *tool;
|
*shared.selected_tool = *tool;
|
||||||
// Preset-backed tools: auto-select the matching bundled brush.
|
|
||||||
let preset_name = match tool {
|
|
||||||
Tool::Pencil => Some("Pencil"),
|
|
||||||
Tool::Pen => Some("Pen"),
|
|
||||||
Tool::Airbrush => Some("Airbrush"),
|
|
||||||
_ => None,
|
|
||||||
};
|
|
||||||
if let Some(name) = preset_name {
|
|
||||||
if let Some(preset) = bundled_brushes().iter().find(|p| p.name == name) {
|
|
||||||
let s = &preset.settings;
|
|
||||||
shared.raster_settings.brush_opacity = s.opaque.clamp(0.0, 1.0);
|
|
||||||
shared.raster_settings.brush_hardness = s.hardness.clamp(0.0, 1.0);
|
|
||||||
shared.raster_settings.brush_spacing = s.dabs_per_radius;
|
|
||||||
shared.raster_settings.active_brush_settings = s.clone();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Right-click context menu for tools with sub-options
|
// Right-click context menu for tools with sub-options
|
||||||
|
|
@ -167,33 +150,6 @@ impl PaneRenderer for ToolbarPane {
|
||||||
ui.close();
|
ui.close();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Tool::SelectLasso => {
|
|
||||||
ui.set_min_width(130.0);
|
|
||||||
if ui.selectable_label(
|
|
||||||
*shared.lasso_mode == LassoMode::Freehand,
|
|
||||||
"Freehand",
|
|
||||||
).clicked() {
|
|
||||||
*shared.lasso_mode = LassoMode::Freehand;
|
|
||||||
*shared.selected_tool = Tool::SelectLasso;
|
|
||||||
ui.close();
|
|
||||||
}
|
|
||||||
if ui.selectable_label(
|
|
||||||
*shared.lasso_mode == LassoMode::Polygonal,
|
|
||||||
"Polygonal",
|
|
||||||
).clicked() {
|
|
||||||
*shared.lasso_mode = LassoMode::Polygonal;
|
|
||||||
*shared.selected_tool = Tool::SelectLasso;
|
|
||||||
ui.close();
|
|
||||||
}
|
|
||||||
if ui.selectable_label(
|
|
||||||
*shared.lasso_mode == LassoMode::Magnetic,
|
|
||||||
"Magnetic",
|
|
||||||
).clicked() {
|
|
||||||
*shared.lasso_mode = LassoMode::Magnetic;
|
|
||||||
*shared.selected_tool = Tool::SelectLasso;
|
|
||||||
ui.close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -220,13 +176,6 @@ impl PaneRenderer for ToolbarPane {
|
||||||
RegionSelectMode::Lasso => "Lasso",
|
RegionSelectMode::Lasso => "Lasso",
|
||||||
};
|
};
|
||||||
format!("{} - {}{}\nRight-click for options", tool.display_name(), mode, hint)
|
format!("{} - {}{}\nRight-click for options", tool.display_name(), mode, hint)
|
||||||
} else if *tool == Tool::SelectLasso {
|
|
||||||
let mode = match *shared.lasso_mode {
|
|
||||||
LassoMode::Freehand => "Freehand",
|
|
||||||
LassoMode::Polygonal => "Polygonal",
|
|
||||||
LassoMode::Magnetic => "Magnetic",
|
|
||||||
};
|
|
||||||
format!("{} - {}{}\nRight-click for options", tool.display_name(), mode, hint)
|
|
||||||
} else {
|
} else {
|
||||||
format!("{}{}", tool.display_name(), hint)
|
format!("{}{}", tool.display_name(), hint)
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,758 +0,0 @@
|
||||||
//! Unified raster tool interface.
|
|
||||||
//!
|
|
||||||
//! Every raster tool operates on three GPU textures of identical dimensions:
|
|
||||||
//!
|
|
||||||
//! | Buffer | Access | Purpose |
|
|
||||||
//! |--------|--------|---------|
|
|
||||||
//! | **A** | Read-only | Source pixels, uploaded from layer/float at mousedown. |
|
|
||||||
//! | **B** | Write-only | Output / display. Compositor shows B while the tool is active. |
|
|
||||||
//! | **C** | Read+Write | Scratch. Dabs accumulate here across the stroke; composite A+C→B each frame. |
|
|
||||||
//!
|
|
||||||
//! All three are `Rgba8Unorm` with the same pixel dimensions. The framework
|
|
||||||
//! allocates and validates them in [`begin_raster_workspace`]; tools only
|
|
||||||
//! dispatch shaders.
|
|
||||||
|
|
||||||
use std::sync::Arc;
|
|
||||||
use uuid::Uuid;
|
|
||||||
use eframe::egui;
|
|
||||||
|
|
||||||
// ── WorkspaceSource ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/// Describes whether the tool is operating on a raster layer or a floating selection.
|
|
||||||
#[derive(Clone, Debug)]
|
|
||||||
pub enum WorkspaceSource {
|
|
||||||
/// Operating on the full raster layer.
|
|
||||||
Layer {
|
|
||||||
layer_id: Uuid,
|
|
||||||
time: f64,
|
|
||||||
/// The keyframe's own UUID (the A-canvas key in `GpuBrushEngine`).
|
|
||||||
kf_id: Uuid,
|
|
||||||
/// Full canvas dimensions (may differ from workspace dims for floating selections).
|
|
||||||
canvas_w: u32,
|
|
||||||
canvas_h: u32,
|
|
||||||
},
|
|
||||||
/// Operating on the floating selection.
|
|
||||||
Float,
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── RasterWorkspace ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/// GPU buffer IDs and metadata for a single tool operation.
|
|
||||||
///
|
|
||||||
/// Created by [`begin_raster_workspace`] on mousedown. All three canvas UUIDs
|
|
||||||
/// index into `GpuBrushEngine::canvases` and are valid for the lifetime of the
|
|
||||||
/// active tool. They are queued for removal in `pending_canvas_removals` after
|
|
||||||
/// commit or cancel.
|
|
||||||
#[derive(Debug)]
|
|
||||||
pub struct RasterWorkspace {
|
|
||||||
/// A canvas (Rgba8Unorm) — source pixels, uploaded at mousedown, read-only for tools.
|
|
||||||
pub a_canvas_id: Uuid,
|
|
||||||
/// B canvas (Rgba8Unorm) — output / display; compositor shows this while active.
|
|
||||||
pub b_canvas_id: Uuid,
|
|
||||||
/// C canvas (Rgba8Unorm) — scratch; tools accumulate dabs here across the stroke.
|
|
||||||
pub c_canvas_id: Uuid,
|
|
||||||
/// Optional R8Unorm selection mask (same pixel dimensions as A/B/C).
|
|
||||||
/// `None` means the entire workspace is selected.
|
|
||||||
pub mask_texture: Option<Arc<wgpu::Texture>>,
|
|
||||||
/// Pixel dimensions. A, B, C, and mask are all guaranteed to be this size.
|
|
||||||
pub width: u32,
|
|
||||||
pub height: u32,
|
|
||||||
/// Top-left position in document-pixel space.
|
|
||||||
/// `(0, 0)` for a layer workspace; `(float.x, float.y)` for a float workspace.
|
|
||||||
pub x: i32,
|
|
||||||
pub y: i32,
|
|
||||||
/// Where the workspace came from — drives commit behaviour.
|
|
||||||
pub source: WorkspaceSource,
|
|
||||||
/// CPU snapshot taken at mousedown for undo / cancel.
|
|
||||||
/// Length is always `width * height * 4` (sRGB premultiplied RGBA).
|
|
||||||
pub before_pixels: Vec<u8>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl RasterWorkspace {
|
|
||||||
/// Panic-safe bounds check. Asserts that every GPU canvas exists and has
|
|
||||||
/// the dimensions declared by this workspace. Called by the framework
|
|
||||||
/// before `begin()` and before each `update()`.
|
|
||||||
pub fn validate(&self, gpu: &crate::gpu_brush::GpuBrushEngine) {
|
|
||||||
for (name, id) in [
|
|
||||||
("A", self.a_canvas_id),
|
|
||||||
("B", self.b_canvas_id),
|
|
||||||
("C", self.c_canvas_id),
|
|
||||||
] {
|
|
||||||
let canvas = gpu.canvases.get(&id).unwrap_or_else(|| {
|
|
||||||
panic!(
|
|
||||||
"RasterWorkspace::validate: buffer '{}' (id={}) not found in GpuBrushEngine",
|
|
||||||
name, id
|
|
||||||
)
|
|
||||||
});
|
|
||||||
assert_eq!(
|
|
||||||
canvas.width, self.width,
|
|
||||||
"RasterWorkspace::validate: buffer '{}' width {} != workspace width {}",
|
|
||||||
name, canvas.width, self.width
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
canvas.height, self.height,
|
|
||||||
"RasterWorkspace::validate: buffer '{}' height {} != workspace height {}",
|
|
||||||
name, canvas.height, self.height
|
|
||||||
);
|
|
||||||
}
|
|
||||||
let expected = (self.width * self.height * 4) as usize;
|
|
||||||
assert_eq!(
|
|
||||||
self.before_pixels.len(), expected,
|
|
||||||
"RasterWorkspace::validate: before_pixels.len()={} != expected {}",
|
|
||||||
self.before_pixels.len(), expected
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns the three canvas UUIDs as an array (convenient for bulk removal).
|
|
||||||
pub fn canvas_ids(&self) -> [Uuid; 3] {
|
|
||||||
[self.a_canvas_id, self.b_canvas_id, self.c_canvas_id]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── WorkspaceInitPacket ───────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/// Data sent to `prepare()` on the first frame to create and upload the A/B/C canvases.
|
|
||||||
///
|
|
||||||
/// The canvas UUIDs are pre-allocated in `begin_raster_workspace()` (UI thread).
|
|
||||||
/// The actual `wgpu::Texture` creation and pixel upload happens in `prepare()`.
|
|
||||||
pub struct WorkspaceInitPacket {
|
|
||||||
/// A canvas UUID (already in `RasterWorkspace::a_canvas_id`).
|
|
||||||
pub a_canvas_id: Uuid,
|
|
||||||
/// Pixel data to upload to A. Length must equal `width * height * 4`.
|
|
||||||
pub a_pixels: Vec<u8>,
|
|
||||||
/// B canvas UUID.
|
|
||||||
pub b_canvas_id: Uuid,
|
|
||||||
/// C canvas UUID.
|
|
||||||
pub c_canvas_id: Uuid,
|
|
||||||
pub width: u32,
|
|
||||||
pub height: u32,
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── ActiveToolRender ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/// Passed to `VelloRenderContext` so the compositor can blit the tool's B output
|
|
||||||
/// in the correct position in the layer stack.
|
|
||||||
///
|
|
||||||
/// While an `ActiveToolRender` is set:
|
|
||||||
/// - If `layer_id == Some(id)`: blit B at that layer's compositor slot.
|
|
||||||
/// - If `layer_id == None`: blit B at the float's compositor slot.
|
|
||||||
#[derive(Clone, Debug)]
|
|
||||||
pub struct ActiveToolRender {
|
|
||||||
/// B canvas to blit.
|
|
||||||
pub b_canvas_id: Uuid,
|
|
||||||
/// Position of the B canvas in document space.
|
|
||||||
pub x: i32,
|
|
||||||
pub y: i32,
|
|
||||||
/// Pixel dimensions of the B canvas.
|
|
||||||
pub width: u32,
|
|
||||||
pub height: u32,
|
|
||||||
/// `Some(layer_id)` → B replaces this layer's render slot.
|
|
||||||
/// `None` → B replaces the float render slot.
|
|
||||||
pub layer_id: Option<Uuid>,
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── PendingGpuWork ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/// GPU work to execute in `VelloCallback::prepare()`.
|
|
||||||
///
|
|
||||||
/// Tools compute dab lists and other CPU-side data in `update()` (UI thread),
|
|
||||||
/// store them as a `Box<dyn PendingGpuWork>`, and return that work through
|
|
||||||
/// `RasterTool::take_pending_gpu_work()` each frame. `prepare()` then calls
|
|
||||||
/// `execute()` with the render-thread `device`/`queue`/`gpu`.
|
|
||||||
///
|
|
||||||
/// `execute()` takes `&self` so the work object need not be consumed; it lives
|
|
||||||
/// in the `VelloRenderContext` (which is immutable in `prepare()`).
|
|
||||||
pub trait PendingGpuWork: Send + Sync {
|
|
||||||
fn execute(
|
|
||||||
&self,
|
|
||||||
device: &wgpu::Device,
|
|
||||||
queue: &wgpu::Queue,
|
|
||||||
gpu: &mut crate::gpu_brush::GpuBrushEngine,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── RasterTool trait ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/// Unified interface for all raster tools.
|
|
||||||
///
|
|
||||||
/// All methods run on the UI thread. They update the tool's internal state
|
|
||||||
/// and store pending GPU op descriptors in fields that `StagePane` forwards
|
|
||||||
/// to `VelloRenderContext` for execution by `VelloCallback::prepare()`.
|
|
||||||
pub trait RasterTool: Send + Sync {
|
|
||||||
/// Called on **mousedown** after [`begin_raster_workspace`] has allocated and
|
|
||||||
/// validated A, B, and C. The tool should initialise its internal state and
|
|
||||||
/// optionally queue an initial GPU dispatch (e.g. identity composite for
|
|
||||||
/// transform so the handle frame appears immediately).
|
|
||||||
fn begin(
|
|
||||||
&mut self,
|
|
||||||
ws: &RasterWorkspace,
|
|
||||||
pos: egui::Vec2,
|
|
||||||
dt: f32,
|
|
||||||
settings: &crate::tools::RasterToolSettings,
|
|
||||||
);
|
|
||||||
|
|
||||||
/// Called every frame while the pointer is held (including the first drag frame).
|
|
||||||
/// The tool should accumulate new work into C and queue a composite A+C→B pass.
|
|
||||||
/// `dt` is the elapsed time in seconds since the previous call; used by time-based
|
|
||||||
/// brushes (airbrush, etc.) to fire dabs at the correct rate when stationary.
|
|
||||||
fn update(
|
|
||||||
&mut self,
|
|
||||||
ws: &RasterWorkspace,
|
|
||||||
pos: egui::Vec2,
|
|
||||||
dt: f32,
|
|
||||||
settings: &crate::tools::RasterToolSettings,
|
|
||||||
);
|
|
||||||
|
|
||||||
/// Called on **pointer release**. Returns `true` if a GPU readback of B should
|
|
||||||
/// be performed and the result committed to the document. Returns `false` if
|
|
||||||
/// the operation was a no-op (e.g. the pointer never moved).
|
|
||||||
fn finish(&mut self, ws: &RasterWorkspace) -> bool;
|
|
||||||
|
|
||||||
/// Called on **Escape** or tool switch mid-stroke. The caller restores the
|
|
||||||
/// source pixels from `ws.before_pixels` without creating an undo entry; the
|
|
||||||
/// tool just cleans up internal state.
|
|
||||||
fn cancel(&mut self, ws: &RasterWorkspace);
|
|
||||||
|
|
||||||
/// Called once per frame (in the VelloCallback construction, UI thread) to
|
|
||||||
/// extract pending GPU work accumulated by `begin()` / `update()`.
|
|
||||||
///
|
|
||||||
/// The tool clears its internal pending work and returns it. `prepare()` on
|
|
||||||
/// the render thread then calls `work.execute()`. Default: no GPU work.
|
|
||||||
fn take_pending_gpu_work(&mut self) -> Option<Box<dyn PendingGpuWork>> {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── BrushRasterTool ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
use lightningbeam_core::brush_engine::{BrushEngine, GpuDab, StrokeState};
|
|
||||||
use lightningbeam_core::brush_settings::BrushSettings;
|
|
||||||
use lightningbeam_core::raster_layer::{RasterBlendMode, StrokePoint, StrokeRecord};
|
|
||||||
|
|
||||||
/// GPU work for one frame of a brush stroke: dispatch dabs into C, then composite A+C→B.
|
|
||||||
struct PendingBrushWork {
|
|
||||||
dabs: Vec<GpuDab>,
|
|
||||||
bbox: (i32, i32, i32, i32),
|
|
||||||
a_id: Uuid,
|
|
||||||
b_id: Uuid,
|
|
||||||
c_id: Uuid,
|
|
||||||
canvas_w: u32,
|
|
||||||
canvas_h: u32,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl PendingGpuWork for PendingBrushWork {
|
|
||||||
fn execute(
|
|
||||||
&self,
|
|
||||||
device: &wgpu::Device,
|
|
||||||
queue: &wgpu::Queue,
|
|
||||||
gpu: &mut crate::gpu_brush::GpuBrushEngine,
|
|
||||||
) {
|
|
||||||
// 1. Accumulate this frame's dabs into C (if any).
|
|
||||||
if !self.dabs.is_empty() {
|
|
||||||
gpu.render_dabs(device, queue, self.c_id, &self.dabs, self.bbox, self.canvas_w, self.canvas_h);
|
|
||||||
}
|
|
||||||
// 2. Always composite A + C → B so B shows A's content even with no dabs this frame.
|
|
||||||
// On begin() with empty C this initialises B = A, avoiding a transparent flash.
|
|
||||||
gpu.composite_a_c_to_b(device, queue, self.a_id, self.c_id, self.b_id, self.canvas_w, self.canvas_h);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Raster tool for paint brushes (Normal blend mode).
|
|
||||||
///
|
|
||||||
/// Each `update()` call computes new dabs for that frame and stores them as
|
|
||||||
/// `PendingBrushWork`. `take_pending_gpu_work()` hands the work to `prepare()`
|
|
||||||
/// which dispatches the dab and composite shaders on the render thread.
|
|
||||||
pub struct BrushRasterTool {
|
|
||||||
color: [f32; 4],
|
|
||||||
brush: BrushSettings,
|
|
||||||
blend_mode: RasterBlendMode,
|
|
||||||
stroke_state: StrokeState,
|
|
||||||
last_point: Option<StrokePoint>,
|
|
||||||
pending: Option<Box<PendingBrushWork>>,
|
|
||||||
/// True after at least one non-empty frame (so finish() knows a commit is needed).
|
|
||||||
has_dabs: bool,
|
|
||||||
/// Offset to convert world coordinates to canvas-local coordinates.
|
|
||||||
canvas_offset_x: i32,
|
|
||||||
canvas_offset_y: i32,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl BrushRasterTool {
|
|
||||||
/// Create a new brush tool.
|
|
||||||
///
|
|
||||||
/// `color` — linear premultiplied RGBA, matches the format expected by `GpuDab`.
|
|
||||||
pub fn new(
|
|
||||||
color: [f32; 4],
|
|
||||||
brush: BrushSettings,
|
|
||||||
blend_mode: RasterBlendMode,
|
|
||||||
) -> Self {
|
|
||||||
Self {
|
|
||||||
color,
|
|
||||||
brush,
|
|
||||||
blend_mode,
|
|
||||||
stroke_state: StrokeState::new(),
|
|
||||||
last_point: None,
|
|
||||||
pending: None,
|
|
||||||
has_dabs: false,
|
|
||||||
canvas_offset_x: 0,
|
|
||||||
canvas_offset_y: 0,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn make_stroke_point(pos: egui::Vec2, off_x: i32, off_y: i32) -> StrokePoint {
|
|
||||||
StrokePoint {
|
|
||||||
x: pos.x - off_x as f32,
|
|
||||||
y: pos.y - off_y as f32,
|
|
||||||
pressure: 1.0,
|
|
||||||
tilt_x: 0.0,
|
|
||||||
tilt_y: 0.0,
|
|
||||||
timestamp: 0.0,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn dispatch_dabs(
|
|
||||||
&mut self,
|
|
||||||
ws: &RasterWorkspace,
|
|
||||||
pt: StrokePoint,
|
|
||||||
dt: f32,
|
|
||||||
) {
|
|
||||||
// Use a 2-point segment when we have a previous point so the engine
|
|
||||||
// interpolates dabs along the path. First mousedown uses a single point.
|
|
||||||
let points = match self.last_point.take() {
|
|
||||||
Some(prev) => vec![prev, pt.clone()],
|
|
||||||
None => vec![pt.clone()],
|
|
||||||
};
|
|
||||||
let record = StrokeRecord {
|
|
||||||
brush_settings: self.brush.clone(),
|
|
||||||
color: self.color,
|
|
||||||
blend_mode: self.blend_mode,
|
|
||||||
tool_params: [0.0; 4],
|
|
||||||
points,
|
|
||||||
};
|
|
||||||
let (dabs, bbox) = BrushEngine::compute_dabs(&record, &mut self.stroke_state, dt);
|
|
||||||
if !dabs.is_empty() {
|
|
||||||
self.has_dabs = true;
|
|
||||||
self.pending = Some(Box::new(PendingBrushWork {
|
|
||||||
dabs,
|
|
||||||
bbox,
|
|
||||||
a_id: ws.a_canvas_id,
|
|
||||||
b_id: ws.b_canvas_id,
|
|
||||||
c_id: ws.c_canvas_id,
|
|
||||||
canvas_w: ws.width,
|
|
||||||
canvas_h: ws.height,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
self.last_point = Some(pt);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl RasterTool for BrushRasterTool {
|
|
||||||
fn begin(&mut self, ws: &RasterWorkspace, pos: egui::Vec2, _dt: f32, _settings: &crate::tools::RasterToolSettings) {
|
|
||||||
self.canvas_offset_x = ws.x;
|
|
||||||
self.canvas_offset_y = ws.y;
|
|
||||||
let pt = Self::make_stroke_point(pos, ws.x, ws.y);
|
|
||||||
self.dispatch_dabs(ws, pt, 0.0);
|
|
||||||
// Always ensure a composite is queued on begin() so B is initialised from A
|
|
||||||
// on the first frame even if no dabs fired (large spacing, etc.).
|
|
||||||
if self.pending.is_none() {
|
|
||||||
self.pending = Some(Box::new(PendingBrushWork {
|
|
||||||
dabs: vec![],
|
|
||||||
bbox: (0, 0, ws.width as i32, ws.height as i32),
|
|
||||||
a_id: ws.a_canvas_id,
|
|
||||||
b_id: ws.b_canvas_id,
|
|
||||||
c_id: ws.c_canvas_id,
|
|
||||||
canvas_w: ws.width,
|
|
||||||
canvas_h: ws.height,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn update(&mut self, ws: &RasterWorkspace, pos: egui::Vec2, dt: f32, _settings: &crate::tools::RasterToolSettings) {
|
|
||||||
let pt = Self::make_stroke_point(pos, ws.x, ws.y);
|
|
||||||
self.dispatch_dabs(ws, pt, dt);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn finish(&mut self, _ws: &RasterWorkspace) -> bool {
|
|
||||||
self.has_dabs
|
|
||||||
}
|
|
||||||
|
|
||||||
fn cancel(&mut self, _ws: &RasterWorkspace) {
|
|
||||||
self.pending = None;
|
|
||||||
self.has_dabs = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
fn take_pending_gpu_work(&mut self) -> Option<Box<dyn PendingGpuWork>> {
|
|
||||||
self.pending.take().map(|w| w as Box<dyn PendingGpuWork>)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── EffectBrushTool ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/// Raster tool for effect brushes (Blur, Sharpen, Dodge, Burn, Sponge, Desaturate).
|
|
||||||
///
|
|
||||||
/// C accumulates a per-pixel influence weight (R channel, 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.
|
|
||||||
}
|
|
||||||
|
|
@ -1,60 +0,0 @@
|
||||||
use super::{BrushParams, RasterToolDef, RasterToolSettings};
|
|
||||||
use eframe::egui;
|
|
||||||
use lightningbeam_core::{brush_settings::BrushSettings, raster_layer::RasterBlendMode};
|
|
||||||
|
|
||||||
pub struct BlurSharpenTool;
|
|
||||||
pub static BLUR_SHARPEN: BlurSharpenTool = BlurSharpenTool;
|
|
||||||
|
|
||||||
impl RasterToolDef for BlurSharpenTool {
|
|
||||||
fn blend_mode(&self) -> RasterBlendMode { RasterBlendMode::BlurSharpen }
|
|
||||||
fn header_label(&self) -> &'static str { "Blur / Sharpen" }
|
|
||||||
fn brush_params(&self, s: &RasterToolSettings) -> BrushParams {
|
|
||||||
BrushParams {
|
|
||||||
base_settings: BrushSettings::default(),
|
|
||||||
radius: s.blur_sharpen_radius,
|
|
||||||
opacity: s.blur_sharpen_strength,
|
|
||||||
hardness: s.blur_sharpen_hardness,
|
|
||||||
spacing: s.blur_sharpen_spacing,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
fn tool_params(&self, s: &RasterToolSettings) -> [f32; 4] {
|
|
||||||
[s.blur_sharpen_mode as f32, s.blur_sharpen_kernel, 0.0, 0.0]
|
|
||||||
}
|
|
||||||
fn show_brush_preset_picker(&self) -> bool { false }
|
|
||||||
fn render_ui(&self, ui: &mut egui::Ui, s: &mut RasterToolSettings) {
|
|
||||||
ui.horizontal(|ui| {
|
|
||||||
if ui.selectable_label(s.blur_sharpen_mode == 0, "Blur").clicked() {
|
|
||||||
s.blur_sharpen_mode = 0;
|
|
||||||
}
|
|
||||||
if ui.selectable_label(s.blur_sharpen_mode == 1, "Sharpen").clicked() {
|
|
||||||
s.blur_sharpen_mode = 1;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
ui.horizontal(|ui| {
|
|
||||||
ui.label("Size:");
|
|
||||||
ui.add(egui::Slider::new(&mut s.blur_sharpen_radius, 1.0_f32..=500.0).logarithmic(true).suffix(" px"));
|
|
||||||
});
|
|
||||||
ui.horizontal(|ui| {
|
|
||||||
ui.label("Strength:");
|
|
||||||
ui.add(egui::Slider::new(&mut s.blur_sharpen_strength, 0.0_f32..=1.0)
|
|
||||||
.custom_formatter(|v, _| format!("{:.0}%", v * 100.0)));
|
|
||||||
});
|
|
||||||
ui.horizontal(|ui| {
|
|
||||||
ui.label("Hardness:");
|
|
||||||
ui.add(egui::Slider::new(&mut s.blur_sharpen_hardness, 0.0_f32..=1.0)
|
|
||||||
.custom_formatter(|v, _| format!("{:.0}%", v * 100.0)));
|
|
||||||
});
|
|
||||||
ui.horizontal(|ui| {
|
|
||||||
ui.label("Kernel:");
|
|
||||||
ui.add(egui::Slider::new(&mut s.blur_sharpen_kernel, 1.0_f32..=20.0)
|
|
||||||
.logarithmic(true)
|
|
||||||
.custom_formatter(|v, _| format!("{:.1} px", v)));
|
|
||||||
});
|
|
||||||
ui.horizontal(|ui| {
|
|
||||||
ui.label("Spacing:");
|
|
||||||
ui.add(egui::Slider::new(&mut s.blur_sharpen_spacing, 0.5_f32..=20.0)
|
|
||||||
.logarithmic(true)
|
|
||||||
.custom_formatter(|v, _| format!("{:.1}", v)));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,29 +0,0 @@
|
||||||
use super::{BrushParams, RasterToolDef, RasterToolSettings};
|
|
||||||
use eframe::egui;
|
|
||||||
use lightningbeam_core::raster_layer::RasterBlendMode;
|
|
||||||
|
|
||||||
pub struct CloneStampTool;
|
|
||||||
pub static CLONE_STAMP: CloneStampTool = CloneStampTool;
|
|
||||||
|
|
||||||
impl RasterToolDef for CloneStampTool {
|
|
||||||
fn blend_mode(&self) -> RasterBlendMode { RasterBlendMode::CloneStamp }
|
|
||||||
fn header_label(&self) -> &'static str { "Clone Stamp" }
|
|
||||||
fn brush_params(&self, s: &RasterToolSettings) -> BrushParams {
|
|
||||||
BrushParams {
|
|
||||||
base_settings: s.active_brush_settings.clone(),
|
|
||||||
radius: s.brush_radius,
|
|
||||||
opacity: s.brush_opacity,
|
|
||||||
hardness: s.brush_hardness,
|
|
||||||
spacing: s.brush_spacing,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
/// For Clone Stamp, tool_params are filled by stage.rs at stroke-start time
|
|
||||||
/// (offset = clone_source - stroke_start), not from settings directly.
|
|
||||||
fn tool_params(&self, _s: &RasterToolSettings) -> [f32; 4] { [0.0; 4] }
|
|
||||||
fn uses_alt_click(&self) -> bool { true }
|
|
||||||
fn render_ui(&self, ui: &mut egui::Ui, s: &mut RasterToolSettings) {
|
|
||||||
if s.clone_source.is_none() {
|
|
||||||
ui.label("Alt+click to set source point.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,54 +0,0 @@
|
||||||
use super::{BrushParams, RasterToolDef, RasterToolSettings};
|
|
||||||
use eframe::egui;
|
|
||||||
use lightningbeam_core::{brush_settings::BrushSettings, raster_layer::RasterBlendMode};
|
|
||||||
|
|
||||||
pub struct DodgeBurnTool;
|
|
||||||
pub static DODGE_BURN: DodgeBurnTool = DodgeBurnTool;
|
|
||||||
|
|
||||||
impl RasterToolDef for DodgeBurnTool {
|
|
||||||
fn blend_mode(&self) -> RasterBlendMode { RasterBlendMode::DodgeBurn }
|
|
||||||
fn header_label(&self) -> &'static str { "Dodge / Burn" }
|
|
||||||
fn brush_params(&self, s: &RasterToolSettings) -> BrushParams {
|
|
||||||
BrushParams {
|
|
||||||
base_settings: BrushSettings::default(),
|
|
||||||
radius: s.dodge_burn_radius,
|
|
||||||
opacity: s.dodge_burn_exposure,
|
|
||||||
hardness: s.dodge_burn_hardness,
|
|
||||||
spacing: s.dodge_burn_spacing,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
fn tool_params(&self, s: &RasterToolSettings) -> [f32; 4] {
|
|
||||||
[s.dodge_burn_mode as f32, 0.0, 0.0, 0.0]
|
|
||||||
}
|
|
||||||
fn show_brush_preset_picker(&self) -> bool { false }
|
|
||||||
fn render_ui(&self, ui: &mut egui::Ui, s: &mut RasterToolSettings) {
|
|
||||||
ui.horizontal(|ui| {
|
|
||||||
if ui.selectable_label(s.dodge_burn_mode == 0, "Dodge").clicked() {
|
|
||||||
s.dodge_burn_mode = 0;
|
|
||||||
}
|
|
||||||
if ui.selectable_label(s.dodge_burn_mode == 1, "Burn").clicked() {
|
|
||||||
s.dodge_burn_mode = 1;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
ui.horizontal(|ui| {
|
|
||||||
ui.label("Size:");
|
|
||||||
ui.add(egui::Slider::new(&mut s.dodge_burn_radius, 1.0_f32..=500.0).logarithmic(true).suffix(" px"));
|
|
||||||
});
|
|
||||||
ui.horizontal(|ui| {
|
|
||||||
ui.label("Exposure:");
|
|
||||||
ui.add(egui::Slider::new(&mut s.dodge_burn_exposure, 0.0_f32..=1.0)
|
|
||||||
.custom_formatter(|v, _| format!("{:.0}%", v * 100.0)));
|
|
||||||
});
|
|
||||||
ui.horizontal(|ui| {
|
|
||||||
ui.label("Hardness:");
|
|
||||||
ui.add(egui::Slider::new(&mut s.dodge_burn_hardness, 0.0_f32..=1.0)
|
|
||||||
.custom_formatter(|v, _| format!("{:.0}%", v * 100.0)));
|
|
||||||
});
|
|
||||||
ui.horizontal(|ui| {
|
|
||||||
ui.label("Spacing:");
|
|
||||||
ui.add(egui::Slider::new(&mut s.dodge_burn_spacing, 0.5_f32..=20.0)
|
|
||||||
.logarithmic(true)
|
|
||||||
.custom_formatter(|v, _| format!("{:.1}", v)));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,23 +0,0 @@
|
||||||
use super::{BrushParams, RasterToolDef, RasterToolSettings};
|
|
||||||
use eframe::egui;
|
|
||||||
use lightningbeam_core::raster_layer::RasterBlendMode;
|
|
||||||
|
|
||||||
pub struct EraseTool;
|
|
||||||
pub static ERASE: EraseTool = EraseTool;
|
|
||||||
|
|
||||||
impl RasterToolDef for EraseTool {
|
|
||||||
fn blend_mode(&self) -> RasterBlendMode { RasterBlendMode::Erase }
|
|
||||||
fn header_label(&self) -> &'static str { "Eraser" }
|
|
||||||
fn brush_params(&self, s: &RasterToolSettings) -> BrushParams {
|
|
||||||
BrushParams {
|
|
||||||
base_settings: s.active_eraser_settings.clone(),
|
|
||||||
radius: s.eraser_radius,
|
|
||||||
opacity: s.eraser_opacity,
|
|
||||||
hardness: s.eraser_hardness,
|
|
||||||
spacing: s.eraser_spacing,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
fn tool_params(&self, _s: &RasterToolSettings) -> [f32; 4] { [0.0; 4] }
|
|
||||||
fn is_eraser(&self) -> bool { true }
|
|
||||||
fn render_ui(&self, _ui: &mut egui::Ui, _s: &mut RasterToolSettings) {}
|
|
||||||
}
|
|
||||||
|
|
@ -1,28 +0,0 @@
|
||||||
use super::{BrushParams, RasterToolDef, RasterToolSettings};
|
|
||||||
use eframe::egui;
|
|
||||||
use lightningbeam_core::raster_layer::RasterBlendMode;
|
|
||||||
|
|
||||||
pub struct HealingBrushTool;
|
|
||||||
pub static HEALING_BRUSH: HealingBrushTool = HealingBrushTool;
|
|
||||||
|
|
||||||
impl RasterToolDef for HealingBrushTool {
|
|
||||||
fn blend_mode(&self) -> RasterBlendMode { RasterBlendMode::Healing }
|
|
||||||
fn header_label(&self) -> &'static str { "Healing Brush" }
|
|
||||||
fn brush_params(&self, s: &RasterToolSettings) -> BrushParams {
|
|
||||||
BrushParams {
|
|
||||||
base_settings: s.active_brush_settings.clone(),
|
|
||||||
radius: s.brush_radius,
|
|
||||||
opacity: s.brush_opacity,
|
|
||||||
hardness: s.brush_hardness,
|
|
||||||
spacing: s.brush_spacing,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
/// tool_params are filled by stage.rs at stroke-start time (clone offset).
|
|
||||||
fn tool_params(&self, _s: &RasterToolSettings) -> [f32; 4] { [0.0; 4] }
|
|
||||||
fn uses_alt_click(&self) -> bool { true }
|
|
||||||
fn render_ui(&self, ui: &mut egui::Ui, s: &mut RasterToolSettings) {
|
|
||||||
if s.clone_source.is_none() {
|
|
||||||
ui.label("Alt+click to set source point.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,268 +0,0 @@
|
||||||
/// Per-tool module for raster painting tools.
|
|
||||||
///
|
|
||||||
/// Each tool implements `RasterToolDef`. Adding a new tool requires:
|
|
||||||
/// 1. A new file in this directory implementing `RasterToolDef`.
|
|
||||||
/// 2. One entry in `raster_tool_def()` below.
|
|
||||||
/// 3. Core changes: `RasterBlendMode` variant, `brush_engine.rs` constant, WGSL branch.
|
|
||||||
|
|
||||||
use eframe::egui;
|
|
||||||
use lightningbeam_core::{
|
|
||||||
brush_settings::BrushSettings,
|
|
||||||
raster_layer::RasterBlendMode,
|
|
||||||
tool::Tool,
|
|
||||||
};
|
|
||||||
|
|
||||||
pub mod paint;
|
|
||||||
pub mod erase;
|
|
||||||
pub mod smudge;
|
|
||||||
pub mod clone_stamp;
|
|
||||||
pub mod healing_brush;
|
|
||||||
pub mod pattern_stamp;
|
|
||||||
pub mod dodge_burn;
|
|
||||||
pub mod sponge;
|
|
||||||
pub mod blur_sharpen;
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Shared settings struct (replaces 20+ individual SharedPaneState / EditorApp fields)
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/// All per-tool settings for raster painting. Owned by `EditorApp`; borrowed
|
|
||||||
/// by `SharedPaneState` as a single `&'a mut RasterToolSettings`.
|
|
||||||
pub struct RasterToolSettings {
|
|
||||||
// --- Paint brush ---
|
|
||||||
pub brush_radius: f32,
|
|
||||||
pub brush_opacity: f32,
|
|
||||||
pub brush_hardness: f32,
|
|
||||||
pub brush_spacing: f32,
|
|
||||||
/// true = paint with FG (stroke) color, false = BG (fill) color
|
|
||||||
pub brush_use_fg: bool,
|
|
||||||
pub active_brush_settings: BrushSettings,
|
|
||||||
// --- Eraser ---
|
|
||||||
pub eraser_radius: f32,
|
|
||||||
pub eraser_opacity: f32,
|
|
||||||
pub eraser_hardness: f32,
|
|
||||||
pub eraser_spacing: f32,
|
|
||||||
pub active_eraser_settings: BrushSettings,
|
|
||||||
// --- Smudge ---
|
|
||||||
pub smudge_radius: f32,
|
|
||||||
pub smudge_hardness: f32,
|
|
||||||
pub smudge_spacing: f32,
|
|
||||||
pub smudge_strength: f32,
|
|
||||||
// --- Clone / Healing ---
|
|
||||||
/// World-space source point set by Alt+click.
|
|
||||||
pub clone_source: Option<egui::Vec2>,
|
|
||||||
// --- Pattern stamp ---
|
|
||||||
pub pattern_type: u32,
|
|
||||||
pub pattern_scale: f32,
|
|
||||||
// --- Dodge / Burn ---
|
|
||||||
pub dodge_burn_radius: f32,
|
|
||||||
pub dodge_burn_hardness: f32,
|
|
||||||
pub dodge_burn_spacing: f32,
|
|
||||||
pub dodge_burn_exposure: f32,
|
|
||||||
/// 0 = dodge (lighten), 1 = burn (darken)
|
|
||||||
pub dodge_burn_mode: u32,
|
|
||||||
// --- Sponge ---
|
|
||||||
pub sponge_radius: f32,
|
|
||||||
pub sponge_hardness: f32,
|
|
||||||
pub sponge_spacing: f32,
|
|
||||||
pub sponge_flow: f32,
|
|
||||||
/// 0 = saturate, 1 = desaturate
|
|
||||||
pub sponge_mode: u32,
|
|
||||||
// --- Blur / Sharpen ---
|
|
||||||
pub blur_sharpen_radius: f32,
|
|
||||||
pub blur_sharpen_hardness: f32,
|
|
||||||
pub blur_sharpen_spacing: f32,
|
|
||||||
pub blur_sharpen_strength: f32,
|
|
||||||
/// Neighborhood kernel radius in canvas pixels (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,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,22 +0,0 @@
|
||||||
use super::{BrushParams, RasterToolDef, RasterToolSettings};
|
|
||||||
use eframe::egui;
|
|
||||||
use lightningbeam_core::raster_layer::RasterBlendMode;
|
|
||||||
|
|
||||||
pub struct PaintTool;
|
|
||||||
pub static PAINT: PaintTool = PaintTool;
|
|
||||||
|
|
||||||
impl RasterToolDef for PaintTool {
|
|
||||||
fn blend_mode(&self) -> RasterBlendMode { RasterBlendMode::Normal }
|
|
||||||
fn header_label(&self) -> &'static str { "Brush" }
|
|
||||||
fn brush_params(&self, s: &RasterToolSettings) -> BrushParams {
|
|
||||||
BrushParams {
|
|
||||||
base_settings: s.active_brush_settings.clone(),
|
|
||||||
radius: s.brush_radius,
|
|
||||||
opacity: s.brush_opacity,
|
|
||||||
hardness: s.brush_hardness,
|
|
||||||
spacing: s.brush_spacing,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
fn tool_params(&self, _s: &RasterToolSettings) -> [f32; 4] { [0.0; 4] }
|
|
||||||
fn render_ui(&self, _ui: &mut egui::Ui, _s: &mut RasterToolSettings) {}
|
|
||||||
}
|
|
||||||
|
|
@ -1,49 +0,0 @@
|
||||||
use super::{BrushParams, RasterToolDef, RasterToolSettings};
|
|
||||||
use eframe::egui;
|
|
||||||
use lightningbeam_core::raster_layer::RasterBlendMode;
|
|
||||||
|
|
||||||
pub struct PatternStampTool;
|
|
||||||
pub static PATTERN_STAMP: PatternStampTool = PatternStampTool;
|
|
||||||
|
|
||||||
const PATTERN_NAMES: &[&str] = &[
|
|
||||||
"Checkerboard", "Dots", "H-Lines", "V-Lines", "Diagonal \\", "Diagonal /", "Crosshatch",
|
|
||||||
];
|
|
||||||
|
|
||||||
impl RasterToolDef for PatternStampTool {
|
|
||||||
fn blend_mode(&self) -> RasterBlendMode { RasterBlendMode::PatternStamp }
|
|
||||||
fn header_label(&self) -> &'static str { "Pattern Stamp" }
|
|
||||||
fn brush_params(&self, s: &RasterToolSettings) -> BrushParams {
|
|
||||||
BrushParams {
|
|
||||||
base_settings: s.active_brush_settings.clone(),
|
|
||||||
radius: s.brush_radius,
|
|
||||||
opacity: s.brush_opacity,
|
|
||||||
hardness: s.brush_hardness,
|
|
||||||
spacing: s.brush_spacing,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
fn tool_params(&self, s: &RasterToolSettings) -> [f32; 4] {
|
|
||||||
[s.pattern_type as f32, s.pattern_scale, 0.0, 0.0]
|
|
||||||
}
|
|
||||||
fn render_ui(&self, ui: &mut egui::Ui, s: &mut RasterToolSettings) {
|
|
||||||
let selected_name = PATTERN_NAMES
|
|
||||||
.get(s.pattern_type as usize)
|
|
||||||
.copied()
|
|
||||||
.unwrap_or("Checkerboard");
|
|
||||||
ui.horizontal(|ui| {
|
|
||||||
ui.label("Pattern:");
|
|
||||||
egui::ComboBox::from_id_salt("pattern_type")
|
|
||||||
.selected_text(selected_name)
|
|
||||||
.show_ui(ui, |ui| {
|
|
||||||
for (i, name) in PATTERN_NAMES.iter().enumerate() {
|
|
||||||
ui.selectable_value(&mut s.pattern_type, i as u32, *name);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
ui.horizontal(|ui| {
|
|
||||||
ui.label("Scale:");
|
|
||||||
ui.add(egui::Slider::new(&mut s.pattern_scale, 4.0_f32..=256.0)
|
|
||||||
.logarithmic(true).suffix(" px"));
|
|
||||||
});
|
|
||||||
ui.add_space(4.0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,44 +0,0 @@
|
||||||
use super::{BrushParams, RasterToolDef, RasterToolSettings};
|
|
||||||
use eframe::egui;
|
|
||||||
use lightningbeam_core::{brush_settings::BrushSettings, raster_layer::RasterBlendMode};
|
|
||||||
|
|
||||||
pub struct SmudgeTool;
|
|
||||||
pub static SMUDGE: SmudgeTool = SmudgeTool;
|
|
||||||
|
|
||||||
impl RasterToolDef for SmudgeTool {
|
|
||||||
fn blend_mode(&self) -> RasterBlendMode { RasterBlendMode::Smudge }
|
|
||||||
fn header_label(&self) -> &'static str { "Smudge" }
|
|
||||||
fn brush_params(&self, s: &RasterToolSettings) -> BrushParams {
|
|
||||||
BrushParams {
|
|
||||||
base_settings: BrushSettings::default(),
|
|
||||||
radius: s.smudge_radius,
|
|
||||||
opacity: 1.0, // strength is a separate smudge_dist multiplier
|
|
||||||
hardness: s.smudge_hardness,
|
|
||||||
spacing: s.smudge_spacing,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
fn tool_params(&self, _s: &RasterToolSettings) -> [f32; 4] { [0.0; 4] }
|
|
||||||
fn show_brush_preset_picker(&self) -> bool { false }
|
|
||||||
fn render_ui(&self, ui: &mut egui::Ui, s: &mut RasterToolSettings) {
|
|
||||||
ui.horizontal(|ui| {
|
|
||||||
ui.label("Size:");
|
|
||||||
ui.add(egui::Slider::new(&mut s.smudge_radius, 1.0_f32..=200.0).logarithmic(true).suffix(" px"));
|
|
||||||
});
|
|
||||||
ui.horizontal(|ui| {
|
|
||||||
ui.label("Strength:");
|
|
||||||
ui.add(egui::Slider::new(&mut s.smudge_strength, 0.0_f32..=1.0)
|
|
||||||
.custom_formatter(|v, _| format!("{:.0}%", v * 100.0)));
|
|
||||||
});
|
|
||||||
ui.horizontal(|ui| {
|
|
||||||
ui.label("Hardness:");
|
|
||||||
ui.add(egui::Slider::new(&mut s.smudge_hardness, 0.0_f32..=1.0)
|
|
||||||
.custom_formatter(|v, _| format!("{:.0}%", v * 100.0)));
|
|
||||||
});
|
|
||||||
ui.horizontal(|ui| {
|
|
||||||
ui.label("Spacing:");
|
|
||||||
ui.add(egui::Slider::new(&mut s.smudge_spacing, 0.5_f32..=20.0)
|
|
||||||
.logarithmic(true)
|
|
||||||
.custom_formatter(|v, _| format!("{:.1}", v)));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,54 +0,0 @@
|
||||||
use super::{BrushParams, RasterToolDef, RasterToolSettings};
|
|
||||||
use eframe::egui;
|
|
||||||
use lightningbeam_core::{brush_settings::BrushSettings, raster_layer::RasterBlendMode};
|
|
||||||
|
|
||||||
pub struct SpongeTool;
|
|
||||||
pub static SPONGE: SpongeTool = SpongeTool;
|
|
||||||
|
|
||||||
impl RasterToolDef for SpongeTool {
|
|
||||||
fn blend_mode(&self) -> RasterBlendMode { RasterBlendMode::Sponge }
|
|
||||||
fn header_label(&self) -> &'static str { "Sponge" }
|
|
||||||
fn brush_params(&self, s: &RasterToolSettings) -> BrushParams {
|
|
||||||
BrushParams {
|
|
||||||
base_settings: BrushSettings::default(),
|
|
||||||
radius: s.sponge_radius,
|
|
||||||
opacity: s.sponge_flow,
|
|
||||||
hardness: s.sponge_hardness,
|
|
||||||
spacing: s.sponge_spacing,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
fn tool_params(&self, s: &RasterToolSettings) -> [f32; 4] {
|
|
||||||
[s.sponge_mode as f32, 0.0, 0.0, 0.0]
|
|
||||||
}
|
|
||||||
fn show_brush_preset_picker(&self) -> bool { false }
|
|
||||||
fn render_ui(&self, ui: &mut egui::Ui, s: &mut RasterToolSettings) {
|
|
||||||
ui.horizontal(|ui| {
|
|
||||||
if ui.selectable_label(s.sponge_mode == 0, "Saturate").clicked() {
|
|
||||||
s.sponge_mode = 0;
|
|
||||||
}
|
|
||||||
if ui.selectable_label(s.sponge_mode == 1, "Desaturate").clicked() {
|
|
||||||
s.sponge_mode = 1;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
ui.horizontal(|ui| {
|
|
||||||
ui.label("Size:");
|
|
||||||
ui.add(egui::Slider::new(&mut s.sponge_radius, 1.0_f32..=500.0).logarithmic(true).suffix(" px"));
|
|
||||||
});
|
|
||||||
ui.horizontal(|ui| {
|
|
||||||
ui.label("Flow:");
|
|
||||||
ui.add(egui::Slider::new(&mut s.sponge_flow, 0.0_f32..=1.0)
|
|
||||||
.custom_formatter(|v, _| format!("{:.0}%", v * 100.0)));
|
|
||||||
});
|
|
||||||
ui.horizontal(|ui| {
|
|
||||||
ui.label("Hardness:");
|
|
||||||
ui.add(egui::Slider::new(&mut s.sponge_hardness, 0.0_f32..=1.0)
|
|
||||||
.custom_formatter(|v, _| format!("{:.0}%", v * 100.0)));
|
|
||||||
});
|
|
||||||
ui.horizontal(|ui| {
|
|
||||||
ui.label("Spacing:");
|
|
||||||
ui.add(egui::Slider::new(&mut s.sponge_spacing, 0.5_f32..=20.0)
|
|
||||||
.logarithmic(true)
|
|
||||||
.custom_formatter(|v, _| format!("{:.1}", v)));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -13,19 +13,6 @@ fn main() {
|
||||||
let wrapper_dir = Path::new(&manifest_dir).join("cmake");
|
let wrapper_dir = Path::new(&manifest_dir).join("cmake");
|
||||||
let neural_audio_dir = Path::new(&manifest_dir).join("../vendor/NeuralAudio");
|
let neural_audio_dir = Path::new(&manifest_dir).join("../vendor/NeuralAudio");
|
||||||
|
|
||||||
// Copy our patched CAPI files over the submodule versions before building.
|
|
||||||
// The upstream submodule uses `wchar_t*` on all platforms; our patch makes
|
|
||||||
// Linux/macOS use `const char*` instead, matching what the Rust FFI sends.
|
|
||||||
let capi_dir = neural_audio_dir.join("NeuralAudioCAPI");
|
|
||||||
let override_dir = Path::new(&manifest_dir).join("capi-override");
|
|
||||||
for filename in &["NeuralAudioCApi.h", "NeuralAudioCApi.cpp"] {
|
|
||||||
let src = override_dir.join(filename);
|
|
||||||
let dst = capi_dir.join(filename);
|
|
||||||
std::fs::copy(&src, &dst)
|
|
||||||
.unwrap_or_else(|e| panic!("Failed to copy {} override: {}", filename, e));
|
|
||||||
println!("cargo:rerun-if-changed=capi-override/{}", filename);
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut cfg = cmake::Config::new(&wrapper_dir);
|
let mut cfg = cmake::Config::new(&wrapper_dir);
|
||||||
// Force single-config generator on Unix to avoid libraries landing in Release/ subdirs
|
// Force single-config generator on Unix to avoid libraries landing in Release/ subdirs
|
||||||
if !cfg!(target_os = "windows") {
|
if !cfg!(target_os = "windows") {
|
||||||
|
|
@ -63,4 +50,6 @@ fn main() {
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
println!("cargo:rerun-if-changed=../vendor/NeuralAudio/NeuralAudioCAPI/NeuralAudioCApi.h");
|
||||||
|
println!("cargo:rerun-if-changed=../vendor/NeuralAudio/NeuralAudioCAPI/NeuralAudioCApi.cpp");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,83 +0,0 @@
|
||||||
#include "NeuralAudioCApi.h"
|
|
||||||
#include "NeuralModel.h"
|
|
||||||
|
|
||||||
struct NeuralModel
|
|
||||||
{
|
|
||||||
NeuralAudio::NeuralModel* model;
|
|
||||||
};
|
|
||||||
|
|
||||||
#ifdef _WIN32
|
|
||||||
NeuralModel* CreateModelFromFile(const wchar_t* modelPath)
|
|
||||||
#else
|
|
||||||
NeuralModel* CreateModelFromFile(const char* modelPath)
|
|
||||||
#endif
|
|
||||||
{
|
|
||||||
NeuralModel* model = new NeuralModel();
|
|
||||||
|
|
||||||
model->model = NeuralAudio::NeuralModel::CreateFromFile(modelPath);
|
|
||||||
|
|
||||||
return model;
|
|
||||||
}
|
|
||||||
|
|
||||||
void DeleteModel(NeuralModel* model)
|
|
||||||
{
|
|
||||||
delete model->model;
|
|
||||||
delete model;
|
|
||||||
}
|
|
||||||
|
|
||||||
void SetLSTMLoadMode(int loadMode)
|
|
||||||
{
|
|
||||||
NeuralAudio::NeuralModel::SetLSTMLoadMode((NeuralAudio::EModelLoadMode)loadMode);
|
|
||||||
}
|
|
||||||
|
|
||||||
void SetWaveNetLoadMode(int loadMode)
|
|
||||||
{
|
|
||||||
NeuralAudio::NeuralModel::SetWaveNetLoadMode((NeuralAudio::EModelLoadMode)loadMode);
|
|
||||||
}
|
|
||||||
|
|
||||||
void SetAudioInputLevelDBu(float audioDBu)
|
|
||||||
{
|
|
||||||
NeuralAudio::NeuralModel::SetAudioInputLevelDBu(audioDBu);
|
|
||||||
}
|
|
||||||
|
|
||||||
void SetDefaultMaxAudioBufferSize(int maxSize)
|
|
||||||
{
|
|
||||||
NeuralAudio::NeuralModel::SetDefaultMaxAudioBufferSize(maxSize);
|
|
||||||
}
|
|
||||||
|
|
||||||
int GetLoadMode(NeuralModel* model)
|
|
||||||
{
|
|
||||||
return model->model->GetLoadMode();
|
|
||||||
}
|
|
||||||
|
|
||||||
bool IsStatic(NeuralModel* model)
|
|
||||||
{
|
|
||||||
return model->model->IsStatic();
|
|
||||||
}
|
|
||||||
|
|
||||||
void SetMaxAudioBufferSize(NeuralModel* model, int maxSize)
|
|
||||||
{
|
|
||||||
model->model->SetMaxAudioBufferSize(maxSize);
|
|
||||||
}
|
|
||||||
|
|
||||||
float GetRecommendedInputDBAdjustment(NeuralModel* model)
|
|
||||||
{
|
|
||||||
return model->model->GetRecommendedInputDBAdjustment();
|
|
||||||
}
|
|
||||||
|
|
||||||
float GetRecommendedOutputDBAdjustment(NeuralModel* model)
|
|
||||||
{
|
|
||||||
return model->model->GetRecommendedOutputDBAdjustment();
|
|
||||||
}
|
|
||||||
|
|
||||||
float GetSampleRate(NeuralModel* model)
|
|
||||||
{
|
|
||||||
return model->model->GetSampleRate();
|
|
||||||
}
|
|
||||||
|
|
||||||
void Process(NeuralModel* model, float* input, float* output, size_t numSamples)
|
|
||||||
{
|
|
||||||
model->model->Process(input, output, numSamples);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1,50 +0,0 @@
|
||||||
#pragma once
|
|
||||||
|
|
||||||
#include <stddef.h>
|
|
||||||
|
|
||||||
#ifdef __cplusplus
|
|
||||||
extern "C" {
|
|
||||||
#endif
|
|
||||||
|
|
||||||
#ifdef _MSC_VER
|
|
||||||
#define NA_EXTERN extern __declspec(dllexport)
|
|
||||||
#else
|
|
||||||
#define NA_EXTERN extern
|
|
||||||
#endif
|
|
||||||
|
|
||||||
struct NeuralModel;
|
|
||||||
|
|
||||||
|
|
||||||
#ifdef _WIN32
|
|
||||||
NA_EXTERN NeuralModel* CreateModelFromFile(const wchar_t* modelPath);
|
|
||||||
#else
|
|
||||||
NA_EXTERN NeuralModel* CreateModelFromFile(const char* modelPath);
|
|
||||||
#endif
|
|
||||||
|
|
||||||
NA_EXTERN void DeleteModel(NeuralModel* model);
|
|
||||||
|
|
||||||
NA_EXTERN void SetLSTMLoadMode(int loadMode);
|
|
||||||
|
|
||||||
NA_EXTERN void SetWaveNetLoadMode(int loadMode);
|
|
||||||
|
|
||||||
NA_EXTERN void SetAudioInputLevelDBu(float audioDBu);
|
|
||||||
|
|
||||||
NA_EXTERN void SetDefaultMaxAudioBufferSize(int maxSize);
|
|
||||||
|
|
||||||
NA_EXTERN int GetLoadMode(NeuralModel* model);
|
|
||||||
|
|
||||||
NA_EXTERN bool IsStatic(NeuralModel* model);
|
|
||||||
|
|
||||||
NA_EXTERN void SetMaxAudioBufferSize(NeuralModel* model, int maxSize);
|
|
||||||
|
|
||||||
NA_EXTERN float GetRecommendedInputDBAdjustment(NeuralModel* model);
|
|
||||||
|
|
||||||
NA_EXTERN float GetRecommendedOutputDBAdjustment(NeuralModel* model);
|
|
||||||
|
|
||||||
NA_EXTERN float GetSampleRate(NeuralModel* model);
|
|
||||||
|
|
||||||
NA_EXTERN void Process(NeuralModel* model, float* input, float* output, size_t numSamples);
|
|
||||||
|
|
||||||
#ifdef __cplusplus
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
Brush presets sourced from the mypaint/mypaint-brushes repository.
|
|
||||||
https://github.com/mypaint/mypaint-brushes
|
|
||||||
|
|
||||||
License: CC0 1.0 Universal (Public Domain Dedication)
|
|
||||||
https://creativecommons.org/publicdomain/zero/1.0/
|
|
||||||
|
|
||||||
Contributors:
|
|
||||||
classic/ brushes — original MyPaint contributors
|
|
||||||
deevad/ brushes — David Revoy (deevad), http://www.davidrevoy.com
|
|
||||||
|
|
@ -1,204 +0,0 @@
|
||||||
{
|
|
||||||
"comment": "MyPaint brush file",
|
|
||||||
"group": "",
|
|
||||||
"description": "An airbrush",
|
|
||||||
"notes": "A brush preset part of the Brushkit v0.6 \n created in october 2012 by David Revoy ( aka Deevad ) \n source: http://www.davidrevoy.com/article142/ressource-mypaint-brushes \n license: CC-Zero/Public-Domain",
|
|
||||||
"parent_brush_name": "",
|
|
||||||
"settings": {
|
|
||||||
"anti_aliasing": {
|
|
||||||
"base_value": 1.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"change_color_h": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"change_color_hsl_s": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"change_color_hsv_s": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"change_color_l": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"change_color_v": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"color_h": {
|
|
||||||
"base_value": 0.08902229845626071,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"color_s": {
|
|
||||||
"base_value": 1.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"color_v": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"colorize": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"custom_input": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {
|
|
||||||
"pressure": [
|
|
||||||
[0.0, 0.0],
|
|
||||||
[1.0, 1.0]
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"custom_input_slowness": {
|
|
||||||
"base_value": 0.71,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"dabs_per_actual_radius": {
|
|
||||||
"base_value": 5.75,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"dabs_per_basic_radius": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"dabs_per_second": {
|
|
||||||
"base_value": 30.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"direction_filter": {
|
|
||||||
"base_value": 2.99,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"elliptical_dab_angle": {
|
|
||||||
"base_value": 90.0,
|
|
||||||
"inputs": {
|
|
||||||
"direction": [
|
|
||||||
[0.0, 0.0],
|
|
||||||
[180.0, 180.0]
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"elliptical_dab_ratio": {
|
|
||||||
"base_value": 1.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"eraser": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"hardness": {
|
|
||||||
"base_value": 0.48,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"lock_alpha": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"offset_by_random": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"offset_by_speed": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"offset_by_speed_slowness": {
|
|
||||||
"base_value": 1.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"opaque": {
|
|
||||||
"base_value": 0.52,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"opaque_linearize": {
|
|
||||||
"base_value": 2.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"opaque_multiply": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {
|
|
||||||
"pressure": [
|
|
||||||
[0.0, 0.0],
|
|
||||||
[0.111111, 0.5],
|
|
||||||
[0.308642, 0.833333],
|
|
||||||
[1.0, 1.0]
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"radius_by_random": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"radius_logarithmic": {
|
|
||||||
"base_value": 4.7,
|
|
||||||
"inputs": {
|
|
||||||
"custom": [
|
|
||||||
[-2.0, 0.45],
|
|
||||||
[2.0, -0.45]
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"restore_color": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"slow_tracking": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"slow_tracking_per_dab": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"smudge": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"smudge_length": {
|
|
||||||
"base_value": 0.5,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"smudge_radius_log": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"speed1_gamma": {
|
|
||||||
"base_value": 4.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"speed1_slowness": {
|
|
||||||
"base_value": 0.04,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"speed2_gamma": {
|
|
||||||
"base_value": 4.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"speed2_slowness": {
|
|
||||||
"base_value": 0.8,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"stroke_duration_logarithmic": {
|
|
||||||
"base_value": 4.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"stroke_holdtime": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"stroke_threshold": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"tracking_noise": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"version": 3
|
|
||||||
}
|
|
||||||
|
|
@ -1,203 +0,0 @@
|
||||||
{
|
|
||||||
"comment": "MyPaint brush file",
|
|
||||||
"group": "",
|
|
||||||
"parent_brush_name": "classic/brush",
|
|
||||||
"settings": {
|
|
||||||
"anti_aliasing": {
|
|
||||||
"base_value": 1.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"change_color_h": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"change_color_hsl_s": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"change_color_hsv_s": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"change_color_l": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"change_color_v": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"color_h": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"color_s": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"color_v": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"colorize": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"custom_input": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"custom_input_slowness": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"dabs_per_actual_radius": {
|
|
||||||
"base_value": 5.82,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"dabs_per_basic_radius": {
|
|
||||||
"base_value": 0.51,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"dabs_per_second": {
|
|
||||||
"base_value": 70.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"direction_filter": {
|
|
||||||
"base_value": 2.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"elliptical_dab_angle": {
|
|
||||||
"base_value": 90.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"elliptical_dab_ratio": {
|
|
||||||
"base_value": 1.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"eraser": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"hardness": {
|
|
||||||
"base_value": 0.89,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"lock_alpha": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"offset_by_random": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"offset_by_speed": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"offset_by_speed_slowness": {
|
|
||||||
"base_value": 1.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"opaque": {
|
|
||||||
"base_value": 1.0,
|
|
||||||
"inputs": {
|
|
||||||
"pressure": [
|
|
||||||
[0.0, -0.989583],
|
|
||||||
[0.38253, -0.59375],
|
|
||||||
[0.656627, 0.041667],
|
|
||||||
[1.0, 1.0]
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"opaque_linearize": {
|
|
||||||
"base_value": 0.44,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"opaque_multiply": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {
|
|
||||||
"pressure": [
|
|
||||||
[0.0, 0.0],
|
|
||||||
[0.015, 0.0],
|
|
||||||
[0.069277, 0.9375],
|
|
||||||
[0.25, 1.0],
|
|
||||||
[1.0, 1.0]
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"radius_by_random": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"radius_logarithmic": {
|
|
||||||
"base_value": 1.01,
|
|
||||||
"inputs": {
|
|
||||||
"pressure": [
|
|
||||||
[0.0, -1.86375],
|
|
||||||
[0.237952, -1.42],
|
|
||||||
[0.5, -0.355],
|
|
||||||
[0.76506, 1.42],
|
|
||||||
[1.0, 2.13]
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"restore_color": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"slow_tracking": {
|
|
||||||
"base_value": 4.47,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"slow_tracking_per_dab": {
|
|
||||||
"base_value": 2.48,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"smudge": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"smudge_length": {
|
|
||||||
"base_value": 0.5,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"smudge_radius_log": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"speed1_gamma": {
|
|
||||||
"base_value": 2.87,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"speed1_slowness": {
|
|
||||||
"base_value": 0.04,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"speed2_gamma": {
|
|
||||||
"base_value": 4.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"speed2_slowness": {
|
|
||||||
"base_value": 0.8,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"stroke_duration_logarithmic": {
|
|
||||||
"base_value": 4.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"stroke_holdtime": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"stroke_threshold": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"tracking_noise": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"version": 3
|
|
||||||
}
|
|
||||||
|
|
@ -1,215 +0,0 @@
|
||||||
{
|
|
||||||
"comment": "MyPaint brush file",
|
|
||||||
"description": "",
|
|
||||||
"group": "",
|
|
||||||
"notes": "",
|
|
||||||
"parent_brush_name": "classic/calligraphy",
|
|
||||||
"settings": {
|
|
||||||
"anti_aliasing": {
|
|
||||||
"base_value": 3.53,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"change_color_h": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"change_color_hsl_s": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"change_color_hsv_s": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"change_color_l": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"change_color_v": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"color_h": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"color_s": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"color_v": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"colorize": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"custom_input": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"custom_input_slowness": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"dabs_per_actual_radius": {
|
|
||||||
"base_value": 2.2,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"dabs_per_basic_radius": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"dabs_per_second": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"direction_filter": {
|
|
||||||
"base_value": 2.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"elliptical_dab_angle": {
|
|
||||||
"base_value": 45.92,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"elliptical_dab_ratio": {
|
|
||||||
"base_value": 5.46,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"eraser": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"hardness": {
|
|
||||||
"base_value": 0.74,
|
|
||||||
"inputs": {
|
|
||||||
"pressure": [
|
|
||||||
[0.0, 0.0],
|
|
||||||
[1.0, 0.05]
|
|
||||||
],
|
|
||||||
"speed1": [
|
|
||||||
[0.0, -0.0],
|
|
||||||
[1.0, -0.04]
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"lock_alpha": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"offset_by_random": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"offset_by_speed": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"offset_by_speed_slowness": {
|
|
||||||
"base_value": 1.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"opaque": {
|
|
||||||
"base_value": 1.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"opaque_linearize": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"opaque_multiply": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {
|
|
||||||
"pressure": [
|
|
||||||
[0.0, 0.0],
|
|
||||||
[0.015, 0.0],
|
|
||||||
[0.015, 1.0],
|
|
||||||
[1.0, 1.0]
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"pressure_gain_log": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"radius_by_random": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"radius_logarithmic": {
|
|
||||||
"base_value": 2.02,
|
|
||||||
"inputs": {
|
|
||||||
"pressure": [
|
|
||||||
[0.0, 0.0],
|
|
||||||
[1.0, 0.5]
|
|
||||||
],
|
|
||||||
"speed1": [
|
|
||||||
[0.0, -0.0],
|
|
||||||
[1.0, -0.12]
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"restore_color": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"slow_tracking": {
|
|
||||||
"base_value": 0.65,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"slow_tracking_per_dab": {
|
|
||||||
"base_value": 0.8,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"smudge": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"smudge_length": {
|
|
||||||
"base_value": 0.5,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"smudge_radius_log": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"snap_to_pixel": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"speed1_gamma": {
|
|
||||||
"base_value": 2.87,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"speed1_slowness": {
|
|
||||||
"base_value": 0.04,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"speed2_gamma": {
|
|
||||||
"base_value": 4.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"speed2_slowness": {
|
|
||||||
"base_value": 0.8,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"stroke_duration_logarithmic": {
|
|
||||||
"base_value": 4.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"stroke_holdtime": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"stroke_threshold": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"tracking_noise": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"version": 3
|
|
||||||
}
|
|
||||||
|
|
@ -1,219 +0,0 @@
|
||||||
{
|
|
||||||
"comment": "MyPaint brush file",
|
|
||||||
"description": "A chalk brush attempt, using many tiny particles on canvas to simulate grain",
|
|
||||||
"group": "",
|
|
||||||
"notes": "A brush preset part of the Brushkit v0.6 \n created in october 2012 by David Revoy ( aka Deevad ) \n source: http://www.davidrevoy.com/article142/ressource-mypaint-brushes \n license: CC-Zero/Public-Domain",
|
|
||||||
"parent_brush_name": "deevad/chalk",
|
|
||||||
"settings": {
|
|
||||||
"anti_aliasing": {
|
|
||||||
"base_value": 1.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"change_color_h": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"change_color_hsl_s": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"change_color_hsv_s": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"change_color_l": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"change_color_v": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"color_h": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"color_s": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"color_v": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"colorize": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"custom_input": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"custom_input_slowness": {
|
|
||||||
"base_value": 0.69,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"dabs_per_actual_radius": {
|
|
||||||
"base_value": 3.93,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"dabs_per_basic_radius": {
|
|
||||||
"base_value": 5.07,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"dabs_per_second": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"direction_filter": {
|
|
||||||
"base_value": 2.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"elliptical_dab_angle": {
|
|
||||||
"base_value": 90.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"elliptical_dab_ratio": {
|
|
||||||
"base_value": 1.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"eraser": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"hardness": {
|
|
||||||
"base_value": 0.67,
|
|
||||||
"inputs": {
|
|
||||||
"pressure": [
|
|
||||||
[0.0, -0.4],
|
|
||||||
[0.667722, -0.0625],
|
|
||||||
[1.0, 0.6]
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"lock_alpha": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"offset_by_random": {
|
|
||||||
"base_value": 2.0,
|
|
||||||
"inputs": {
|
|
||||||
"pressure": [
|
|
||||||
[0.0, 0.0],
|
|
||||||
[1.0, -2.0]
|
|
||||||
],
|
|
||||||
"speed1": [
|
|
||||||
[0.0, -0.2142857142857142],
|
|
||||||
[4.0, 1.5]
|
|
||||||
],
|
|
||||||
"speed2": [
|
|
||||||
[0.0, -0.2142857142857142],
|
|
||||||
[4.0, 1.5]
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"offset_by_speed": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"offset_by_speed_slowness": {
|
|
||||||
"base_value": 1.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"opaque": {
|
|
||||||
"base_value": 0.2,
|
|
||||||
"inputs": {
|
|
||||||
"pressure": [
|
|
||||||
[0.0, 0.0],
|
|
||||||
[1.0, 0.4]
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"opaque_linearize": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"opaque_multiply": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {
|
|
||||||
"pressure": [
|
|
||||||
[0.0, 0.0],
|
|
||||||
[1.0, 1.0]
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"pressure_gain_log": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"radius_by_random": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"radius_logarithmic": {
|
|
||||||
"base_value": 0.58,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"restore_color": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"slow_tracking": {
|
|
||||||
"base_value": 2.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"slow_tracking_per_dab": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"smudge": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"smudge_length": {
|
|
||||||
"base_value": 0.5,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"smudge_radius_log": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"snap_to_pixel": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"speed1_gamma": {
|
|
||||||
"base_value": 4.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"speed1_slowness": {
|
|
||||||
"base_value": 0.04,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"speed2_gamma": {
|
|
||||||
"base_value": 4.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"speed2_slowness": {
|
|
||||||
"base_value": 0.8,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"stroke_duration_logarithmic": {
|
|
||||||
"base_value": 4.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"stroke_holdtime": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"stroke_threshold": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"tracking_noise": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"version": 3
|
|
||||||
}
|
|
||||||
|
|
@ -1,195 +0,0 @@
|
||||||
{
|
|
||||||
"comment": "MyPaint brush file",
|
|
||||||
"group": "",
|
|
||||||
"parent_brush_name": "",
|
|
||||||
"settings": {
|
|
||||||
"anti_aliasing": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"change_color_h": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"change_color_hsl_s": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"change_color_hsv_s": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"change_color_l": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"change_color_v": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"color_h": {
|
|
||||||
"base_value": 0.6354166666666666,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"color_s": {
|
|
||||||
"base_value": 0.8807339449541285,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"color_v": {
|
|
||||||
"base_value": 0.42745098039215684,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"colorize": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"custom_input": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"custom_input_slowness": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"dabs_per_actual_radius": {
|
|
||||||
"base_value": 5.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"dabs_per_basic_radius": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"dabs_per_second": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"direction_filter": {
|
|
||||||
"base_value": 2.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"elliptical_dab_angle": {
|
|
||||||
"base_value": 90.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"elliptical_dab_ratio": {
|
|
||||||
"base_value": 1.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"eraser": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"hardness": {
|
|
||||||
"base_value": 0.2,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"lock_alpha": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"offset_by_random": {
|
|
||||||
"base_value": 1.6,
|
|
||||||
"inputs": {
|
|
||||||
"pressure": [
|
|
||||||
[0, 0],
|
|
||||||
[1.0, -1.4]
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"offset_by_speed": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"offset_by_speed_slowness": {
|
|
||||||
"base_value": 1.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"opaque": {
|
|
||||||
"base_value": 0.4,
|
|
||||||
"inputs": {
|
|
||||||
"pressure": [
|
|
||||||
[0, 0],
|
|
||||||
[1.0, 0.4]
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"opaque_linearize": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"opaque_multiply": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {
|
|
||||||
"pressure": [
|
|
||||||
[0, 0],
|
|
||||||
[1.0, 1.0]
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"radius_by_random": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"radius_logarithmic": {
|
|
||||||
"base_value": 0.7,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"restore_color": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"slow_tracking": {
|
|
||||||
"base_value": 2.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"slow_tracking_per_dab": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"smudge": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"smudge_length": {
|
|
||||||
"base_value": 0.5,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"smudge_radius_log": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"speed1_gamma": {
|
|
||||||
"base_value": 4.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"speed1_slowness": {
|
|
||||||
"base_value": 0.04,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"speed2_gamma": {
|
|
||||||
"base_value": 4.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"speed2_slowness": {
|
|
||||||
"base_value": 0.8,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"stroke_duration_logarithmic": {
|
|
||||||
"base_value": 4.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"stroke_holdtime": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"stroke_threshold": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"tracking_noise": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"version": 3
|
|
||||||
}
|
|
||||||
|
|
@ -1,210 +0,0 @@
|
||||||
{
|
|
||||||
"comment": "MyPaint brush file",
|
|
||||||
"description": "",
|
|
||||||
"group": "",
|
|
||||||
"notes": "",
|
|
||||||
"parent_brush_name": "classic/dry_brush",
|
|
||||||
"settings": {
|
|
||||||
"anti_aliasing": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"change_color_h": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"change_color_hsl_s": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"change_color_hsv_s": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"change_color_l": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"change_color_v": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"color_h": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"color_s": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"color_v": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"colorize": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"custom_input": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"custom_input_slowness": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"dabs_per_actual_radius": {
|
|
||||||
"base_value": 6.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"dabs_per_basic_radius": {
|
|
||||||
"base_value": 6.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"dabs_per_second": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"direction_filter": {
|
|
||||||
"base_value": 2.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"elliptical_dab_angle": {
|
|
||||||
"base_value": 90.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"elliptical_dab_ratio": {
|
|
||||||
"base_value": 1.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"eraser": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"hardness": {
|
|
||||||
"base_value": 0.2,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"lock_alpha": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"offset_by_random": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {
|
|
||||||
"pressure": [
|
|
||||||
[0.0, 0.0],
|
|
||||||
[1.0, 1.4]
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"offset_by_speed": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"offset_by_speed_slowness": {
|
|
||||||
"base_value": 1.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"opaque": {
|
|
||||||
"base_value": 0.8,
|
|
||||||
"inputs": {
|
|
||||||
"pressure": [
|
|
||||||
[0.0, 0.0],
|
|
||||||
[1.0, 0.2]
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"opaque_linearize": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"opaque_multiply": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {
|
|
||||||
"pressure": [
|
|
||||||
[0.0, 0.0],
|
|
||||||
[1.0, 1.0]
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"pressure_gain_log": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"radius_by_random": {
|
|
||||||
"base_value": 0.1,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"radius_logarithmic": {
|
|
||||||
"base_value": 0.6,
|
|
||||||
"inputs": {
|
|
||||||
"speed2": [
|
|
||||||
[0.0, 0.042857],
|
|
||||||
[4.0, -0.3]
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"restore_color": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"slow_tracking": {
|
|
||||||
"base_value": 2.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"slow_tracking_per_dab": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"smudge": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"smudge_length": {
|
|
||||||
"base_value": 0.5,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"smudge_radius_log": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"snap_to_pixel": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"speed1_gamma": {
|
|
||||||
"base_value": 4.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"speed1_slowness": {
|
|
||||||
"base_value": 0.04,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"speed2_gamma": {
|
|
||||||
"base_value": 4.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"speed2_slowness": {
|
|
||||||
"base_value": 0.8,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"stroke_duration_logarithmic": {
|
|
||||||
"base_value": 4.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"stroke_holdtime": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"stroke_threshold": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"tracking_noise": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"version": 3
|
|
||||||
}
|
|
||||||
|
|
@ -1,200 +0,0 @@
|
||||||
{
|
|
||||||
"comment": "MyPaint brush file",
|
|
||||||
"description": "",
|
|
||||||
"group": "",
|
|
||||||
"notes": "",
|
|
||||||
"parent_brush_name": "classic/ink_blot",
|
|
||||||
"settings": {
|
|
||||||
"anti_aliasing": {
|
|
||||||
"base_value": 1.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"change_color_h": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"change_color_hsl_s": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"change_color_hsv_s": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"change_color_l": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"change_color_v": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"color_h": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"color_s": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"color_v": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"colorize": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"custom_input": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"custom_input_slowness": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"dabs_per_actual_radius": {
|
|
||||||
"base_value": 3.32,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"dabs_per_basic_radius": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"dabs_per_second": {
|
|
||||||
"base_value": 15.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"direction_filter": {
|
|
||||||
"base_value": 2.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"elliptical_dab_angle": {
|
|
||||||
"base_value": 90.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"elliptical_dab_ratio": {
|
|
||||||
"base_value": 1.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"eraser": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"hardness": {
|
|
||||||
"base_value": 0.28,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"lock_alpha": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"offset_by_random": {
|
|
||||||
"base_value": 0.17,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"offset_by_speed": {
|
|
||||||
"base_value": 0.02,
|
|
||||||
"inputs": {
|
|
||||||
"custom": [
|
|
||||||
[-2.0, 0.0],
|
|
||||||
[2.0, 0.0]
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"offset_by_speed_slowness": {
|
|
||||||
"base_value": 1.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"opaque": {
|
|
||||||
"base_value": 1.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"opaque_linearize": {
|
|
||||||
"base_value": 0.9,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"opaque_multiply": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {
|
|
||||||
"pressure": [
|
|
||||||
[0.0, 0.0],
|
|
||||||
[1.0, 1.0]
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"pressure_gain_log": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"radius_by_random": {
|
|
||||||
"base_value": 0.63,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"radius_logarithmic": {
|
|
||||||
"base_value": 2.5,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"restore_color": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"slow_tracking": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"slow_tracking_per_dab": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"smudge": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"smudge_length": {
|
|
||||||
"base_value": 0.5,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"smudge_radius_log": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"snap_to_pixel": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"speed1_gamma": {
|
|
||||||
"base_value": 4.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"speed1_slowness": {
|
|
||||||
"base_value": 0.04,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"speed2_gamma": {
|
|
||||||
"base_value": 4.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"speed2_slowness": {
|
|
||||||
"base_value": 0.8,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"stroke_duration_logarithmic": {
|
|
||||||
"base_value": 4.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"stroke_holdtime": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"stroke_threshold": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"tracking_noise": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"version": 3
|
|
||||||
}
|
|
||||||
|
|
@ -1,189 +0,0 @@
|
||||||
{
|
|
||||||
"comment": "MyPaint brush file",
|
|
||||||
"group": "",
|
|
||||||
"description": "A small brush to trace regular lines",
|
|
||||||
"notes": "A brush preset part of the Brushkit v0.6 \n created in october 2012 by David Revoy ( aka Deevad ) \n source: http://www.davidrevoy.com/article142/ressource-mypaint-brushes \n license: CC-Zero/Public-Domain",
|
|
||||||
"parent_brush_name": "",
|
|
||||||
"settings": {
|
|
||||||
"anti_aliasing": {
|
|
||||||
"base_value": 2.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"change_color_h": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"change_color_hsl_s": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"change_color_hsv_s": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"change_color_l": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"change_color_v": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"color_h": {
|
|
||||||
"base_value": 0.1289192800566187,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"color_s": {
|
|
||||||
"base_value": 1.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"color_v": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"colorize": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"custom_input": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"custom_input_slowness": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"dabs_per_actual_radius": {
|
|
||||||
"base_value": 4.43,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"dabs_per_basic_radius": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"dabs_per_second": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"direction_filter": {
|
|
||||||
"base_value": 2.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"elliptical_dab_angle": {
|
|
||||||
"base_value": 90.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"elliptical_dab_ratio": {
|
|
||||||
"base_value": 1.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"eraser": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"hardness": {
|
|
||||||
"base_value": 0.8,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"lock_alpha": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"offset_by_random": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"offset_by_speed": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"offset_by_speed_slowness": {
|
|
||||||
"base_value": 1.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"opaque": {
|
|
||||||
"base_value": 1.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"opaque_linearize": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"opaque_multiply": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {
|
|
||||||
"pressure": [
|
|
||||||
[0.0, 0.0],
|
|
||||||
[0.015, 0.0],
|
|
||||||
[0.015, 1.0],
|
|
||||||
[1.0, 1.0]
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"radius_by_random": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"radius_logarithmic": {
|
|
||||||
"base_value": 0.7999999999999998,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"restore_color": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"slow_tracking": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"slow_tracking_per_dab": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"smudge": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"smudge_length": {
|
|
||||||
"base_value": 0.5,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"smudge_radius_log": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"speed1_gamma": {
|
|
||||||
"base_value": 2.87,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"speed1_slowness": {
|
|
||||||
"base_value": 0.04,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"speed2_gamma": {
|
|
||||||
"base_value": 4.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"speed2_slowness": {
|
|
||||||
"base_value": 0.8,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"stroke_duration_logarithmic": {
|
|
||||||
"base_value": 1.18,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"stroke_holdtime": {
|
|
||||||
"base_value": 10.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"stroke_threshold": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"tracking_noise": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"version": 3
|
|
||||||
}
|
|
||||||
|
|
@ -1,215 +0,0 @@
|
||||||
{
|
|
||||||
"comment": "MyPaint brush file",
|
|
||||||
"description": "",
|
|
||||||
"group": "",
|
|
||||||
"notes": "",
|
|
||||||
"parent_brush_name": "classic/pen",
|
|
||||||
"settings": {
|
|
||||||
"anti_aliasing": {
|
|
||||||
"base_value": 1.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"change_color_h": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"change_color_hsl_s": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"change_color_hsv_s": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"change_color_l": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"change_color_v": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"color_h": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"color_s": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"color_v": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"colorize": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"custom_input": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"custom_input_slowness": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"dabs_per_actual_radius": {
|
|
||||||
"base_value": 2.2,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"dabs_per_basic_radius": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"dabs_per_second": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"direction_filter": {
|
|
||||||
"base_value": 2.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"elliptical_dab_angle": {
|
|
||||||
"base_value": 90.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"elliptical_dab_ratio": {
|
|
||||||
"base_value": 1.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"eraser": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"hardness": {
|
|
||||||
"base_value": 0.9,
|
|
||||||
"inputs": {
|
|
||||||
"pressure": [
|
|
||||||
[0.0, 0.0],
|
|
||||||
[1.0, 0.05]
|
|
||||||
],
|
|
||||||
"speed1": [
|
|
||||||
[0.0, -0.0],
|
|
||||||
[1.0, -0.09]
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"lock_alpha": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"offset_by_random": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"offset_by_speed": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"offset_by_speed_slowness": {
|
|
||||||
"base_value": 1.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"opaque": {
|
|
||||||
"base_value": 1.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"opaque_linearize": {
|
|
||||||
"base_value": 0.9,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"opaque_multiply": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {
|
|
||||||
"pressure": [
|
|
||||||
[0.0, 0.0],
|
|
||||||
[0.015, 0.0],
|
|
||||||
[0.015, 1.0],
|
|
||||||
[1.0, 1.0]
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"pressure_gain_log": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"radius_by_random": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"radius_logarithmic": {
|
|
||||||
"base_value": 0.96,
|
|
||||||
"inputs": {
|
|
||||||
"pressure": [
|
|
||||||
[0.0, 0.0],
|
|
||||||
[1.0, 0.5]
|
|
||||||
],
|
|
||||||
"speed1": [
|
|
||||||
[0.0, -0.0],
|
|
||||||
[1.0, -0.15]
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"restore_color": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"slow_tracking": {
|
|
||||||
"base_value": 0.65,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"slow_tracking_per_dab": {
|
|
||||||
"base_value": 0.8,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"smudge": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"smudge_length": {
|
|
||||||
"base_value": 0.5,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"smudge_radius_log": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"snap_to_pixel": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"speed1_gamma": {
|
|
||||||
"base_value": 2.87,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"speed1_slowness": {
|
|
||||||
"base_value": 0.04,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"speed2_gamma": {
|
|
||||||
"base_value": 4.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"speed2_slowness": {
|
|
||||||
"base_value": 0.8,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"stroke_duration_logarithmic": {
|
|
||||||
"base_value": 4.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"stroke_holdtime": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"stroke_threshold": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"tracking_noise": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"version": 3
|
|
||||||
}
|
|
||||||
|
|
@ -1,195 +0,0 @@
|
||||||
{
|
|
||||||
"comment": "MyPaint brush file",
|
|
||||||
"group": "",
|
|
||||||
"parent_brush_name": "classic/pencil",
|
|
||||||
"settings": {
|
|
||||||
"anti_aliasing": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"change_color_h": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"change_color_hsl_s": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"change_color_hsv_s": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"change_color_l": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"change_color_v": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"color_h": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"color_s": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"color_v": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"colorize": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"custom_input": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"custom_input_slowness": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"dabs_per_actual_radius": {
|
|
||||||
"base_value": 4.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"dabs_per_basic_radius": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"dabs_per_second": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"direction_filter": {
|
|
||||||
"base_value": 2.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"elliptical_dab_angle": {
|
|
||||||
"base_value": 90.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"elliptical_dab_ratio": {
|
|
||||||
"base_value": 1.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"eraser": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"hardness": {
|
|
||||||
"base_value": 0.1,
|
|
||||||
"inputs": {
|
|
||||||
"pressure": [
|
|
||||||
[0.0, 0.0],
|
|
||||||
[1.0, 0.3]
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"lock_alpha": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"offset_by_random": {
|
|
||||||
"base_value": 0.5,
|
|
||||||
"inputs": {
|
|
||||||
"pressure": [
|
|
||||||
[0.0, 0.0],
|
|
||||||
[1.0, -0.3]
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"offset_by_speed": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"offset_by_speed_slowness": {
|
|
||||||
"base_value": 1.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"opaque": {
|
|
||||||
"base_value": 0.7,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"opaque_linearize": {
|
|
||||||
"base_value": 0.9,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"opaque_multiply": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {
|
|
||||||
"pressure": [
|
|
||||||
[0.0, 0.0],
|
|
||||||
[1.0, 1.0]
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"radius_by_random": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"radius_logarithmic": {
|
|
||||||
"base_value": 0.2,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"restore_color": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"slow_tracking": {
|
|
||||||
"base_value": 1.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"slow_tracking_per_dab": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"smudge": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"smudge_length": {
|
|
||||||
"base_value": 0.5,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"smudge_radius_log": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"speed1_gamma": {
|
|
||||||
"base_value": 4.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"speed1_slowness": {
|
|
||||||
"base_value": 0.04,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"speed2_gamma": {
|
|
||||||
"base_value": 4.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"speed2_slowness": {
|
|
||||||
"base_value": 0.8,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"stroke_duration_logarithmic": {
|
|
||||||
"base_value": 4.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"stroke_holdtime": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"stroke_threshold": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
},
|
|
||||||
"tracking_noise": {
|
|
||||||
"base_value": 0.0,
|
|
||||||
"inputs": {}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"version": 3
|
|
||||||
}
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
||||||
<circle cx="12" cy="12" r="10"/>
|
|
||||||
<path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/>
|
|
||||||
<line x1="12" y1="17" x2="12.01" y2="17"/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 297 B |
Loading…
Reference in New Issue