Merge PR #6939: Mixer Brush: Clean Brush empties the brush, and the automatic Load and Clean can be switched off

This commit is contained in:
aiongg
2026-10-20 18:39:27 +00:00
30 changed files with 443 additions and 16 deletions
+40 -8
View File
@@ -644,13 +644,13 @@ fn mixer_brush(s: &mut Session, p: &Value) -> Result<Value> {
mix: pct(p, "mix", bm.mix * 100.0),
flow: pct(p, "flow", bm.flow * 100.0),
sample_all_layers: flag(p, "sampleAllLayers", bm.sample_all_layers),
load_after_stroke: flag(p, "loadAfterStroke", bm.load_after_stroke),
clean_after_stroke: flag(p, "cleanAfterStroke", bm.clean_after_stroke),
};
let sample_all = m.sample_all_layers;
let (clean, load_after) = (flag(p, "cleanAfterStroke", true), flag(p, "loadAfterStroke", true));
let paint = color(p.get("color"), s.tools.foreground);
let mut state = s.tools.mixer.clone();
if load_after || state.reservoir.is_none() {
state.load(color(p.get("color"), s.tools.foreground));
}
state.begin_stroke(paint);
let id = if crate::channel_cmds::is_channel_target(p) { None } else { Some(layer_id(s, p)?) };
let stroke = Stroke { brush, points: pts };
let dmg = s.edit("Mixer Brush", |doc, _| {
@@ -672,13 +672,43 @@ fn mixer_brush(s: &mut Session, p: &Value) -> Result<Value> {
let (surf, lock) = crate::channel_cmds::target_surface(doc, id, p)?;
Ok(apply_mixer_stroke(surf, sample.as_ref(), &stroke, &m, &mut state, sel.as_ref(), lock))
})?;
if clean {
state.clean();
}
state.end_stroke(paint, m.clean_after_stroke, m.load_after_stroke);
s.tools.mixer = state;
Ok(damage_json(s, dmg))
}
/// `brush.mixer.load`: Load Brush fills the Mixer Brush's reservoir with the color. The next stroke
/// keeps that paint unless a different foreground is chosen first. Returns the paint the brush now
/// carries.
fn mixer_load(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "brush.mixer.load";
let paint = match p.get("color").filter(|v| !v.is_null()) {
None => Some(s.tools.foreground),
Some(Value::String(h)) => crate::commands::parse_hex(h),
Some(Value::Array(a)) => {
let c: Option<Vec<f32>> = a.iter().map(|x| x.as_f64().filter(|x| (0.0..=1.0).contains(x)).map(|x| x as f32)).collect();
match c.as_deref() {
Some(&[r, g, b]) => Some([r, g, b, 1.0]),
Some(&[r, g, b, a]) => Some([r, g, b, a]),
_ => None,
}
}
Some(_) => None,
};
let paint = paint.ok_or_else(|| bad(cmd, "`color` must be \"#rrggbb\" or [r, g, b, a?] in 0..1"))?;
s.tools.mixer.load(paint);
s.tools.mixer.color = Some(s.tools.foreground);
Ok(serde_json::to_value(&s.tools.mixer).unwrap_or_default())
}
/// `brush.mixer.clean`: Clean Brush empties the Mixer Brush. The next stroke starts empty unless
/// a different color is chosen first. Returns the paint the brush now carries.
fn mixer_clean(s: &mut Session, _: &Value) -> Result<Value> {
s.tools.mixer.clean();
s.tools.mixer.color = Some(s.tools.foreground);
Ok(serde_json::to_value(&s.tools.mixer).unwrap_or_default())
}
/// `paint.colorReplacement`'s stroke and settings. The replacement colour is the brush colour
/// (the foreground unless `color` says otherwise). It paints layer pixels only: a mask, alpha
/// channel or Quick Mask `target` is refused rather than recolouring the layer instead.
@@ -1135,11 +1165,13 @@ pub fn specs() -> Vec<CommandSpec> {
spec!(
"paint.mixerBrush",
"Mixer Brush",
r##"{"points":[…],"brush":{…}?,"preset":name?,"size":0.5..5000 px?,"wet":0..100=brush.mixer.wet,"load":0..100=brush.mixer.load,"mix":0..100=brush.mixer.mix,"flow":0..100=brush.mixer.flow,"color":"#rrggbb"?=foreground,"sampleAllLayers":bool=brush.mixer.sampleAllLayers,"cleanAfterStroke":bool=true,"loadAfterStroke":bool=true,"seed":u64?}"##,
r##"{"points":[…],"brush":{…}?,"preset":name?,"size":0.5..5000 px?,"wet":0..100=brush.mixer.wet,"load":0..100=brush.mixer.load,"mix":0..100=brush.mixer.mix,"flow":0..100=brush.mixer.flow,"color":"#rrggbb"?=foreground (a color other than the one recorded by the last stroke, load or clean loads it first; a load or clean records the foreground),"sampleAllLayers":bool=brush.mixer.sampleAllLayers,"cleanAfterStroke":bool=brush.mixer.cleanAfterStroke (empty the brush after the stroke),"loadAfterStroke":bool=brush.mixer.loadAfterStroke (load the brush with `color` after the stroke),"seed":u64?}"##,
has_paintable,
mixer_brush,
true
),
spec!("brush.mixer.load", "Load Mixer Brush", r##"{"color":"#rrggbb"|[r,g,b,a?] 0..1?=foreground}"##, always, mixer_load, true),
spec!("brush.mixer.clean", "Clean Mixer Brush", "{}", always, mixer_clean, true),
spec!(
"paint.colorReplacement",
"Color Replacement",
+95
View File
@@ -814,6 +814,101 @@ fn mixer_brush_uses_the_brush_mixer_settings() {
assert!(rgba(&s, 190, 5)[3] > 0.95);
}
/// A red image with an empty layer above it, a blue foreground, and a Mixer Brush that samples all
/// layers, keeps its paint along the stroke (Load 100 %) and has the automatic Load off.
fn mixer_over_red() -> Session {
let mut s = session(200, 30);
s.execute("edit.fill", json!({"color": "#ff0000"})).unwrap();
s.execute("layer.new.layer", json!({})).unwrap();
s.execute("tools.setColors", json!({"foreground": "#0000ff"})).unwrap();
let mixer = json!({"load": 1, "sampleAllLayers": true, "loadAfterStroke": false});
s.execute("tools.setBrush", json!({"brush": {"pressureSize": false, "spacing": 0.25, "mixer": mixer}})).unwrap();
s
}
fn mixer_row(s: &mut Session, y: i32) -> [f32; 4] {
s.execute("paint.mixerBrush", json!({"points": [[5, y], [195, y]], "size": 6})).unwrap();
rgba(s, 100, y)
}
#[test]
fn clean_brush_keeps_the_foreground_out_of_the_next_stroke() {
let mut s = mixer_over_red();
s.execute("brush.mixer.load", json!({})).unwrap();
let state = s.execute("brush.mixer.clean", json!({})).unwrap();
assert_eq!((state["reservoir"].clone(), state["pickup"].clone()), (Value::Null, Value::Null));
let c = mixer_row(&mut s, 15);
assert!(c[3] > 0.1 && c[0] > 0.99 && c[2] < 0.01, "the clean brush lays down only the red it picks up: {c:?}");
}
#[test]
fn the_automatic_clean_empties_the_reservoir() {
let mut s = mixer_over_red();
// The brush was never loaded, so the first stroke takes the foreground.
assert!(mixer_row(&mut s, 5)[2] > 0.2);
assert_eq!((s.tools.mixer.reservoir, s.tools.mixer.pickup), (None, None));
let c = mixer_row(&mut s, 15);
assert!(c[3] > 0.1 && c[2] < 0.01, "the cleaned brush lays down no blue: {c:?}");
// With the automatic Load on as well (the default), the brush is cleaned and then refilled
// after the stroke, so the next one starts with a full reservoir and no pickup.
s.execute("tools.setBrush", json!({"brush": {"mixer": {"loadAfterStroke": true}}})).unwrap();
mixer_row(&mut s, 25);
assert_eq!((s.tools.mixer.reservoir, s.tools.mixer.amount, s.tools.mixer.pickup), (Some([0.0, 0.0, 1.0, 1.0]), 1.0, None));
}
#[test]
fn load_brush_after_a_clean_paints_the_foreground_again() {
let mut s = mixer_over_red();
s.execute("brush.mixer.clean", json!({})).unwrap();
assert!(mixer_row(&mut s, 5)[2] < 0.01);
let state = s.execute("brush.mixer.load", json!({})).unwrap();
assert_eq!(state["reservoir"], json!([0.0, 0.0, 1.0, 1.0]));
assert!(mixer_row(&mut s, 15)[2] > 0.2, "the loaded brush paints blue");
// A color param loads that color instead of the foreground, and the next stroke paints it.
s.execute("brush.mixer.load", json!({"color": [0.0, 1.0, 0.0]})).unwrap();
assert_eq!((s.tools.mixer.reservoir, s.tools.mixer.amount), (Some([0.0, 1.0, 0.0, 1.0]), 1.0));
assert_eq!(s.tools.mixer.next_load(s.tools.foreground), Some([0.0, 1.0, 0.0, 1.0]));
let c = mixer_row(&mut s, 25);
assert!(c[1] > 0.2 && c[2] < 0.01, "the stroke paints the loaded green, not the blue foreground: {c:?}");
}
#[test]
fn with_the_automatic_load_off_a_new_foreground_loads_at_the_next_stroke() {
let mut s = mixer_over_red();
assert!(mixer_row(&mut s, 5)[2] > 0.2);
s.execute("tools.setColors", json!({"foreground": "#00ff00"})).unwrap();
let c = mixer_row(&mut s, 15);
assert!(c[1] > 0.2, "the new foreground is loaded: {c:?}");
// The automatic Clean emptied it again, and the foreground did not change.
let c = mixer_row(&mut s, 25);
assert!(c[3] > 0.1 && c[1] < 0.01 && c[2] < 0.01, "{c:?}");
}
#[test]
fn load_and_clean_brush_refuse_bad_params_and_keep_the_paint() {
let mut s = mixer_over_red();
s.execute("brush.mixer.load", json!({"color": "#336699"})).unwrap();
let before = s.tools.mixer.clone();
for bad in [
json!({"color": 5}),
json!({"color": "#zz0000"}),
json!({"color": "#ff"}),
json!({"color": [2, 0, 0]}),
json!({"color": [0, 0]}),
json!({"color": [0, 0, 0, 0, 0]}),
json!({"color": {"r": 1}}),
json!({"color": ["a", 0, 0]}),
] {
assert!(s.execute("brush.mixer.load", bad.clone()).is_err(), "{bad}");
assert_eq!(s.tools.mixer, before, "{bad}");
}
// Clean Brush takes no params, so anything extra is ignored, without a document too.
let mut empty = Session::new();
empty.execute("brush.mixer.clean", json!({"color": [], "junk": {"x": null}})).unwrap();
empty.execute("brush.mixer.load", json!({"color": null})).unwrap();
assert_eq!(empty.tools.mixer.reservoir, Some(empty.tools.foreground));
}
#[test]
fn coalesced_set_brush_calls_journal_once_per_gesture() {
let mut s = session(20, 20);
+78 -3
View File
@@ -26,11 +26,15 @@ pub struct MixerSettings {
pub flow: f32,
/// Pick up colour from all visible layers instead of the target layer only.
pub sample_all_layers: bool,
/// Load the brush with the stroke color after each stroke (the automatic Load option).
pub load_after_stroke: bool,
/// Empty the brush after each stroke (the automatic Clean option).
pub clean_after_stroke: bool,
}
impl Default for MixerSettings {
fn default() -> Self {
Self { wet: 0.5, load: 0.5, mix: 0.5, flow: 1.0, sample_all_layers: false }
Self { wet: 0.5, load: 0.5, mix: 0.5, flow: 1.0, sample_all_layers: false, load_after_stroke: true, clean_after_stroke: true }
}
}
@@ -44,18 +48,47 @@ pub struct MixerState {
pub amount: f32,
/// Colour picked up from the canvas (straight RGBA), None = clean.
pub pickup: Option<[f32; 4]>,
/// The color recorded by the last stroke (its color) or the last Load Brush or Clean Brush
/// (the foreground). None = no stroke, load or clean yet.
pub color: Option<[f32; 4]>,
}
impl MixerState {
/// Load the reservoir with a colour (full).
/// Load Brush: fill the reservoir with a color (full).
pub fn load(&mut self, color: [f32; 4]) {
self.reservoir = Some(color);
self.amount = 1.0;
}
/// Clean the brush (drop the picked-up colour).
/// Clean Brush: empty the reservoir and the pickup well.
pub fn clean(&mut self) {
self.reservoir = None;
self.amount = 0.0;
self.pickup = None;
}
/// Before a stroke in `color`: choosing a color other than [`MixerState::color`] loads the
/// reservoir with it.
pub fn begin_stroke(&mut self, color: [f32; 4]) {
if self.color != Some(color) {
self.load(color);
self.color = Some(color);
}
}
/// After a stroke in `color`: the automatic Clean empties the brush, then the automatic Load
/// fills the reservoir again.
pub fn end_stroke(&mut self, color: [f32; 4], clean: bool, load: bool) {
if clean {
self.clean();
}
if load {
self.load(color);
}
self.color = Some(color);
}
/// The reservoir color the next stroke in `color` starts with (the Current Brush Load
/// swatch); None = a clean brush.
pub fn next_load(&self, color: [f32; 4]) -> Option<[f32; 4]> {
if self.color != Some(color) { Some(color) } else { self.reservoir }
}
}
/// Paint a Mixer Brush stroke. `sample` is the surface colour is picked up from (the layer itself
@@ -209,6 +242,48 @@ mod tests {
assert!(st.pickup.is_none());
}
#[test]
fn a_clean_brush_paints_only_the_canvas_color_it_picks_up() {
let mut st = MixerState::default();
st.load([0.0, 0.0, 1.0, 1.0]);
st.pickup = Some([0.0, 1.0, 0.0, 1.0]);
st.clean();
assert_eq!((st.reservoir, st.amount, st.pickup), (None, 0.0, None));
// On red, the clean wet brush smears red and lays down none of the blue it was loaded with.
let mut s = Surface::new(PixelFormat::RGBA8);
s.fill_rect(Rect::new(0, 0, 200, 20), &[1.0, 0.0, 0.0, 1.0]);
apply_mixer_stroke(&mut s, None, &stroke(10.0), &MixerSettings::default(), &mut st, None, false);
let c = s.rgba(100, 10);
assert!(c[0] > 0.99 && c[1] < 0.01 && c[2] < 0.01, "{c:?}");
}
#[test]
fn a_new_color_loads_the_brush_and_the_automatic_options_run_after_the_stroke() {
let (blue, green) = ([0.0, 0.0, 1.0, 1.0], [0.0, 1.0, 0.0, 1.0]);
let mut st = MixerState::default();
// A brush never loaded takes the stroke's color.
assert_eq!(st.next_load(blue), Some(blue));
st.begin_stroke(blue);
assert_eq!((st.reservoir, st.amount), (Some(blue), 1.0));
// Clean on, Load off: the next stroke in the same color starts empty.
st.end_stroke(blue, true, false);
assert_eq!(st.next_load(blue), None);
st.begin_stroke(blue);
assert_eq!((st.reservoir, st.pickup), (None, None));
// Choosing another color loads it.
assert_eq!(st.next_load(green), Some(green));
st.begin_stroke(green);
assert_eq!((st.reservoir, st.amount), (Some(green), 1.0));
// Load on, Clean off: the reservoir is full again and the pickup stays.
st.amount = 0.1;
st.pickup = Some(blue);
st.end_stroke(green, false, true);
assert_eq!((st.reservoir, st.amount, st.pickup), (Some(green), 1.0, Some(blue)));
// Both on: a fresh load with no pickup.
st.end_stroke(green, true, true);
assert_eq!((st.reservoir, st.amount, st.pickup), (Some(green), 1.0, None));
}
#[test]
fn dry_empty_brush_paints_nothing() {
let mut s = Surface::new(PixelFormat::RGBA8);
+59 -1
View File
@@ -97,7 +97,15 @@ fn edit_section(i: usize, b: &mut BrushSettings) {
b.transfer.flow = dynamic(0.25, Control::Fade, 0.3);
b.transfer.wetness = dynamic(0.35, Control::PenPressure, 0.1);
b.transfer.mix = dynamic(0.45, Control::Fade, 0.2);
b.mixer = paint::MixerSettings { wet: 0.9, load: 0.15, mix: 0.7, flow: 0.8, sample_all_layers: true };
b.mixer = paint::MixerSettings {
wet: 0.9,
load: 0.15,
mix: 0.7,
flow: 0.8,
sample_all_layers: true,
load_after_stroke: false,
clean_after_stroke: false,
};
}
7 => {
let p = &mut b.pose;
@@ -488,6 +496,56 @@ fn mixer_brush_options_update_the_persisted_mixer_settings() {
assert_eq!(params["brush"]["mixer"]["sampleAllLayers"], true);
}
#[test]
fn mixer_brush_clean_brush_and_the_automatic_load_keep_the_foreground_out() {
use egui_kittest::kittest::Queryable;
let mut h = options_bar_harness(crate::state::Tool::MixerBrush);
// A red image, an empty layer above it, a blue foreground, and a brush that samples all layers.
for (id, p) in [
("file.new", json!({"width": 200, "height": 40, "background": "transparent"})),
("edit.fill", json!({"color": "#ff0000"})),
("layer.new.layer", json!({})),
("tools.setColors", json!({"foreground": "#0000ff"})),
("tools.setBrush", json!({"brush": {"size": 8, "pressureSize": false, "mixer": {"sampleAllLayers": true}}})),
] {
h.state_mut().run(id, p).unwrap();
}
h.run_steps(3);
for label in ["Current Brush Load", "Load the brush after each stroke", "Clean the brush after each stroke"] {
assert!(h.get_by_label(label).rect().is_positive(), "missing Mixer Brush option {label}");
}
// Turn the automatic Load off: one tools.setBrush call.
h.get_by_label("Load the brush after each stroke").click();
h.run_steps(3);
assert!(!h.state().session.tools.brush.mixer.load_after_stroke);
let (id, params) = last_journal(h.state()).unwrap();
assert_eq!((id.as_str(), &params["brush"]["mixer"]["loadAfterStroke"]), ("tools.setBrush", &json!(false)));
// Current Brush Load › Clean Brush empties the brush.
h.get_by_label("Current Brush Load").click();
h.run_steps(3);
h.get_by_label("Clean Brush").click();
h.run_steps(3);
assert_eq!(last_journal(h.state()).map(|(id, _)| id), Some("brush.mixer.clean".to_string()));
assert_eq!(h.state().session.tools.mixer.reservoir, None);
// The stroke the canvas commits on release lays down only the red it picks up.
crate::retouch_ui::finish_stroke(h.state_mut(), crate::state::Tool::MixerBrush, &[[5.0, 20.0, 1.0], [195.0, 20.0, 1.0]], egui::Modifiers::NONE);
let d = h.state().session.active().unwrap();
let c = d.doc.layer(d.active_layer.unwrap()).unwrap().surface().unwrap().rgba(100, 20);
assert!(c[3] > 0.1 && c[0] > 0.99 && c[2] < 0.01, "the stroke carries no foreground: {c:?}");
// With the automatic Load off, the stroke did not refill the brush either.
assert_eq!(h.state().session.tools.mixer.reservoir, None);
// Load Brush fills it with the foreground again.
h.get_by_label("Current Brush Load").click();
h.run_steps(3);
h.get_by_label("Load Brush").click();
h.run_steps(3);
assert_eq!(h.state().session.tools.mixer.reservoir, Some([0.0, 0.0, 1.0, 1.0]));
}
#[test]
fn drop_targets_and_actions_reorder_presets() {
use crate::brushes_tab::{Action, apply, drop_target, group_key};
+2
View File
@@ -565,6 +565,8 @@ fn mixer_options(ui: &mut egui::Ui, b: &mut BrushSettings) {
pct(ui, tl!("Mix"), &mut m.mix, 1.0);
pct(ui, tl!("Flow"), &mut m.flow, 1.0);
widgets::checkbox(ui, &mut m.sample_all_layers, "Sample All Layers");
widgets::checkbox(ui, &mut m.load_after_stroke, tl!("Load the brush after each stroke"));
widgets::checkbox(ui, &mut m.clean_after_stroke, tl!("Clean the brush after each stroke"));
}
/// Tilt is stored in degrees (±90, W3C Pointer Events); Photoshop shows it as ±100 %.
+5
View File
@@ -2171,6 +2171,11 @@
Color Replacement Tool Nástroj Nahrazení barvy
Color Replacement Nahrazení barvy
Wet Vlhkost
Current Brush Load Aktuální náplň štětce
Load Brush Naplnit štětec
Clean Brush Vyčistit štětec
Load the brush after each stroke Naplnit štětec po každém tahu
Clean the brush after each stroke Vyčistit štětec po každém tahu
Mix Míchání
Wetness Jitter Kolísání vlhkosti
Mix Jitter Kolísání míchání
Can't render this file because it contains an unexpected character in line 6 and column 47.
+5
View File
@@ -2140,6 +2140,11 @@
Mixer Brush Tool Mischer-Pinsel-Werkzeug
Color Replacement Tool Farbersatz-Werkzeug
Wet Feucht
Current Brush Load Aktuelle Pinselladung
Load Brush Pinsel laden
Clean Brush Pinsel reinigen
Load the brush after each stroke Pinsel nach jedem Strich laden
Clean the brush after each stroke Pinsel nach jedem Strich reinigen
Mix Mischen
Wetness Jitter Feuchtigkeitsschwankung
Mix Jitter Mischschwankung
Can't render this file because it contains an unexpected character in line 6 and column 47.
+5
View File
@@ -1984,6 +1984,11 @@
Color Replacement Tool Αντικατάσταση χρώματος (Color Replacement Tool)
Color Replacement Αντικατάσταση χρώματος
Wet Υγρασία
Current Brush Load Τρέχουσα φόρτωση πινέλου
Load Brush Φόρτωση πινέλου
Clean Brush Καθαρισμός πινέλου
Load the brush after each stroke Φόρτωση του πινέλου μετά από κάθε πινελιά
Clean the brush after each stroke Καθαρισμός του πινέλου μετά από κάθε πινελιά
Load Φόρτωση
Mix Ανάμειξη
8 bit 8 bit ανά κανάλι
Can't render this file because it contains an unexpected character in line 1030 and column 18.
+5
View File
@@ -2158,6 +2158,11 @@
Mixer Brush Tool Herramienta Pincel mezclador
Color Replacement Tool Herramienta Sustitución de color
Wet Húmedo
Current Brush Load Carga actual del pincel
Load Brush Cargar pincel
Clean Brush Limpiar pincel
Load the brush after each stroke Cargar el pincel después de cada trazo
Clean the brush after each stroke Limpiar el pincel después de cada trazo
Mix Mezcla
Wetness Jitter Variación de humedad
Mix Jitter Variación de mezcla
Can't render this file because it contains an unexpected character in line 4 and column 47.
+5
View File
@@ -2118,6 +2118,11 @@
Apply the pen's tilt and rotation to the tip shape Appliquer l'inclinaison et la rotation du stylet à la forme de la pointe
Mixer Brush Pinceau mélangeur
Wet Humidité
Current Brush Load Charge actuelle du pinceau
Load Brush Charger le pinceau
Clean Brush Nettoyer le pinceau
Load the brush after each stroke Charger le pinceau après chaque trait
Clean the brush after each stroke Nettoyer le pinceau après chaque trait
Mix Mélange
Wetness Jitter Variation de l'humidité
Mix Jitter Variation du mélange
Can't render this file because it contains an unexpected character in line 6 and column 47.
+5
View File
@@ -1407,6 +1407,11 @@
Warp: Hajlítás:
Weight Vastagság
Wet Nedvesség
Current Brush Load Az ecset jelenlegi töltete
Load Brush Ecset feltöltése
Clean Brush Ecset tisztítása
Load the brush after each stroke Az ecset feltöltése minden ecsetvonás után
Clean the brush after each stroke Az ecset tisztítása minden ecsetvonás után
Wet Edges Nedves szélek
Wetness Jitter Nedvesség ingadozása
Width Szélesség
Can't render this file because it contains an unexpected character in line 6 and column 47.
+5
View File
@@ -2112,6 +2112,11 @@
Apply the pen's tilt and rotation to the tip shape Terapkan kemiringan dan rotasi pena ke bentuk ujung
Mixer Brush Kuas Pencampur
Wet Basah
Current Brush Load Muatan Kuas Saat Ini
Load Brush Muat Kuas
Clean Brush Bersihkan Kuas
Load the brush after each stroke Muat kuas setelah setiap goresan
Clean the brush after each stroke Bersihkan kuas setelah setiap goresan
Mix Campur
Wetness Jitter Jitter Kebasahan
Mix Jitter Jitter Campuran
Can't render this file because it contains an unexpected character in line 4 and column 47.
+5
View File
@@ -2153,6 +2153,11 @@
Mixer Brush Tool Strumento Pennello miscelatore
Color Replacement Tool Strumento Sostituzione colore
Wet Bagnato
Current Brush Load Carico attuale del pennello
Load Brush Carica pennello
Clean Brush Pulisci pennello
Load the brush after each stroke Carica il pennello dopo ogni tratto
Clean the brush after each stroke Pulisci il pennello dopo ogni tratto
Mix Miscela
Wetness Jitter Variazione bagnato
Mix Jitter Variazione miscela
1 # Italian (it) catalog. Columns, separated by a TAB: context <TAB> source <TAB> translation
2153
2154
2155
2156
2157
2158
2159
2160
2161 # Gradient Editor window, opened from the options bar's gradient swatch.
2162
2163
+5
View File
@@ -2153,6 +2153,11 @@
Mixer Brush Tool 混合ブラシツール
Color Replacement Tool 色の置き換えツール
Wet ウェット
Current Brush Load 現在のブラシの絵の具
Load Brush ブラシに補充
Clean Brush ブラシを洗浄
Load the brush after each stroke ストロークごとにブラシに補充
Clean the brush after each stroke ストロークごとにブラシを洗浄
Mix 混合
Wetness Jitter ウェットのジッター
Mix Jitter 混合のジッター
Can't render this file because it contains an unexpected character in line 6 and column 47.
+5
View File
@@ -2114,6 +2114,11 @@
Apply the pen's tilt and rotation to the tip shape 펜의 기울기와 회전을 브러시 끝 모양에 적용
Mixer Brush 혼합 브러시
Wet 젖음
Current Brush Load 현재 브러시 물감
Load Brush 브러시 채우기
Clean Brush 브러시 세척
Load the brush after each stroke 획마다 브러시 채우기
Clean the brush after each stroke 획마다 브러시 세척
Mix 혼합
Wetness Jitter 젖음 지터
Mix Jitter 혼합 지터
1 # Korean (ko) catalog. UTF-8: context<TAB>English source<TAB>translation.
2114
2115
2116
2117
2118
2119
2120
2121
2122
2123
2124
+14 -1
View File
@@ -1272,7 +1272,20 @@ mod tests {
#[test]
fn mixer_brush_ui_strings_have_translations_in_every_registered_language() {
const STRINGS: &[&str] = &["Mixer Brush", "Mixer Brush Tool", "Wet", "Load", "Mix", "Flow", "Sample All Layers"];
const STRINGS: &[&str] = &[
"Mixer Brush",
"Mixer Brush Tool",
"Wet",
"Load",
"Mix",
"Flow",
"Sample All Layers",
"Current Brush Load",
"Load Brush",
"Clean Brush",
"Load the brush after each stroke",
"Clean the brush after each stroke",
];
for lang in Lang::all() {
for source in STRINGS {
let translated = tr(lang, source);
+5
View File
@@ -2118,6 +2118,11 @@
Mixer Brush Tool Mengpenseelgereedschap
Color Replacement Tool Kleurvervangingsgereedschap
Wet Nat
Current Brush Load Huidige penseellading
Load Brush Penseel laden
Clean Brush Penseel reinigen
Load the brush after each stroke Penseel laden na elke streek
Clean the brush after each stroke Penseel reinigen na elke streek
Mix Mengen
Wetness Jitter Nattigheidsvariatie
Mix Jitter Mengvariatie
Can't render this file because it contains an unexpected character in line 8 and column 34.
+5
View File
@@ -2137,6 +2137,11 @@
Mixer Brush Tool Narzędzie Pędzel mieszający
Color Replacement Tool Narzędzie Zamiana koloru
Wet Wilgotność
Current Brush Load Bieżący ładunek pędzla
Load Brush Napełnij pędzel
Clean Brush Wyczyść pędzel
Load the brush after each stroke Napełniaj pędzel po każdym pociągnięciu
Clean the brush after each stroke Czyść pędzel po każdym pociągnięciu
Mix Mieszanie
Wetness Jitter Rozrzut wilgotności
Mix Jitter Rozrzut mieszania
Can't render this file because it contains an unexpected character in line 6 and column 47.
+5
View File
@@ -2112,6 +2112,11 @@
Apply the pen's tilt and rotation to the tip shape Aplicar a inclinação e a rotação da caneta à forma da ponta
Mixer Brush Pincel misturador
Wet Molhado
Current Brush Load Carga atual do pincel
Load Brush Carregar pincel
Clean Brush Limpar pincel
Load the brush after each stroke Carregar o pincel após cada traço
Clean the brush after each stroke Limpar o pincel após cada traço
Mix Mistura
Wetness Jitter Variação da umidade
Mix Jitter Variação da mistura
Can't render this file because it contains an unexpected character in line 6 and column 47.
+5
View File
@@ -1023,6 +1023,11 @@
Mix Смешивание
Auto Clean Brush Автоочистка кисти
Wet Влага
Current Brush Load Текущая загрузка кисти
Load Brush Загрузить кисть
Clean Brush Очистить кисть
Load the brush after each stroke Загружать кисть после каждого штриха
Clean the brush after each stroke Очищать кисть после каждого штриха
Rate Скорость
Airbrush: keeps depositing paint while the pointer rests. Аэрограф: продолжает наносить краску, пока курсор неподвижен.
Pulled String Mode Режим натянутой нити
Can't render this file because it contains an unexpected character in line 6 and column 47.
+5
View File
@@ -2095,6 +2095,11 @@
Apply the pen's tilt and rotation to the tip shape Primeni nagib i rotaciju olovke na oblik vrha
Mixer Brush Četkica za mešanje
Wet Vlažnost
Current Brush Load Trenutno punjenje četkice
Load Brush Napuni četkicu
Clean Brush Očisti četkicu
Load the brush after each stroke Napuni četkicu posle svakog poteza
Clean the brush after each stroke Očisti četkicu posle svakog poteza
Mix Mešanje
Wetness Jitter Kolebanje vlažnosti
Mix Jitter Kolebanje mešanja
Can't render this file because it contains an unexpected character in line 6 and column 47.
+5
View File
@@ -2092,6 +2092,11 @@
Mixer Brush Tool Karıştırıcı Fırça Aracı
Color Replacement Tool Renk Değiştirme Aracı
Wet Islaklık
Current Brush Load Geçerli Fırça Yükü
Load Brush Fırçayı Yükle
Clean Brush Fırçayı Temizle
Load the brush after each stroke Her vuruştan sonra fırçayı yükle
Clean the brush after each stroke Her vuruştan sonra fırçayı temizle
Mix Karışım
Wetness Jitter Islaklık Titreme
Mix Jitter Karıştırma Titreşimi
Can't render this file because it contains an unexpected character in line 8 and column 34.
+5
View File
@@ -1000,6 +1000,11 @@
Mix Змішування
Auto Clean Brush Автоматично очищувати пензель
Wet Вологість
Current Brush Load Поточне завантаження пензля
Load Brush Завантажити пензель
Clean Brush Очистити пензель
Load the brush after each stroke Завантажувати пензель після кожного штриха
Clean the brush after each stroke Очищати пензель після кожного штриха
Rate Частота
Airbrush: keeps depositing paint while the pointer rests. Аерограф: продовжує наносити фарбу, коли вказівник нерухомий.
Pulled String Mode Режим натягнутої нитки
1 # Ukrainian (uk) catalog. UTF-8: context<TAB>English source<TAB>translation.
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
+5
View File
@@ -1296,6 +1296,11 @@
Mixer Brush Cọ trộn
Mixer Brush Tool Công cụ Cọ trộn
Wet Ướt
Current Brush Load Lượng sơn hiện tại của cọ
Load Brush Nạp sơn cho cọ
Clean Brush Làm sạch cọ
Load the brush after each stroke Nạp sơn cho cọ sau mỗi nét vẽ
Clean the brush after each stroke Làm sạch cọ sau mỗi nét vẽ
Load Tải
Mix Trộn
Flow Lưu lượng
Can't render this file because it contains an unexpected character in line 6 and column 47.
+5
View File
@@ -2221,6 +2221,11 @@ cameraRaw Blending 混合
Apply the pen's tilt and rotation to the tip shape 将笔的倾斜和旋转应用于笔尖形状
Mixer Brush 混合器画笔
Wet 湿润
Current Brush Load 当前画笔载入
Load Brush 载入画笔
Clean Brush 清理画笔
Load the brush after each stroke 每次描边后载入画笔
Clean the brush after each stroke 每次描边后清理画笔
Mix 混合
Wetness Jitter 湿润度抖动
Mix Jitter 混合抖动
1 # Simplified Chinese (zh-hans) catalog. UTF-8, TAB-separated columns:
2221
2222
2223
2224
2225
2226
2227
2228
2229
2230
2231
+5
View File
@@ -2160,6 +2160,11 @@
Color Replacement Tool 顏色取代工具
Color Replacement 顏色取代
Wet 濕度
Current Brush Load 目前筆刷載入
Load Brush 載入筆刷
Clean Brush 清理筆刷
Load the brush after each stroke 每次描邊後載入筆刷
Clean the brush after each stroke 每次描邊後清理筆刷
Mix 混合
Wetness Jitter 濕度快速變換
Mix Jitter 混合快速變換
Can't render this file because it contains an unexpected character in line 6 and column 47.
+52
View File
@@ -1122,6 +1122,17 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
brush_preset_chip(ui, b, &mut app.ui);
crate::brush_picker::settings_toggle(app, ui);
widgets::vline(ui, 22.0);
mixer_load_swatch(app, ui);
let m = &mut b.mixer;
for (icon, on, tip) in [
("brush", &mut m.load_after_stroke, tl!("Load the brush after each stroke")),
("brush-cleaning", &mut m.clean_after_stroke, tl!("Clean the brush after each stroke")),
] {
if icons::button(ui, icon, 24.0, *on, tip).clicked() {
*on = !*on;
}
}
widgets::vline(ui, 22.0);
for (label, value) in [
(tl!("Wet"), &mut b.mixer.wet),
(tl!("Load"), &mut b.mixer.load),
@@ -4124,6 +4135,47 @@ fn smoothing_options(ui: &mut egui::Ui, b: &mut photocraft_engine::BrushSettings
});
}
/// Mixer Brush: the Current Brush Load swatch shows the paint the next stroke starts with (an
/// empty swatch when the brush is clean). Its menu holds Load Brush and Clean Brush.
fn mixer_load_swatch(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
let t = Tokens::get(ui.ctx());
let (r, _) = ui.allocate_exact_size(vec2(40.0, 26.0), Sense::hover());
let resp = ui.interact(r, ui.id().with("mixer-brush-load"), Sense::click());
if resp.hovered() {
ui.painter().rect_filled(r, t.radius_sm, t.hover);
}
let chip = Rect::from_min_size(pos2(r.left() + 4.0, r.center().y - 8.0), vec2(16.0, 16.0));
let p = ui.painter();
match app.session.tools.mixer.next_load(app.session.tools.foreground) {
Some(c) => {
p.rect_filled(chip, 2.0, c32(c));
}
None => {
p.rect_filled(chip, 2.0, t.field);
p.line_segment([chip.left_bottom(), chip.right_top()], Stroke::new(1.0, t.text_faint));
}
}
p.rect_stroke(chip, 2.0, Stroke::new(1.0, t.field_border), StrokeKind::Outside);
icons::paint(ui, Rect::from_center_size(pos2(r.right() - 9.0, r.center().y), vec2(10.0, 10.0)), "chevron-down", 9.0, t.text_faint);
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tl!("Current Brush Load")));
let resp = resp.on_hover_text(tl!("Current Brush Load"));
let mut run = None;
egui::Popup::menu(&resp).show(|ui| {
if ui.button(tl!("Load Brush")).clicked() {
run = Some("brush.mixer.load");
}
if ui.button(tl!("Clean Brush")).clicked() {
run = Some("brush.mixer.clean");
}
});
if let Some(id) = run
&& let Err(e) = app.run(id, json!({}))
{
app.ui.status = e;
app.ui.status_error = true;
}
}
/// Options-bar brush chip: a click shows or hides Photoshop's Brush Preset picker below it (size,
/// hardness, the preset library), the one a right-click on the canvas opens.
fn brush_preset_chip(ui: &mut egui::Ui, b: &photocraft_engine::BrushSettings, state: &mut crate::state::UiState) {
+1 -1
View File
@@ -91,7 +91,7 @@ tablets against Photoshop side by side.
| Tool | Status | Notes |
|---|---|---|
| Brush, Pencil (aliased, Auto Erase), Eraser (Brush/Pencil/Block) | done | Pencil 1 px quality #2139 |
| Mixer Brush (Wet, Load, Mix, Flow, Sample All Layers) | partial | "incomplete" (#2247) |
| Mixer Brush (Wet, Load, Mix, Flow, Sample All Layers, Current Brush Load with Load Brush and Clean Brush, automatic Load and Clean) | partial | No live result while dragging, no Alt-click loading (#2247) |
| Clone Stamp (opacity, flow, aligned, sample, overlay) | partial | Clone Source W/H link and Alt target cursor (TOOL-213-8) |
| Pattern Stamp (aligned, impressionist) | done | |
| History Brush | done | Art History Brush missing |
+1 -1
View File
@@ -133,7 +133,7 @@ Corpus floors (`crates/io/tests/corpus.rs`): the enforced minimums; the corpus t
| Id | Target | Status | Issue | Note |
|---|---|---|---|---|
| TOOL-213-1 | Pencil tool: aliased 1 px painting, square pixel-aligned cursor | done | [#213](https://github.com/storytold/photocraft/issues/213) | Tool::Pencil (B group): live aliased strokes on the pixel grid, square cursor, Auto Erase; pencil_tests.rs |
| TOOL-213-2 | Mixer Brush tool on the toolbar | done | [#213](https://github.com/storytold/photocraft/issues/213) | Tool::MixerBrush in the Brush (B) flyout; Wet/Load/Mix/Flow/Sample All Layers options, command/control dispatch and selection-clipping tests |
| TOOL-213-2 | Mixer Brush tool on the toolbar | done | [#213](https://github.com/storytold/photocraft/issues/213) | Tool::MixerBrush in the Brush (B) flyout; Wet/Load/Mix/Flow/Sample All Layers options, the Current Brush Load swatch with Load Brush and Clean Brush (brush.mixer.load, brush.mixer.clean) and the automatic Load and Clean toggles; command/control dispatch and selection-clipping tests |
| TOOL-213-3 | Patch tool with a live preview of the patched area | done | [#213](https://github.com/storytold/photocraft/issues/213) | paint.patch (retouch_cmds/patch.rs, source/destination) + Tool::Patch (J group: lasso, then drag the selection); the drag shows the heal live (patch_preview.rs, coarse colour fit: 1000x1000 in 52 ms) |
| TOOL-213-4 | Content-Aware Move tool reusing inpaint completion | partial | [#213](https://github.com/storytold/photocraft/issues/213) | paint.contentAwareMove (retouch_cmds/content_aware_move.rs: move/extend, Structure 1-7, Color 0-10, Sample All Layers, Transform On Drop's scaleX/scaleY/rotate; background job) + Tool::ContentAwareMove (J group: lasso, then drag; Transform On Drop puts a scale/rotate box on the dropped patch, cam_transform.rs: Enter commits as one step, Esc cancels). cam_preview.rs: once the pointer or the box rests, the result shows (a cancellable background job on a smaller sampling window, small selections only, #4385). Missing: a progressive preview for big selections |
| TOOL-213-5 | Painting a type/shape/smart/fill layer offers to rasterize, then continues the stroke | done | [#213](https://github.com/storytold/photocraft/issues/213) | rasterize_prompt.rs: OK rasterizes then paints (two history states), Cancel does nothing; drivable via ui.dialog.confirm |
+1 -1
View File
@@ -17,7 +17,7 @@ id = "TOOL-213-2"
target = "Mixer Brush tool on the toolbar"
status = "done"
issue = 213
note = "Tool::MixerBrush in the Brush (B) flyout; Wet/Load/Mix/Flow/Sample All Layers options, command/control dispatch and selection-clipping tests"
note = "Tool::MixerBrush in the Brush (B) flyout; Wet/Load/Mix/Flow/Sample All Layers options, the Current Brush Load swatch with Load Brush and Clean Brush (brush.mixer.load, brush.mixer.clean) and the automatic Load and Clean toggles; command/control dispatch and selection-clipping tests"
[[item]]
id = "TOOL-213-3"