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 <noreply@anthropic.com>
This commit is contained in:
parent
0cc39de41b
commit
bb52e3aa46
|
|
@ -150,6 +150,23 @@ pub fn bucket_fill(
|
||||||
color: [f32; 4],
|
color: [f32; 4],
|
||||||
opacity: f32,
|
opacity: f32,
|
||||||
selection: Option<&Surface>,
|
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 {
|
) -> bool {
|
||||||
if !area.contains(seed.0, seed.1) {
|
if !area.contains(seed.0, seed.1) {
|
||||||
return false;
|
return false;
|
||||||
|
|
@ -159,7 +176,7 @@ pub fn bucket_fill(
|
||||||
drop(img);
|
drop(img);
|
||||||
// Composite only over the filled region's box.
|
// Composite only over the filled region's box.
|
||||||
let b = region.bbox;
|
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
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
//! Paint helpers: Paint Bucket and Gradient tool.
|
//! 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 serde_json::{Value, json};
|
||||||
|
|
||||||
use crate::commands::{CommandSpec, blend_from_str};
|
use crate::commands::{CommandSpec, blend_from_str};
|
||||||
|
|
@ -39,11 +39,31 @@ fn bucket(s: &mut Session, p: &Value) -> Result<Value> {
|
||||||
let c = color(p.get("color"), fg);
|
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 (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);
|
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 filled = s.edit("Paint Bucket", |doc, active| {
|
||||||
let area = doc.bounds();
|
let area = doc.bounds();
|
||||||
let sel = doc.selection.clone();
|
let sel = doc.selection.clone();
|
||||||
let (surf, _) = crate::channel_cmds::target_surface(doc, *active, p)?;
|
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();
|
surf.prune();
|
||||||
Ok(ok)
|
Ok(ok)
|
||||||
})?;
|
})?;
|
||||||
|
|
@ -92,7 +112,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||||
label: "Paint Bucket",
|
label: "Paint Bucket",
|
||||||
menu: &[],
|
menu: &[],
|
||||||
shortcut: None,
|
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,
|
enabled: crate::commands::has_paintable,
|
||||||
run: bucket,
|
run: bucket,
|
||||||
journal: true,
|
journal: true,
|
||||||
|
|
@ -141,6 +161,23 @@ mod tests {
|
||||||
assert_eq!(px(&s, 5, 5), vec![0.0, 0.0, 1.0, 1.0]);
|
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]
|
#[test]
|
||||||
fn gradient_tool_with_selection_and_reverse() {
|
fn gradient_tool_with_selection_and_reverse() {
|
||||||
let mut s = session();
|
let mut s = session();
|
||||||
|
|
|
||||||
|
|
@ -125,7 +125,7 @@ pub fn resolve(s: &Session, key: &str) -> Option<Pattern> {
|
||||||
photocraft_doc::pattern::find(&s.patterns.items, key, key).cloned()
|
photocraft_doc::pattern::find(&s.patterns.items, key, key).cloned()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn resolve_param(s: &Session, cmd: &str, p: &Value) -> Result<Pattern> {
|
pub(crate) fn resolve_param(s: &Session, cmd: &str, p: &Value) -> Result<Pattern> {
|
||||||
let key = p.get("pattern").and_then(Value::as_str).unwrap_or("");
|
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)")))
|
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<Value> {
|
||||||
Ok(json!({"path": path, "count": pats.len(), "bytes": bytes.len()}))
|
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 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| {
|
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)
|
(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)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue