Add the Content-Aware Move tool (#213) (#814)

* Content-Aware Move: move or extend the selected pixels, the gaps filled content-aware (#213)

New command paint.contentAwareMove {offset, mode: move|extend, structure 1..7,
color 0..10, sampleAllLayers}. The content is pasted at the offset, its colour
fitted to the new place by `color` (0 keeps it, 10 is a full seamless clone),
and an edge band whose width `structure` sets is re-synthesised from the
surroundings. In move mode the old place is then filled with
content_aware::fill, never sampling the moved content (no ghost). The
selection follows the content. One undo step; runs as a background job with
progress and cancel. Works on layer pixels, masks and channels, at any depth.

Timing (retouch_bench, release, 6016x6016, elliptical selections dragged
300 px): 250x250 move 240 ms / extend 129 ms, 500x500 0.93 s / 0.53 s,
1000x1000 2.6 s / 1.5 s.

Known limit: at low Structure the wide band comes out smeared (the PatchMatch
vote blurs a thin band between high-contrast regions); that is the shared
completion, not this command.

generated with Claude Code

* Content-Aware Move Tool in the J group (#213)

Tool::ContentAwareMove next to the Patch Tool: outside the selection a drag
draws a lasso, inside it drags the selection and runs paint.contentAwareMove
on release (as a background job). Options bar: Mode (Move/Extend),
Structure, Color, Sample All Layers. The drag shows the moving outline; a
live preview of the result is not part of this change. New strings are
translated in every catalog. Scorecard TOOL-213-4: missing -> partial
(Transform On Drop and the live preview are still open).

generated with Claude Code

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
alexanderalber 2026-10-08 00:40:14 +02:00 committed by GitHub
parent 727daf5d67
commit c887ed94cc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
24 changed files with 686 additions and 26 deletions

View File

@ -1,4 +1,5 @@
//! Timing: Healing Brush and Spot Healing dab cost, and Patch cost, on a 6016×6016 document.
//! Timing: Healing Brush and Spot Healing dab cost, and Patch and Content-Aware Move cost, on a
//! 6016×6016 document.
//! `cargo run --release -p photocraft-engine --example retouch_bench [side] [depth]`
//!
//! Each measurement is one single-dab stroke through the full command path (`Session::execute`:
@ -69,4 +70,15 @@ fn main() {
photocraft_engine::retouch_cmds::patch_preview(&s, &json!({"offset": [300, 200]}), 128 * 128, 1).unwrap();
println!("patch preview {size:>4}×{size:<4}: {:>8.1?}", t.elapsed());
}
// Content-Aware Move: the same selections dragged 300 px, then undone (both fills: the edge band
// at the new place and the old place), and Extend at Color 5 (one fill, plus the colour fit).
for size in [100, 250, 500, 1000] {
for (mode, color) in [("move", 0), ("extend", 5)] {
s.execute("select.rect", json!({"x": 2300, "y": 2300, "width": size, "height": size, "ellipse": true})).unwrap();
let t = Instant::now();
s.execute("paint.contentAwareMove", json!({"offset": [300, 200], "mode": mode, "color": color})).unwrap();
println!("contentAwareMove {mode:<6} {size:>4}×{size:<4}: {:>8.1?}", t.elapsed());
s.execute("edit.undo", json!({})).unwrap();
}
}
}

View File

@ -269,6 +269,32 @@ fn content_aware_fill_and_scale_run_as_jobs() {
assert_eq!(v["to"][0], 120);
}
#[test]
fn content_aware_move_runs_as_a_job() {
let mut inline = session(160, 120);
let mut s = session(160, 120);
for t in [&mut inline, &mut s] {
t.execute("select.rect", json!({"x": 20, "y": 40, "width": 30, "height": 30})).unwrap();
}
let p = json!({"offset": [90, 10], "structure": 3, "color": 4});
inline.execute("paint.contentAwareMove", p.clone()).unwrap();
let before = s.active().unwrap().doc.clone();
let id = job(s.start("paint.contentAwareMove", p.clone()).unwrap());
let e = wait_event(&mut s, id);
let JobOutcome::Done(v) = &e.outcome else { panic!("{e:?}") };
assert_eq!(v["offset"], json!([90, 10]));
assert_eq!(pixels(&s), pixels(&inline), "same result as the synchronous command");
let sel = |s: &Session| s.active().unwrap().doc.selection.as_ref().unwrap().read_region(s.active().unwrap().doc.bounds());
assert_eq!(sel(&s), sel(&inline), "the selection moved the same way");
// Cancelled at once: the document stays as it was.
s.undo();
let id = job(s.start("paint.contentAwareMove", p).unwrap());
assert!(s.cancel_job(id));
s.join_cancelled_jobs();
assert_eq!(wait_event(&mut s, id).outcome, JobOutcome::Cancelled);
assert_eq!(pixels(&s), before.layer(s.active().unwrap().active_layer.unwrap()).unwrap().surface().unwrap().read_region(before.bounds()));
}
#[test]
fn open_runs_as_a_job_and_cancel_adds_nothing() {
let mut src = session(40, 30);

View File

@ -1,5 +1,5 @@
//! Retouching tools as commands: Clone Stamp, Healing Brush, Spot Healing Brush, Patch, Dodge,
//! Burn, Sponge, Blur, Sharpen, Smudge and History Brush.
//! Retouching tools as commands: Clone Stamp, Healing Brush, Spot Healing Brush, Patch,
//! Content-Aware Move, Dodge, Burn, Sponge, Blur, Sharpen, Smudge and History Brush.
//!
//! Every command takes a Photoshop-style brush (`points`, `size`, `hardness`, `opacity`, `flow`,
//! `spacing`, `layer`), respects the active selection as a mask and the layer's transparency lock, and
@ -23,6 +23,7 @@ use serde_json::{Value, json};
use crate::commands::{CommandSpec, blend_from_str};
use crate::{EngineError, Result, Session};
mod content_aware_move;
mod patch;
pub use patch::preview as patch_preview;
@ -699,6 +700,16 @@ pub fn specs() -> Vec<CommandSpec> {
run: patch::patch,
journal: true,
},
CommandSpec {
id: "paint.contentAwareMove",
label: "Content-Aware Move",
menu: &[],
shortcut: None,
params: r#"{"offset":[dx,dy] (how far the selection was dragged),"mode":"move|extend"="move","structure":1..7=4 (7 keeps the content up to its edge, lower blends a wider edge band),"color":0..10=0 (how far the content's colour adapts to its new place),"sampleAllLayers":bool=false,"layer":id?=active,"target":"pixels"|"mask"|"quickMask"|{"channel":i}=Channels panel target} → {"damage","offset","mode"} (a background job; the selection moves with the content)"#,
enabled: content_aware_move::enabled,
run: content_aware_move::content_aware_move,
journal: true,
},
CommandSpec {
id: "paint.dodge",
label: "Dodge",

View File

@ -0,0 +1,316 @@
//! The Content-Aware Move Tool as a command: move or duplicate the selected pixels, and let
//! content-aware fill close the gaps.
//!
//! The selection is the content, `offset` how far the user dragged it. At the new place the content
//! is pasted, its colour fitted to the new surroundings by `color` (0 keeps it, 10 is a full
//! seamless clone as the Patch Tool's), and a band along its edge, inside the selection, is
//! re-synthesised from the surroundings so that it merges in. `structure` sets that band: 7 keeps
//! the content up to the selection's edge, lower values hand a wider band to the surroundings. In
//! `move` mode the place the content left is then filled content-aware (`extend` keeps it). That
//! fill never samples the moved content, so the object doesn't come back as a ghost; the edge band
//! may sample it (it is the context the band blends towards), but not the content's old place.
//! (`content_aware::fill` treats excluded pixels as unknown, so excluding the content there would
//! turn the whole moved area into one hole and smear the band further.)
//!
//! The selection moves with the content, as in Photoshop. A soft selection counts wherever it is
//! above zero (as the Patch Tool's); the band does the blending. The fills are PatchMatch
//! completions and can take seconds on large selections, so the command runs as a background job
//! (#210): with progress, cancellable, and the document unchanged until it applies.
use photocraft_algo::content_aware::{FillOptions, fill_with};
use super::patch::{coverage, offset, selection_area, target_layer};
use super::*;
const CMD: &str = "paint.contentAwareMove";
/// Largest selection, in pixels of its bounding box (as the Patch Tool's). Each fill window is the
/// selection plus a margin; at this size one window holds about 37 M pixels and the completion's
/// working set stays within a few GB.
const MAX_PIXELS: u64 = 16 << 20;
/// Cap on the sampling margin around the content (the window grows with the selection).
const MAX_MARGIN: i32 = 512;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Mode {
/// The content leaves its place, which is filled from the surroundings.
Move,
/// The content is duplicated; its place stays.
Extend,
}
/// A pixel target and a selection to move.
pub(super) fn enabled(s: &Session) -> std::result::Result<(), String> {
has_pixel_layer(s)?;
let d = s.active().ok_or("no document open")?;
if d.doc.selection.is_some() { Ok(()) } else { Err("select the area to move first".into()) }
}
/// Optional whole-number param in `lo..=hi`; absent or null gives `default`.
fn level(p: &Value, k: &str, default: u8, lo: u8, hi: u8) -> Result<u8> {
match p.get(k) {
None | Some(Value::Null) => Ok(default),
Some(v) => match v.as_f64() {
Some(x) if x.is_finite() && (f64::from(lo)..=f64::from(hi)).contains(&x) => Ok(x.round() as u8),
_ => Err(bad(CMD, format!("`{k}` must be a number {lo}..{hi}"))),
},
}
}
/// What a move does, checked before any work starts.
#[derive(Clone, Copy, Debug)]
struct Plan {
id: Option<LayerId>,
canvas: Rect,
/// The selected area (where the content comes from).
area: Rect,
offset: (i32, i32),
mode: Mode,
structure: u8,
color: u8,
all_layers: bool,
}
fn plan(s: &Session, p: &Value) -> Result<Plan> {
let mode = match string(p, "mode", "move") {
"move" => Mode::Move,
"extend" => Mode::Extend,
o => return Err(bad(CMD, format!("unknown mode `{o}` (move|extend)"))),
};
let structure = level(p, "structure", 4, 1, 7)?;
let color = level(p, "color", 0, 0, 10)?;
let (dx, dy) = offset(CMD, p)?;
let id = target_layer(s, p)?;
let d = s.active().ok_or(EngineError::NoDocument)?;
let canvas = d.doc.bounds();
let sel = d.doc.selection.as_ref().ok_or_else(|| bad(CMD, "select the area to move first"))?;
let area = selection_area(sel, canvas);
if area.is_empty() {
return Err(bad(CMD, "the selection is empty"));
}
let pixels = u64::from(area.width()) * u64::from(area.height());
if pixels > MAX_PIXELS {
return Err(bad(CMD, format!("the selection is too large to move ({pixels} pixels, at most {MAX_PIXELS})")));
}
if (dx, dy) == (0, 0) {
return Err(bad(CMD, "drag the selection to where the content should go"));
}
// Content dropped beyond the edge would be lost: keep it on the canvas, as the Patch Tool does.
if !canvas.contains_rect(&area.translate(dx, dy)) {
return Err(bad(CMD, "the moved selection must stay inside the canvas"));
}
let all_layers = flag(p, "sampleAllLayers", false) && targets_pixels(p);
Ok(Plan { id, canvas, area, offset: (dx, dy), mode, structure, color, all_layers })
}
/// Width in pixels of the edge band re-synthesised at the new place, for Structure 1..7 and content
/// `min_side` pixels across. 7 keeps everything; the fractions grow the band roughly geometrically
/// towards 1, so the default 4 blends about a tenth of the content into its surroundings.
fn band_width(structure: u8, min_side: u32) -> usize {
const FRACTION: [f32; 7] = [0.30, 0.22, 0.15, 0.10, 0.06, 0.03, 0.0];
let f = FRACTION.get(usize::from(structure.saturating_sub(1))).copied().unwrap_or(0.0);
if f == 0.0 {
return 0;
}
((min_side as f32 * f).round() as usize).clamp(2, 128)
}
/// The cells of a `w × h` mask whose whole `(2r+1)²` neighbourhood, as far as it lies on the grid,
/// is set (a square erosion in O(w·h) through a summed-area table). The grid's border doesn't erode:
/// a selection reaching the canvas edge keeps its content there.
fn erode(w: usize, h: usize, m: &[bool], r: usize) -> Vec<bool> {
if r == 0 {
return m.to_vec();
}
let w1 = w + 1;
let mut sat = vec![0u32; w1 * (h + 1)];
for y in 0..h {
let mut row = 0u32;
for x in 0..w {
row += u32::from(m.get(y * w + x).copied().unwrap_or(false));
sat[(y + 1) * w1 + x + 1] = sat[y * w1 + x + 1] + row;
}
}
let mut out = vec![false; w * h];
for y in 0..h {
let (y0, y1) = (y.saturating_sub(r), (y + r + 1).min(h));
for x in 0..w {
if !m.get(y * w + x).copied().unwrap_or(false) {
continue;
}
let (x0, x1) = (x.saturating_sub(r), (x + r + 1).min(w));
let set = sat[y1 * w1 + x1] + sat[y0 * w1 + x0] - sat[y0 * w1 + x1] - sat[y1 * w1 + x0];
out[y * w + x] = set as usize == (x1 - x0) * (y1 - y0);
}
}
out
}
/// Where a fill samples from around `r`: as Edit › Content-Aware Fill's automatic sampling area,
/// three quarters of the extent around it (at least 32, at most [`MAX_MARGIN`] pixels).
fn window(r: Rect, canvas: Rect) -> Rect {
let ext = r.width().max(r.height()) as i32;
r.inflate((ext * 3 / 4).clamp(32, MAX_MARGIN)).intersect(&canvas)
}
/// The pixels a step works on: the targeted surface, or with Sample All Layers the visible
/// composite (encoded in the target's format).
fn read(doc: &mut Document, plan: &Plan, p: &Value, rect: Rect) -> Result<Region> {
let (surf, _) = crate::channel_cmds::target_surface(doc, plan.id, p)?;
if plan.all_layers {
let fmt = surf.format();
return Ok(composite_region(doc, None, SampleLayers::All, rect, fmt));
}
Ok(Region::read(surf, rect))
}
/// Write `out` over `mask` into the target, replacing the pixels (the result already holds what
/// should show there). With the transparency lock, alpha is kept and transparent pixels stay as
/// they are. Returns the written rectangle.
fn replace(doc: &mut Document, plan: &Plan, p: &Value, out: &Region, mask: &[bool]) -> Result<Rect> {
let (surf, lock) = crate::channel_cmds::target_surface(doc, plan.id, p)?;
let fmt = surf.format();
let a = alpha_index(&fmt);
let rect = out.rect;
let mut px = surf.read_region(rect);
let n = out.ch;
if px.len() != out.data.len() || mask.len() * n != px.len() {
return Err(EngineError::Other("internal error: content-aware move buffers disagree in size".into()));
}
for ((dst, src), m) in px.chunks_exact_mut(n).zip(out.data.chunks_exact(n)).zip(mask) {
if !*m {
continue;
}
match a.filter(|_| lock) {
Some(ai) => {
if dst.get(ai).is_some_and(|v| *v > 0.0) {
for (c, (d, s)) in dst.iter_mut().zip(src).enumerate() {
if c != ai {
*d = *s;
}
}
}
}
None => dst.copy_from_slice(src),
}
}
surf.write_region(rect, &px);
Ok(rect)
}
/// Selection coverage over `rect` as a mask (set wherever it is above zero).
fn mask(sel: &Surface, rect: Rect) -> Vec<bool> {
coverage(sel, rect).into_iter().map(|c| c > 0.0).collect()
}
/// Fill options: the fill's own colour adaptation (Edit › Content-Aware Fill's default), seeded so
/// the result is reproducible.
fn fill_options() -> FillOptions {
FillOptions { seed: 0x00ca_4e00, ..FillOptions::default() }
}
/// The new target surface and the rectangle it changed: the content pasted at the offset, then
/// (`move`) its old place filled. Works on `doc`, a copy of the document.
fn run_move(doc: &mut Document, sel: &Surface, plan: &Plan, p: &Value, ctx: &crate::jobs::JobCtx, label: &str) -> Result<(Surface, Rect)> {
let (dx, dy) = plan.offset;
let opts = fill_options();
let split = if plan.mode == Mode::Move { 0.4 } else { 1.0 };
let cancelled = |_| EngineError::Cancelled;
// 1. The content at its new place.
let dst_area = plan.area.translate(dx, dy);
let win = window(dst_area, plan.canvas);
let (w, h) = (win.width() as usize, win.height() as usize);
let here = read(doc, plan, p, win)?;
let fmt = crate::channel_cmds::target_surface(doc, plan.id, p)?.0.format();
let n = here.ch;
// The content with its original surroundings, laid over this window.
let mut src = read(doc, plan, p, win.translate(-dx, -dy))?;
src.rect = win;
let moved = mask(sel, win.translate(-dx, -dy));
let old_place = mask(sel, win);
let side = plan.area.width().min(plan.area.height());
let core = erode(w, h, &moved, band_width(plan.structure, side));
ctx.check()?;
// Colour: fit the kept content to the new surroundings (seamless clone), mixed in by `color`.
let fitted = if plan.color > 0 && core.iter().any(|c| *c) {
let healed = heal_region(&fmt, &src, &here, &core);
let k = f32::from(plan.color) / 10.0;
let mut f = src.clone();
f.data.iter_mut().zip(&healed.data).for_each(|(s, h)| *s += (h - *s) * k);
f
} else {
src
};
let mut buf = here;
for (i, _) in core.iter().enumerate().filter(|(_, c)| **c) {
let r = i * n..(i + 1) * n;
if let (Some(d), Some(s)) = (buf.data.get_mut(r.clone()), fitted.data.get(r)) {
d.copy_from_slice(s);
}
}
// Structure: the band between the kept core and the selection's edge comes from the surroundings
// and the kept core (see the module docs), never from the content's old place.
let band: Vec<bool> = moved.iter().zip(&core).map(|(m, c)| *m && !*c).collect();
if band.iter().any(|b| *b) {
let allowed: Vec<bool> = old_place.iter().map(|o| !*o).collect();
buf.data = ctx.stage(0.0, split, label, |ctl| fill_with(w, h, n, &buf.data, &band, &allowed, &opts, ctl)).map_err(cancelled)?;
}
let mut damage = replace(doc, plan, p, &buf, &moved)?;
// 2. Move: fill the place the content left (where the content didn't land on it).
if plan.mode == Mode::Move {
ctx.check()?;
let win = window(plan.area, plan.canvas);
let (w, h) = (win.width() as usize, win.height() as usize);
let img = read(doc, plan, p, win)?;
let landed = mask(sel, win.translate(-dx, -dy));
let hole: Vec<bool> = mask(sel, win).iter().zip(&landed).map(|(s, l)| *s && !*l).collect();
if hole.iter().any(|h| *h) {
let allowed: Vec<bool> = landed.iter().map(|l| !*l).collect();
let data = ctx.stage(split, 1.0, label, |ctl| fill_with(w, h, img.ch, &img.data, &hole, &allowed, &opts, ctl)).map_err(cancelled)?;
damage = damage.union(&replace(doc, plan, p, &Region { rect: win, ch: img.ch, data }, &hole)?);
}
}
let surf = crate::channel_cmds::target_surface(doc, plan.id, p)?.0.clone();
Ok((surf, damage))
}
pub(super) fn content_aware_move(s: &mut Session, p: &Value) -> Result<Value> {
let plan = plan(s, p)?;
let label = if plan.mode == Mode::Move { "Content-Aware Move" } else { "Content-Aware Extend" };
let d = s.active().ok_or(EngineError::NoDocument)?;
let doc = d.doc.clone();
let p = p.clone();
crate::jobs::run(
s,
label,
true,
{
let p = p.clone();
move |ctx| {
ctx.progress(0.0, label);
let mut work = (*doc).clone();
let sel = doc.selection.as_ref().ok_or_else(|| bad(CMD, "select the area to move first"))?;
run_move(&mut work, sel, &plan, &p, ctx, label)
}
},
move |s, (surf, damage)| {
let (dx, dy) = plan.offset;
s.edit(label, |doc, _| {
*crate::channel_cmds::target_surface(doc, plan.id, &p)?.0 = surf;
// The selection follows the content, ready for another drag.
if let Some(sel) = doc.selection.as_ref() {
doc.selection = Some(crate::layer_multi_cmds::shift_surface(sel, dx, dy));
}
Ok(())
})?;
if let Some(st) = s.active_mut() {
st.last_damage = Some(damage);
}
let mode = if plan.mode == Mode::Move { "move" } else { "extend" };
Ok(json!({ "damage": damage_json(damage), "offset": [dx, dy], "mode": mode }))
},
)
}

View File

@ -32,7 +32,7 @@ pub(super) fn enabled(s: &Session) -> std::result::Result<(), String> {
}
/// The layer `p` targets (`None` for an alpha channel or the Quick Mask), as `parse_brush`.
fn target_layer(s: &Session, p: &Value) -> Result<Option<LayerId>> {
pub(super) fn target_layer(s: &Session, p: &Value) -> Result<Option<LayerId>> {
if crate::channel_cmds::is_channel_target(p) {
return Ok(None);
}
@ -43,25 +43,25 @@ fn target_layer(s: &Session, p: &Value) -> Result<Option<LayerId>> {
}
/// Integer drag offset, rejecting non-finite and absurd values.
fn offset(p: &Value) -> Result<(i32, i32)> {
let (dx, dy) = point(p, "offset").ok_or_else(|| bad(CMD, "missing `offset` ([dx, dy])"))?;
pub(super) fn offset(cmd: &str, p: &Value) -> Result<(i32, i32)> {
let (dx, dy) = point(p, "offset").ok_or_else(|| bad(cmd, "missing `offset` ([dx, dy])"))?;
let ok = |v: f64| v.is_finite() && v.abs() < 1e7;
if !ok(dx) || !ok(dy) {
return Err(bad(CMD, "`offset` must be two finite numbers"));
return Err(bad(cmd, "`offset` must be two finite numbers"));
}
Ok((dx.round() as i32, dy.round() as i32))
}
/// Selected area within the canvas: the selection's content bounds, or the whole canvas when the
/// selection covers everything outside its tiles (an inverted selection).
fn selection_area(sel: &Surface, canvas: Rect) -> Rect {
pub(super) fn selection_area(sel: &Surface, canvas: Rect) -> Rect {
let outside = sel.default_pixel().first().copied().unwrap_or(0.0);
let b = if outside > 0.0 { canvas } else { sel.content_bounds() };
b.intersect(&canvas)
}
/// Selection coverage over `rect` (row-major, 0..1).
fn coverage(sel: &Surface, rect: Rect) -> Vec<f32> {
pub(super) fn coverage(sel: &Surface, rect: Rect) -> Vec<f32> {
// Read tile by tile (a per-pixel lookup costs a tile search each).
let n = sel.channels().max(1);
sel.read_region(rect).chunks_exact(n).map(|px| px[0].clamp(0.0, 1.0)).collect()
@ -151,7 +151,7 @@ fn plan(s: &Session, p: &Value) -> Result<Plan> {
"destination" => PatchMode::Destination,
o => return Err(bad(CMD, format!("unknown mode `{o}` (source|destination)"))),
};
let (dx, dy) = offset(p)?;
let (dx, dy) = offset(CMD, p)?;
let id = target_layer(s, p)?;
let d = s.active().ok_or(EngineError::Other("no document open".into()))?;
let canvas = d.doc.bounds();

View File

@ -695,3 +695,182 @@ fn patch_preview_matches_the_command_and_its_coarse_solve_is_close() {
assert!(worst < 0.03, "coarse preview off by {worst}");
assert!(patch_preview(&s, &json!({"offset": [500, 0]}), 64, 1).is_err());
}
// ---------------------------------------------------------------------------------------------
// Content-Aware Move
// ---------------------------------------------------------------------------------------------
/// `texture` with a solid blue object over `x0..x0+12`, `y0..y0+12`.
fn with_object(x0: i32, y0: i32) -> impl Fn(i32, i32) -> [f32; 4] {
move |x, y| if (x0..x0 + 12).contains(&x) && (y0..y0 + 12).contains(&y) { [0.05, 0.1, 0.9, 1.0] } else { texture(x, y) }
}
fn is_object(px: [f32; 4]) -> bool {
px[2] > 0.8 && px[0] < 0.2
}
fn selected(s: &Session, x: i32, y: i32) -> bool {
s.active().unwrap().doc.selection.as_ref().is_some_and(|sel| sel.sample_channel(x, y, 0) > 0.0)
}
#[test]
fn content_aware_move_moves_the_object_and_fills_its_place_at_all_depths() {
for depth in DEPTHS {
let mut s = session(128, 64, depth, "rgb");
paint_layer(&mut s, with_object(20, 26));
s.execute("select.rect", json!({"x": 16, "y": 22, "width": 20, "height": 20})).unwrap();
let r = s.execute("paint.contentAwareMove", json!({"offset": [70, 0]})).unwrap();
assert_eq!(r["offset"], json!([70, 0]));
assert_eq!(r["mode"], "move");
// The object's core is at the new place; its old place holds texture again.
for (x, y) in [(93, 32), (95, 30), (98, 35)] {
assert!(is_object(rgba(&s, x, y)), "depth {depth}: object at {x},{y}: {:?}", rgba(&s, x, y));
}
for y in 26..38 {
for x in 20..32 {
assert!(!is_object(rgba(&s, x, y)), "depth {depth}: old place {x},{y} still shows the object");
}
}
// Far away nothing changed.
for (x, y) in [(2, 2), (60, 60), (125, 5)] {
let (got, want) = (rgba(&s, x, y), texture(x, y));
assert!((got[0] - want[0]).abs() <= tol(depth), "depth {depth}: {x},{y} changed: {got:?}");
}
// The selection followed the content.
assert!(selected(&s, 96, 32) && !selected(&s, 26, 32), "depth {depth}: selection moved");
// One step: undo restores pixels and selection, redo brings the move back.
s.execute("edit.undo", json!({})).unwrap();
assert!(is_object(rgba(&s, 25, 31)) && !is_object(rgba(&s, 95, 31)), "depth {depth}: undo restores the object");
assert!(selected(&s, 26, 32) && !selected(&s, 96, 32), "depth {depth}: undo restores the selection");
s.execute("edit.redo", json!({})).unwrap();
assert!(is_object(rgba(&s, 95, 31)) && !is_object(rgba(&s, 25, 31)), "depth {depth}: redo");
}
}
#[test]
fn content_aware_extend_keeps_the_original() {
let mut s = session(128, 64, 8, "rgb");
paint_layer(&mut s, with_object(20, 26));
s.execute("select.rect", json!({"x": 16, "y": 22, "width": 20, "height": 20})).unwrap();
let r = s.execute("paint.contentAwareMove", json!({"offset": [60, -10], "mode": "extend"})).unwrap();
assert_eq!(r["mode"], "extend");
assert!(is_object(rgba(&s, 25, 31)), "the original stays");
assert!(is_object(rgba(&s, 85, 21)), "the copy lands");
assert_eq!(s.active().unwrap().history.undo_label(), Some("Content-Aware Extend"));
}
#[test]
fn content_aware_move_structure_and_color_shape_the_result() {
// Structure 7, Color 0: the content is copied exactly, edge to edge.
let mut s = session(128, 64, 16, "rgb");
paint_layer(&mut s, texture);
s.execute("select.rect", json!({"x": 10, "y": 10, "width": 24, "height": 24})).unwrap();
s.execute("paint.contentAwareMove", json!({"offset": [70, 20], "structure": 7, "mode": "extend"})).unwrap();
for (x, y) in [(10, 10), (22, 22), (33, 33)] {
let (got, want) = (rgba(&s, x + 70, y + 20), texture(x, y));
for c in 0..4 {
assert!((got[c] - want[c]).abs() <= tol(16), "strict copy at {x},{y}: {got:?} vs {want:?}");
}
}
// A lower Structure re-synthesises an edge band: the edge differs from a plain copy, the
// centre doesn't.
let mut s = session(128, 64, 16, "rgb");
paint_layer(&mut s, texture);
let copy = |s: &Session, x: i32, y: i32| (rgba(s, x + 70, y + 20), rgba(s, x, y));
s.execute("select.rect", json!({"x": 10, "y": 10, "width": 24, "height": 24})).unwrap();
s.execute("paint.contentAwareMove", json!({"offset": [70, 20], "structure": 1, "mode": "extend"})).unwrap();
let (centre, orig) = copy(&s, 22, 22);
assert_eq!(centre, orig, "the centre is kept");
let edge = (0..24).filter(|i| copy(&s, 10 + i, 10).0 != copy(&s, 10 + i, 10).1).count();
assert!(edge > 12, "the top edge row is blended in ({edge} of 24 pixels differ)");
// Color 10 fits a bright patch to a dark place; Color 0 keeps it bright.
let mean = |color: u64| {
let mut s = session(128, 64, 32, "rgb");
paint_layer(&mut s, |x, _| if x < 64 { [0.8, 0.8, 0.8, 1.0] } else { [0.2, 0.2, 0.2, 1.0] });
s.execute("select.rect", json!({"x": 20, "y": 20, "width": 16, "height": 16})).unwrap();
s.execute("paint.contentAwareMove", json!({"offset": [70, 0], "structure": 7, "color": color, "mode": "extend"})).unwrap();
(94..102).map(|x| rgba(&s, x, 28)[0]).sum::<f32>() / 8.0
};
let (kept, fitted) = (mean(0), mean(10));
assert!((kept - 0.8).abs() < 1e-3, "color 0 keeps the colour: {kept}");
assert!(fitted < 0.3, "color 10 adapts to the dark surroundings: {fitted}");
}
#[test]
fn content_aware_move_overlapping_at_the_edge_and_on_other_targets() {
// A short drag (the content overlaps its old place) from the canvas edge.
let mut s = session(96, 48, 8, "rgb");
paint_layer(&mut s, with_object(0, 18));
s.execute("select.rect", json!({"x": 0, "y": 14, "width": 16, "height": 20})).unwrap();
s.execute("paint.contentAwareMove", json!({"offset": [8, 0]})).unwrap();
assert!(is_object(rgba(&s, 14, 24)), "object moved right");
assert!(!is_object(rgba(&s, 2, 24)), "its old left part is filled: {:?}", rgba(&s, 2, 24));
assert!(rgba(&s, 0, 24)[3] > 0.99, "the edge stays opaque");
// On a transparent layer the old place becomes transparent like its surroundings.
let mut s = session(96, 48, 8, "rgb");
s.execute("layer.new.layer", json!({})).unwrap();
s.execute("paint.pencil", json!({"points": [[30, 24]], "size": 10, "color": "#1020e0"})).unwrap();
s.execute("select.rect", json!({"x": 20, "y": 14, "width": 20, "height": 20})).unwrap();
s.execute("paint.contentAwareMove", json!({"offset": [40, 0]})).unwrap();
assert!(rgba(&s, 30, 24)[3] < 0.05, "old place cleared: {:?}", rgba(&s, 30, 24));
assert!(rgba(&s, 70, 24)[3] > 0.95, "object moved: {:?}", rgba(&s, 70, 24));
// A layer mask and a grayscale document work like any retouch target.
s.execute("layer.layerMask.revealAll", json!({})).unwrap();
s.execute("paint.contentAwareMove", json!({"offset": [-20, 0], "target": "mask"})).unwrap();
let mut g = session(64, 48, 16, "gray");
g.execute("select.rect", json!({"x": 4, "y": 4, "width": 10, "height": 10})).unwrap();
g.execute("paint.contentAwareMove", json!({"offset": [30, 20], "structure": 2, "color": 5})).unwrap();
}
#[test]
fn content_aware_move_samples_all_layers_onto_an_empty_layer() {
let mut s = session(128, 64, 8, "rgb");
paint_layer(&mut s, with_object(20, 26));
s.execute("layer.new.layer", json!({})).unwrap();
s.execute("select.rect", json!({"x": 16, "y": 22, "width": 20, "height": 20})).unwrap();
// Without Sample All Layers the empty layer has nothing to move.
s.execute("paint.contentAwareMove", json!({"offset": [70, 0]})).unwrap();
assert!(rgba(&s, 95, 31)[3] < 0.01);
s.execute("edit.undo", json!({})).unwrap();
s.execute("paint.contentAwareMove", json!({"offset": [70, 0], "sampleAllLayers": true})).unwrap();
assert!(is_object(rgba(&s, 95, 31)), "the object is painted onto the empty layer: {:?}", rgba(&s, 95, 31));
let fill = rgba(&s, 25, 31);
assert!(fill[3] > 0.99 && !is_object(fill), "its old place is covered with fill: {fill:?}");
assert!(rgba(&s, 2, 2)[3] < 0.01, "elsewhere the layer stays empty");
}
#[test]
fn content_aware_move_fails_gracefully() {
let mut s = session(64, 48, 8, "rgb");
assert!(!s.is_enabled("paint.contentAwareMove"), "no selection");
assert!(s.execute("paint.contentAwareMove", json!({"offset": [10, 0]})).is_err());
s.execute("select.rect", json!({"x": 10, "y": 10, "width": 10, "height": 10})).unwrap();
assert!(s.is_enabled("paint.contentAwareMove"));
for p in [
json!({}),
json!({"offset": [10]}),
json!({"offset": "far"}),
json!({"offset": [1e300, 0]}),
json!({"offset": [-1e300, 1e300]}),
json!({"offset": [0, 0]}),
json!({"offset": [0.4, -0.4]}),
json!({"offset": [10, 0], "mode": "bogus"}),
json!({"offset": [10, 0], "structure": 0}),
json!({"offset": [10, 0], "structure": 8}),
json!({"offset": [10, 0], "structure": "high"}),
json!({"offset": [10, 0], "color": -1}),
json!({"offset": [10, 0], "color": 11}),
json!({"offset": [10, 0], "color": [1]}),
json!({"offset": [50, 0]}),
json!({"offset": [0, -30]}),
json!({"offset": [10, 0], "target": "mask"}),
json!({"offset": [10, 0], "target": {"channel": 7}}),
json!({"offset": [10, 0], "layer": 999}),
] {
assert!(s.execute("paint.contentAwareMove", p.clone()).is_err(), "{p}");
}
assert!(s.execute("paint.contentAwareMove", json!({"offset": [10, 0], "structure": 7.0, "color": null})).is_ok());
}

View File

@ -300,6 +300,7 @@ pub(crate) fn freehand_tool(tool: Tool) -> bool {
| Tool::Sponge
| Tool::Lasso
| Tool::Patch
| Tool::ContentAwareMove
| Tool::QuickSelection
)
}
@ -2194,8 +2195,8 @@ fn draw_drag_preview(app: &mut PhotocraftApp, painter: &egui::Painter, xf: &View
};
crate::tool_feedback::draw_ants(painter, &pts, true);
}
// Patch Tool dragging the patch: the selection outline follows the pointer.
Tool::Patch if crate::retouch_ui::patch_drags_selection(app, d.start, d.modifiers) => {
// Patch / Content-Aware Move dragging the selection: its outline follows the pointer.
Tool::Patch | Tool::ContentAwareMove if crate::retouch_ui::patch_drags_selection(app, d.start, d.modifiers) => {
let start = d.start;
let [dx, dy] = crate::retouch_ui::patch_offset(app, start, last);
if let Some((_, _, segs)) = &app.outline_cache {
@ -2203,7 +2204,7 @@ fn draw_drag_preview(app: &mut PhotocraftApp, painter: &egui::Painter, xf: &View
marching_ants_segments(painter, xf, &moved, painter.ctx().input(|i| i.time));
}
}
Tool::Lasso | Tool::Patch => {
Tool::Lasso | Tool::Patch | Tool::ContentAwareMove => {
let mut pts: Vec<Pos2> = d.points.iter().map(|p| xf.to_screen(p[0] as f32, p[1] as f32)).collect();
if let Some(lasso) = &d.lasso {
pts.push(xf.to_screen(lasso.cursor[0] as f32, lasso.cursor[1] as f32));
@ -2626,7 +2627,10 @@ pub(crate) fn finish_gesture(app: &mut PhotocraftApp, d: Drag) {
let _ = app.run("select.rect", json!({"x": x0, "y": y0, "width": x1 - x0, "height": y1 - y0, "mode": mode, "ellipse": d.tool == Tool::EllipseMarquee, "antiAlias": aa, "feather": feather}));
}
Tool::Patch if crate::retouch_ui::patch_drags_selection(app, d.start, d.modifiers) => crate::retouch_ui::finish_patch(app, d.start, [end[0], end[1]]),
Tool::Lasso | Tool::Patch => {
Tool::ContentAwareMove if crate::retouch_ui::patch_drags_selection(app, d.start, d.modifiers) => {
crate::retouch_ui::finish_content_aware_move(app, d.start, [end[0], end[1]])
}
Tool::Lasso | Tool::Patch | Tool::ContentAwareMove => {
let pts: Vec<[f64; 2]> = d.points.iter().map(|p| [p[0], p[1]]).collect();
if pts.len() >= 3 {
let mode = selection_mode(app, d.modifiers);

View File

@ -2069,6 +2069,10 @@ cameraRaw Color Barvy
Enable {name} Povolit {name}
Hold Alt while drawing for straight segments Podržením Alt při kreslení vytvoříte rovné úseky
Drag or click polygon points · hold Alt during lasso for straight segments · {add} add · {sub} before drawing subtracts Táhněte nebo klikejte body mnohoúhelníku · podržením Alt během lasa kreslíte rovné úseky · {add} přidat · {sub} před kreslením odečte
Move Přesunout
Extend Rozšířit
Structure: Struktura:
Content-Aware Move Tool Nástroj přesun s ohledem na obsah
TIFF Options Možnosti TIFF
Including layers will increase file size. Zahrnutí vrstev zvětší velikost souboru.
Discard Layers and Save a Copy Zahodit vrstvy a uložit kopii

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

@ -2115,6 +2115,9 @@
Enable {name} {name} aktivieren
Hold Alt while drawing for straight segments Beim Zeichnen Alt gedrückt halten für gerade Segmente
Drag or click polygon points · hold Alt during lasso for straight segments · {add} add · {sub} before drawing subtracts Ziehen oder Polygonpunkte klicken · Alt während des Lassos halten für gerade Segmente · {add} hinzufügen · {sub} vor dem Zeichnen zieht ab
Extend Erweitern
Structure: Struktur:
Content-Aware Move Tool Inhaltsbasiert-verschieben-Werkzeug
TIFF Options TIFF-Optionen
Including layers will increase file size. Das Einbeziehen von Ebenen vergrößert die Datei.
Discard Layers and Save a Copy Ebenen verwerfen und Kopie speichern

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

@ -2144,6 +2144,9 @@ cameraRaw Amount Cantidad
Enable {name} Activar {name}
Hold Alt while drawing for straight segments Mantenga Alt al dibujar para segmentos rectos
Drag or click polygon points · hold Alt during lasso for straight segments · {add} add · {sub} before drawing subtracts Arrastre o haga clic en los puntos del polígono · mantenga Alt durante el lazo para segmentos rectos · {add} añadir · {sub} antes de dibujar resta
Extend Extender
Structure: Estructura:
Content-Aware Move Tool Herramienta Mover según el contenido
TIFF Options Opciones de TIFF
Including layers will increase file size. Incluir las capas aumentará el tamaño del archivo.
Discard Layers and Save a Copy Descartar capas y guardar una copia

Can't render this file because it contains an unexpected character in line 4 and column 47.

View File

@ -2504,6 +2504,9 @@ cameraRaw Blending Fusion
Enable {name} Activer {name}
Hold Alt while drawing for straight segments Maintenez Alt en dessinant pour des segments droits
Drag or click polygon points · hold Alt during lasso for straight segments · {add} add · {sub} before drawing subtracts Faites glisser ou cliquez des points de polygone · maintenez Alt pendant le lasso pour des segments droits · {add} ajouter · {sub} avant de dessiner soustrait
Extend Extension
Structure: Structure :
Content-Aware Move Tool Outil Déplacement selon le contenu
TIFF Options Options TIFF
Including layers will increase file size. L'inclusion des calques augmentera la taille du fichier.
Discard Layers and Save a Copy Supprimer les calques et enregistrer une copie

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

@ -2491,6 +2491,9 @@ cameraRaw Blending Pembauran
Enable {name} Aktifkan {name}
Hold Alt while drawing for straight segments Tahan Alt saat menggambar untuk segmen lurus
Drag or click polygon points · hold Alt during lasso for straight segments · {add} add · {sub} before drawing subtracts Seret atau klik titik poligon · tahan Alt saat lasso untuk segmen lurus · {add} tambah · {sub} sebelum menggambar mengurangi
Extend Perluas
Structure: Struktur:
Content-Aware Move Tool Alat Content-Aware Move
TIFF Options Opsi TIFF
Including layers will increase file size. Menyertakan layer akan memperbesar ukuran file.
Discard Layers and Save a Copy Buang Layer dan Simpan Salinan

Can't render this file because it contains an unexpected character in line 4 and column 47.

View File

@ -2052,6 +2052,10 @@ cameraRaw Color カラー
Enable {name} {name}を有効にする
Hold Alt while drawing for straight segments 描画中に Alt を押すと直線セグメントになります
Drag or click polygon points · hold Alt during lasso for straight segments · {add} add · {sub} before drawing subtracts ドラッグまたは多角形の点をクリック · なげなわ中に Alt で直線セグメント · {add} 追加 · 描画前の {sub} で削除
Move 移動
Extend 拡張
Structure: 構造:
Content-Aware Move Tool コンテンツに応じた移動ツール
TIFF Options TIFF オプション
Including layers will increase file size. レイヤーを含めるとファイルサイズが大きくなります。
Discard Layers and Save a Copy レイヤーを破棄してコピーを保存

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

@ -2656,6 +2656,10 @@ cameraRaw Color 색상
@id filter.sharpen.sharpen 선명하게
Hold Alt while drawing for straight segments 그리는 중 Alt를 누르면 직선 구간
Drag or click polygon points · hold Alt during lasso for straight segments · {add} add · {sub} before drawing subtracts 드래그하거나 다각형 점 클릭 · 올가미 중 Alt를 누르면 직선 구간 · {add} 추가 · 그리기 전 {sub} 빼기
Move 이동
Extend 확장
Structure: 구조:
Content-Aware Move Tool 내용 인식 이동 도구
TIFF Options TIFF 옵션
Including layers will increase file size. 레이어를 포함하면 파일 크기가 커집니다.
Discard Layers and Save a Copy 레이어를 삭제하고 사본 저장

Can't render this file because it has a wrong number of fields in line 10.

View File

@ -2514,6 +2514,9 @@ cameraRaw Whites Brancos
Enable {name} Ativar {name}
Hold Alt while drawing for straight segments Segure Alt ao desenhar para segmentos retos
Drag or click polygon points · hold Alt during lasso for straight segments · {add} add · {sub} before drawing subtracts Arraste ou clique nos pontos do polígono · segure Alt durante o laço para segmentos retos · {add} adicionar · {sub} antes de desenhar subtrai
Extend Estender
Structure: Estrutura:
Content-Aware Move Tool Ferramenta Mover com reconhecimento de conteúdo
TIFF Options Opções de TIFF
Including layers will increase file size. Incluir as camadas aumentará o tamanho do arquivo.
Discard Layers and Save a Copy Descartar camadas e salvar uma cópia

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

@ -2347,6 +2347,9 @@ cameraRaw Roughness Неровность
Enable {name} Включить {name}
Hold Alt while drawing for straight segments Удерживайте Alt при рисовании для прямых отрезков
Drag or click polygon points · hold Alt during lasso for straight segments · {add} add · {sub} before drawing subtracts Перетащите или щёлкайте точки многоугольника · удерживайте Alt во время лассо для прямых отрезков · {add} добавить · {sub} перед рисованием вычитает
Extend Расширение
Structure: Структура:
Content-Aware Move Tool Перемещение с учётом содержимого
TIFF Options Параметры TIFF
Including layers will increase file size. Включение слоёв увеличит размер файла.
Discard Layers and Save a Copy Удалить слои и сохранить копию

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

@ -2078,6 +2078,10 @@ cameraRaw Blending 混合
Enable {name} 启用{name}
Hold Alt while drawing for straight segments 绘制中按住 Alt 可逐点连接直线
Drag or click polygon points · hold Alt during lasso for straight segments · {add} add · {sub} before drawing subtracts 拖动圈选或逐点绘制 · 圈选中按住 Alt 画直线 · {add} 添加 · 起笔前按 {sub} 减去
Move 移动
Extend 扩展
Structure: 结构:
Content-Aware Move Tool 内容感知移动工具
TIFF Options TIFF 选项
Including layers will increase file size. 包含图层会增加文件大小。
Discard Layers and Save a Copy 扔掉图层并存储副本

Can't render this file because it has a wrong number of fields in line 14.

View File

@ -2047,6 +2047,10 @@ cameraRaw Blending 混合
Enable {name} 啟用{name}
Hold Alt while drawing for straight segments 繪製中按住 Alt 可逐點連接直線
Drag or click polygon points · hold Alt during lasso for straight segments · {add} add · {sub} before drawing subtracts 拖曳圈選或逐點繪製 · 圈選中按住 Alt 畫直線 · {add} 增加 · 起筆前按 {sub} 減去
Move 移動
Extend 延伸
Structure: 結構:
Content-Aware Move Tool 內容感知移動工具
TIFF Options TIFF 選項
Including layers will increase file size. 包含圖層會增加檔案大小。
Discard Layers and Save a Copy 放棄圖層並儲存拷貝

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

@ -85,6 +85,7 @@ pub fn tool_icon(t: Tool) -> &'static str {
Tool::Zoom => "zoom-in",
Tool::SpotHealing | Tool::Healing => "bandage",
Tool::Patch => "lasso-select",
Tool::ContentAwareMove => "arrow-left-right",
Tool::CloneStamp => "stamp",
Tool::HistoryBrush => "clock",
Tool::Blur => "droplet",

View File

@ -24,7 +24,7 @@ const TOOL_SECTIONS: &[&[&[Tool]]] = &[
&[Tool::Eyedropper, Tool::Ruler, Tool::Note, Tool::Count],
],
&[
&[Tool::SpotHealing, Tool::Healing, Tool::Patch],
&[Tool::SpotHealing, Tool::Healing, Tool::Patch, Tool::ContentAwareMove],
&[Tool::Brush, Tool::Pencil, Tool::MixerBrush],
&[Tool::CloneStamp],
&[Tool::HistoryBrush],

View File

@ -76,8 +76,8 @@ pub fn finish_stroke(app: &mut PhotocraftApp, tool: Tool, points: &[[f64; 3]], m
true
}
/// Patch Tool: a drag that starts inside the selection (without ⇧ or ⌥) drags the patch; any
/// other drag draws a lasso selection.
/// Patch Tool and Content-Aware Move Tool: a drag that starts inside the selection (without ⇧ or
/// ⌥) drags the selection; any other drag draws a lasso selection.
pub fn patch_drags_selection(app: &PhotocraftApp, at: [f64; 2], mods: egui::Modifiers) -> bool {
if mods.shift || mods.alt {
return false;
@ -86,8 +86,8 @@ pub fn patch_drags_selection(app: &PhotocraftApp, at: [f64; 2], mods: egui::Modi
sel.sample_channel(at[0].floor() as i32, at[1].floor() as i32, 0) > 0.0
}
/// Whole-pixel patch offset for a drag from `start` to `end`, limited so the dragged outline stays
/// on the canvas (the engine rejects a patch that leaves it).
/// Whole-pixel offset for a drag from `start` to `end`, limited so the dragged outline stays on the
/// canvas (the engine rejects a patch or move that leaves it).
pub fn patch_offset(app: &mut PhotocraftApp, start: [f64; 2], end: [f64; 2]) -> [i32; 2] {
let (dx, dy) = ((end[0] - start[0]).round(), (end[1] - start[1]).round());
let (dx, dy) = if dx.is_finite() && dy.is_finite() { (dx.clamp(-1e7, 1e7) as i32, dy.clamp(-1e7, 1e7) as i32) } else { (0, 0) };
@ -118,6 +118,28 @@ pub fn finish_patch(app: &mut PhotocraftApp, start: [f64; 2], end: [f64; 2]) {
}
}
/// Content-Aware Move Tool: the selection was dragged from `start` to `end`. The engine runs it as
/// a background job (progress dialog, Esc cancels); the selection follows the content.
pub fn finish_content_aware_move(app: &mut PhotocraftApp, start: [f64; 2], end: [f64; 2]) {
let off = patch_offset(app, start, end);
if off == [0, 0] {
return;
}
let o = &app.ui.tool_options;
let p = json!({
"offset": off,
"mode": o.cam_mode,
"structure": o.cam_structure.round().clamp(1.0, 7.0),
"color": o.cam_color.round().clamp(0.0, 10.0),
"sampleAllLayers": o.sample_all_layers,
"target": crate::canvas::paint_target(app),
});
if let Err(e) = app.run("paint.contentAwareMove", p) {
app.ui.status = e;
app.ui.status_error = true;
}
}
/// Object Selection: the dragged rectangle.
pub fn finish_object_selection(app: &mut PhotocraftApp, start: [f64; 2], end: [f64; 2], mods: egui::Modifiers) {
let (x, y) = (start[0].min(end[0]), start[1].min(end[1]));
@ -180,7 +202,7 @@ fn pct(ui: &mut egui::Ui, label: &str, v: &mut f32) {
/// Options bar for the retouching and smart-selection tools. Returns false for other tools.
pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui, tool: Tool) -> bool {
if !tool.is_brushlike() && !matches!(tool, Tool::QuickSelection | Tool::ObjectSelection | Tool::Patch)
if !tool.is_brushlike() && !matches!(tool, Tool::QuickSelection | Tool::ObjectSelection | Tool::Patch | Tool::ContentAwareMove)
|| matches!(tool, Tool::Brush | Tool::Pencil | Tool::MixerBrush | Tool::Eraser)
{
return false;
@ -209,6 +231,18 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui, tool: Tool) -> bo
crate::widgets::vline(ui, 22.0);
opt(ui, tl!("Lasso around an area, then drag the selection"));
}
Tool::ContentAwareMove => {
opt(ui, tl!("Mode:"));
let modes = [("move".to_string(), tl!("Move")), ("extend".to_string(), tl!("Extend"))];
crate::widgets::dropdown(ui, "cam-mode", &mut o.cam_mode, &modes, 90.0);
opt(ui, tl!("Structure:"));
crate::widgets::value_field(ui, &mut o.cam_structure, 1.0..=7.0, "", 40.0);
opt(ui, tl!("Color:"));
crate::widgets::value_field(ui, &mut o.cam_color, 0.0..=10.0, "", 40.0);
crate::widgets::checkbox(ui, &mut o.sample_all_layers, tl!("Sample All Layers"));
crate::widgets::vline(ui, 22.0);
opt(ui, tl!("Lasso around an area, then drag the selection"));
}
Tool::Healing | Tool::CloneStamp => {
crate::widgets::checkbox(ui, &mut o.clone_aligned, tl!("Aligned"));
opt(ui, tl!("Sample:"));
@ -422,4 +456,33 @@ mod tests {
let px = active(&app).surface().unwrap().rgba(71, 30);
assert!(px[0] > 0.95 && px[1] > 0.95 && px[2] > 0.95, "blemish patched with the white background: {px:?}");
}
#[test]
fn content_aware_move_tool_lassoes_then_moves_or_extends() {
for (mode, keeps) in [("move", false), ("extend", true)] {
let mut app = app();
app.run("paint.pencil", json!({"points": [[20, 30], [24, 30]], "size": 6, "color": "#0000ff"})).unwrap();
let blue = |app: &PhotocraftApp, x: i32| active(app).surface().unwrap().rgba(x, 30)[0] < 0.5;
app.ui.tool = Tool::ContentAwareMove;
app.ui.tool_options.cam_mode = mode.into();
app.ui.tool_options.cam_structure = 7.0;
let m = egui::Modifiers::NONE;
tool_event(&mut app, ToolEvent::Down { x: 12.0, y: 20.0, pressure: 1.0 }, m);
for [x, y] in [[32.0, 20.0], [32.0, 40.0], [12.0, 40.0]] {
tool_event(&mut app, ToolEvent::Move { x, y, pressure: 1.0 }, m);
}
tool_event(&mut app, ToolEvent::Up { x: 12.0, y: 40.0 }, m);
assert!(app.session.active().unwrap().doc.selection.is_some(), "lasso made a selection");
assert!(blue(&app, 22), "the lasso alone changes no pixels");
tool_event(&mut app, ToolEvent::Down { x: 22.0, y: 30.0, pressure: 1.0 }, m);
tool_event(&mut app, ToolEvent::Move { x: 50.0, y: 31.0, pressure: 1.0 }, m);
tool_event(&mut app, ToolEvent::Up { x: 72.0, y: 30.0 }, m);
assert!(!app.ui.status_error, "{mode}: {}", app.ui.status);
assert!(blue(&app, 72), "{mode}: the content lands where it was dropped");
assert_eq!(blue(&app, 22), keeps, "{mode}: the original place");
assert_eq!(app.session.journal.last().map(|(id, p)| (id.as_str(), p["mode"].as_str())), Some(("paint.contentAwareMove", Some(mode))));
let sel = app.session.active().unwrap().doc.selection.as_ref().unwrap();
assert!(sel.sample_channel(72, 30, 0) > 0.0 && sel.sample_channel(22, 30, 0) == 0.0, "{mode}: the selection follows");
}
}
}

View File

@ -71,6 +71,7 @@ pub enum Tool {
SpotHealing,
Healing,
Patch,
ContentAwareMove,
CloneStamp,
HistoryBrush,
Blur,
@ -94,7 +95,7 @@ pub enum Tool {
}
impl Tool {
pub const ALL: [Tool; 45] = [
pub const ALL: [Tool; 46] = [
Tool::Move,
Tool::RectMarquee,
Tool::EllipseMarquee,
@ -120,6 +121,7 @@ impl Tool {
Tool::SpotHealing,
Tool::Healing,
Tool::Patch,
Tool::ContentAwareMove,
Tool::CloneStamp,
Tool::HistoryBrush,
Tool::Blur,
@ -171,6 +173,7 @@ impl Tool {
Tool::SpotHealing => "Spot Healing Brush Tool",
Tool::Healing => "Healing Brush Tool",
Tool::Patch => "Patch Tool",
Tool::ContentAwareMove => "Content-Aware Move Tool",
Tool::CloneStamp => "Clone Stamp Tool",
Tool::HistoryBrush => "History Brush Tool",
Tool::Blur => "Blur Tool",
@ -227,7 +230,7 @@ impl Tool {
Tool::Type => 'T',
Tool::Hand => 'H',
Tool::Zoom => 'Z',
Tool::SpotHealing | Tool::Healing | Tool::Patch => 'J',
Tool::SpotHealing | Tool::Healing | Tool::Patch | Tool::ContentAwareMove => 'J',
Tool::CloneStamp => 'S',
Tool::HistoryBrush => 'Y',
Tool::Blur | Tool::Sharpen | Tool::Smudge => '\0',
@ -387,6 +390,10 @@ pub struct ToolOptions {
pub spot_type: String,
/// Patch: source (repair the selection) | destination (repair where it is dragged).
pub patch_mode: String,
/// Content-Aware Move: move | extend, Structure 1..7, Color 0..10.
pub cam_mode: String,
pub cam_structure: f32,
pub cam_color: f32,
/// Dodge/Burn: shadows | midtones | highlights, exposure %, protect tones.
pub tone_range: String,
pub exposure: f32,
@ -487,6 +494,9 @@ impl Default for ToolOptions {
clone_sample: "current".into(),
spot_type: "contentAware".into(),
patch_mode: "source".into(),
cam_mode: "move".into(),
cam_structure: 4.0,
cam_color: 0.0,
tone_range: "midtones".into(),
exposure: 50.0,
protect_tones: true,

View File

@ -8,7 +8,7 @@ Generated by `cargo xtask scorecard` from `perf/budgets.toml`, `perf/baseline.js
|---|---:|---:|---:|---|
| [Performance](#performance) | 3 | 22 | 13 | 47 scenarios: 3 within budget, 22 over budget or failing, 15 tracked only, 6 not run, 7 not measurable yet (baseline 2026-10-05) |
| [File compatibility](#file-compatibility) | 2 | 8 | 17 | io corpus: oracle ≥ 146/170, round trip ≥ 169/170; psd-tools corpus: oracle ≥ 229/309, round trip ≥ 307/309; photoshop oracles: oracle ≥ 132/256, round trip ≥ 258/256 |
| [Tools](#tools) | 6 | 13 | 11 | |
| [Tools](#tools) | 6 | 14 | 10 | |
| [Workspace and UI](#workspace-and-ui) | 0 | 5 | 18 | settings that do nothing: 57 of 135 (target 0) |
| [Type](#type) | 0 | 3 | 6 | |
| [Automation](#automation) | 3 | 2 | 6 | 626 of 626 menu items live (100.0%) |
@ -124,7 +124,7 @@ Corpus floors (`crates/io/tests/corpus.rs`): the enforced minimums; the corpus t
| 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-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 | missing | [#213](https://github.com/storytold/photocraft/issues/213) | inpaint::complete only used by spot healing (retouch_cmds.rs:386) |
| 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; background job) + Tool::ContentAwareMove (J group: lasso, then drag). Missing: Transform On Drop, a live preview of the result (the drag shows the outline only) |
| 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 |
| TOOL-213-6 | Fill dialog: contents, blend, opacity, preserve transparency; remembered; Shift+Backspace | done | [#213](https://github.com/storytold/photocraft/issues/213) | fill_ui.rs dialog over edit.fill (fill_cmds.rs): all contents, mode, opacity, preserve; remembered in prefs; Shift+F5 / Shift+Backspace |
| TOOL-213-7 | Live stroke preview for every brush-based retouch tool | missing | [#213](https://github.com/storytold/photocraft/issues/213) | Live stroke only for Brush and Eraser (canvas.rs:1684) |

View File

@ -29,9 +29,9 @@ note = "paint.patch (retouch_cmds/patch.rs, source/destination) + Tool::Patch (J
[[item]]
id = "TOOL-213-4"
target = "Content-Aware Move tool reusing inpaint completion"
status = "missing"
status = "partial"
issue = 213
note = "inpaint::complete only used by spot healing (retouch_cmds.rs:386)"
note = "paint.contentAwareMove (retouch_cmds/content_aware_move.rs: move/extend, Structure 1-7, Color 0-10, Sample All Layers; background job) + Tool::ContentAwareMove (J group: lasso, then drag). Missing: Transform On Drop, a live preview of the result (the drag shows the outline only)"
[[item]]
id = "TOOL-213-5"