Brush: paint with a blend mode (options-bar Mode), wired in the UI

paint.stroke/paint.pencil now honour a "mode" param (normal|multiply|screen|…),
and the options-bar Mode dropdown sets the session brush's blend mode (it was
decorative). The Eraser is forced to Normal (no blend mode in Photoshop). New
brush_blend_mode_multiply test; clippy + wasm green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-03 13:19:21 -04:00
parent 9dc394dfc8
commit c89c33c704
4 changed files with 42 additions and 6 deletions

View File

@ -179,17 +179,28 @@ fn stroke_with(s: &mut Session, p: &Value, label: &str, brush: BrushSettings, pt
Ok(damage_json(s, dmg))
}
/// Applies the options-bar blend `mode` to a brush. `"mode"` accepts any blend-mode name
/// (normal|multiply|screen|…). The Eraser has no blend mode in Photoshop, so it is forced to Normal.
fn with_blend_mode(mut b: BrushSettings, p: &Value) -> BrushSettings {
if b.erase {
b.mode = photocraft_color::BlendMode::Normal;
} else if let Some(m) = p.get("mode").and_then(Value::as_str).and_then(crate::commands::blend_from_str) {
b.mode = m;
}
b
}
/// `paint.stroke`: the Brush (and Eraser) tool.
pub fn paint_stroke(s: &mut Session, p: &Value) -> Result<Value> {
let pts = parse_points(p, "paint.stroke")?;
let brush = resolve_brush(s, p, "paint.stroke")?;
let brush = with_blend_mode(resolve_brush(s, p, "paint.stroke")?, p);
let label = if brush.erase { "Eraser" } else { "Brush Tool" };
stroke_with(s, p, label, brush, pts, false)
}
fn pencil(s: &mut Session, p: &Value) -> Result<Value> {
let pts = parse_points(p, "paint.pencil")?;
let mut brush = resolve_brush(s, p, "paint.pencil")?;
let mut brush = with_blend_mode(resolve_brush(s, p, "paint.pencil")?, p);
brush.aliased = true;
if num(p, "hardness").is_none() {
brush.hardness = 1.0;
@ -423,7 +434,7 @@ macro_rules! spec {
/// Brush command specs.
pub fn specs() -> Vec<CommandSpec> {
vec![
spec!("paint.pencil", "Pencil", r##"{"points":[[x,y,pressure?,tiltX?,tiltY?,rotation?,timeMs?,wheel?],…],"brush":{…}?,"preset":name?,"size":px?,"opacity":0..1?,"color":"#rrggbb"?=foreground,"erase":bool?,"autoErase":bool=false,"seed":u64?,"target":"pixels"|"mask"|"quickMask"|{"channel":i}=Channels panel target}"##, has_paintable, pencil, true),
spec!("paint.pencil", "Pencil", r##"{"points":[[x,y,pressure?,tiltX?,tiltY?,rotation?,timeMs?,wheel?],…],"brush":{…}?,"preset":name?,"size":px?,"opacity":0..1?,"color":"#rrggbb"?=foreground,"mode":"normal|multiply|screen|…"="normal","erase":bool?,"autoErase":bool=false,"seed":u64?,"target":"pixels"|"mask"|"quickMask"|{"channel":i}=Channels panel target}"##, has_paintable, pencil, true),
spec!("paint.mixerBrush", "Mixer Brush", r##"{"points":[…],"brush":{…}?,"preset":name?,"size":px?,"wet":0..100=50,"load":0..100=50,"mix":0..100=50,"flow":0..100=100,"color":"#rrggbb"?=foreground,"sampleAllLayers":bool=false,"cleanAfterStroke":bool=true,"loadAfterStroke":bool=true,"seed":u64?}"##, has_paintable, mixer_brush, true),
spec!("paint.colorReplacement", "Color Replacement", r##"{"points":[…],"brush":{…}?,"size":px?,"mode":"hue|saturation|color|luminosity"="color","sampling":"continuous|once|backgroundSwatch"="continuous","limits":"contiguous|discontiguous|findEdges"="contiguous","tolerance":0..100=30,"antiAlias":bool=true,"color":"#rrggbb"?=foreground,"seed":u64?}"##, crate::commands::has_paintable, color_replacement, true),
spec!("brush.presets.list", "List Brush Presets", r##"{"full":bool=false}"##, always, presets_list, false),

View File

@ -203,3 +203,26 @@ fn works_on_cmyk_and_16_bit() {
}
}
#[test]
fn brush_blend_mode_multiply() {
// Painting with a blend mode (options-bar "Mode"): blue × yellow = black (Multiply).
let fill = |s: &mut Session| {
s.edit("bg", |doc, a| {
doc.layer_mut(a.unwrap()).unwrap().surface_mut().unwrap().fill_rect(Rect::new(0, 0, 40, 40), &[1.0, 1.0, 0.0, 1.0]);
Ok(())
})
.unwrap();
};
let mut s = session(40, 40);
fill(&mut s);
s.execute("paint.stroke", json!({"points": [[5, 20], [35, 20]], "size": 12, "color": "#0000ff", "mode": "multiply", "brush": {"hardness": 1.0}})).unwrap();
let c = rgba(&s, 20, 20);
assert!(c[0] < 0.1 && c[1] < 0.1 && c[2] < 0.1, "multiply blue×yellow ≈ black: {c:?}");
// Normal mode paints opaque blue at the same spot.
let mut s2 = session(40, 40);
fill(&mut s2);
s2.execute("paint.stroke", json!({"points": [[5, 20], [35, 20]], "size": 12, "color": "#0000ff", "brush": {"hardness": 1.0}})).unwrap();
let n = rgba(&s2, 20, 20);
assert!(n[2] > 0.9 && n[0] < 0.1, "normal paints blue: {n:?}");
}

View File

@ -687,7 +687,7 @@ fn build() -> Vec<CommandSpec> {
destructive_adjust(s, "Desaturate", Adjustment::HueSaturation { hue: 0.0, saturation: -100.0, lightness: 0.0, colorize: false }, &Value::Null)
}),
// Paint
cmd!("paint.stroke", "Brush Stroke", [], None, r##"{"points":[[x,y,pressure?,tiltX?,tiltY?,rotation?,timeMs?,wheel?],…],"brush":{…BrushSettings}?,"preset":name?,"size":px?,"hardness":0..1?,"opacity":0..1?,"flow":0..1?,"spacing":0..10?,"color":"#rrggbb"?=foreground,"erase":bool?,"smoothing":0..1?,"zoom":number=1,"seed":u64?,"target":"pixels"|"mask"|"quickMask"|{"channel":i}=Channels panel target}"##, has_paintable, crate::brush_cmds::paint_stroke),
cmd!("paint.stroke", "Brush Stroke", [], None, r##"{"points":[[x,y,pressure?,tiltX?,tiltY?,rotation?,timeMs?,wheel?],…],"brush":{…BrushSettings}?,"preset":name?,"size":px?,"hardness":0..1?,"opacity":0..1?,"flow":0..1?,"spacing":0..10?,"color":"#rrggbb"?=foreground,"mode":"normal|multiply|screen|…"="normal","erase":bool?,"smoothing":0..1?,"zoom":number=1,"seed":u64?,"target":"pixels"|"mask"|"quickMask"|{"channel":i}=Channels panel target}"##, has_paintable, crate::brush_cmds::paint_stroke),
cmd!("tools.setColors", "Set Colors", [], None, r##"{"foreground":"#rrggbb"?,"background":"#rrggbb"?}"##, always, |s, p| {
s.tools.foreground = color_param(p, "foreground", s.tools.foreground);
s.tools.background = color_param(p, "background", s.tools.background);

View File

@ -307,9 +307,11 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
brush_preset_chip(ui, b);
widgets::vline(ui, 22.0);
opt_label(ui, "Mode");
let mut mode = BlendMode::Normal;
let mut mode = b.mode;
let opts: Vec<(BlendMode, &str)> = BlendMode::LAYER_MODES.iter().map(|m| (*m, m.label())).collect();
widgets::dropdown(ui, "brush-mode", &mut mode, &opts, 96.0);
if widgets::dropdown(ui, "brush-mode", &mut mode, &opts, 96.0) {
b.mode = mode;
}
opt_label(ui, "Opacity");
let mut o = b.opacity * 100.0;
if widgets::value_field(ui, &mut o, 0.0..=100.0, "%", 62.0).changed() {