mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-14 19:26:51 -04:00
paint.remove takes several strokes at once (`strokes`) and fills them as one job and one undo step, so the fill sees every hole together; and `newLayer` writes the fill to a new layer above the target, only where the hole is, leaving the target as it was (with Sample All Layers it fills from the composite). The options bar shows Photoshop's three options; with Remove after each stroke off, strokes collect as a tint on the canvas until Enter or the ✓ applies them and Esc or ⊘ drops them. Strokes are dropped when the tool or document changes under them.
This commit is contained in:
1 parent
286ff73533
commit
de22bca760
28 files changed
+414
-19
No files matched your search
@@ -1170,7 +1170,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
menu: &[],
|
||||
shortcut: None,
|
||||
params: brush_params!(
|
||||
r#","closeLoops":bool=true (a stroke around an object removes what it encloses),"sampleAllLayers":bool=false → {"damage"} (a background job)"#
|
||||
r#","strokes":[[[x,y,pressure?],…],…]? (several strokes removed together, as one job and one undo step; else "points"),"closeLoops":bool=true (a stroke around an object removes what it encloses),"sampleAllLayers":bool=false,"newLayer":bool=false (the fill goes to a new layer above the target, which stays as it was) → {"damage","layer"?} (a background job)"#
|
||||
),
|
||||
enabled: has_pixel_layer,
|
||||
run: remove::remove,
|
||||
|
||||
@@ -7,10 +7,17 @@
|
||||
//! selection, when there is one, limits what changes. Large holes take seconds, so the command
|
||||
//! runs as a background job (#210): with progress, cancellable, and the document unchanged until
|
||||
//! it applies, in one undo step.
|
||||
//!
|
||||
//! Several strokes can go in one removal (`strokes`): Photoshop's *Remove after each stroke*
|
||||
//! off, where the strokes collect and are applied together, so the fill sees every hole at once
|
||||
//! instead of sampling from an area the next stroke is about to remove. With `newLayer` the fill
|
||||
//! lands on a new layer above the target, only where the hole is, and the target stays as it was
|
||||
//! (Photoshop's *Create a new layer*; with Sample All Layers it fills from the composite).
|
||||
|
||||
use super::content_aware_move::{Target, replace};
|
||||
use super::patch::coverage;
|
||||
use super::*;
|
||||
use photocraft_doc::Layer;
|
||||
|
||||
const CMD: &str = "paint.remove";
|
||||
|
||||
@@ -38,11 +45,14 @@ fn sampling_margin(area: Rect) -> i32 {
|
||||
/// What a removal does, checked before any work starts.
|
||||
struct Plan {
|
||||
id: Option<LayerId>,
|
||||
stroke: Stroke,
|
||||
/// Where the fill samples from: the stroke plus a margin, on the canvas.
|
||||
/// One stroke (`points`), or several applied together (`strokes`).
|
||||
strokes: Vec<Stroke>,
|
||||
/// Where the fill samples from: the strokes plus a margin, on the canvas.
|
||||
window: Rect,
|
||||
close_loops: bool,
|
||||
all_layers: bool,
|
||||
/// The fill goes to a new layer above the target instead of into it.
|
||||
new_layer: bool,
|
||||
}
|
||||
|
||||
/// The stroke's bounding box (points ± the brush radius), before any coverage is rendered.
|
||||
@@ -56,11 +66,37 @@ fn stroke_bounds(stroke: &Stroke) -> Rect {
|
||||
Rect::new((x0 - r).floor() as i32, (y0 - r).floor() as i32, (x1 + r).ceil() as i32, (y1 + r).ceil() as i32)
|
||||
}
|
||||
|
||||
/// The strokes of the request: `strokes` (several, each a list of points, sharing the brush
|
||||
/// and the target) or the one in `points`.
|
||||
fn parse_strokes(s: &Session, p: &Value) -> Result<(Vec<Stroke>, Option<LayerId>)> {
|
||||
let Some(list) = p.get("strokes") else {
|
||||
let (stroke, id) = parse_brush(s, p, CMD)?;
|
||||
return Ok((vec![stroke], id));
|
||||
};
|
||||
let list = list.as_array().ok_or_else(|| bad(CMD, "`strokes` is a list of point lists"))?;
|
||||
if list.is_empty() {
|
||||
return Err(bad(CMD, "`strokes` is empty"));
|
||||
}
|
||||
let mut strokes = Vec::with_capacity(list.len());
|
||||
let mut id = None;
|
||||
for points in list {
|
||||
let mut one = p.clone();
|
||||
if let Some(o) = one.as_object_mut() {
|
||||
o.remove("strokes");
|
||||
o.insert("points".into(), points.clone());
|
||||
}
|
||||
let (stroke, layer) = parse_brush(s, &one, CMD)?;
|
||||
strokes.push(stroke);
|
||||
id = layer;
|
||||
}
|
||||
Ok((strokes, id))
|
||||
}
|
||||
|
||||
fn plan(s: &Session, p: &Value) -> Result<Plan> {
|
||||
let (stroke, id) = parse_brush(s, p, CMD)?;
|
||||
let (strokes, id) = parse_strokes(s, p)?;
|
||||
let d = s.active().ok_or(EngineError::NoDocument)?;
|
||||
let canvas = d.doc.bounds();
|
||||
let bounds = stroke_bounds(&stroke);
|
||||
let bounds = strokes.iter().map(stroke_bounds).reduce(|a, b| a.union(&b)).ok_or_else(|| bad(CMD, "`strokes` is empty"))?;
|
||||
let pixels = u64::from(bounds.width()) * u64::from(bounds.height());
|
||||
if pixels > MAX_PIXELS {
|
||||
return Err(bad(CMD, format!("the stroke is too large to remove ({pixels} pixels, at most {MAX_PIXELS})")));
|
||||
@@ -72,23 +108,27 @@ fn plan(s: &Session, p: &Value) -> Result<Plan> {
|
||||
let window = area.inflate(sampling_margin(area).min(MAX_MARGIN)).intersect(&canvas);
|
||||
let close_loops = flag(p, "closeLoops", true);
|
||||
let all_layers = flag(p, "sampleAllLayers", false) && targets_pixels(p);
|
||||
Ok(Plan { id, stroke, window, close_loops, all_layers })
|
||||
// A mask or channel has no "above": the fill goes where the stroke went.
|
||||
let new_layer = flag(p, "newLayer", false) && targets_pixels(p);
|
||||
Ok(Plan { id, strokes, window, close_loops, all_layers, new_layer })
|
||||
}
|
||||
|
||||
/// The hole over the plan's window: the stroke's footprint, its enclosed areas and a margin,
|
||||
/// The hole over the plan's window: the strokes' footprints, their enclosed areas and a margin,
|
||||
/// limited to the selection. Empty when nothing would change.
|
||||
fn hole(plan: &Plan, sel: Option<&Surface>) -> Vec<bool> {
|
||||
let win = plan.window;
|
||||
let (w, h) = (win.width() as usize, win.height() as usize);
|
||||
let (bounds, cov) = stroke_coverage(&plan.stroke);
|
||||
let mut m = vec![false; w * h];
|
||||
let bw = bounds.width() as usize;
|
||||
for (i, c) in cov.iter().enumerate() {
|
||||
let (x, y) = (bounds.x0 + (i % bw.max(1)) as i32, bounds.y0 + (i / bw.max(1)) as i32);
|
||||
if *c > 0.0 && win.contains(x, y) {
|
||||
let j = (y - win.y0) as usize * w + (x - win.x0) as usize;
|
||||
if let Some(v) = m.get_mut(j) {
|
||||
*v = true;
|
||||
for stroke in &plan.strokes {
|
||||
let (bounds, cov) = stroke_coverage(stroke);
|
||||
let bw = bounds.width() as usize;
|
||||
for (i, c) in cov.iter().enumerate() {
|
||||
let (x, y) = (bounds.x0 + (i % bw.max(1)) as i32, bounds.y0 + (i / bw.max(1)) as i32);
|
||||
if *c > 0.0 && win.contains(x, y) {
|
||||
let j = (y - win.y0) as usize * w + (x - win.x0) as usize;
|
||||
if let Some(v) = m.get_mut(j) {
|
||||
*v = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -135,6 +175,34 @@ fn run_remove(doc: &mut Document, plan: &Plan, p: &Value, ctx: &crate::jobs::Job
|
||||
Ok(Some((Region { rect: win, ch: img.ch, data }, hole)))
|
||||
}
|
||||
|
||||
/// The fill on a new layer above `id`: the hole's pixels (opaque where the source had no alpha),
|
||||
/// transparent elsewhere. Returns the damage and the new layer.
|
||||
fn fill_to_new_layer(doc: &mut Document, id: Option<LayerId>, out: &Region, hole: &[bool]) -> Result<(Rect, LayerId)> {
|
||||
let base = doc.pixel_format();
|
||||
let fmt = PixelFormat::new(base.mode, base.sample, true);
|
||||
let n = fmt.channels();
|
||||
let colours = n.saturating_sub(1);
|
||||
if out.ch != n && out.ch != colours {
|
||||
return Err(EngineError::Other("internal error: the fill's channels don't match the document".into()));
|
||||
}
|
||||
let mut data = vec![0.0f32; hole.len().saturating_mul(n)];
|
||||
for ((dst, src), m) in data.chunks_exact_mut(n).zip(out.data.chunks_exact(out.ch)).zip(hole) {
|
||||
if !*m {
|
||||
continue;
|
||||
}
|
||||
for (d, s) in dst.iter_mut().zip(src.iter().take(colours)) {
|
||||
*d = *s;
|
||||
}
|
||||
if let Some(a) = dst.last_mut() {
|
||||
*a = if out.ch == n { src.last().copied().unwrap_or(1.0) } else { 1.0 };
|
||||
}
|
||||
}
|
||||
let mut l = Layer::raster(doc.next_layer_name("Layer"), fmt);
|
||||
crate::pixels_mut(&mut l)?.write_region(out.rect, &data);
|
||||
let nid = doc.insert_above(id, l);
|
||||
Ok((out.rect, nid))
|
||||
}
|
||||
|
||||
pub(super) fn remove(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let plan = plan(s, p)?;
|
||||
let label = "Remove";
|
||||
@@ -142,6 +210,7 @@ pub(super) fn remove(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let doc = d.doc.clone();
|
||||
let p = p.clone();
|
||||
let id = plan.id;
|
||||
let new_layer = plan.new_layer;
|
||||
crate::jobs::run(
|
||||
s,
|
||||
label,
|
||||
@@ -156,6 +225,17 @@ pub(super) fn remove(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
},
|
||||
move |s, filled| {
|
||||
let Some((out, hole)) = filled else { return Ok(json!({"changed": false})) };
|
||||
if new_layer {
|
||||
let (damage, nid) = s.edit(label, |doc, active| {
|
||||
let r = fill_to_new_layer(doc, id, &out, &hole)?;
|
||||
*active = Some(r.1);
|
||||
Ok(r)
|
||||
})?;
|
||||
if let Some(st) = s.active_mut() {
|
||||
st.last_damage = Some(damage);
|
||||
}
|
||||
return Ok(json!({ "damage": damage_json(damage), "layer": nid.0 }));
|
||||
}
|
||||
let damage = s.edit(label, |doc, _| replace(doc, Target { id, all_layers: false }, &p, &out, &hole))?;
|
||||
if let Some(st) = s.active_mut() {
|
||||
st.last_damage = Some(damage);
|
||||
|
||||
@@ -1580,3 +1580,63 @@ fn spot_healing_keeps_film_grain_through_overlapping_strokes() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_several_strokes_apply_as_one_job_and_one_undo_step() {
|
||||
let mut s = square_session(8, "rgb");
|
||||
let bg = rgba(&s, 10, 10);
|
||||
let past = s.active().unwrap().history.past_len();
|
||||
// Each stroke covers half of the square; together they remove it.
|
||||
let r = s.execute("paint.remove", json!({"strokes": [[[38, 30], [45, 30]], [[47, 30], [54, 30]]], "size": 16})).unwrap();
|
||||
assert!(r["damage"].is_array(), "{r}");
|
||||
for (x, y) in [(41, 30), (46, 30), (51, 30)] {
|
||||
assert!(near(rgba(&s, x, y), bg, 0.03), "({x}, {y}): {:?} vs {bg:?}", rgba(&s, x, y));
|
||||
}
|
||||
assert_eq!(s.active().unwrap().history.past_len(), past + 1, "one undo step for both strokes");
|
||||
for p in [
|
||||
json!({"strokes": [], "size": 16}),
|
||||
json!({"strokes": "x", "size": 16}),
|
||||
json!({"strokes": [[]], "size": 16}),
|
||||
json!({"strokes": [[[1, 1]], "x"], "size": 16}),
|
||||
] {
|
||||
assert!(s.execute("paint.remove", p).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_to_a_new_layer_leaves_the_target_untouched() {
|
||||
let mut s = square_session(8, "rgb");
|
||||
let target = s.active().unwrap().active_layer.unwrap();
|
||||
let bg = rgba(&s, 10, 10);
|
||||
let square = rgba(&s, 46, 30);
|
||||
let past = s.active().unwrap().history.past_len();
|
||||
let r = s.execute("paint.remove", json!({"points": [[38, 30], [54, 30]], "size": 16, "newLayer": true})).unwrap();
|
||||
let nid = LayerId(r["layer"].as_u64().expect("the new layer's id"));
|
||||
let d = s.active().unwrap();
|
||||
assert_eq!(d.active_layer, Some(nid), "the new layer is active");
|
||||
assert_ne!(nid, target);
|
||||
let order: Vec<LayerId> = d.doc.walk().into_iter().map(|(_, _, l)| l.id).collect();
|
||||
assert_eq!(order, vec![target, nid], "the new layer sits above the target");
|
||||
assert_eq!(d.doc.layer(nid).unwrap().name, "Layer 1");
|
||||
// The target keeps its pixels; the new layer carries the fill inside the hole only.
|
||||
let old = d.doc.layer(target).unwrap().surface().unwrap();
|
||||
assert!(near(old.rgba(46, 30), square, 0.001));
|
||||
let new = d.doc.layer(nid).unwrap().surface().unwrap();
|
||||
assert!(near(new.rgba(46, 30), bg, 0.03), "{:?}", new.rgba(46, 30));
|
||||
assert_eq!(new.rgba(10, 10)[3], 0.0, "transparent away from the hole");
|
||||
assert_eq!(d.history.past_len(), past + 1);
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
let d = s.active().unwrap();
|
||||
assert!(d.doc.layer(nid).is_none(), "undo removes the layer");
|
||||
assert_eq!(d.active_layer, Some(target));
|
||||
// On an empty layer with Sample All Layers, the composite is what gets filled, onto a new layer.
|
||||
s.execute("layer.new.layer", json!({})).unwrap();
|
||||
let empty = s.active().unwrap().active_layer.unwrap();
|
||||
let r = s.execute("paint.remove", json!({"points": [[38, 30], [54, 30]], "size": 16, "sampleAllLayers": true, "newLayer": true})).unwrap();
|
||||
let nid = LayerId(r["layer"].as_u64().unwrap());
|
||||
let d = s.active().unwrap();
|
||||
let order: Vec<LayerId> = d.doc.walk().into_iter().map(|(_, _, l)| l.id).collect();
|
||||
assert_eq!(order, vec![target, empty, nid]);
|
||||
assert!(near(d.doc.layer(nid).unwrap().surface().unwrap().rgba(46, 30), bg, 0.03));
|
||||
assert!(near(d.doc.layer(target).unwrap().surface().unwrap().rgba(46, 30), square, 0.001));
|
||||
}
|
||||
@@ -4251,6 +4251,8 @@ fn draw_smoothing_string(painter: &egui::Painter, xf: &ViewXform, (brush, pointe
|
||||
|
||||
fn draw_drag_preview(app: &mut PhotocraftApp, painter: &egui::Painter, xf: &ViewXform) {
|
||||
draw_tool_state(app, painter, xf, painter.ctx().input(|i| i.pointer.hover_pos()));
|
||||
// Remove strokes waiting for ↩ stay tinted between drags.
|
||||
crate::retouch_ui::draw_remove_pending(app, painter, xf);
|
||||
let Some(d) = &app.drag else {
|
||||
app.trail = None;
|
||||
return;
|
||||
|
||||
@@ -3661,3 +3661,6 @@ navigator Zoom Zvětšení
|
||||
Light Blue Světle modrá
|
||||
Light Red Světle červená
|
||||
Medium Blue Středně modrá
|
||||
Remove after each stroke Odstranit po každém tahu
|
||||
Apply the removal ({key}) Použít odstranění ({key})
|
||||
Strokes to remove: {n} (Enter applies, Esc cancels) Tahy k odstranění: {n} (Enter použije, Esc zruší)
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -2687,3 +2687,6 @@ curves Smooth Glätten
|
||||
Light Red Hellrot
|
||||
Medium Blue Mittelblau
|
||||
Light Gray Hellgrau
|
||||
Remove after each stroke Nach jedem Strich entfernen
|
||||
Apply the removal ({key}) Entfernen anwenden ({key})
|
||||
Strokes to remove: {n} (Enter applies, Esc cancels) Striche zum Entfernen: {n} (Eingabe wendet an, Esc bricht ab)
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -3745,3 +3745,6 @@ curves Smooth Εξομάλυνση
|
||||
Light Red Ανοιχτό κόκκινο
|
||||
Medium Blue Μεσαίο μπλε
|
||||
Light Gray Ανοιχτό γκρι
|
||||
Remove after each stroke Αφαίρεση μετά από κάθε πινελιά
|
||||
Apply the removal ({key}) Εφαρμογή της αφαίρεσης ({key})
|
||||
Strokes to remove: {n} (Enter applies, Esc cancels) Πινελιές προς αφαίρεση: {n} (Enter εφαρμόζει, Esc ακυρώνει)
|
||||
|
Can't render this file because it contains an unexpected character in line 1000 and column 18.
|
@@ -2728,3 +2728,6 @@ curves Smooth Suavizar
|
||||
Light Red Rojo claro
|
||||
Medium Blue Azul medio
|
||||
Light Gray Gris claro
|
||||
Remove after each stroke Eliminar después de cada trazo
|
||||
Apply the removal ({key}) Aplicar la eliminación ({key})
|
||||
Strokes to remove: {n} (Enter applies, Esc cancels) Trazos por eliminar: {n} (Intro aplica, Esc cancela)
|
||||
|
Can't render this file because it contains an unexpected character in line 4 and column 47.
|
@@ -3048,3 +3048,6 @@ curves Smooth Lisser
|
||||
Light Red Rouge clair
|
||||
Medium Blue Bleu moyen
|
||||
Light Gray Gris clair
|
||||
Remove after each stroke Supprimer après chaque trait
|
||||
Apply the removal ({key}) Appliquer la suppression ({key})
|
||||
Strokes to remove: {n} (Enter applies, Esc cancels) Traits à supprimer : {n} (Entrée applique, Échap annule)
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -3035,3 +3035,6 @@ curves Smooth Haluskan
|
||||
Light Red Merah Terang
|
||||
Medium Blue Biru Sedang
|
||||
Light Gray Abu-abu Muda
|
||||
Remove after each stroke Hapus setelah setiap goresan
|
||||
Apply the removal ({key}) Terapkan penghapusan ({key})
|
||||
Strokes to remove: {n} (Enter applies, Esc cancels) Goresan untuk dihapus: {n} (Enter menerapkan, Esc membatalkan)
|
||||
|
Can't render this file because it contains an unexpected character in line 4 and column 47.
|
@@ -4011,3 +4011,6 @@ navigator Zoom Zoom
|
||||
{index} of {total} {index} di {total}
|
||||
{shortcut} assigned. {shortcut} assegnata.
|
||||
{shortcut} is already in use by {commands} and will be removed from it when you click OK. {shortcut} è già usata da {commands} e verrà rimossa quando fai clic su OK.
|
||||
Remove after each stroke Rimuovi dopo ogni tratto
|
||||
Apply the removal ({key}) Applica la rimozione ({key})
|
||||
Strokes to remove: {n} (Enter applies, Esc cancels) Tratti da rimuovere: {n} (Invio applica, Esc annulla)
|
||||
|
Can't render this file because it contains an unexpected character in line 2590 and column 53.
|
@@ -3657,3 +3657,6 @@ curves Smooth スムーズ
|
||||
Light Red ライトレッド
|
||||
Medium Blue ミディアムブルー
|
||||
Light Gray ライトグレー
|
||||
Remove after each stroke ストロークごとに削除
|
||||
Apply the removal ({key}) 削除を適用 ({key})
|
||||
Strokes to remove: {n} (Enter applies, Esc cancels) 削除するストローク: {n}(Enter で適用、Esc で取り消し)
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -3217,3 +3217,6 @@ curves Smooth 매끄럽게
|
||||
Light Red 연한 빨강
|
||||
Medium Blue 중간 파랑
|
||||
Light Gray 연한 회색
|
||||
Remove after each stroke 획마다 제거
|
||||
Apply the removal ({key}) 제거 적용 ({key})
|
||||
Strokes to remove: {n} (Enter applies, Esc cancels) 제거할 획: {n} (Enter 적용, Esc 취소)
|
||||
|
Can't render this file because it contains an unexpected character in line 3099 and column 53.
|
@@ -3807,3 +3807,6 @@ curves Smooth Vloeiend maken
|
||||
Light Red Lichtrood
|
||||
Medium Blue Middelblauw
|
||||
Light Gray Lichtgrijs
|
||||
Remove after each stroke Verwijderen na elke streek
|
||||
Apply the removal ({key}) Verwijdering toepassen ({key})
|
||||
Strokes to remove: {n} (Enter applies, Esc cancels) Streken om te verwijderen: {n} (Enter past toe, Esc annuleert)
|
||||
|
Can't render this file because it contains an unexpected character in line 8 and column 34.
|
@@ -3644,3 +3644,6 @@ curves Smooth Wygładź
|
||||
Light Red Jasnoczerwony
|
||||
Medium Blue Średnioniebieski
|
||||
Light Gray Jasnoszary
|
||||
Remove after each stroke Usuwaj po każdym pociągnięciu
|
||||
Apply the removal ({key}) Zastosuj usuwanie ({key})
|
||||
Strokes to remove: {n} (Enter applies, Esc cancels) Pociągnięcia do usunięcia: {n} (Enter stosuje, Esc anuluje)
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -3837,3 +3837,6 @@ layerLabel No Color Sem cor
|
||||
Light Red Vermelho-claro
|
||||
Medium Blue Azul médio
|
||||
Light Gray Cinza-claro
|
||||
Remove after each stroke Remover após cada traço
|
||||
Apply the removal ({key}) Aplicar a remoção ({key})
|
||||
Strokes to remove: {n} (Enter applies, Esc cancels) Traços a remover: {n} (Enter aplica, Esc cancela)
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -2992,3 +2992,6 @@ curves Smooth Сгладить
|
||||
Light Red Светло-красный
|
||||
Medium Blue Средне-синий
|
||||
Light Gray Светло-серый
|
||||
Remove after each stroke Удалять после каждого штриха
|
||||
Apply the removal ({key}) Применить удаление ({key})
|
||||
Strokes to remove: {n} (Enter applies, Esc cancels) Штрихов к удалению: {n} (Enter применяет, Esc отменяет)
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -3745,3 +3745,6 @@ curves Smooth Згладити
|
||||
Light Red Світло-червоний
|
||||
Medium Blue Середньо-синій
|
||||
Light Gray Світло-сірий
|
||||
Remove after each stroke Видаляти після кожного штриха
|
||||
Apply the removal ({key}) Застосувати видалення ({key})
|
||||
Strokes to remove: {n} (Enter applies, Esc cancels) Штрихів для видалення: {n} (Enter застосовує, Esc скасовує)
|
||||
|
Can't render this file because it contains an unexpected character in line 1356 and column 18.
|
@@ -3531,3 +3531,6 @@ curves Smooth Làm mượt
|
||||
Stroke Path… Đi nét đường dẫn…
|
||||
Apply mask to layer before removing? Áp dụng mặt nạ vào lớp trước khi xóa?
|
||||
Delete the vector mask? Xóa mặt nạ vectơ?
|
||||
Remove after each stroke Xóa sau mỗi nét vẽ
|
||||
Apply the removal ({key}) Áp dụng việc xóa ({key})
|
||||
Strokes to remove: {n} (Enter applies, Esc cancels) Nét vẽ cần xóa: {n} (Enter áp dụng, Esc hủy)
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -2652,3 +2652,6 @@ curves Smooth 平滑
|
||||
Light Red 浅红色
|
||||
Medium Blue 中蓝色
|
||||
Light Gray 浅灰色
|
||||
Remove after each stroke 每次描边后移除
|
||||
Apply the removal ({key}) 应用移除 ({key})
|
||||
Strokes to remove: {n} (Enter applies, Esc cancels) 待移除的描边:{n}(Enter 应用,Esc 取消)
|
||||
|
Can't render this file because it contains an unexpected character in line 2532 and column 53.
|
@@ -2637,3 +2637,6 @@ curves Smooth 平滑
|
||||
Light Red 淺紅色
|
||||
Medium Blue 中藍色
|
||||
Light Gray 淺灰色
|
||||
Remove after each stroke 每次描邊後移除
|
||||
Apply the removal ({key}) 套用移除 ({key})
|
||||
Strokes to remove: {n} (Enter applies, Esc cancels) 待移除的描邊:{n}(Enter 套用,Esc 取消)
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -484,6 +484,11 @@ pub struct PhotocraftApp {
|
||||
committed_stroke: Option<canvas::CommittedStroke>,
|
||||
/// Footprint trail of a retouching drag (see `stroke_trail`).
|
||||
trail: Option<stroke_trail::Trail>,
|
||||
/// Remove tool strokes waiting to be applied together (Remove after each stroke off), the
|
||||
/// document they belong to, and their tint on the canvas (`retouch_ui`).
|
||||
pub(crate) remove_pending: Vec<Vec<[f64; 3]>>,
|
||||
pub(crate) remove_pending_doc: Option<photocraft_doc::DocId>,
|
||||
pub(crate) remove_trail: Option<stroke_trail::Trail>,
|
||||
/// The open menus' items, reused between frames (`menus::ItemCache`).
|
||||
pub(crate) menu_cache: Option<menus::ItemCache>,
|
||||
/// Move tool drag shown live (`move_ui`).
|
||||
@@ -709,6 +714,9 @@ impl PhotocraftApp {
|
||||
live_stroke: None,
|
||||
committed_stroke: None,
|
||||
trail: None,
|
||||
remove_pending: Vec::new(),
|
||||
remove_pending_doc: None,
|
||||
remove_trail: None,
|
||||
menu_cache: None,
|
||||
move_preview: None,
|
||||
move_hover: None,
|
||||
|
||||
@@ -84,7 +84,13 @@ pub fn finish_stroke(app: &mut PhotocraftApp, tool: Tool, points: &[[f64; 3]], m
|
||||
let pts = json!(points);
|
||||
let (cmd, mut p): (&str, Value) = match tool {
|
||||
Tool::SpotHealing => ("paint.spotHealing", json!({"type": o.spot_type, "sampleAllLayers": o.sample_all_layers})),
|
||||
Tool::Remove => ("paint.remove", json!({"sampleAllLayers": o.sample_all_layers})),
|
||||
Tool::Remove => {
|
||||
if !o.remove_after_each_stroke {
|
||||
remove_pending_add(app, points);
|
||||
return true;
|
||||
}
|
||||
("paint.remove", remove_params(&o))
|
||||
}
|
||||
Tool::MixerBrush => ("paint.mixerBrush", json!({})),
|
||||
Tool::ColorReplacement => ("paint.colorReplacement", color_replacement_params(app)),
|
||||
Tool::PatternStamp => {
|
||||
@@ -154,6 +160,87 @@ pub fn finish_stroke(app: &mut PhotocraftApp, tool: Tool, points: &[[f64; 3]], m
|
||||
true
|
||||
}
|
||||
|
||||
/// `paint.remove` params from the options bar (without the strokes).
|
||||
fn remove_params(o: &crate::state::ToolOptions) -> Value {
|
||||
json!({"sampleAllLayers": o.sample_all_layers, "newLayer": o.remove_new_layer})
|
||||
}
|
||||
|
||||
/// Remove after each stroke off: a finished stroke waits with the others, tinted on the canvas,
|
||||
/// until ↩ or the options bar's ✓ applies them all as one removal (Esc or ⊘ drops them).
|
||||
pub fn remove_pending_add(app: &mut PhotocraftApp, points: &[[f64; 3]]) {
|
||||
let Some(st) = app.session.active() else { return };
|
||||
let (doc, size) = (st.doc.id, [st.doc.size.width, st.doc.size.height]);
|
||||
if app.remove_pending_doc != Some(doc) {
|
||||
app.remove_pending.clear();
|
||||
app.remove_trail = None;
|
||||
app.remove_pending_doc = Some(doc);
|
||||
}
|
||||
let diameter = app.session.tools.brush.size;
|
||||
app.remove_trail.get_or_insert_with(|| crate::stroke_trail::Trail::new(size)).feed(points, diameter);
|
||||
app.remove_pending.push(points.to_vec());
|
||||
app.ui.status = crate::i18n::fmt(tl!("Strokes to remove: {n} (Enter applies, Esc cancels)"), &[("n", &app.remove_pending.len().to_string())]);
|
||||
app.ui.status_error = false;
|
||||
}
|
||||
|
||||
/// Apply the waiting strokes as one removal (one job, one undo step). False when none wait.
|
||||
pub fn remove_pending_commit(app: &mut PhotocraftApp) -> bool {
|
||||
if app.remove_pending.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let strokes = std::mem::take(&mut app.remove_pending);
|
||||
app.remove_trail = None;
|
||||
app.remove_pending_doc = None;
|
||||
let mut p = remove_params(&app.ui.tool_options);
|
||||
p["strokes"] = json!(strokes);
|
||||
p["target"] = crate::canvas::paint_target(app);
|
||||
app.ui.status.clear();
|
||||
if let Err(e) = app.run("paint.remove", p) {
|
||||
app.ui.status = e;
|
||||
app.ui.status_error = true;
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Drop the waiting strokes. False when none wait.
|
||||
pub fn remove_pending_cancel(app: &mut PhotocraftApp) -> bool {
|
||||
if app.remove_pending.is_empty() {
|
||||
return false;
|
||||
}
|
||||
app.remove_pending.clear();
|
||||
app.remove_trail = None;
|
||||
app.remove_pending_doc = None;
|
||||
app.ui.status.clear();
|
||||
app.ui.status_error = false;
|
||||
true
|
||||
}
|
||||
|
||||
/// Whether Remove strokes are still waiting: they are dropped when the tool or the document
|
||||
/// changed under them (checked every frame).
|
||||
pub fn remove_pending_live(app: &mut PhotocraftApp) -> bool {
|
||||
if app.remove_pending.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let doc = app.session.active().map(|st| st.doc.id);
|
||||
if app.ui.tool == Tool::Remove && doc.is_some() && app.remove_pending_doc == doc {
|
||||
return true;
|
||||
}
|
||||
remove_pending_cancel(app);
|
||||
false
|
||||
}
|
||||
|
||||
/// The tint of the waiting Remove strokes, drawn every frame.
|
||||
pub fn draw_remove_pending(app: &mut PhotocraftApp, painter: &egui::Painter, xf: &ViewXform) {
|
||||
if !remove_pending_live(app) {
|
||||
return;
|
||||
}
|
||||
let Some(bounds) = app.session.active().map(|st| st.doc.bounds()) else { return };
|
||||
let doc_rect = xf.doc_rect(bounds);
|
||||
let col = Tokens::get(painter.ctx()).danger.gamma_multiply(0.45);
|
||||
if let Some(trail) = app.remove_trail.as_mut() {
|
||||
trail.draw(painter, doc_rect, xf.flip, col);
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
@@ -356,7 +443,23 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui, tool: Tool) -> bo
|
||||
crate::widgets::checkbox(ui, &mut o.sample_all_layers, tl!("Sample All Layers"));
|
||||
}
|
||||
Tool::Remove => {
|
||||
// Photoshop's options bar: Sample All Layers, Create a new layer, Remove after each
|
||||
// stroke; with strokes waiting, ✓ applies them and ⊘ drops them.
|
||||
crate::widgets::checkbox(ui, &mut o.sample_all_layers, tl!("Sample All Layers"));
|
||||
crate::widgets::checkbox(ui, &mut o.remove_new_layer, tl!("Create a new layer"));
|
||||
crate::widgets::checkbox(ui, &mut o.remove_after_each_stroke, tl!("Remove after each stroke"));
|
||||
if !app.remove_pending.is_empty() {
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
if crate::widgets::primary_button(ui, "✓", 32.0)
|
||||
.on_hover_text(crate::i18n::fmt(tl!("Apply the removal ({key})"), &[("key", &crate::shortcuts::pretty("Enter"))]))
|
||||
.clicked()
|
||||
{
|
||||
remove_pending_commit(app);
|
||||
} else if crate::widgets::secondary_button(ui, "⊘", 32.0).on_hover_text(tl!("Cancel (Esc)")).clicked() {
|
||||
remove_pending_cancel(app);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
Tool::Patch => {
|
||||
// Photoshop's options bar: Patch: Normal (Source / Destination) or Content-Aware
|
||||
@@ -955,6 +1058,76 @@ mod tests {
|
||||
assert!(px.iter().zip(bg).all(|(a, b)| (a - b).abs() < 0.02), "{px:?} vs {bg:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_after_each_stroke_off_collects_strokes_until_enter_or_escape() {
|
||||
let mut app = app();
|
||||
app.run("paint.pencil", json!({"points": [[0, 30], [100, 30]], "size": 200, "color": "#808080"})).unwrap();
|
||||
app.run("paint.pencil", json!({"points": [[30, 20], [30, 40]], "size": 4, "color": "#000000"})).unwrap();
|
||||
app.ui.tool = Tool::Remove;
|
||||
app.ui.tool_options.remove_after_each_stroke = false;
|
||||
let m = egui::Modifiers::NONE;
|
||||
let journal = app.session.journal.len();
|
||||
for x in [28.0, 32.0] {
|
||||
tool_event(&mut app, ToolEvent::Down { x, y: 18.0, pressure: 1.0 }, m);
|
||||
tool_event(&mut app, ToolEvent::Move { x, y: 42.0, pressure: 1.0 }, m);
|
||||
tool_event(&mut app, ToolEvent::Up { x, y: 42.0 }, m);
|
||||
}
|
||||
// Nothing ran yet: the strokes wait, tinted, and the status says so.
|
||||
assert_eq!(app.session.journal.len(), journal, "no command before Enter");
|
||||
assert_eq!(app.remove_pending.len(), 2);
|
||||
assert!(app.remove_trail.is_some());
|
||||
assert!(app.ui.status.contains("2") && !app.ui.status_error, "{}", app.ui.status);
|
||||
// Esc drops them.
|
||||
assert!(remove_pending_cancel(&mut app));
|
||||
assert!(app.remove_pending.is_empty() && app.remove_trail.is_none());
|
||||
assert!(!remove_pending_cancel(&mut app));
|
||||
// Collected again, Enter applies them as one `paint.remove` with both strokes.
|
||||
for x in [28.0, 32.0] {
|
||||
tool_event(&mut app, ToolEvent::Down { x, y: 18.0, pressure: 1.0 }, m);
|
||||
tool_event(&mut app, ToolEvent::Up { x, y: 42.0 }, m);
|
||||
}
|
||||
let bg = active(&app).surface().unwrap().rgba(70, 30);
|
||||
assert!(remove_pending_commit(&mut app));
|
||||
assert!(!app.ui.status_error, "{}", app.ui.status);
|
||||
let (id, p) = app.session.journal.last().cloned().expect("a journaled command");
|
||||
assert_eq!(id, "paint.remove");
|
||||
assert_eq!(p["strokes"].as_array().map(Vec::len), Some(2));
|
||||
assert!(p.get("points").is_none());
|
||||
assert!(app.remove_pending.is_empty());
|
||||
let px = active(&app).surface().unwrap().rgba(30, 30);
|
||||
assert!(px.iter().zip(bg).all(|(a, b)| (a - b).abs() < 0.02), "{px:?} vs {bg:?}");
|
||||
assert!(!remove_pending_commit(&mut app), "nothing left to apply");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_strokes_waiting_are_dropped_when_the_tool_changes() {
|
||||
let mut app = app();
|
||||
app.ui.tool = Tool::Remove;
|
||||
app.ui.tool_options.remove_after_each_stroke = false;
|
||||
remove_pending_add(&mut app, &[[10.0, 10.0, 1.0], [20.0, 10.0, 1.0]]);
|
||||
assert_eq!(app.remove_pending.len(), 1);
|
||||
assert!(remove_pending_live(&mut app));
|
||||
app.ui.tool = Tool::SpotHealing;
|
||||
assert!(!remove_pending_live(&mut app));
|
||||
assert!(app.remove_pending.is_empty(), "the strokes belong to the Remove tool");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_a_new_layer_sends_new_layer_to_the_command() {
|
||||
let mut app = app();
|
||||
app.run("paint.pencil", json!({"points": [[0, 30], [100, 30]], "size": 200, "color": "#808080"})).unwrap();
|
||||
app.ui.tool = Tool::Remove;
|
||||
app.ui.tool_options.remove_new_layer = true;
|
||||
let layers = app.session.active().unwrap().doc.walk().len();
|
||||
let m = egui::Modifiers::NONE;
|
||||
tool_event(&mut app, ToolEvent::Down { x: 30.0, y: 18.0, pressure: 1.0 }, m);
|
||||
tool_event(&mut app, ToolEvent::Up { x: 30.0, y: 42.0 }, m);
|
||||
assert!(!app.ui.status_error, "{}", app.ui.status);
|
||||
let (id, p) = app.session.journal.last().cloned().unwrap();
|
||||
assert_eq!((id.as_str(), p["newLayer"].as_bool()), ("paint.remove", Some(true)));
|
||||
assert_eq!(app.session.active().unwrap().doc.walk().len(), layers + 1, "the fill went to a new layer");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn red_eye_is_in_the_j_flyout() {
|
||||
let j = [Tool::Remove, Tool::SpotHealing, Tool::Healing, Tool::Patch, Tool::ContentAwareMove, Tool::RedEye];
|
||||
|
||||
@@ -481,6 +481,17 @@ pub fn handle(app: &mut PhotocraftApp, ctx: &egui::Context) {
|
||||
// brush keys [ ] ⇧[ ⇧] (`tools.decreaseBrushSize`…) are commands, dispatched above with any
|
||||
// Keyboard Shortcuts override.
|
||||
let pressed = |k: Key| ctx.input_mut(|i| i.consume_key(Modifiers::NONE, k));
|
||||
// Remove tool with strokes waiting (Remove after each stroke off): ↩ applies them, Esc drops them.
|
||||
if !app.remove_pending.is_empty() {
|
||||
if pressed(Key::Enter) {
|
||||
crate::retouch_ui::remove_pending_commit(app);
|
||||
return;
|
||||
}
|
||||
if pressed(Key::Escape) {
|
||||
crate::retouch_ui::remove_pending_cancel(app);
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Enter / Escape commit or cancel in-progress tool state (polygonal lasso, crop).
|
||||
if crate::lasso_ui::active(app) {
|
||||
let mods = ctx.input(|i| i.modifiers);
|
||||
|
||||
@@ -508,6 +508,11 @@ pub struct ToolOptions {
|
||||
pub contiguous: bool,
|
||||
pub anti_alias: bool,
|
||||
pub sample_all_layers: bool,
|
||||
/// Remove tool: the fill goes to a new layer above the target (Photoshop's Create a new layer).
|
||||
pub remove_new_layer: bool,
|
||||
/// Remove tool: each stroke is filled when released (Photoshop's default). Off, the strokes
|
||||
/// collect as a tint on the canvas and ↩ applies them together (Esc drops them).
|
||||
pub remove_after_each_stroke: bool,
|
||||
pub feather: f32,
|
||||
/// linear | radial | angle | reflected | diamond
|
||||
pub gradient_style: String,
|
||||
@@ -744,6 +749,8 @@ impl Default for ToolOptions {
|
||||
contiguous: true,
|
||||
anti_alias: true,
|
||||
sample_all_layers: false,
|
||||
remove_new_layer: false,
|
||||
remove_after_each_stroke: true,
|
||||
feather: 0.0,
|
||||
gradient_style: "linear".into(),
|
||||
gradient_reverse: false,
|
||||
|
||||
+1
-1
@@ -171,7 +171,7 @@ Corpus floors (`crates/io/tests/corpus.rs`): the enforced minimums; the corpus t
|
||||
| TOOL-214-22 | Crop gear menu: Show Cropped Area (H), Enable Crop Shield with Match Canvas / custom colour, opacity and Auto Adjust Opacity | done | [#1919](https://github.com/storytold/photocraft/issues/1919) | crop_shield.rs: fill() resolves the shield (canvas.rs paints it around upright and turned frames), gear popup in the options bar, H ahead of the Hand key in shortcuts.rs, Custom… opens the Color Picker; ToolOptions crop_shield, ui.set cropShield. Use Classic Mode / Auto Center Preview: TOOL-214-23 |
|
||||
| TOOL-214-23 | Crop: Photoshop's default mode (the image turns and moves behind an upright box, Auto Center Preview keeps a resized box's centre in place and centres a drawn one) and Use Classic Mode (P) in the gear menu | done | [#1919](https://github.com/storytold/photocraft/issues/1919) | crop_mode.rs: the crop model (crop_rect + crop_angle) and image.crop are shared by both modes; default mode sets the view's rotation to -crop_angle while the tool shows a frame (picking the tool, in either mode, turns a Rotate View turn upright about the view centre, not restored after Enter/Esc or by P; Classic Mode keeps the camera upright with the frame drawn turned; as Photoshop 25.1), pivoting so the box stays put, moves view and frame together for drags inside and arrow keys, keeps a resized box's centre in place live (Auto Center Preview; a drawn box is centred on release, a moved one never), readout = the image's turn; gear menu order and Classic Mode greying as Photoshop 25.1; CropShield classic_mode / auto_center_preview (ui.set cropShield), P ahead of the Pen key |
|
||||
| TOOL-1053-1 | Shape stroke controls: solid/dashed/dotted, custom dash/gap/offset, caps, joins, alignment, miter limit and opacity | done | [#1053](https://github.com/storytold/photocraft/issues/1053) | shape_stroke_ui.rs: shared staged editor, renderer samples, cached Properties canvas preview; existing shape.create/edit/info; serde tool defaults, ui.set shapeStroke; pcraft and vstk style round trips. Gradient fill UI and saved stroke libraries remain open; Line retains filled-bar geometry |
|
||||
| TOOL-213-19 | Remove tool: paint over an object (or circle it) and it is filled from its surroundings | partial | [#213](https://github.com/storytold/photocraft/issues/213) | paint.remove (retouch_cmds/remove.rs, background job) + Tool::Remove in the J flyout; loops close over what they enclose; fill by non-local patch completion with texture features, onion-peel start, best-patch copy and gradient-domain seam hiding (algo/nonlocal.rs, Newson et al. IPOL 2017); sparse holes in tiles (algo/remove.rs). remove_quality on 20 holes in public-domain photos: the fill keeps 0.89 of the texture it replaces (0.54 with Content-Aware Fill's completion), edge gradient 1.03 of the original's; remove_bench 24 MP: 100 px ring 120 ms, 1000 px ring 2.5 s, 3000 px line 1.7 s. Missing: generative AI mode, Remove after each stroke off, live preview of the result |
|
||||
| TOOL-213-19 | Remove tool: paint over an object (or circle it) and it is filled from its surroundings | partial | [#213](https://github.com/storytold/photocraft/issues/213) | paint.remove (retouch_cmds/remove.rs, background job) + Tool::Remove in the J flyout; loops close over what they enclose; fill by non-local patch completion with texture features, onion-peel start, best-patch copy and gradient-domain seam hiding (algo/nonlocal.rs, Newson et al. IPOL 2017); sparse holes in tiles (algo/remove.rs). remove_quality on 20 holes in public-domain photos: the fill keeps 0.89 of the texture it replaces (0.54 with Content-Aware Fill's completion), edge gradient 1.03 of the original's; remove_bench 24 MP: 100 px ring 120 ms, 1000 px ring 2.5 s, 3000 px line 1.7 s. Options bar: Sample All Layers, Create a new layer (the fill on a new layer above, `newLayer`), Remove after each stroke off (strokes collect as a tint and ↩ applies them as one `strokes` job, Esc drops them). Missing: generative AI mode, live preview of the result |
|
||||
| TOOL-1045-1 | Color Replacement tool on the toolbar | done | [#1045](https://github.com/storytold/photocraft/issues/1045) | Tool::ColorReplacement in the Brush (B) flyout after the Mixer Brush; Mode/Sampling/Limits/Tolerance/Anti-alias options drive paint.colorReplacement with the foreground colour; live preview (LiveDab with the stroke's finishing dab), one History step; color_replacement_tests.rs |
|
||||
|
||||
## Workspace and UI
|
||||
|
||||
@@ -481,7 +481,10 @@ best-patch copy and gradient-domain seam hiding. On 20 holes in public-domain ph
|
||||
original's texture (Wexler/PatchMatch completion as Content-Aware Fill uses it: 0.54). On a 24 MP
|
||||
document a 100 px ring takes 120 ms and a 1000 px ring 2.5 s (`remove_bench`). It is not
|
||||
generative: large objects over complex structure fill less convincingly than Photoshop's AI mode
|
||||
(#41).
|
||||
(#41). 2026-10-12: the options bar has Photoshop's three options: Sample All Layers, Create a new
|
||||
layer (the fill lands on a new layer above the target, `paint.remove` `newLayer`) and Remove after
|
||||
each stroke; off, the strokes collect as a tint and ↩ (or ✓) applies them together as one job and
|
||||
one undo step (`strokes`), Esc (or ⊘) drops them (#1750).
|
||||
|
||||
2026-10-08: the tools update above is checked against the current toolbar groups in
|
||||
[`panels.rs`](../crates/ui-egui/src/panels.rs), the Pencil and tool-cycle tests in
|
||||
|
||||
@@ -297,7 +297,7 @@ id = "TOOL-213-19"
|
||||
target = "Remove tool: paint over an object (or circle it) and it is filled from its surroundings"
|
||||
status = "partial"
|
||||
issue = 213
|
||||
note = "paint.remove (retouch_cmds/remove.rs, background job) + Tool::Remove in the J flyout; loops close over what they enclose; fill by non-local patch completion with texture features, onion-peel start, best-patch copy and gradient-domain seam hiding (algo/nonlocal.rs, Newson et al. IPOL 2017); sparse holes in tiles (algo/remove.rs). remove_quality on 20 holes in public-domain photos: the fill keeps 0.89 of the texture it replaces (0.54 with Content-Aware Fill's completion), edge gradient 1.03 of the original's; remove_bench 24 MP: 100 px ring 120 ms, 1000 px ring 2.5 s, 3000 px line 1.7 s. Missing: generative AI mode, Remove after each stroke off, live preview of the result"
|
||||
note = "paint.remove (retouch_cmds/remove.rs, background job) + Tool::Remove in the J flyout; loops close over what they enclose; fill by non-local patch completion with texture features, onion-peel start, best-patch copy and gradient-domain seam hiding (algo/nonlocal.rs, Newson et al. IPOL 2017); sparse holes in tiles (algo/remove.rs). remove_quality on 20 holes in public-domain photos: the fill keeps 0.89 of the texture it replaces (0.54 with Content-Aware Fill's completion), edge gradient 1.03 of the original's; remove_bench 24 MP: 100 px ring 120 ms, 1000 px ring 2.5 s, 3000 px line 1.7 s. Options bar: Sample All Layers, Create a new layer (the fill on a new layer above, `newLayer`), Remove after each stroke off (strokes collect as a tint and ↩ applies them as one `strokes` job, Esc drops them). Missing: generative AI mode, live preview of the result"
|
||||
|
||||
[[item]]
|
||||
id = "TOOL-1045-1"
|
||||
|
||||
Reference in new issue
Block a user