diff --git a/crates/engine/src/brush_cmds.rs b/crates/engine/src/brush_cmds.rs index 7a30a3c4..e7178a74 100644 --- a/crates/engine/src/brush_cmds.rs +++ b/crates/engine/src/brush_cmds.rs @@ -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 { 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 { 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 { 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), diff --git a/crates/engine/src/brush_cmds/tests.rs b/crates/engine/src/brush_cmds/tests.rs index 85426bf5..f60eaa91 100644 --- a/crates/engine/src/brush_cmds/tests.rs +++ b/crates/engine/src/brush_cmds/tests.rs @@ -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:?}"); +} diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index 3f3f1c82..8e3916b0 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -687,7 +687,7 @@ fn build() -> Vec { 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); diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index c293c03b..ec584aea 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -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() {