* 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:
parent
727daf5d67
commit
c887ed94cc
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 }))
|
||||
},
|
||||
)
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) |
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Reference in New Issue