mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-14 19:26:51 -04:00
* Content-Aware Move: live preview of the result while the pointer rests (#4385) A cancellable, debounced background job shows the move/extend result during the drag and in the Transform On Drop box. Co-Authored-By: Claude Opus 5.5 <[email protected]> * docs: regenerate scorecard after merging main Co-Authored-By: Claude Opus 5.5 <[email protected]> --------- Co-authored-by: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
88f84aab59
commit
5aa6e33adf
9 files changed
+386
-16
No files matched your search
@@ -27,6 +27,7 @@ mod content_aware_move;
|
||||
mod patch;
|
||||
mod pattern_stamp;
|
||||
mod remove;
|
||||
pub use content_aware_move::{ContentAwarePreview, preview as content_aware_move_preview};
|
||||
pub use patch::preview as patch_preview;
|
||||
|
||||
fn bad(cmd: &str, msg: impl Into<String>) -> EngineError {
|
||||
|
||||
@@ -96,6 +96,8 @@ struct Plan {
|
||||
mode: Mode,
|
||||
structure: u8,
|
||||
color: u8,
|
||||
/// Most pixels the fills may sample around the content ([`MAX_MARGIN`], less for a preview).
|
||||
margin: i32,
|
||||
}
|
||||
|
||||
/// Transform On Drop's `scaleX` / `scaleY` (%) and `rotate` (degrees): the map from a source point
|
||||
@@ -193,7 +195,7 @@ fn landed_mask(cov: &[f32], area: Rect, inv: &Homography, rect: Rect) -> Vec<boo
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn plan(s: &Session, p: &Value) -> Result<Plan> {
|
||||
fn plan(s: &Session, p: &Value, margin: i32) -> Result<Plan> {
|
||||
let mode = match string(p, "mode", "move") {
|
||||
"move" => Mode::Move,
|
||||
"extend" => Mode::Extend,
|
||||
@@ -242,7 +244,7 @@ fn plan(s: &Session, p: &Value) -> Result<Plan> {
|
||||
}
|
||||
};
|
||||
let all_layers = flag(p, "sampleAllLayers", false) && targets_pixels(p);
|
||||
Ok(Plan { target: Target { id, all_layers }, canvas, area, offset: (dx, dy), map, dest, mode, structure, color })
|
||||
Ok(Plan { target: Target { id, all_layers }, canvas, area, offset: (dx, dy), map, dest, mode, structure, color, margin })
|
||||
}
|
||||
|
||||
/// Width in pixels of the edge band re-synthesised at the new place, for Structure 1..7 and content
|
||||
@@ -293,7 +295,12 @@ fn erode(w: usize, h: usize, m: &[bool], r: usize) -> Vec<bool> {
|
||||
/// Where a fill samples from around `r`: Photoshop's Content-Aware window
|
||||
/// ([`crate::fill_cmds::sampling_window`]), at most [`MAX_MARGIN`] pixels around `r`.
|
||||
pub(super) fn window(r: Rect, canvas: Rect) -> Rect {
|
||||
crate::fill_cmds::sampling_window(r, canvas).intersect(&r.inflate(MAX_MARGIN))
|
||||
window_within(r, canvas, MAX_MARGIN)
|
||||
}
|
||||
|
||||
/// [`window`] with at most `margin` pixels around `r`.
|
||||
fn window_within(r: Rect, canvas: Rect, margin: i32) -> Rect {
|
||||
crate::fill_cmds::sampling_window(r, canvas).intersect(&r.inflate(margin))
|
||||
}
|
||||
|
||||
/// The pixels a step works on: the targeted surface, or with Sample All Layers the visible
|
||||
@@ -433,7 +440,7 @@ fn run_move(doc: &mut Document, sel: &Surface, plan: &Plan, p: &Value, ctx: &cra
|
||||
|
||||
// 1. The content at its new place: the content with its original surroundings laid over the
|
||||
// window there; never sampling the content's old place.
|
||||
let win = window(plan.dest, plan.canvas);
|
||||
let win = window_within(plan.dest, plan.canvas, plan.margin);
|
||||
let (content, moved) = match &inv {
|
||||
None => {
|
||||
let mut content = read(doc, plan.target, p, win.translate(-dx, -dy))?;
|
||||
@@ -443,7 +450,7 @@ fn run_move(doc: &mut Document, sel: &Surface, plan: &Plan, p: &Value, ctx: &cra
|
||||
Some(inv) => {
|
||||
// Only the content and a ring around it (the colour fit's reference) matter: read
|
||||
// the source behind that, a few pixels past the area, and resample it.
|
||||
let src = mapped_bounds(inv, plan.dest.inflate(8)).intersect(&plan.area.inflate(MAX_MARGIN)).intersect(&plan.canvas);
|
||||
let src = mapped_bounds(inv, plan.dest.inflate(8)).intersect(&plan.area.inflate(plan.margin)).intersect(&plan.canvas);
|
||||
let src = if src.is_empty() { plan.area } else { src };
|
||||
(resample(&read(doc, plan.target, p, src)?, inv, win), landed_mask(&cov, plan.area, inv, win))
|
||||
}
|
||||
@@ -454,7 +461,7 @@ fn run_move(doc: &mut Document, sel: &Surface, plan: &Plan, p: &Value, ctx: &cra
|
||||
// 2. Move: fill the place the content left (where the content didn't land on it).
|
||||
if plan.mode == Mode::Move {
|
||||
ctx.check()?;
|
||||
let win = window(plan.area, plan.canvas);
|
||||
let win = window_within(plan.area, plan.canvas, plan.margin);
|
||||
let (w, h) = (win.width() as usize, win.height() as usize);
|
||||
let img = read(doc, plan.target, p, win)?;
|
||||
let landed = match &inv {
|
||||
@@ -478,7 +485,7 @@ fn run_move(doc: &mut Document, sel: &Surface, plan: &Plan, p: &Value, ctx: &cra
|
||||
}
|
||||
|
||||
pub(super) fn content_aware_move(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let plan = plan(s, p)?;
|
||||
let plan = plan(s, p, MAX_MARGIN)?;
|
||||
let label = if plan.mode == Mode::Move { "Content-Aware Move" } else { "Content-Aware Extend" };
|
||||
let d = s.active().ok_or(EngineError::NoDocument)?;
|
||||
let doc = d.doc.clone();
|
||||
@@ -520,3 +527,40 @@ pub(super) fn content_aware_move(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/// Largest selection (bounding box, in pixels) the live preview computes.
|
||||
const PREVIEW_MAX_PIXELS: u64 = 256 * 256;
|
||||
/// Sampling margin of a preview: a fraction of the command's, so a preview is a small solve.
|
||||
const PREVIEW_MARGIN: i32 = 48;
|
||||
|
||||
/// A live preview of `paint.contentAwareMove` that can run on any thread: the document as of
|
||||
/// [`preview`], with the move's params. Cheaper than the command (a small sampling margin, and
|
||||
/// only small selections), so the result is close to, not the same as, what the command makes.
|
||||
pub struct ContentAwarePreview {
|
||||
doc: std::sync::Arc<Document>,
|
||||
plan: Plan,
|
||||
params: Value,
|
||||
}
|
||||
|
||||
impl ContentAwarePreview {
|
||||
/// The active document with the move applied (nothing is recorded, the session is not
|
||||
/// touched) and the rectangle it changed. Stops with `Cancelled` once `ctx` is cancelled.
|
||||
pub fn run(&self, ctx: &crate::jobs::JobCtx) -> Result<(Document, Rect)> {
|
||||
let mut work = (*self.doc).clone();
|
||||
let sel = self.doc.selection.as_ref().ok_or_else(|| bad(CMD, "select the area to move first"))?;
|
||||
let (_, damage, _) = run_move(&mut work, sel, &self.plan, &self.params, ctx, "Content-Aware Move preview")?;
|
||||
Ok((work, damage))
|
||||
}
|
||||
}
|
||||
|
||||
/// Prepare the preview of `paint.contentAwareMove` with params `p` on the active document. Errors
|
||||
/// like the command does, and for a selection larger than a preview takes.
|
||||
pub fn preview(s: &Session, p: &Value) -> Result<ContentAwarePreview> {
|
||||
let plan = plan(s, p, PREVIEW_MARGIN)?;
|
||||
let pixels = u64::from(plan.area.width()) * u64::from(plan.area.height());
|
||||
if pixels > PREVIEW_MAX_PIXELS {
|
||||
return Err(bad(CMD, format!("the selection is too large to preview ({pixels} pixels, at most {PREVIEW_MAX_PIXELS})")));
|
||||
}
|
||||
let d = s.active().ok_or(EngineError::NoDocument)?;
|
||||
Ok(ContentAwarePreview { doc: d.doc.clone(), plan, params: p.clone() })
|
||||
}
|
||||
@@ -917,6 +917,31 @@ fn content_aware_move_moves_the_object_and_fills_its_place_at_all_depths() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn content_aware_move_preview_shows_the_result_without_touching_the_session() {
|
||||
use crate::jobs::JobCtx;
|
||||
let mut s = session(128, 64, 8, "rgb");
|
||||
paint_layer(&mut s, with_object(20, 26));
|
||||
s.execute("select.rect", json!({"x": 16, "y": 22, "width": 20, "height": 20})).unwrap();
|
||||
let before = s.active().unwrap().revision;
|
||||
let p = json!({"offset": [70, 0]});
|
||||
let (doc, damage) = content_aware_move_preview(&s, &p).unwrap().run(&JobCtx::new()).unwrap();
|
||||
let px = |d: &Document, x, y| d.layers[0].surface().unwrap().rgba(x, y);
|
||||
assert!(is_object(px(&doc, 95, 31)) && !is_object(px(&doc, 25, 31)), "the preview shows the move");
|
||||
assert!(damage.contains(95, 31) && damage.contains(25, 31), "{damage:?}");
|
||||
// The session is untouched.
|
||||
assert!(is_object(rgba(&s, 25, 31)) && !is_object(rgba(&s, 95, 31)));
|
||||
assert_eq!(s.active().unwrap().revision, before);
|
||||
// A cancelled preview stops; a selection past the preview's size and bad params are errors.
|
||||
let ctx = JobCtx::new();
|
||||
ctx.cancel();
|
||||
assert!(matches!(content_aware_move_preview(&s, &p).unwrap().run(&ctx), Err(EngineError::Cancelled)));
|
||||
assert!(content_aware_move_preview(&s, &json!({"offset": [0, 0]})).is_err());
|
||||
assert!(content_aware_move_preview(&s, &json!({"offset": [70, 0], "rotate": "x"})).is_err());
|
||||
s.execute("select.rect", json!({"x": 0, "y": 0, "width": 300, "height": 300})).unwrap();
|
||||
assert!(content_aware_move_preview(&s, &json!({"offset": [1, 0]})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn content_aware_move_transform_on_drop_scales_and_rotates_the_content() {
|
||||
// A horizontal 16×6 bar in a 36×36 selection, dropped 80 px right and turned 90° with 125%
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
//! Content-Aware Move drags and its Transform On Drop box shown with the result: once the pointer
|
||||
//! (or the box) rests for a moment, the move is computed as a background job
|
||||
//! ([`photocraft_engine::retouch_cmds::content_aware_move_preview`]: a smaller sampling window than
|
||||
//! the command and only small selections) and the canvas shows the document with it. Any change of
|
||||
//! the offset, scale or rotation cancels the job and starts the wait again, so the UI never waits
|
||||
//! for the blend. Releasing (or Enter) runs the exact `paint.contentAwareMove`; the document is
|
||||
//! untouched until then. Not available on wasm (no threads).
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::sync::mpsc::{Receiver, TryRecvError};
|
||||
|
||||
use photocraft_doc::{DocId, Document};
|
||||
use photocraft_engine::jobs::JobCtx;
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::PhotocraftApp;
|
||||
use crate::state::Tool;
|
||||
|
||||
/// Preview keys: `BASE + n`, one per result shown (see `canvas::display_doc`).
|
||||
const BASE: u64 = 1 << 45;
|
||||
/// How long the move must stay unchanged before a preview is computed.
|
||||
const DEBOUNCE_MS: f64 = 150.0;
|
||||
/// How often a running job is polled for its result.
|
||||
const POLL_MS: u64 = 40;
|
||||
|
||||
struct Running {
|
||||
key: String,
|
||||
ctx: JobCtx,
|
||||
rx: Receiver<photocraft_engine::Result<Document>>,
|
||||
}
|
||||
|
||||
pub(crate) struct CamPreview {
|
||||
doc: DocId,
|
||||
revision: u64,
|
||||
/// The params the move has now, and when they last changed (ms).
|
||||
key: String,
|
||||
since: f64,
|
||||
/// Params whose preview failed (too large a selection, say): not tried again.
|
||||
failed: Option<String>,
|
||||
running: Option<Running>,
|
||||
/// The latest finished preview, with the params it shows.
|
||||
shown: Option<(String, Arc<Document>)>,
|
||||
serial: u64,
|
||||
}
|
||||
|
||||
impl CamPreview {
|
||||
fn stop(&mut self) {
|
||||
if let Some(r) = self.running.take() {
|
||||
r.ctx.cancel();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for CamPreview {
|
||||
fn drop(&mut self) {
|
||||
self.stop();
|
||||
}
|
||||
}
|
||||
|
||||
/// `paint.contentAwareMove`'s params for what the pointer or the box has now; `None` without a
|
||||
/// move to preview.
|
||||
fn wanted(app: &mut PhotocraftApp) -> Option<Value> {
|
||||
if app.ui.tool != Tool::ContentAwareMove || cfg!(target_arch = "wasm32") {
|
||||
return None;
|
||||
}
|
||||
if let Some(t) = &app.ui.cam_transform {
|
||||
let place = crate::cam_transform::placement(t)?;
|
||||
if place.offset == [0, 0] && place.is_plain_move() {
|
||||
return None;
|
||||
}
|
||||
return Some(crate::cam_transform::params(app, &place));
|
||||
}
|
||||
let d = app.drag.as_ref().filter(|d| d.tool == Tool::ContentAwareMove)?;
|
||||
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;
|
||||
}
|
||||
let off = crate::retouch_ui::patch_offset(app, start, end);
|
||||
(off != [0, 0]).then(|| crate::retouch_ui::content_aware_move_params(app, off.map(i64::from)))
|
||||
}
|
||||
|
||||
/// The document to show for the move on document `idx`: with the move applied, once its preview
|
||||
/// has been computed for the current offset and transform.
|
||||
pub(crate) fn display_doc(app: &mut PhotocraftApp, idx: usize) -> Option<(Arc<Document>, u64)> {
|
||||
if app.session.active_index() != Some(idx) {
|
||||
return None;
|
||||
}
|
||||
step(app, crate::gpu_canvas::now_ms())
|
||||
}
|
||||
|
||||
fn step(app: &mut PhotocraftApp, now: f64) -> Option<(Arc<Document>, u64)> {
|
||||
let Some(params) = wanted(app) else {
|
||||
app.cam_preview = None;
|
||||
return None;
|
||||
};
|
||||
let st = app.session.active()?;
|
||||
let (doc, revision) = (st.doc.id, st.revision);
|
||||
let key = params.to_string();
|
||||
if app.cam_preview.as_ref().is_some_and(|p| p.doc != doc || p.revision != revision) {
|
||||
app.cam_preview = None;
|
||||
}
|
||||
let p = app.cam_preview.get_or_insert_with(|| CamPreview {
|
||||
doc,
|
||||
revision,
|
||||
key: String::new(),
|
||||
since: now,
|
||||
failed: None,
|
||||
running: None,
|
||||
shown: None,
|
||||
serial: 0,
|
||||
});
|
||||
if p.key != key {
|
||||
p.key = key.clone();
|
||||
p.since = now;
|
||||
// What is computing is already out of date.
|
||||
if p.running.as_ref().is_some_and(|r| r.key != key) {
|
||||
p.stop();
|
||||
}
|
||||
}
|
||||
if let Some(r) = &p.running {
|
||||
match r.rx.try_recv() {
|
||||
Ok(result) => {
|
||||
let r = p.running.take();
|
||||
match (result, r) {
|
||||
(Ok(d), Some(r)) => {
|
||||
// Duotone documents display through their inks.
|
||||
let d = photocraft_engine::mode_cmds::display_document(&d).unwrap_or(d);
|
||||
p.shown = Some((r.key, Arc::new(d)));
|
||||
p.serial += 1;
|
||||
}
|
||||
(_, Some(r)) => p.failed = Some(r.key),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Err(TryRecvError::Disconnected) => {
|
||||
if let Some(r) = p.running.take() {
|
||||
p.failed = Some(r.key);
|
||||
}
|
||||
}
|
||||
Err(TryRecvError::Empty) => {}
|
||||
}
|
||||
}
|
||||
let settled = now - p.since >= DEBOUNCE_MS;
|
||||
let done = p.shown.as_ref().is_some_and(|s| s.0 == key);
|
||||
if settled && !done && p.running.is_none() && p.failed.as_deref() != Some(&key) {
|
||||
match start(app, ¶ms, key.clone()) {
|
||||
Some(r) => app.cam_preview.as_mut()?.running = Some(r),
|
||||
None => app.cam_preview.as_mut()?.failed = Some(key.clone()),
|
||||
}
|
||||
}
|
||||
let p = app.cam_preview.as_ref()?;
|
||||
// Only a preview of the move as it is now: an older one would show a wrong place.
|
||||
let (_, d) = p.shown.as_ref().filter(|s| s.0 == key)?;
|
||||
Some((d.clone(), BASE + p.serial))
|
||||
}
|
||||
|
||||
/// Compute the preview of `params` on a worker thread.
|
||||
fn start(app: &PhotocraftApp, params: &Value, key: String) -> Option<Running> {
|
||||
let job = photocraft_engine::retouch_cmds::content_aware_move_preview(&app.session, params).ok()?;
|
||||
let ctx = JobCtx::new();
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
let worker = ctx.clone();
|
||||
std::thread::Builder::new()
|
||||
.name("cam-preview".into())
|
||||
.spawn(move || {
|
||||
let _ = tx.send(job.run(&worker).map(|(d, _)| d));
|
||||
})
|
||||
.ok()?;
|
||||
Some(Running { key, ctx, rx })
|
||||
}
|
||||
|
||||
/// Keep frames coming while a preview waits out its debounce or computes.
|
||||
pub(crate) fn wake(app: &PhotocraftApp, ctx: &egui::Context) {
|
||||
let Some(p) = &app.cam_preview else { return };
|
||||
if p.running.is_some() {
|
||||
ctx.request_repaint_after(std::time::Duration::from_millis(POLL_MS));
|
||||
} else if p.shown.as_ref().is_none_or(|s| s.0 != p.key) && p.failed.as_deref() != Some(&p.key) {
|
||||
let left = (DEBOUNCE_MS - (crate::gpu_canvas::now_ms() - p.since)).max(1.0);
|
||||
ctx.request_repaint_after(std::time::Duration::from_millis(left as u64));
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the canvas shows a preview.
|
||||
pub(crate) fn showing(app: &PhotocraftApp) -> bool {
|
||||
app.cam_preview.as_ref().is_some_and(|p| p.shown.as_ref().is_some_and(|s| s.0 == p.key))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::canvas::{ToolEvent, tool_event};
|
||||
use serde_json::json;
|
||||
|
||||
fn blue(d: &Document, x: i32, y: i32) -> bool {
|
||||
d.layers[0].surface().unwrap().rgba(x, y)[0] < 0.5
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_drag_previews_the_move_after_it_rests_and_never_touches_the_document() {
|
||||
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": [[16, 30], [28, 30]], "size": 4, "color": "#0000ff"})).unwrap();
|
||||
app.run("select.lasso", json!({"points": [[6, 14], [38, 14], [38, 46], [6, 46]]})).unwrap();
|
||||
app.ui.tool = Tool::ContentAwareMove;
|
||||
let m = egui::Modifiers::NONE;
|
||||
let steps = app.session.active().unwrap().history.past_len();
|
||||
|
||||
// Nothing to preview before the patch moves.
|
||||
tool_event(&mut app, ToolEvent::Down { x: 22.0, y: 30.0, pressure: 1.0 }, m);
|
||||
assert!(step(&mut app, 0.0).is_none() && app.cam_preview.is_none());
|
||||
tool_event(&mut app, ToolEvent::Move { x: 72.0, y: 30.0, pressure: 1.0 }, m);
|
||||
// Still moving: the wait starts and nothing is computed or shown yet.
|
||||
assert!(step(&mut app, 1000.0).is_none());
|
||||
assert!(step(&mut app, 1000.0 + DEBOUNCE_MS / 2.0).is_none() && app.cam_preview.as_ref().unwrap().running.is_none());
|
||||
// At rest: the job runs, off this thread, and its result shows once it arrives.
|
||||
let mut now = 1000.0 + DEBOUNCE_MS;
|
||||
let mut shown = step(&mut app, now);
|
||||
for _ in 0..600 {
|
||||
if shown.is_some() {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(10));
|
||||
now += 10.0;
|
||||
shown = step(&mut app, now);
|
||||
}
|
||||
let (preview, key) = shown.expect("a preview once the pointer rested");
|
||||
assert!(key > BASE && showing(&app));
|
||||
assert!(blue(&preview, 72, 30) && !blue(&preview, 22, 30), "the preview shows the move");
|
||||
let doc = app.session.active().unwrap().doc.clone();
|
||||
assert!(blue(&doc, 22, 30) && !blue(&doc, 72, 30), "the document is untouched");
|
||||
assert_eq!(app.session.active().unwrap().history.past_len(), steps);
|
||||
|
||||
// Moving on drops the stale preview at once and cancels what is not shown yet.
|
||||
tool_event(&mut app, ToolEvent::Move { x: 60.0, y: 30.0, pressure: 1.0 }, m);
|
||||
assert!(step(&mut app, now + 5.0).is_none() && !showing(&app));
|
||||
// Release commits the exact move; the preview is gone.
|
||||
tool_event(&mut app, ToolEvent::Up { x: 60.0, y: 30.0 }, m);
|
||||
assert!(step(&mut app, now + 10.0).is_none() && app.cam_preview.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_transform_on_drop_box_previews_the_dropped_patch() {
|
||||
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": [[16, 30], [28, 30]], "size": 4, "color": "#0000ff"})).unwrap();
|
||||
app.run("select.lasso", json!({"points": [[6, 14], [38, 14], [38, 46], [6, 46]]})).unwrap();
|
||||
app.ui.tool = Tool::ContentAwareMove;
|
||||
app.ui.tool_options.cam_transform_on_drop = true;
|
||||
let m = egui::Modifiers::NONE;
|
||||
tool_event(&mut app, ToolEvent::Down { x: 22.0, y: 30.0, pressure: 1.0 }, m);
|
||||
tool_event(&mut app, ToolEvent::Move { x: 72.0, y: 30.0, pressure: 1.0 }, m);
|
||||
tool_event(&mut app, ToolEvent::Up { x: 72.0, y: 30.0 }, m);
|
||||
assert!(crate::cam_transform::active(&app));
|
||||
let (mut now, mut shown) = (0.0, step(&mut app, 0.0));
|
||||
for _ in 0..800 {
|
||||
if shown.is_some() {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(10));
|
||||
now += 10.0;
|
||||
shown = step(&mut app, now);
|
||||
}
|
||||
let (preview, _) = shown.expect("the box shows the result");
|
||||
assert!(blue(&preview, 72, 30) && !blue(&preview, 22, 30));
|
||||
// Cancelling the box drops the preview.
|
||||
crate::cam_transform::cancel(&mut app);
|
||||
assert!(step(&mut app, now + 1.0).is_none() && app.cam_preview.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_selection_too_large_to_preview_is_not_retried() {
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
|
||||
app.run("file.new", json!({"width": 600, "height": 400})).unwrap();
|
||||
app.run("select.rect", json!({"x": 10, "y": 10, "width": 300, "height": 300})).unwrap();
|
||||
app.ui.tool = Tool::ContentAwareMove;
|
||||
let m = egui::Modifiers::NONE;
|
||||
tool_event(&mut app, ToolEvent::Down { x: 100.0, y: 100.0, pressure: 1.0 }, m);
|
||||
tool_event(&mut app, ToolEvent::Move { x: 140.0, y: 100.0, pressure: 1.0 }, m);
|
||||
assert!(step(&mut app, 0.0).is_none());
|
||||
assert!(step(&mut app, DEBOUNCE_MS + 1.0).is_none());
|
||||
let p = app.cam_preview.as_ref().unwrap();
|
||||
assert!(p.running.is_none() && p.failed.as_deref() == Some(p.key.as_str()));
|
||||
}
|
||||
}
|
||||
@@ -112,6 +112,17 @@ pub(crate) fn cancel_stale(app: &mut PhotocraftApp) {
|
||||
}
|
||||
}
|
||||
|
||||
/// `paint.contentAwareMove`'s params for the box's placement and the options bar.
|
||||
pub(crate) fn params(app: &PhotocraftApp, place: &Placement) -> serde_json::Value {
|
||||
let mut p = crate::retouch_ui::content_aware_move_params(app, place.offset);
|
||||
if !place.is_plain_move() {
|
||||
p["scaleX"] = place.scale[0].into();
|
||||
p["scaleY"] = place.scale[1].into();
|
||||
p["rotate"] = place.rotate.into();
|
||||
}
|
||||
p
|
||||
}
|
||||
|
||||
/// ↩ / ✓: the move with the transformed patch, as one background job and History step.
|
||||
pub(crate) fn commit(app: &mut PhotocraftApp) {
|
||||
cancel_stale(app);
|
||||
@@ -122,12 +133,7 @@ pub(crate) fn commit(app: &mut PhotocraftApp) {
|
||||
crate::retouch_ui::patch_hint(app);
|
||||
return;
|
||||
}
|
||||
let mut p = crate::retouch_ui::content_aware_move_params(app, place.offset);
|
||||
if !place.is_plain_move() {
|
||||
p["scaleX"] = place.scale[0].into();
|
||||
p["scaleY"] = place.scale[1].into();
|
||||
p["rotate"] = place.rotate.into();
|
||||
}
|
||||
let p = params(app, &place);
|
||||
if let Err(e) = app.run("paint.contentAwareMove", p) {
|
||||
app.ui.status = e;
|
||||
app.ui.status_error = true;
|
||||
|
||||
@@ -1076,6 +1076,10 @@ pub(crate) fn display_doc(app: &mut PhotocraftApp, idx: usize) -> (std::sync::Ar
|
||||
if let Some(shown) = crate::patch_preview::display_doc(app, idx) {
|
||||
return shown;
|
||||
}
|
||||
// Content-Aware Move drag or box: the result, once computed in the background.
|
||||
if let Some(shown) = crate::cam_preview::display_doc(app, idx) {
|
||||
return shown;
|
||||
}
|
||||
// A blend mode hovered in the Layers panel.
|
||||
if let Some(shown) = crate::blend_preview::display_doc(app, idx) {
|
||||
return shown;
|
||||
@@ -1146,7 +1150,7 @@ const NAVIGATOR_SETTLE_MS: f64 = 200.0;
|
||||
/// of document `idx` and catches up on release: each step of the drag is a new preview, and a
|
||||
/// thumbnail composite of the whole document per step makes painting lag on large images.
|
||||
fn navigator_waits_for_drag(app: &PhotocraftApp, idx: usize) -> bool {
|
||||
crate::move_ui::showing(app) || crate::patch_preview::showing(app) || live_stroke(app, idx).is_some()
|
||||
crate::move_ui::showing(app) || crate::patch_preview::showing(app) || crate::cam_preview::showing(app) || live_stroke(app, idx).is_some()
|
||||
}
|
||||
|
||||
/// The Navigator panel's image of document `idx`. With the CPU canvas that is the canvas texture
|
||||
@@ -3485,6 +3489,7 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect:
|
||||
crate::type_tool::draw_overlay(app, &painter, &xf);
|
||||
crate::transform_tool::draw_overlay(app, &painter, &xf);
|
||||
crate::cam_transform::draw(app, &painter, &xf);
|
||||
crate::cam_preview::wake(app, painter.ctx());
|
||||
crate::distort_ui::draw_overlay(app, &painter, &xf);
|
||||
crate::perspective_crop_ui::draw_overlay(app, &painter, &xf);
|
||||
crate::retouch_ui::draw_source_marker(app, &painter, &xf);
|
||||
|
||||
@@ -34,6 +34,7 @@ pub mod brush_preview;
|
||||
pub mod brush_resize;
|
||||
pub mod brush_sections;
|
||||
pub mod brushes_tab;
|
||||
pub(crate) mod cam_preview;
|
||||
mod cam_transform;
|
||||
mod camera_raw_detail_ui;
|
||||
mod camera_raw_preview_ui;
|
||||
@@ -520,6 +521,8 @@ pub struct PhotocraftApp {
|
||||
pub(crate) blend_preview: Option<blend_preview::BlendPreview>,
|
||||
/// Patch Tool drag: the healed document at the pointer (`patch_preview`).
|
||||
pub(crate) patch_preview: Option<patch_preview::PatchPreview>,
|
||||
/// Content-Aware Move drag or box shown with its result (`cam_preview`).
|
||||
pub(crate) cam_preview: Option<cam_preview::CamPreview>,
|
||||
/// The pixels a Magnetic Lasso border follows (`magnetic_lasso_ui`).
|
||||
pub(crate) magnetic: magnetic_lasso_ui::Runtime,
|
||||
/// The next tool `Down` is a right-button drag that erases (see `paint_mouse`).
|
||||
@@ -747,6 +750,7 @@ impl PhotocraftApp {
|
||||
object_finder: None,
|
||||
blend_preview: None,
|
||||
patch_preview: None,
|
||||
cam_preview: None,
|
||||
magnetic: Default::default(),
|
||||
secondary_erase: false,
|
||||
held_erase: false,
|
||||
|
||||
+1
-1
@@ -133,7 +133,7 @@ Corpus floors (`crates/io/tests/corpus.rs`): the enforced minimums; the corpus t
|
||||
| TOOL-213-1 | Pencil tool: aliased 1 px painting, square pixel-aligned cursor | done | [#213](https://github.com/storytold/photocraft/issues/213) | Tool::Pencil (B group): live aliased strokes on the pixel grid, square cursor, Auto Erase; pencil_tests.rs |
|
||||
| TOOL-213-2 | Mixer Brush tool on the toolbar | done | [#213](https://github.com/storytold/photocraft/issues/213) | Tool::MixerBrush in the Brush (B) flyout; Wet/Load/Mix/Flow/Sample All Layers options, command/control dispatch and selection-clipping tests |
|
||||
| TOOL-213-3 | Patch tool with a live preview of the patched area | done | [#213](https://github.com/storytold/photocraft/issues/213) | paint.patch (retouch_cmds/patch.rs, source/destination) + Tool::Patch (J group: lasso, then drag the selection); the drag shows the heal live (patch_preview.rs, coarse colour fit: 1000x1000 in 52 ms) |
|
||||
| TOOL-213-4 | Content-Aware Move tool reusing inpaint completion | partial | [#213](https://github.com/storytold/photocraft/issues/213) | paint.contentAwareMove (retouch_cmds/content_aware_move.rs: move/extend, Structure 1-7, Color 0-10, Sample All Layers, Transform On Drop's scaleX/scaleY/rotate; background job) + Tool::ContentAwareMove (J group: lasso, then drag; Transform On Drop puts a scale/rotate box on the dropped patch, cam_transform.rs: Enter commits as one step, Esc cancels). Missing: a live preview of the result (the drag and the box show the outline only) |
|
||||
| TOOL-213-4 | Content-Aware Move tool reusing inpaint completion | partial | [#213](https://github.com/storytold/photocraft/issues/213) | paint.contentAwareMove (retouch_cmds/content_aware_move.rs: move/extend, Structure 1-7, Color 0-10, Sample All Layers, Transform On Drop's scaleX/scaleY/rotate; background job) + Tool::ContentAwareMove (J group: lasso, then drag; Transform On Drop puts a scale/rotate box on the dropped patch, cam_transform.rs: Enter commits as one step, Esc cancels). cam_preview.rs: once the pointer or the box rests, the result shows (a cancellable background job on a smaller sampling window, small selections only, #4385). Missing: a progressive preview for big selections |
|
||||
| TOOL-213-5 | Painting a type/shape/smart/fill layer offers to rasterize, then continues the stroke | done | [#213](https://github.com/storytold/photocraft/issues/213) | rasterize_prompt.rs: OK rasterizes then paints (two history states), Cancel does nothing; drivable via ui.dialog.confirm |
|
||||
| TOOL-213-6 | Fill dialog: contents, blend, opacity, preserve transparency; remembered; Shift+Backspace | done | [#213](https://github.com/storytold/photocraft/issues/213) | fill_ui.rs dialog over edit.fill (fill_cmds.rs): all contents, mode, opacity, preserve; remembered in prefs; Shift+F5 / Shift+Backspace |
|
||||
| TOOL-213-7 | Live stroke preview for every brush-based retouch tool | partial | [#213](https://github.com/storytold/photocraft/issues/213) | Live (canvas.rs strokes_live): Brush, Pencil, Eraser, Blur, Sharpen, Smudge, Dodge, Burn, Sponge, Clone Stamp, Healing Brush, History Brush (#1026, #1040); the release commits the live stroke's pixels (P32). Not yet live: Spot Healing Brush, Mixer Brush, Background Eraser, Pattern Stamp |
|
||||
|
||||
@@ -31,7 +31,7 @@ id = "TOOL-213-4"
|
||||
target = "Content-Aware Move tool reusing inpaint completion"
|
||||
status = "partial"
|
||||
issue = 213
|
||||
note = "paint.contentAwareMove (retouch_cmds/content_aware_move.rs: move/extend, Structure 1-7, Color 0-10, Sample All Layers, Transform On Drop's scaleX/scaleY/rotate; background job) + Tool::ContentAwareMove (J group: lasso, then drag; Transform On Drop puts a scale/rotate box on the dropped patch, cam_transform.rs: Enter commits as one step, Esc cancels). Missing: a live preview of the result (the drag and the box show the outline only)"
|
||||
note = "paint.contentAwareMove (retouch_cmds/content_aware_move.rs: move/extend, Structure 1-7, Color 0-10, Sample All Layers, Transform On Drop's scaleX/scaleY/rotate; background job) + Tool::ContentAwareMove (J group: lasso, then drag; Transform On Drop puts a scale/rotate box on the dropped patch, cam_transform.rs: Enter commits as one step, Esc cancels). cam_preview.rs: once the pointer or the box rests, the result shows (a cancellable background job on a smaller sampling window, small selections only, #4385). Missing: a progressive preview for big selections"
|
||||
|
||||
[[item]]
|
||||
id = "TOOL-213-5"
|
||||
|
||||
Reference in new issue
Block a user