From bb52e3aa468f10b766078c97a2e4c795eb193b55 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 3 Oct 2026 13:07:52 -0400 Subject: [PATCH] Paint Bucket: fill with a pattern (not just a colour) The Paint Bucket tool only filled the foreground colour; Photoshop's Fill dropdown also offers Pattern. paint.bucket now takes contents="pattern" (+ pattern/scale/angle), flood-filling the matched region with the pattern. algo gains bucket_fill_src (fill colour from a per-pixel source closure); bucket_fill delegates to it. pattern_cmds resolve_param/placement are now pub(crate) for reuse. New bucket_fills_with_a_pattern test; clippy + wasm green. Co-Authored-By: Claude Opus 4.8 --- crates/algo/src/paint.rs | 19 +++++++++++++- crates/engine/src/paint_cmds.rs | 43 ++++++++++++++++++++++++++++--- crates/engine/src/pattern_cmds.rs | 4 +-- 3 files changed, 60 insertions(+), 6 deletions(-) diff --git a/crates/algo/src/paint.rs b/crates/algo/src/paint.rs index ba01c2c0..ade904b7 100644 --- a/crates/algo/src/paint.rs +++ b/crates/algo/src/paint.rs @@ -150,6 +150,23 @@ pub fn bucket_fill( color: [f32; 4], opacity: f32, selection: Option<&Surface>, +) -> bool { + bucket_fill_src(s, area, seed, tolerance, contiguous, anti_alias, opacity, selection, |_, _| color) +} + +/// Like [`bucket_fill`] but the fill colour comes from `src(x, y)` — e.g. a pattern sampled at the +/// canvas position — so the Paint Bucket can fill with a pattern as well as a solid colour. +#[allow(clippy::too_many_arguments)] +pub fn bucket_fill_src( + s: &mut Surface, + area: Rect, + seed: (i32, i32), + tolerance: f32, + contiguous: bool, + anti_alias: bool, + opacity: f32, + selection: Option<&Surface>, + src: impl Fn(i32, i32) -> [f32; 4] + Sync, ) -> bool { if !area.contains(seed.0, seed.1) { return false; @@ -159,7 +176,7 @@ pub fn bucket_fill( drop(img); // Composite only over the filled region's box. let b = region.bbox; - composite_area(s, b, BlendMode::Normal, |x, y| region.at(x, y) * opacity * selection.map_or(1.0, |m| m.sample_channel(x, y, 0)), |_, _| color, false); + composite_area(s, b, BlendMode::Normal, |x, y| region.at(x, y) * opacity * selection.map_or(1.0, |m| m.sample_channel(x, y, 0)), src, false); true } diff --git a/crates/engine/src/paint_cmds.rs b/crates/engine/src/paint_cmds.rs index bc3ea321..fc53760c 100644 --- a/crates/engine/src/paint_cmds.rs +++ b/crates/engine/src/paint_cmds.rs @@ -1,6 +1,6 @@ //! Paint helpers: Paint Bucket and Gradient tool. -use photocraft_algo::paint::{GradientShape, bucket_fill, paint_gradient}; +use photocraft_algo::paint::{GradientShape, bucket_fill, bucket_fill_src, paint_gradient}; use serde_json::{Value, json}; use crate::commands::{CommandSpec, blend_from_str}; @@ -39,11 +39,31 @@ fn bucket(s: &mut Session, p: &Value) -> Result { let c = color(p.get("color"), fg); let (x, y) = (f(p, "x", 0.0).floor() as i32, f(p, "y", 0.0).floor() as i32); let (tol, contiguous, aa, opacity) = (f(p, "tolerance", 32.0), b(p, "contiguous", true), b(p, "antiAlias", true), f(p, "opacity", 100.0) / 100.0); + // Fill source: foreground colour (default) or a pattern (the Paint Bucket "Fill" dropdown). + let source = p.get("contents").or_else(|| p.get("source")).and_then(Value::as_str).unwrap_or("foreground"); + let pattern = if source == "pattern" { + let pat = crate::pattern_cmds::resolve_param(s, "paint.bucket", p)?; + let tile = photocraft_compose::pattern::Tile::new(&pat).ok_or_else(|| EngineError::BadParams { cmd: "paint.bucket".into(), msg: "the pattern is empty".into() })?; + let (scale, angle, _, phase) = crate::pattern_cmds::placement(p); + Some((tile, scale, angle, phase)) + } else { + None + }; let filled = s.edit("Paint Bucket", |doc, active| { let area = doc.bounds(); let sel = doc.selection.clone(); let (surf, _) = crate::channel_cmds::target_surface(doc, *active, p)?; - let ok = bucket_fill(surf, area, (x, y), tol, contiguous, aa, c, opacity, sel.as_ref()); + let ok = if let Some((tile, scale, angle, phase)) = &pattern { + // Render the pattern over the canvas once, then sample it at each filled pixel. + let place = photocraft_compose::pattern::Placement::new(photocraft_geom::Rect::EMPTY, false, *phase, *scale, *angle); + let rendered = photocraft_compose::pattern::render(tile, &place, area); + let w = area.width() as usize; + bucket_fill_src(surf, area, (x, y), tol, contiguous, aa, opacity, sel.as_ref(), |px, py| { + rendered[(py - area.y0) as usize * w + (px - area.x0) as usize] + }) + } else { + bucket_fill(surf, area, (x, y), tol, contiguous, aa, c, opacity, sel.as_ref()) + }; surf.prune(); Ok(ok) })?; @@ -92,7 +112,7 @@ pub fn specs() -> Vec { label: "Paint Bucket", menu: &[], shortcut: None, - params: r##"{"x":px,"y":px,"tolerance":0..255=32,"contiguous":bool=true,"antiAlias":bool=true,"color":"#rrggbb"=foreground,"opacity":1..100=100,"target":"pixels"|"mask"|"quickMask"|{"channel":i}=Channels panel target}"##, + params: r##"{"x":px,"y":px,"tolerance":0..255=32,"contiguous":bool=true,"antiAlias":bool=true,"contents":"foreground|pattern"="foreground","color":"#rrggbb"=foreground,"pattern":id|name (contents=pattern),"scale":%=100,"angle":deg,"opacity":1..100=100,"target":"pixels"|"mask"|"quickMask"|{"channel":i}=Channels panel target}"##, enabled: crate::commands::has_paintable, run: bucket, journal: true, @@ -141,6 +161,23 @@ mod tests { assert_eq!(px(&s, 5, 5), vec![0.0, 0.0, 1.0, 1.0]); } + #[test] + fn bucket_fills_with_a_pattern() { + // Paint Bucket with contents="pattern" flood-fills the region with a pattern, not a colour. + let mut s = session(); + s.edit("wall", |doc, _| { + doc.layers[0].surface_mut().unwrap().fill_rect(Rect::new(10, 0, 11, 10), &[0.0, 0.0, 0.0, 1.0]); + Ok(()) + }) + .unwrap(); + s.execute("paint.bucket", json!({"x": 2, "y": 2, "contents": "pattern", "pattern": "Diagonal Lines", "antiAlias": false, "tolerance": 0})).unwrap(); + // The filled region (left of the wall) now carries the pattern's light/dark variation, + // while the wall and the region past it are untouched. + let left: Vec<[f32; 4]> = (0..10).map(|x| { let p = px(&s, x, 0); [p[0], p[1], p[2], p[3]] }).collect(); + assert!(left.iter().any(|p| p[0] < 0.3) && left.iter().any(|p| p[0] > 0.9), "pattern varies in the fill: {left:?}"); + assert_eq!(px(&s, 15, 5), vec![1.0, 1.0, 1.0, 1.0], "region past the wall untouched"); + } + #[test] fn gradient_tool_with_selection_and_reverse() { let mut s = session(); diff --git a/crates/engine/src/pattern_cmds.rs b/crates/engine/src/pattern_cmds.rs index bb775a20..84676d47 100644 --- a/crates/engine/src/pattern_cmds.rs +++ b/crates/engine/src/pattern_cmds.rs @@ -125,7 +125,7 @@ pub fn resolve(s: &Session, key: &str) -> Option { photocraft_doc::pattern::find(&s.patterns.items, key, key).cloned() } -fn resolve_param(s: &Session, cmd: &str, p: &Value) -> Result { +pub(crate) fn resolve_param(s: &Session, cmd: &str, p: &Value) -> Result { let key = p.get("pattern").and_then(Value::as_str).unwrap_or(""); resolve(s, key).ok_or_else(|| bad(cmd, format!("no pattern \"{key}\" (see pattern.list)"))) } @@ -289,7 +289,7 @@ fn export(s: &mut Session, p: &Value) -> Result { Ok(json!({"path": path, "count": pats.len(), "bytes": bytes.len()})) } -fn placement(p: &Value) -> (f32, f32, bool, (f32, f32)) { +pub(crate) fn placement(p: &Value) -> (f32, f32, bool, (f32, f32)) { let num = |k: &str, d: f64| p.get(k).and_then(Value::as_f64).unwrap_or(d) as f32; let phase = p.get("phase").and_then(Value::as_array).map_or((0.0, 0.0), |a| { (a.first().and_then(Value::as_f64).unwrap_or(0.0) as f32, a.get(1).and_then(Value::as_f64).unwrap_or(0.0) as f32)