Add the Patch tool with a live preview (#213) (#381)

* Add the Patch tool (#213)

paint.patch heals the selected area with texture from where the
selection is dragged (Source) or copies the selection onto the
dragged-to area (Destination), seamless-cloned like the Healing Brush.
The solve is clipped to the canvas, so patches at the edge stay opaque.

Tool::Patch joins the J group: outside the selection it draws a lasso
(Shift/Alt add/subtract), inside it drags the patch, the outline follows
the pointer and the offset is clamped to the canvas. Options bar:
Source / Destination. Strings translated in all six catalogs.

Tests at 8/16/32 bit, a UI gesture test, panic_hunt green. Scorecard
TOOL-213-3 is partial: the drag previews the outline, not the pixels.
retouch_bench: patch 250x250 22 ms, 1000x1000 362 ms (release).

generated with Claude Code

* Patch tool: live preview of the heal while dragging (#213)

The canvas shows the document healed at the pointer's offset
(patch_preview.rs, modelled on the Move tool's preview) and
recomposites only the healed area; release commits the exact
paint.patch and its damage rect refreshes the same area.

The preview (retouch_cmds::patch_preview) solves the colour fit on a
coarse grid (at most 128x128 cells) and interpolates it back: the
Poisson result is source + a harmonic correction, so the texture stays
exact and only the smooth correction is approximated (worst pixel 0.022
at 8x coarser in the test). Selection coverage is read tile-wise and
the interpolation runs per row in parallel.

retouch_bench (release, 6016x6016): preview 250x250 7 ms, 500x500
17 ms, 1000x1000 52 ms (exact patch: 21 / 90 / 354 ms). Scorecard
TOOL-213-3 done. Also applies rustfmt to the first commit's code.

generated with Claude Code

* Patch tool: cap patches at 16M pixels, drop product names from comments (#213)

The solve needs roughly 150 bytes per pixel, so the old 64M cap allowed
about 10 GB and risked an allocation abort instead of an error.

generated with Claude Code

* Regenerate docs/scorecard.md

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Tool::ALL holds both the Patch tool and the Mixer Brush (rebase onto #433)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
alexanderalber 2026-10-07 13:42:30 +02:00 committed by GitHub
parent cc5de414dc
commit b4f9d56c93
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 716 additions and 18 deletions

View File

@ -1,4 +1,4 @@
//! Timing: Healing Brush and Spot Healing dab cost on a 6016×6016 document.
//! Timing: Healing Brush and Spot Healing dab cost, and Patch 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`:
@ -57,4 +57,16 @@ fn main() {
let t = Instant::now();
s.execute("paint.healingBrush", json!({"points": [[2300, 3500], [2700, 3500]], "offset": [0, -300], "size": 100, "hardness": 50})).unwrap();
println!("healingBrush stroke 400 px long, size 100: {:.1?}", t.elapsed());
// Patch: an elliptical selection of each size, dragged 300 px; the whole selection is solved.
for size in [100, 250, 500, 1000] {
let (x, y) = (2300, 2300);
s.execute("select.rect", json!({"x": x, "y": y, "width": size, "height": size, "ellipse": true})).unwrap();
let t = Instant::now();
s.execute("paint.patch", json!({"offset": [300, 200]})).unwrap();
println!("patch ellipse {size:>4}×{size:<4}: {:>8.1?}", t.elapsed());
// The Patch Tool's live preview of the same patch (coarse solve, 128² cells, at 100 % zoom).
let t = Instant::now();
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());
}
}

View File

@ -1,5 +1,5 @@
//! Retouching tools as commands: Clone Stamp, Healing Brush, Spot Healing Brush, Dodge, Burn,
//! Sponge, Blur, Sharpen, Smudge and History Brush.
//! Retouching tools as commands: Clone Stamp, Healing Brush, Spot Healing Brush, Patch, 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,9 @@ use serde_json::{Value, json};
use crate::commands::{CommandSpec, blend_from_str};
use crate::{EngineError, Result, Session};
mod patch;
pub use patch::preview as patch_preview;
fn bad(cmd: &str, msg: impl Into<String>) -> EngineError {
EngineError::BadParams { cmd: cmd.into(), msg: msg.into() }
}
@ -683,6 +686,16 @@ pub fn specs() -> Vec<CommandSpec> {
run: spot_healing,
journal: true,
},
CommandSpec {
id: "paint.patch",
label: "Patch",
menu: &[],
shortcut: None,
params: r#"{"offset":[dx,dy] (how far the selection was dragged),"mode":"source|destination"="source","layer":id?=active,"target":"pixels"|"mask"|"quickMask"|{"channel":i}=Channels panel target} → {"damage","offset"}"#,
enabled: patch::enabled,
run: patch::patch,
journal: true,
},
CommandSpec {
id: "paint.dodge",
label: "Dodge",

View File

@ -0,0 +1,231 @@
//! The Patch Tool as a command: heal the selected area with texture from another place.
//!
//! The selection is the patch, `offset` is how far the user dragged it. In `source` mode (the
//! default) the selected area is repaired with the pixels under the dragged outline; in
//! `destination` mode the selected pixels are copied to the dragged outline and repair it. Either way the copied texture is seamless-cloned (`heal_region`, as the Healing Brush), so it
//! takes the colour and lighting around the repaired area. A feathered selection blends the result
//! in by its coverage. Only the targeted surface is read and written: in Normal mode the patch
//! samples the current layer.
use super::*;
const CMD: &str = "paint.patch";
/// Largest patch, in pixels of the selection's bounding box. The solve holds the two regions, the
/// result and per-channel float planes of it (on the order of 150 bytes per pixel with four
/// channels), so this keeps a patch to about 2.5 GB at worst instead of risking an allocation abort.
const MAX_PIXELS: u64 = 16 << 20;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum PatchMode {
/// The selection is repaired with texture from the dragged-to place.
Source,
/// The dragged-to place is repaired with texture from the selection.
Destination,
}
/// A pixel target and a selection to patch with.
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 patch first".into()) }
}
/// 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>> {
if crate::channel_cmds::is_channel_target(p) {
return Ok(None);
}
match p.get("layer").and_then(Value::as_u64) {
Some(id) => Ok(Some(LayerId(id))),
None => Ok(Some(s.active().and_then(|d| d.active_layer).ok_or(EngineError::Other("no active layer".into()))?)),
}
}
/// 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])"))?;
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"));
}
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 {
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> {
// 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()
}
/// Seamless clone with the membrane solved `step` times coarser: the Poisson result is the source
/// plus a harmonic (smooth) correction `h` of the boundary mismatch, so `h` is solved on a grid of
/// `step`×`step` cells and interpolated back, while the texture stays the full-resolution source.
/// The live preview's approximation of [`heal_region`] (`step` 1 is exactly it).
fn heal_region_coarse(fmt: &PixelFormat, src: &Region, dst: &Region, mask: &[bool], step: usize) -> Region {
if step <= 1 {
return heal_region(fmt, src, dst, mask);
}
let (w, h, n) = (dst.width(), dst.height(), dst.ch);
let (cw, chh) = (w.div_ceil(step), h.div_ceil(step));
// A cell is unknown when every pixel in it is patched; otherwise it holds the mean mismatch
// `dst - src` of its unpatched pixels (the Dirichlet data of the coarse problem).
let mut diff = vec![0.0f32; cw * chh * n];
let mut hole = vec![true; cw * chh];
let mut count = vec![0u32; cw * chh];
for y in 0..h {
for x in 0..w {
let i = y * w + x;
if mask[i] {
continue;
}
let c = (y / step) * cw + x / step;
hole[c] = false;
count[c] += 1;
for k in 0..n {
diff[c * n + k] += dst.data[i * n + k] - src.data[i * n + k];
}
}
}
for (c, m) in count.iter().enumerate() {
if *m > 0 {
diff[c * n..(c + 1) * n].iter_mut().for_each(|v| *v /= *m as f32);
}
}
let membrane = poisson::membrane_fill(cw, chh, n, &diff, &hole);
// Bilinear lookup of the membrane (cell centres at (c + 0.5) · step): the two cells and the
// weight along one axis, per pixel coordinate.
let lerp_at = |p: usize, cells: usize| {
let f = ((p as f32 + 0.5) / step as f32 - 0.5).clamp(0.0, (cells - 1) as f32);
let c0 = f.floor() as usize;
(c0, (c0 + 1).min(cells - 1), f - c0 as f32)
};
let xs: Vec<(usize, usize, f32)> = (0..w).map(|x| lerp_at(x, cw)).collect();
let mut out = dst.clone();
let fill_row = |(y, row): (usize, &mut [f32])| {
let (y0, y1, ty) = lerp_at(y, chh);
for (x, &(x0, x1, tx)) in xs.iter().enumerate() {
let i = y * w + x;
if !mask[i] {
continue;
}
for k in 0..n {
let v = |cx: usize, cy: usize| membrane[(cy * cw + cx) * n + k];
let m = (v(x0, y0) * (1.0 - tx) + v(x1, y0) * tx) * (1.0 - ty) + (v(x0, y1) * (1.0 - tx) + v(x1, y1) * tx) * ty;
row[x * n + k] = src.data[i * n + k] + m;
}
}
};
{
use rayon::prelude::*;
out.data.par_chunks_mut(w * n).enumerate().for_each(fill_row);
}
clamp_samples(fmt, &mut out.data);
out
}
/// What a patch does, checked: the target layer, the selected area, and where it heals from and to.
struct Plan {
id: Option<LayerId>,
canvas: Rect,
area: Rect,
/// Shift of the repaired area from the selection, and of the texture from the repaired area.
dst_shift: (i32, i32),
src_shift: (i32, i32),
destination: bool,
offset: (i32, i32),
}
fn plan(s: &Session, p: &Value) -> Result<Plan> {
let mode = match string(p, "mode", "source") {
"source" => PatchMode::Source,
"destination" => PatchMode::Destination,
o => return Err(bad(CMD, format!("unknown mode `{o}` (source|destination)"))),
};
let (dx, dy) = offset(p)?;
let id = target_layer(s, p)?;
let d = s.active().ok_or(EngineError::Other("no document open".into()))?;
let canvas = d.doc.bounds();
let sel = d.doc.selection.as_ref().ok_or_else(|| bad(CMD, "select the area to patch 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 patch ({pixels} pixels, at most {MAX_PIXELS})")));
}
if (dx, dy) == (0, 0) {
return Err(bad(CMD, "drag the selection to the area to sample from"));
}
// Both the texture and the repaired area must lie on the canvas: off-canvas pixels are empty.
if !canvas.contains_rect(&area.translate(dx, dy)) {
return Err(bad(CMD, "the dragged patch must stay inside the canvas"));
}
let (dst_shift, src_shift) = match mode {
PatchMode::Source => ((0, 0), (dx, dy)),
PatchMode::Destination => ((dx, dy), (-dx, -dy)),
};
Ok(Plan { id, canvas, area, dst_shift, src_shift, destination: mode == PatchMode::Destination, offset: (dx, dy) })
}
/// Heal `surf` at the selection (shifted by `dst_shift`) with texture read `src_shift` away, the
/// membrane solved `step` times coarser (1 = exact). Returns the damaged rectangle.
fn patch_surface(surf: &mut Surface, sel: &Surface, plan: &Plan, lock: bool, step: usize) -> Rect {
let Plan { canvas, area, dst_shift, src_shift, .. } = *plan;
// The repaired area plus a two-pixel ring: the ring is the Dirichlet boundary of the solve. It is
// clipped to where both the repaired and the sampled pixels lie on the canvas, so a patch at the
// canvas edge sees a free (Neumann) boundary there instead of the empty pixels beyond it.
let dst_area = area.translate(dst_shift.0, dst_shift.1);
let g = dst_area.inflate(2).intersect(&canvas).intersect(&canvas.translate(-src_shift.0, -src_shift.1));
if g.is_empty() {
return Rect::EMPTY;
}
let fmt = surf.format();
let dst = Region::read(surf, g);
let mut src = Region::read(surf, g.translate(src_shift.0, src_shift.1));
src.rect = g;
// Selection coverage moved to the repaired place.
let cov = coverage(sel, g.translate(-dst_shift.0, -dst_shift.1));
let mask: Vec<bool> = cov.iter().map(|c| *c > 0.0).collect();
let healed = heal_region_coarse(&fmt, &src, &dst, &mask, step);
apply_coverage(surf, g, &cov, 1.0, None, lock, &healed, BlendMode::Normal)
}
pub(super) fn patch(s: &mut Session, p: &Value) -> Result<Value> {
let plan = plan(s, p)?;
let label = if plan.destination { "Patch (Destination)" } else { "Patch" };
let dmg = run_stroke(s, label, plan.id, p, |pre, surf, _, lock| {
let sel = pre.selection.as_ref().ok_or_else(|| bad(CMD, "select the area to patch first"))?;
Ok(patch_surface(surf, sel, &plan, lock, 1))
})?;
Ok(json!({ "damage": damage_json(dmg), "offset": [plan.offset.0, plan.offset.1] }))
}
/// The live preview of `paint.patch` with params `p`: the active document with the patch applied
/// (nothing is recorded), and the rectangle it changed. For large patches the membrane is solved
/// coarser, so the preview costs about the same whatever the selection's size: the texture is
/// exact, the colour fit approximate. `max_cells` caps the coarse grid, `min_step` sets a floor on
/// the coarsening (the view's zoom-out factor: finer detail wouldn't show).
pub fn preview(s: &Session, p: &Value, max_cells: u64, min_step: u32) -> Result<(Document, Rect)> {
let plan = plan(s, p)?;
let d = s.active().ok_or(EngineError::Other("no document open".into()))?;
let pixels = u64::from(plan.area.width() + 4) * u64::from(plan.area.height() + 4);
// Smallest step whose grid fits `max_cells`: step² · cells ≥ pixels.
let need = (pixels as f64 / max_cells.max(1) as f64).sqrt().ceil() as usize;
let step = need.max(min_step as usize).clamp(1, 64);
let mut doc = (*d.doc).clone();
let sel = d.doc.selection.as_ref().ok_or_else(|| bad(CMD, "select the area to patch first"))?;
let (surf, lock) = crate::channel_cmds::target_surface(&mut doc, plan.id, p)?;
let dmg = patch_surface(surf, sel, &plan, lock, step);
Ok((doc, dmg))
}

View File

@ -538,3 +538,134 @@ fn smudge_across_a_transparent_edge_has_no_dark_fringe() {
assert!(n > 5, "depth {depth}: colour dragged into the transparent area");
}
}
/// `texture` with a solid red blemish over `x0..x0+8`, `y 20..28`.
fn blemished(x0: i32) -> impl Fn(i32, i32) -> [f32; 4] {
move |x, y| if (x0..x0 + 8).contains(&x) && (20..28).contains(&y) { [1.0, 0.0, 0.0, 1.0] } else { texture(x, y) }
}
/// Every pixel of `rect` (x0, y0, x1, y1) is within `t` of the clean texture.
fn assert_repaired(s: &Session, rect: (i32, i32, i32, i32), t: f32, what: &str) {
for y in rect.1..rect.3 {
for x in rect.0..rect.2 {
let (got, want) = (rgba(s, x, y), texture(x, y));
for c in 0..4 {
assert!((got[c] - want[c]).abs() < t, "{what} at {x},{y} channel {c}: {got:?} want {want:?}");
}
}
}
}
#[test]
fn patch_source_repairs_the_selection_with_texture_from_the_drag_target() {
for depth in DEPTHS {
let mut s = session(96, 48, depth, "rgb");
paint_layer(&mut s, blemished(40));
s.execute("select.rect", json!({"x": 36, "y": 16, "width": 16, "height": 16})).unwrap();
let r = s.execute("paint.patch", json!({"offset": [-30, 2]})).unwrap();
assert_eq!(r["offset"], json!([-30, 2]));
// Gradient plus noise: the membrane fits the source's colour to the surroundings, the noise
// comes from the source, so the repair is close to (not exactly) the clean texture.
assert_repaired(&s, (36, 16, 52, 32), 0.07, &format!("depth {depth}"));
// Outside the selection nothing changed, including the sampled area.
for (x, y) in [(35, 20), (52, 25), (10, 20), (44, 40)] {
let (got, want) = (rgba(&s, x, y), texture(x, y));
assert!((got[0] - want[0]).abs() <= tol(depth), "depth {depth} untouched {x},{y}: {got:?}");
}
// One undo step restores the blemish.
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(rgba(&s, 44, 24)[1], 0.0, "depth {depth}: undo brings the blemish back");
}
}
#[test]
fn patch_destination_repairs_the_drag_target_from_the_selection() {
let mut s = session(96, 48, 16, "rgb");
paint_layer(&mut s, blemished(40));
// Select clean texture and drag it onto the blemish.
s.execute("select.rect", json!({"x": 6, "y": 16, "width": 16, "height": 16})).unwrap();
s.execute("paint.patch", json!({"offset": [30, 0], "mode": "destination"})).unwrap();
assert_repaired(&s, (36, 16, 52, 32), 0.07, "destination");
// The selected (source) pixels stay as they were.
let (got, want) = (rgba(&s, 10, 20), texture(10, 20));
assert!((got[1] - want[1]).abs() < 1e-3, "source untouched: {got:?}");
}
#[test]
fn patch_at_the_canvas_edge_stays_opaque_and_matches() {
// The blemish touches the left edge; the solve must not pull in the empty pixels beyond it.
let mut s = session(64, 48, 8, "rgb");
paint_layer(&mut s, blemished(0));
s.execute("select.rect", json!({"x": 0, "y": 16, "width": 12, "height": 16})).unwrap();
s.execute("paint.patch", json!({"offset": [30, 0]})).unwrap();
assert_repaired(&s, (0, 16, 12, 32), 0.07, "edge");
}
#[test]
fn patch_follows_a_feathered_selection_and_a_mask_target() {
let mut s = session(96, 48, 8, "rgb");
paint_layer(&mut s, blemished(40));
s.execute("select.rect", json!({"x": 36, "y": 16, "width": 16, "height": 16, "ellipse": true, "feather": 2})).unwrap();
s.execute("paint.patch", json!({"offset": [-30, 0]})).unwrap();
// The blemish (well inside the ellipse) is gone, a corner outside the ellipse is untouched.
assert_repaired(&s, (42, 22, 46, 26), 0.07, "feathered");
let (got, want) = (rgba(&s, 36, 16), texture(36, 16));
assert!((got[0] - want[0]).abs() <= tol(8), "corner outside the ellipse: {got:?}");
// Patching the layer mask works like any retouch target.
s.execute("layer.layerMask.revealAll", json!({})).unwrap();
s.execute("paint.patch", json!({"offset": [-30, 0], "target": "mask"})).unwrap();
}
#[test]
fn patch_fails_gracefully() {
let mut s = session(64, 48, 8, "rgb");
assert!(s.execute("paint.patch", json!({"offset": [10, 0]})).is_err(), "no selection");
s.execute("select.rect", json!({"x": 10, "y": 10, "width": 10, "height": 10})).unwrap();
for p in [
json!({}),
json!({"offset": [10]}),
json!({"offset": "far"}),
json!({"offset": [1e300, 0]}),
json!({"offset": [0, 0]}),
json!({"offset": [10, 0], "mode": "bogus"}),
json!({"offset": [60, 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.patch", p.clone()).is_err(), "{p}");
}
assert!(s.execute("paint.patch", json!({"offset": [10, 0]})).is_ok());
}
#[test]
fn patch_preview_matches_the_command_and_its_coarse_solve_is_close() {
let mut s = session(160, 96, 16, "rgb");
paint_layer(&mut s, blemished(100));
s.execute("select.rect", json!({"x": 80, "y": 4, "width": 60, "height": 60, "ellipse": true, "feather": 3})).unwrap();
let p = json!({"offset": [-70, 20]});
let rev = s.active().unwrap().revision;
let id = s.active().unwrap().active_layer.unwrap();
let layer = |d: &Document, x, y| d.layer(id).unwrap().surface().unwrap().rgba(x, y);
let (exact, dmg) = patch_preview(&s, &p, u64::MAX, 1).unwrap();
let (coarse, _) = patch_preview(&s, &p, 64, 1).unwrap();
assert!(dmg.contains(104, 24) && !dmg.contains(20, 24), "{dmg:?}");
assert_eq!(s.active().unwrap().revision, rev, "the preview records nothing");
s.execute("paint.patch", p).unwrap();
let committed = s.active().unwrap().doc.clone();
let mut worst = 0.0f32;
for y in 0..96 {
for x in 0..160 {
let (e, c, d) = (layer(&exact, x, y), layer(&coarse, x, y), layer(&committed, x, y));
for k in 0..4 {
assert!((e[k] - d[k]).abs() < 1e-6, "step 1 is the command at {x},{y}");
worst = worst.max((c[k] - d[k]).abs());
}
}
}
// 64 cells over a ~64² patch: an 8× coarser membrane. Measured worst pixel 0.022 (under six
// 8-bit steps), all in the colour fit: the texture is exact.
assert!(worst < 0.03, "coarse preview off by {worst}");
assert!(patch_preview(&s, &json!({"offset": [500, 0]}), 64, 1).is_err());
}

View File

@ -282,6 +282,7 @@ pub(crate) fn freehand_tool(tool: Tool) -> bool {
| Tool::Burn
| Tool::Sponge
| Tool::Lasso
| Tool::Patch
| Tool::QuickSelection
)
}
@ -425,6 +426,10 @@ fn display_doc(app: &mut PhotocraftApp, idx: usize) -> (std::sync::Arc<Document>
if let Some(shown) = crate::move_ui::display_doc(app, idx) {
return shown;
}
// Patch Tool drag: the selection healed from where the pointer is.
if let Some(shown) = crate::patch_preview::display_doc(app, idx) {
return shown;
}
let st = &app.session.documents()[idx];
if let Some(l) = live_stroke(app, idx) {
return (l.stroke.doc.clone(), l.display_key());
@ -498,9 +503,9 @@ pub fn navigator_texture(app: &mut PhotocraftApp, ctx: &egui::Context, idx: usiz
{
return Some(t.id());
}
// While a Move drag is under way the navigator keeps its image and catches up on release.
// While a Move or Patch drag is under way the navigator keeps its image and catches up on release.
if let Some((_, _, t)) = &cached
&& crate::move_ui::showing(app)
&& (crate::move_ui::showing(app) || crate::patch_preview::showing(app))
{
return Some(t.id());
}
@ -612,6 +617,13 @@ fn damage_since(app: &PhotocraftApp, idx: usize, seen: (u64, u64), now: (u64, u6
{
return Some(if r.is_empty() { r } else { r.inflate(effect_reach(&st.doc.layers)) });
}
// Between a Patch drag's previews: the areas they healed.
if seen.0 == now.0
&& let Some(st) = app.session.documents().get(idx)
&& let Some(r) = crate::patch_preview::damage(app, st.doc.id, now.0, seen.1 ^ display_key, now.1)
{
return Some(if r.is_empty() { r } else { r.inflate(effect_reach(&st.doc.layers)) });
}
let l = live_stroke(app, idx).filter(|l| seen.0 == now.0 && l.display_key() == now.1)?;
let r = l.since(seen.1 ^ display_key)?;
Some(if r.is_empty() { r } else { r.inflate(effect_reach(&l.stroke.doc.layers)) })
@ -2035,7 +2047,16 @@ fn draw_drag_preview(app: &mut PhotocraftApp, painter: &egui::Painter, xf: &View
};
crate::tool_feedback::draw_ants(painter, &pts, true);
}
Tool::Lasso => {
// 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) => {
let start = d.start;
let [dx, dy] = crate::retouch_ui::patch_offset(app, start, last);
if let Some((_, _, segs)) = &app.outline_cache {
let moved: Vec<crate::outline::Segment> = segs.iter().map(|(a, b)| ([a[0] + dx, a[1] + dy], [b[0] + dx, b[1] + dy])).collect();
marching_ants_segments(painter, xf, &moved, painter.ctx().input(|i| i.time));
}
}
Tool::Lasso | Tool::Patch => {
let pts: Vec<Pos2> = d.points.iter().map(|p| xf.to_screen(p[0] as f32, p[1] as f32)).collect();
crate::tool_feedback::draw_ants(painter, &pts, false);
}
@ -2364,7 +2385,8 @@ fn finish_gesture(app: &mut PhotocraftApp, d: Drag) {
let (aa, feather) = (app.ui.tool_options.anti_alias, app.ui.tool_options.feather);
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::Lasso => {
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 => {
let pts: Vec<[f64; 2]> = d.points.iter().map(|p| [p[0], p[1]]).collect();
if pts.len() >= 3 {
let mode = selection_mode(app, d.modifiers);

View File

@ -2053,3 +2053,6 @@ cameraRaw Blending Míchání
cameraRaw Luminance Jas
cameraRaw Temperature Barevná teplota
cameraRaw Color Barvy
Patch Tool Nástroj záplata
Patch: Záplata:
Lasso around an area, then drag the selection Obtáhněte oblast lasem a pak přetáhněte výběr

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

View File

@ -2130,3 +2130,6 @@ cameraRaw Blending Mezcla
cameraRaw Tint Matiz
cameraRaw Temperature Temperatura de color
cameraRaw Amount Cantidad
Patch Tool Herramienta Parche
Patch: Parche:
Lasso around an area, then drag the selection Rodee un área con el lazo y arrastre la selección

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

View File

@ -2036,3 +2036,6 @@ cameraRaw Blending ブレンド
cameraRaw Blacks 黒レベル
cameraRaw Amount 適用量
cameraRaw Color カラー
Patch Tool パッチツール
Patch: パッチ:
Lasso around an area, then drag the selection 投げ縄で範囲を囲み、選択範囲をドラッグします

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

View File

@ -2330,3 +2330,6 @@ cameraRaw Blending Смешивание
cameraRaw Luminance Detail Детализация яркости
cameraRaw Color Detail Детализация цвета
cameraRaw Roughness Неровность
Patch Tool Заплатка
Patch: Заплатка:
Lasso around an area, then drag the selection Обведите область лассо, затем перетащите выделение

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

View File

@ -1935,3 +1935,6 @@ cameraRaw Amount 强度
cameraRaw Tint 色偏
cameraRaw Masking 蒙版保护
cameraRaw Blending 混合
Patch Tool 修补工具
Patch: 修补:
Lasso around an area, then drag the selection 用套索圈出区域,然后拖动选区

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

View File

@ -2031,3 +2031,6 @@ cameraRaw Amount 強度
cameraRaw Tint 色偏
cameraRaw Masking 遮色片保護
cameraRaw Blending 混合
Patch Tool 修補工具
Patch: 修補:
Lasso around an area, then drag the selection 用套索圈出區域,然後拖曳選取範圍

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

View File

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

View File

@ -83,6 +83,7 @@ pub mod paint_mouse;
pub mod palette;
pub mod panels;
pub mod parity;
pub mod patch_preview;
pub mod perspective_ui;
pub mod plugin_ui;
pub mod point_curve;
@ -258,6 +259,8 @@ pub struct PhotocraftApp {
trail: Option<stroke_trail::Trail>,
/// Move tool drag shown live (`move_ui`).
pub(crate) move_preview: Option<move_ui::MovePreview>,
/// Patch Tool drag: the healed document at the pointer (`patch_preview`).
pub(crate) patch_preview: Option<patch_preview::PatchPreview>,
/// The next tool `Down` is a right-button drag that erases (see `paint_mouse`).
secondary_erase: bool,
/// While a batch of recovered pointer samples is replayed, defer the live-stroke update to one
@ -395,6 +398,7 @@ impl PhotocraftApp {
live_stroke: None,
trail: None,
move_preview: None,
patch_preview: None,
secondary_erase: false,
defer_live_stroke: false,
last_stroke_end: None,

View File

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

View File

@ -0,0 +1,178 @@
//! Patch Tool drags shown live: while the patch is dragged, the canvas shows the document healed
//! at the pointer's offset ([`photocraft_engine::retouch_cmds::patch_preview`]) and recomposites
//! only the healed area. The preview solves the colour fit coarser for large selections, so it
//! keeps up with the pointer; releasing commits the exact `paint.patch`, whose damage rect then
//! refreshes the same area.
use std::sync::Arc;
use photocraft_doc::{DocId, Document};
use photocraft_geom::Rect;
use serde_json::json;
use crate::PhotocraftApp;
use crate::state::Tool;
/// Preview keys of patch drags: `BASE + n`, one per offset shown (see `canvas::display_doc`).
const BASE: u64 = 1 << 38;
/// Cells of the preview's coarse solve: a 128² solve takes about 5 ms (`bench_retouch`), whatever
/// the selection's size.
const MAX_CELLS: u64 = 128 * 128;
pub(crate) struct PatchPreview {
doc: DocId,
revision: u64,
mode: String,
/// Offsets shown so far, by preview key (`BASE + 1 + index`), with the area each changed.
offsets: Vec<([i32; 2], Rect)>,
/// The document at the latest offset.
shown: Option<Arc<Document>>,
}
/// The offset of the current Patch drag, if one is dragging the patch (not drawing a lasso).
fn drag_offset(app: &mut PhotocraftApp) -> Option<[i32; 2]> {
let d = app.drag.as_ref().filter(|d| d.tool == Tool::Patch)?;
let (start, mods) = (d.start, d.modifiers);
let end = d.points.last().map_or(start, |p| [p[0], p[1]]);
if !crate::retouch_ui::patch_drags_selection(app, start, mods) {
return None;
}
Some(crate::retouch_ui::patch_offset(app, start, end))
}
/// The document to show while the patch is dragged on document `idx`: healed at the pointer.
/// `None` without such a drag, at offset (0, 0), or when the patch can't be applied there.
pub(crate) fn display_doc(app: &mut PhotocraftApp, idx: usize) -> Option<(Arc<Document>, u64)> {
if app.session.active_index() != Some(idx) {
return None;
}
let Some(offset) = drag_offset(app) else {
app.patch_preview = None;
return None;
};
let st = app.session.documents().get(idx)?;
let (doc_id, revision) = (st.doc.id, st.revision);
let mode = app.ui.tool_options.patch_mode.clone();
let fresh = app.patch_preview.as_ref().is_some_and(|p| p.doc == doc_id && p.revision == revision && p.mode == mode);
if !fresh {
app.patch_preview = Some(PatchPreview { doc: doc_id, revision, mode: mode.clone(), offsets: Vec::new(), shown: None });
}
if offset == [0, 0] {
return None;
}
if app.patch_preview.as_ref()?.offsets.last().map(|o| o.0) != Some(offset) {
let t0 = crate::gpu_canvas::now_ms();
// Zoomed out, a finer colour fit than the screen shows is wasted.
let min_step = (1.0 / app.current_zoom().max(1e-3)).floor().clamp(1.0, 64.0) as u32;
let params = json!({"offset": offset, "mode": mode, "target": crate::canvas::paint_target(app)});
let result = photocraft_engine::retouch_cmds::patch_preview(&app.session, &params, MAX_CELLS, min_step);
let p = app.patch_preview.as_mut()?;
match result {
Ok((d, area)) => {
// Duotone documents display through their inks.
let d = photocraft_engine::mode_cmds::display_document(&d).unwrap_or(d);
p.shown = Some(Arc::new(d));
p.offsets.push((offset, area));
}
Err(_) => {
p.shown = None;
return None;
}
}
app.perf.span("patch preview", crate::gpu_canvas::now_ms() - t0);
}
let p = app.patch_preview.as_ref()?;
Some((p.shown.clone()?, BASE + p.offsets.len() as u64))
}
/// What changed between preview (or document) key `seen` and `now` of the current Patch drag on
/// document `doc` at `revision`: the areas both healed. `None` when either key isn't one of this
/// drag's (the canvas then recomposites everything).
pub(crate) fn damage(app: &PhotocraftApp, doc: DocId, revision: u64, seen: u64, now: u64) -> Option<Rect> {
let p = app.patch_preview.as_ref().filter(|p| p.doc == doc && p.revision == revision)?;
let ours = |k: u64| k == 0 || is_preview_key(k);
if seen == now || !ours(seen) || !ours(now) {
return None;
}
let area = |k: u64| if k == 0 { Some(Rect::EMPTY) } else { p.offsets.get(usize::try_from(k - BASE - 1).ok()?).map(|o| o.1) };
let (a, b) = (area(seen)?, area(now)?);
Some(if a.is_empty() {
b
} else if b.is_empty() {
a
} else {
a.union(&b)
})
}
/// Whether the canvas shows a Patch drag's healed preview.
pub(crate) fn showing(app: &PhotocraftApp) -> bool {
app.patch_preview.as_ref().is_some_and(|p| p.shown.is_some())
}
/// Whether `key` is a Patch drag preview key.
pub(crate) fn is_preview_key(key: u64) -> bool {
key > BASE && key < BASE << 1
}
/// Release: after `paint.patch` at `offset` succeeded. If the canvas showed this very offset, its
/// caches count the preview as the document, so the commit's damage rect (the same healed area,
/// now solved exactly) refreshes only that area instead of everything.
pub(crate) fn committed(app: &mut PhotocraftApp, preview: Option<PatchPreview>, offset: [i32; 2]) {
let Some(p) = preview.filter(|p| p.shown.is_some() && p.offsets.last().map(|o| o.0) == Some(offset)) else { return };
crate::canvas::shown_as_document(app, p.doc, is_preview_key);
}
/// Take the preview off the app (at release, before the commit changes the document).
pub(crate) fn take(app: &mut PhotocraftApp) -> Option<PatchPreview> {
app.patch_preview.take()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::canvas::{ToolEvent, tool_event};
fn red_at(d: &Document, x: i32, y: i32) -> bool {
d.layers[0].surface().unwrap().rgba(x, y)[1] < 0.5
}
#[test]
fn dragging_the_patch_previews_the_heal_and_release_commits_it() {
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
app.run("file.new", json!({"width": 100, "height": 60})).unwrap();
app.run("paint.pencil", json!({"points": [[68, 30], [74, 30]], "size": 6, "color": "#ff0000"})).unwrap();
app.run("select.lasso", json!({"points": [[60, 20], [84, 20], [84, 40], [60, 40]]})).unwrap();
app.ui.tool = Tool::Patch;
let m = egui::Modifiers::NONE;
let doc = |app: &PhotocraftApp| app.session.documents()[0].doc.clone();
let (id, rev) = (doc(&app).id, app.session.documents()[0].revision);
// Nothing to preview before the patch moves, or while a lasso is drawn.
tool_event(&mut app, ToolEvent::Down { x: 70.0, y: 30.0, pressure: 1.0 }, m);
assert!(display_doc(&mut app, 0).is_none());
tool_event(&mut app, ToolEvent::Move { x: 50.0, y: 30.0, pressure: 1.0 }, m);
let (shown, k1) = display_doc(&mut app, 0).expect("a preview while dragging");
assert!(is_preview_key(k1) && showing(&app));
assert!(!red_at(&shown, 71, 30), "the preview shows the blemish healed");
assert!(red_at(&doc(&app), 71, 30), "the document is untouched until release");
let a = damage(&app, id, rev, 0, k1).unwrap();
assert!(a.contains(71, 30) && !a.contains(20, 30), "only the healed area recomposites: {a:?}");
// Same offset: the same preview, no new solve.
assert_eq!(display_doc(&mut app, 0).unwrap().1, k1);
tool_event(&mut app, ToolEvent::Move { x: 30.0, y: 30.0, pressure: 1.0 }, m);
let k2 = display_doc(&mut app, 0).unwrap().1;
assert!(k2 != k1 && damage(&app, id, rev, k1, k2).is_some_and(|r| r.contains(71, 30)));
assert!(damage(&app, id, rev, k1, 12345).is_none(), "foreign keys recomposite everything");
tool_event(&mut app, ToolEvent::Up { x: 30.0, y: 30.0 }, m);
assert!(!app.ui.status_error, "{}", app.ui.status);
assert!(!red_at(&doc(&app), 71, 30), "release commits the patch");
assert!(display_doc(&mut app, 0).is_none() && !showing(&app));
// A drag starting outside the selection draws a lasso: no preview.
tool_event(&mut app, ToolEvent::Down { x: 5.0, y: 5.0, pressure: 1.0 }, m);
tool_event(&mut app, ToolEvent::Move { x: 30.0, y: 10.0, pressure: 1.0 }, m);
assert!(display_doc(&mut app, 0).is_none());
}
}

View File

@ -1,6 +1,6 @@
//! UI for the retouching tools (healing, clone, history brush, blur/sharpen/smudge, dodge/burn/
//! sponge, Mixer Brush) and the smart selection tools (Quick Selection, Object Selection): gesture
//! → engine command, options bars, and the clone-source marker.
//! UI for the retouching tools (healing, patch, clone, history brush, blur/sharpen/smudge,
//! dodge/burn/sponge, Mixer Brush) and the smart selection tools (Quick Selection, Object
//! Selection): gesture → engine command, options bars, and the clone-source marker.
use egui::{Color32, Stroke, vec2};
use serde_json::{Value, json};
@ -76,6 +76,48 @@ 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.
pub fn patch_drags_selection(app: &PhotocraftApp, at: [f64; 2], mods: egui::Modifiers) -> bool {
if mods.shift || mods.alt {
return false;
}
let Some(sel) = app.session.active().and_then(|st| st.doc.selection.as_ref()) else { return false };
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).
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) };
let Some((canvas, sel)) = app.session.active().and_then(|st| Some((st.doc.bounds(), st.doc.selection.clone()?))) else { return [dx, dy] };
let key = u64::MAX - app.session.active().map_or(0, |st| st.doc.id.0);
let b = app.cached_bounds(key, &sel).intersect(&canvas);
if b.is_empty() {
return [dx, dy];
}
let clamp = |d: i32, lo: i32, hi: i32| if lo > hi { 0 } else { d.clamp(lo, hi) };
[clamp(dx, canvas.x0 - b.x0, canvas.x1 - b.x1), clamp(dy, canvas.y0 - b.y0, canvas.y1 - b.y1)]
}
/// Patch Tool: the patch was dragged from `start` to `end`.
pub fn finish_patch(app: &mut PhotocraftApp, start: [f64; 2], end: [f64; 2]) {
let off = patch_offset(app, start, end);
if off == [0, 0] {
return;
}
let preview = crate::patch_preview::take(app);
let p = json!({"offset": off, "mode": app.ui.tool_options.patch_mode, "target": crate::canvas::paint_target(app)});
match app.run("paint.patch", p) {
Ok(_) => crate::patch_preview::committed(app, preview, off),
Err(e) => {
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]));
@ -138,7 +180,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)
if !tool.is_brushlike() && !matches!(tool, Tool::QuickSelection | Tool::ObjectSelection | Tool::Patch)
|| matches!(tool, Tool::Brush | Tool::Pencil | Tool::MixerBrush | Tool::Eraser)
{
return false;
@ -156,6 +198,17 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui, tool: Tool) -> bo
crate::widgets::vline(ui, 22.0);
crate::widgets::checkbox(ui, &mut o.sample_all_layers, tl!("Sample All Layers"));
}
Tool::Patch => {
opt(ui, tl!("Patch:"));
for (k, l) in [("source", tl!("Source")), ("destination", tl!("Destination"))] {
let mut on = o.patch_mode == k;
if crate::widgets::checkbox(ui, &mut on, l).clicked() {
o.patch_mode = k.into();
}
}
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:"));
@ -340,4 +393,33 @@ mod tests {
assert_eq!(surface.rgba(20, 30), before[0], "outside the selection is unchanged");
assert_eq!(surface.rgba(80, 30), before[2], "the far side outside the selection is unchanged");
}
#[test]
fn patch_tool_lassoes_then_drags_the_patch() {
let mut app = app();
app.run("paint.pencil", json!({"points": [[68, 30], [74, 30]], "size": 6, "color": "#ff0000"})).unwrap();
let red = |app: &PhotocraftApp| active(app).surface().unwrap().rgba(71, 30)[1] < 0.5;
assert!(red(&app));
app.ui.tool = Tool::Patch;
let m = egui::Modifiers::NONE;
// Outside any selection the drag is a lasso.
tool_event(&mut app, ToolEvent::Down { x: 60.0, y: 20.0, pressure: 1.0 }, m);
for [x, y] in [[84.0, 20.0], [84.0, 40.0], [60.0, 40.0]] {
tool_event(&mut app, ToolEvent::Move { x, y, pressure: 1.0 }, m);
}
tool_event(&mut app, ToolEvent::Up { x: 60.0, y: 40.0 }, m);
assert!(app.session.active().unwrap().doc.selection.is_some(), "lasso made a selection");
assert!(red(&app), "the lasso alone changes no pixels");
// ⇧-drag inside the selection adds to it rather than patching.
assert!(!patch_drags_selection(&app, [70.0, 30.0], egui::Modifiers::SHIFT));
// Dragging inside it patches from where it is dropped; the offset is limited to the canvas.
assert!(patch_drags_selection(&app, [70.0, 30.0], m));
assert_eq!(patch_offset(&mut app, [70.0, 30.0], [-200.0, 30.0]), [-60, 0]);
tool_event(&mut app, ToolEvent::Down { x: 70.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: 30.0, y: 30.0 }, m);
assert!(!app.ui.status_error, "{}", app.ui.status);
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:?}");
}
}

View File

@ -70,6 +70,7 @@ pub enum Tool {
Zoom,
SpotHealing,
Healing,
Patch,
CloneStamp,
HistoryBrush,
Blur,
@ -93,7 +94,7 @@ pub enum Tool {
}
impl Tool {
pub const ALL: [Tool; 44] = [
pub const ALL: [Tool; 45] = [
Tool::Move,
Tool::RectMarquee,
Tool::EllipseMarquee,
@ -118,6 +119,7 @@ impl Tool {
Tool::Zoom,
Tool::SpotHealing,
Tool::Healing,
Tool::Patch,
Tool::CloneStamp,
Tool::HistoryBrush,
Tool::Blur,
@ -168,6 +170,7 @@ impl Tool {
Tool::Zoom => "Zoom Tool",
Tool::SpotHealing => "Spot Healing Brush Tool",
Tool::Healing => "Healing Brush Tool",
Tool::Patch => "Patch Tool",
Tool::CloneStamp => "Clone Stamp Tool",
Tool::HistoryBrush => "History Brush Tool",
Tool::Blur => "Blur Tool",
@ -224,7 +227,7 @@ impl Tool {
Tool::Type => 'T',
Tool::Hand => 'H',
Tool::Zoom => 'Z',
Tool::SpotHealing | Tool::Healing => 'J',
Tool::SpotHealing | Tool::Healing | Tool::Patch => 'J',
Tool::CloneStamp => 'S',
Tool::HistoryBrush => 'Y',
Tool::Blur | Tool::Sharpen | Tool::Smudge => '\0',
@ -380,6 +383,8 @@ pub struct ToolOptions {
pub clone_sample: String,
/// Spot Healing: contentAware | createTexture | proximityMatch
pub spot_type: String,
/// Patch: source (repair the selection) | destination (repair where it is dragged).
pub patch_mode: String,
/// Dodge/Burn: shadows | midtones | highlights, exposure %, protect tones.
pub tone_range: String,
pub exposure: f32,
@ -478,6 +483,7 @@ impl Default for ToolOptions {
clone_aligned: true,
clone_sample: "current".into(),
spot_type: "contentAware".into(),
patch_mode: "source".into(),
tone_range: "midtones".into(),
exposure: 50.0,
protect_tones: true,

View File

@ -8,7 +8,7 @@ Generated by `cargo xtask scorecard` from `perf/budgets.toml`, `perf/baseline.js
|---|---:|---:|---:|---|
| [Performance](#performance) | 3 | 22 | 13 | 47 scenarios: 3 within budget, 22 over budget or failing, 15 tracked only, 6 not run, 7 not measurable yet (baseline 2026-10-05) |
| [File compatibility](#file-compatibility) | 1 | 8 | 18 | 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) | 5 | 13 | 12 | |
| [Tools](#tools) | 6 | 13 | 11 | |
| [Workspace and UI](#workspace-and-ui) | 0 | 4 | 19 | settings that do nothing: 60 of 135 (target 0) |
| [Type](#type) | 0 | 3 | 6 | |
| [Automation](#automation) | 1 | 3 | 7 | 626 of 626 menu items live (100.0%) |
@ -123,7 +123,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 | missing | [#213](https://github.com/storytold/photocraft/issues/213) | No patch command or tool |
| 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-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 |

View File

@ -22,9 +22,9 @@ note = "Tool::MixerBrush in the Brush (B) flyout; Wet/Load/Mix/Flow/Sample All L
[[item]]
id = "TOOL-213-3"
target = "Patch tool with a live preview of the patched area"
status = "missing"
status = "done"
issue = 213
note = "No patch command or tool"
note = "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)"
[[item]]
id = "TOOL-213-4"