* 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:
parent
cc5de414dc
commit
b4f9d56c93
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
|
|
@ -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());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
|
|
|
|||
|
|
@ -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, ¶ms, 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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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:?}");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Reference in New Issue