mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-29 04:17:11 -06:00
* 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>
179 lines
8.2 KiB
Rust
179 lines
8.2 KiB
Rust
//! 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());
|
|
}
|
|
}
|