mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-14 19:26:51 -04:00
Free Transform: apply a large layer's warp in the background, with progress and Esc to cancel (#1015) (#4218)
Committing Free Transform on a 300-550 MP layer ran the bicubic warp on the UI thread: several seconds of a frozen window with no feedback. On a 20000x16000 layer the warp alone took about 6.6 s. edit.transform is now a background job (edit_job): the warp reports progress per tile and stops when cancelled. The transform session stays open while it runs, showing the moving pixels without the box, so the layer doesn't jump back to its old place until the result lands. The modal progress dialog shows after the usual delay, and Esc or Cancel cancels the job. A copy or placed layer the session made is folded into the step, or taken back, in its own document. Undo and Redo are refused while the commit runs. Tests and scripts run inline as before. Co-authored-by: Brandon Thomas <[email protected]> Co-authored-by: Claude Opus 5.5 <[email protected]>
This commit is contained in:
6 files changed
+317
-59
No files matched your search
@@ -9,7 +9,7 @@
|
||||
|
||||
use photocraft_color::PixelFormat;
|
||||
use photocraft_geom::Rect;
|
||||
use photocraft_raster::Surface;
|
||||
use photocraft_raster::{Interrupt, Surface};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::resample::{Resample, resize_surface};
|
||||
@@ -143,14 +143,26 @@ fn catmull_rom(t: f64) -> [f64; 4] {
|
||||
/// The result has the same format as `src`, with alpha added if `src` had none; pixels outside
|
||||
/// the warped quad are transparent.
|
||||
pub fn warp_surface(src: &Surface, src_rect: Rect, h: &Homography, interp: Interp) -> Result<Surface, photocraft_raster::AllocationError> {
|
||||
warp(src, src_rect, h, interp, None)
|
||||
warp(src, src_rect, h, interp, None, &Interrupt::NONE)
|
||||
}
|
||||
|
||||
/// [`warp_surface`] reporting progress per destination tile; `Ok(None)` once `ctl` is cancelled.
|
||||
pub fn warp_surface_with(
|
||||
src: &Surface,
|
||||
src_rect: Rect,
|
||||
h: &Homography,
|
||||
interp: Interp,
|
||||
ctl: &Interrupt,
|
||||
) -> Result<Option<Surface>, photocraft_raster::AllocationError> {
|
||||
let out = warp(src, src_rect, h, interp, None, ctl)?;
|
||||
Ok((!ctl.cancelled()).then_some(out))
|
||||
}
|
||||
|
||||
/// [`warp_surface`] keeping only the destination pixels inside `clip`. The work is bounded by
|
||||
/// `clip` even when part of the source lies at or beyond the map's horizon (a Perspective Crop of a
|
||||
/// layer that reaches far past the crop quad), which would make the full warped extent unbounded.
|
||||
pub fn warp_surface_clipped(src: &Surface, src_rect: Rect, h: &Homography, interp: Interp, clip: Rect) -> Result<Surface, photocraft_raster::AllocationError> {
|
||||
warp(src, src_rect, h, interp, Some(clip))
|
||||
warp(src, src_rect, h, interp, Some(clip), &Interrupt::NONE)
|
||||
}
|
||||
|
||||
/// Is `q` (TL, TR, BR, BL) a usable Perspective Crop quad: finite, strictly convex with every
|
||||
@@ -216,7 +228,15 @@ fn corner_bounds(corners: &[(f64, f64); 4]) -> Option<Rect> {
|
||||
Some(Rect::new(bx0, by0, bx1, by1))
|
||||
}
|
||||
|
||||
fn warp(src: &Surface, src_rect: Rect, h: &Homography, interp: Interp, clip: Option<Rect>) -> Result<Surface, photocraft_raster::AllocationError> {
|
||||
/// A cancelled `ctl` skips the remaining tiles; the caller discards the result.
|
||||
fn warp(
|
||||
src: &Surface,
|
||||
src_rect: Rect,
|
||||
h: &Homography,
|
||||
interp: Interp,
|
||||
clip: Option<Rect>,
|
||||
ctl: &Interrupt,
|
||||
) -> Result<Surface, photocraft_raster::AllocationError> {
|
||||
let mut fmt = src.format();
|
||||
let converted;
|
||||
let mut src = if fmt.alpha {
|
||||
@@ -277,7 +297,16 @@ fn warp(src: &Surface, src_rect: Rect, h: &Homography, interp: Interp, clip: Opt
|
||||
tiles.try_reserve_exact(tile_count).map_err(|_| photocraft_raster::AllocationError::NotEnoughMemory { bytes: tile_bytes })?;
|
||||
tiles.extend(dst.tiles().map(|tc| tc.rect().intersect(&dst)).filter(|r| !r.is_empty()));
|
||||
let src_ref: &Surface = src;
|
||||
// Tiles finished so far, for progress reported per tile (from any worker thread).
|
||||
let finished = std::sync::atomic::AtomicUsize::new(0);
|
||||
let total = tiles.len().max(1);
|
||||
let work = |t: &Rect| -> Result<Option<(Rect, Vec<f32>)>, photocraft_raster::AllocationError> {
|
||||
if ctl.cancelled() {
|
||||
return Ok(None);
|
||||
}
|
||||
let k = finished.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1;
|
||||
// The last few percent are the writes.
|
||||
ctl.progress(0.98 * k as f32 / total as f32);
|
||||
// Source footprint of this tile (inverse-mapped corners of its sampled part), padded for the filter.
|
||||
let tc = [(t.x0, t.y0), (t.x1, t.y0), (t.x1, t.y1), (t.x0, t.y1)].map(|(x, y)| (x as f64, y as f64));
|
||||
let wc = tc.map(|(x, y)| inv.0[6] * x + inv.0[7] * y + inv.0[8]);
|
||||
@@ -413,6 +442,9 @@ fn warp(src: &Surface, src_rect: Rect, h: &Homography, interp: Interp, clip: Opt
|
||||
}
|
||||
}
|
||||
let done = done.into_inner().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
if ctl.cancelled() {
|
||||
return Ok(out);
|
||||
}
|
||||
for (r, v) in done {
|
||||
out.try_write_region(r, &v)?;
|
||||
}
|
||||
@@ -424,6 +456,25 @@ fn warp(src: &Surface, src_rect: Rect, h: &Homography, interp: Interp, clip: Opt
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn the_interruptible_warp_reports_progress_and_stops_when_cancelled() {
|
||||
let mut s = Surface::new(PixelFormat::RGBA8);
|
||||
s.fill_rect(Rect::new(0, 0, 600, 400), &[0.2, 0.4, 0.6, 1.0]);
|
||||
let r = s.content_bounds();
|
||||
let h = Homography::rect_to_quad([0.0, 0.0, 600.0, 400.0], [[10.0, 0.0], [590.0, 20.0], [600.0, 400.0], [0.0, 380.0]]).unwrap();
|
||||
let reported = std::sync::Mutex::new(0.0f32);
|
||||
let progress = |p: f32| {
|
||||
let mut m = reported.lock().unwrap();
|
||||
*m = m.max(p);
|
||||
};
|
||||
let never = || false;
|
||||
let out = warp_surface_with(&s, r, &h, Interp::Bicubic, &Interrupt::new(&never, &progress)).unwrap().expect("not cancelled");
|
||||
assert_eq!(out.read_region(r), warp_surface(&s, r, &h, Interp::Bicubic).unwrap().read_region(r));
|
||||
assert!(*reported.lock().unwrap() > 0.9);
|
||||
let always = || true;
|
||||
assert!(warp_surface_with(&s, r, &h, Interp::Bicubic, &Interrupt::cancel_only(&always)).unwrap().is_none());
|
||||
}
|
||||
|
||||
/// Perspective Crop's map lands each quad corner exactly on its rectangle corner; degenerate,
|
||||
/// self-crossing, counter-clockwise and non-finite quads have none.
|
||||
#[test]
|
||||
|
||||
@@ -78,6 +78,22 @@ fn a_job_applies_as_one_undo_step_and_matches_the_inline_result() {
|
||||
assert!(s.jobs().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn free_transform_runs_as_a_job_with_the_inline_result() {
|
||||
let quad = json!({"quad": [[20, 10], [280, 30], [260, 190], [40, 170]]});
|
||||
let mut inline = session(300, 200);
|
||||
inline.execute("edit.transform", quad.clone()).unwrap();
|
||||
|
||||
let mut s = session(300, 200);
|
||||
let steps = s.active().unwrap().history.past_len();
|
||||
let id = job(s.start("edit.transform", quad).unwrap());
|
||||
let e = wait_event(&mut s, id);
|
||||
assert!(matches!(e.outcome, JobOutcome::Done(_)), "{e:?}");
|
||||
assert_eq!(e.label, "Free Transform");
|
||||
assert_eq!(s.active().unwrap().history.past_len(), steps + 1, "one undo step");
|
||||
assert_eq!(pixels(&s), pixels(&inline), "same pixels as the synchronous command");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancel_leaves_the_document_byte_identical() {
|
||||
let mut s = session(1600, 1200);
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
//! Edit › Free Transform / Transform: projective transforms of layers (and the selection).
|
||||
|
||||
use photocraft_algo::transform::{Homography, Interp, warp_output_bounds, warp_surface};
|
||||
use photocraft_algo::transform::{Homography, Interp, warp_output_bounds, warp_surface, warp_surface_with};
|
||||
use photocraft_color::PixelFormat;
|
||||
use photocraft_doc::{Document, Layer, LayerContent, LayerId, Locks};
|
||||
use photocraft_geom::{Affine, Rect};
|
||||
use photocraft_raster::Surface;
|
||||
use photocraft_raster::{Interrupt, Surface};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::commands::CommandSpec;
|
||||
@@ -304,11 +304,33 @@ fn check_layer_budget(doc_sel: Option<&Surface>, l: &Layer, h: &Homography) -> R
|
||||
/// is turned into a normal layer by the caller first ([`crate::extra_cmds::background_to_layer`]).
|
||||
/// Refused, with `l` untouched, when a result would exceed [`MAX_TRANSFORM_PIXELS`].
|
||||
pub(crate) fn transform_layer(doc_sel: Option<&Surface>, group: Locks, l: &mut Layer, h: &Homography, affine: Option<Affine>, interp: Interp) -> Result<()> {
|
||||
check_layer_budget(doc_sel, l, h)?;
|
||||
apply_transform(doc_sel, group, l, h, affine, interp)
|
||||
transform_layer_with(doc_sel, group, l, h, affine, interp, &Interrupt::NONE)
|
||||
}
|
||||
|
||||
fn apply_transform(doc_sel: Option<&Surface>, group: Locks, l: &mut Layer, h: &Homography, affine: Option<Affine>, interp: Interp) -> Result<()> {
|
||||
/// [`transform_layer`] with progress and cancellation for the pixel warp (the slow part on a
|
||||
/// large layer, #1015).
|
||||
fn transform_layer_with(
|
||||
doc_sel: Option<&Surface>,
|
||||
group: Locks,
|
||||
l: &mut Layer,
|
||||
h: &Homography,
|
||||
affine: Option<Affine>,
|
||||
interp: Interp,
|
||||
ctl: &Interrupt,
|
||||
) -> Result<()> {
|
||||
check_layer_budget(doc_sel, l, h)?;
|
||||
apply_transform(doc_sel, group, l, h, affine, interp, ctl)
|
||||
}
|
||||
|
||||
fn apply_transform(
|
||||
doc_sel: Option<&Surface>,
|
||||
group: Locks,
|
||||
l: &mut Layer,
|
||||
h: &Homography,
|
||||
affine: Option<Affine>,
|
||||
interp: Interp,
|
||||
ctl: &Interrupt,
|
||||
) -> Result<()> {
|
||||
crate::allocation::checkpoint("transforming pixels")?;
|
||||
let locks = transform_locks(l, group, false);
|
||||
if locks.position || locks.all {
|
||||
@@ -317,7 +339,7 @@ fn apply_transform(doc_sel: Option<&Surface>, group: Locks, l: &mut Layer, h: &H
|
||||
if h.is_identity() {
|
||||
if let LayerContent::Group(g) = &mut l.content {
|
||||
for c in g.children.iter_mut() {
|
||||
apply_transform(None, locks, c, h, affine, interp)?;
|
||||
apply_transform(None, locks, c, h, affine, interp, ctl)?;
|
||||
}
|
||||
}
|
||||
return Ok(());
|
||||
@@ -328,7 +350,7 @@ fn apply_transform(doc_sel: Option<&Surface>, group: Locks, l: &mut Layer, h: &H
|
||||
match &mut l.content {
|
||||
LayerContent::Group(g) => {
|
||||
for c in g.children.iter_mut() {
|
||||
apply_transform(None, locks, c, h, affine, interp)?;
|
||||
apply_transform(None, locks, c, h, affine, interp, ctl)?;
|
||||
}
|
||||
}
|
||||
LayerContent::Text(t) => {
|
||||
@@ -366,12 +388,12 @@ fn apply_transform(doc_sel: Option<&Surface>, group: Locks, l: &mut Layer, h: &H
|
||||
// Only the selected pixels move: lift them, clear them, warp and paste back.
|
||||
Some(sel) => {
|
||||
let (lifted, mut rest) = split_selected(surf, sel)?;
|
||||
let moved = warp_surface(&lifted, src, h, interp)?;
|
||||
let moved = warp_surface_with(&lifted, src, h, interp, ctl)?.ok_or(EngineError::Cancelled)?;
|
||||
composite_over(&mut rest, &moved);
|
||||
rest.prune();
|
||||
rest
|
||||
}
|
||||
None => warp_surface(surf, src, h, interp)?,
|
||||
None => warp_surface_with(surf, src, h, interp, ctl)?.ok_or(EngineError::Cancelled)?,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -571,49 +593,69 @@ fn transform(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
(m[6].abs() < 1e-12 && m[7].abs() < 1e-12).then(|| Affine { m: [m[0] / m[8], m[3] / m[8], m[1] / m[8], m[4] / m[8], m[2] / m[8], m[5] / m[8]] });
|
||||
let interp = Interp::parse(p.get("interpolation").and_then(Value::as_str).unwrap_or("bicubic"));
|
||||
let lone = lone.is_some();
|
||||
s.edit("Free Transform", |doc, _| {
|
||||
let sel = doc.selection.clone();
|
||||
// The selection outline moves too: price it before anything is built.
|
||||
if let Some(sel) = sel.as_ref().filter(|_| !h.is_identity()) {
|
||||
check_budget(sel.content_bounds(), &h, "selection")?;
|
||||
}
|
||||
if lone {
|
||||
// A targeted unlinked mask, alpha channel or Quick Mask transforms by itself.
|
||||
if !h.is_identity() {
|
||||
let (surf, _) = crate::channel_cmds::target_surface(doc, id, p)?;
|
||||
let src = sel.as_ref().map_or_else(|| surf.content_bounds(), Surface::content_bounds);
|
||||
check_budget(src, &h, "mask or channel")?;
|
||||
*surf = match &sel {
|
||||
Some(sel) => warp_gray_selected(surf, sel, &h, interp)?,
|
||||
None => warp_gray(surf, &h, interp)?,
|
||||
};
|
||||
if let Some(sel) = &doc.selection {
|
||||
doc.selection = Some(warp_gray(sel, &h, Interp::Bilinear)?).filter(|s| !s.content_bounds().is_empty());
|
||||
}
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
let id = id.ok_or_else(|| EngineError::Other("no active layer".into()))?;
|
||||
let is_group = doc.layer(id).is_some_and(Layer::is_group);
|
||||
let group = group_locks(doc, id);
|
||||
// Photoshop turns the Background into a normal layer before transforming it.
|
||||
crate::extra_cmds::background_to_layer(doc, id);
|
||||
let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?;
|
||||
transform_layer(if is_group { None } else { sel.as_ref() }, group, l, &h, affine, interp)?;
|
||||
let p = p.clone();
|
||||
// A background job when started with `Session::start` (#1015): the warp of a large layer runs
|
||||
// on a worker with progress, and can be cancelled.
|
||||
crate::jobs::edit_job(
|
||||
s,
|
||||
"Free Transform",
|
||||
move |doc, _, ctx| ctx.stage(0.0, 1.0, "Transforming", |ctl| transform_doc(doc, id, &p, lone, &h, affine, interp, ctl)),
|
||||
move |()| json!({"rect": rect}),
|
||||
)
|
||||
}
|
||||
|
||||
/// The edit of [`transform`]: the layer (or a lone mask or channel) and the selection outline.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn transform_doc(
|
||||
doc: &mut Document,
|
||||
id: Option<LayerId>,
|
||||
p: &Value,
|
||||
lone: bool,
|
||||
h: &Homography,
|
||||
affine: Option<Affine>,
|
||||
interp: Interp,
|
||||
ctl: &Interrupt,
|
||||
) -> Result<()> {
|
||||
let sel = doc.selection.clone();
|
||||
// The selection outline moves too: price it before anything is built.
|
||||
if let Some(sel) = sel.as_ref().filter(|_| !h.is_identity()) {
|
||||
check_budget(sel.content_bounds(), h, "selection")?;
|
||||
}
|
||||
if lone {
|
||||
// A targeted unlinked mask, alpha channel or Quick Mask transforms by itself.
|
||||
if !h.is_identity() {
|
||||
// Type layers re-render from their new transform.
|
||||
let snapshot = doc.clone();
|
||||
if let Some(l) = doc.layer_mut(id) {
|
||||
refresh_text(&snapshot, l);
|
||||
}
|
||||
// The selection outline moves with the pixels.
|
||||
let (surf, _) = crate::channel_cmds::target_surface(doc, id, p)?;
|
||||
let src = sel.as_ref().map_or_else(|| surf.content_bounds(), Surface::content_bounds);
|
||||
check_budget(src, h, "mask or channel")?;
|
||||
*surf = match &sel {
|
||||
Some(sel) => warp_gray_selected(surf, sel, h, interp)?,
|
||||
None => warp_gray(surf, h, interp)?,
|
||||
};
|
||||
if let Some(sel) = &doc.selection {
|
||||
doc.selection = Some(warp_gray(sel, &h, Interp::Bilinear)?).filter(|s| !s.content_bounds().is_empty());
|
||||
doc.selection = Some(warp_gray(sel, h, Interp::Bilinear)?).filter(|s| !s.content_bounds().is_empty());
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({"rect": rect}))
|
||||
return Ok(());
|
||||
}
|
||||
let id = id.ok_or_else(|| EngineError::Other("no active layer".into()))?;
|
||||
let is_group = doc.layer(id).is_some_and(Layer::is_group);
|
||||
let group = group_locks(doc, id);
|
||||
// Photoshop turns the Background into a normal layer before transforming it.
|
||||
crate::extra_cmds::background_to_layer(doc, id);
|
||||
let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?;
|
||||
transform_layer_with(if is_group { None } else { sel.as_ref() }, group, l, h, affine, interp, ctl)?;
|
||||
if !h.is_identity() {
|
||||
// Type layers re-render from their new transform.
|
||||
let snapshot = doc.clone();
|
||||
if let Some(l) = doc.layer_mut(id) {
|
||||
refresh_text(&snapshot, l);
|
||||
}
|
||||
// The selection outline moves with the pixels.
|
||||
if let Some(sel) = &doc.selection {
|
||||
doc.selection = Some(warp_gray(sel, h, Interp::Bilinear)?).filter(|s| !s.content_bounds().is_empty());
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
|
||||
@@ -449,6 +449,8 @@ fn on_event(app: &mut PhotocraftApp, ctx: &egui::Context, e: JobEvent) {
|
||||
true
|
||||
}
|
||||
});
|
||||
// A Free Transform commit's session ends with its job (then reported like any job).
|
||||
crate::transform_tool::on_job_event(app, &e);
|
||||
if crate::prefs_ui::on_recovery_event(app, &e) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -704,3 +704,65 @@ fn printing_keeps_drawing_and_reports_only_the_final_spooler_result() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn layer_count(app: &PhotocraftApp) -> usize {
|
||||
app.session.active().unwrap().doc.layers.len()
|
||||
}
|
||||
|
||||
/// Free Transform the active layer to the right half of the canvas, then ↩.
|
||||
fn transform_and_commit(h: &mut Harness<'static, PhotocraftApp>, copy: bool) {
|
||||
let ctx = h.ctx.clone();
|
||||
let app = h.state_mut();
|
||||
if copy { crate::transform_tool::begin_copy(app, &ctx) } else { crate::transform_tool::begin(app, &ctx) }.unwrap();
|
||||
app.ui.transform.as_mut().unwrap().quad = [[160.0, 0.0], [320.0, 0.0], [320.0, 120.0], [160.0, 120.0]];
|
||||
crate::transform_tool::commit(app);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_free_transform_commit_runs_in_the_background_and_keeps_its_preview() {
|
||||
let mut h = app_harness();
|
||||
let (layers, steps) = (layer_count(h.state()), h.state().session.active().unwrap().history.past_len());
|
||||
transform_and_commit(&mut h, true);
|
||||
let app = h.state();
|
||||
// The warp runs as a job; the moving pixels stay on screen until it lands, and the box's
|
||||
// history is closed meanwhile.
|
||||
let job = crate::transform_tool::committing(app).expect("the commit runs as a job");
|
||||
assert!(app.session.job(job).is_some_and(|j| j.label == "Free Transform"));
|
||||
assert!(app.ui.transform.is_some() && app.transform_preview.is_some());
|
||||
assert_eq!(crate::transform_tool::is_enabled(app, "edit.undo"), Some(false));
|
||||
assert!(h.state_mut().run("edit.undo", json!({})).is_err());
|
||||
crate::transform_tool::commit(h.state_mut());
|
||||
let t = Instant::now();
|
||||
while h.state().session.has_jobs() && t.elapsed() < Duration::from_secs(10) {
|
||||
h.step();
|
||||
}
|
||||
h.run_steps(2);
|
||||
let app = h.state();
|
||||
assert!(app.ui.transform.is_none() && app.transform_preview.is_none(), "the session ends with the job");
|
||||
// The copy and its transform are one step, as when the commit ran inline.
|
||||
let st = app.session.active().unwrap();
|
||||
assert_eq!((layer_count(app), st.history.past_len()), (layers + 1, steps + 1));
|
||||
assert_eq!(st.history.undo_label(), Some("Free Transform"));
|
||||
let moved = st.doc.layer(st.active_layer.unwrap()).unwrap().surface().unwrap().content_bounds();
|
||||
assert_eq!((moved.x0, moved.y0, moved.x1, moved.y1), (160, 0, 320, 120));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn esc_cancels_a_background_free_transform_commit() {
|
||||
let mut h = app_harness();
|
||||
let (layers, steps) = (layer_count(h.state()), h.state().session.active().unwrap().history.past_len());
|
||||
let before = h.state().session.active().unwrap().doc.clone();
|
||||
transform_and_commit(&mut h, true);
|
||||
let job = crate::transform_tool::committing(h.state()).expect("the commit runs as a job");
|
||||
// Esc reaches the transform tool first (shortcuts), which cancels the job.
|
||||
crate::transform_tool::cancel(h.state_mut());
|
||||
h.run_steps(3);
|
||||
let app = h.state();
|
||||
assert!(app.session.job(job).is_none());
|
||||
assert!(app.ui.transform.is_none() && app.transform_preview.is_none());
|
||||
// The copy is taken back with the transform, leaving nothing to redo.
|
||||
let st = app.session.active().unwrap();
|
||||
assert_eq!((layer_count(app), st.history.past_len()), (layers, steps));
|
||||
assert!(!st.history.can_redo());
|
||||
assert_eq!(st.doc.layers.len(), before.layers.len());
|
||||
}
|
||||
@@ -14,6 +14,7 @@ use std::sync::Arc;
|
||||
use egui::{Color32, CursorIcon, Pos2, Stroke, pos2, vec2};
|
||||
use photocraft_algo::transform::Homography;
|
||||
use photocraft_doc::{DocId, Document, LayerContent, LayerId};
|
||||
use photocraft_engine::jobs::{JobEvent, JobId, JobOutcome};
|
||||
use photocraft_geom::warp::{BezierMesh, Warp, WarpStyle};
|
||||
use serde_json::json;
|
||||
|
||||
@@ -85,6 +86,9 @@ pub struct TransformPreview {
|
||||
/// A layer's session keeps `doc` and `opacity` up to date with edits made while the box is
|
||||
/// open ([`follow_edits`]); `None` for a lone mask or channel and Transform Selection.
|
||||
follow: Option<Follow>,
|
||||
/// The commit running as a background job (#1015): the moving pixels stay on screen, without
|
||||
/// the box, until it lands; Esc cancels it. The session ends with the job.
|
||||
committing: Option<JobId>,
|
||||
/// Content-Aware Scale's live preview (cas_transform.rs).
|
||||
pub(crate) content_aware: Option<crate::cas_transform::Live>,
|
||||
}
|
||||
@@ -229,6 +233,7 @@ pub fn begin(app: &mut PhotocraftApp, ctx: &egui::Context) -> Result<(), String>
|
||||
above: Some(above),
|
||||
styled: styled.then(|| crate::transform_fx::StyledPreview::new(format!("transform-{session}-styled"))),
|
||||
follow: Some(Follow { revision, hidden: lifted.is_none() }),
|
||||
committing: None,
|
||||
gesture: None,
|
||||
warp_drag: None,
|
||||
split_tool: None,
|
||||
@@ -327,6 +332,7 @@ pub(crate) fn begin_content_aware(
|
||||
opacity: layer.opacity * layer.fill_opacity,
|
||||
above: Some(above),
|
||||
follow: Some(Follow { revision, hidden: false }),
|
||||
committing: None,
|
||||
gesture: None,
|
||||
warp_drag: None,
|
||||
split_tool: None,
|
||||
@@ -385,13 +391,20 @@ fn take_back_made(app: &mut PhotocraftApp, document: DocId, layer: LayerId) {
|
||||
|
||||
/// After the transform: the step that made the layer and the transform become one history step,
|
||||
/// the transform's for a copy and Place Embedded for a placed file.
|
||||
fn fold_made(app: &mut PhotocraftApp, made: MadeLayer) {
|
||||
fn fold_made(app: &mut PhotocraftApp, document: DocId, made: MadeLayer) {
|
||||
// A background commit lands in its own document, which need not be the active one by then.
|
||||
let Some(index) = app.session.documents().iter().position(|st| st.doc.id == document) else { return };
|
||||
let active = app.session.active_index();
|
||||
app.session.set_active(index);
|
||||
if let Some(st) = app.session.active_mut() {
|
||||
st.history.purge_last();
|
||||
if made == MadeLayer::Place {
|
||||
st.history.set_current_label(photocraft_engine::file_cmds::PLACE_EMBEDDED);
|
||||
}
|
||||
}
|
||||
if let Some(active) = active {
|
||||
app.session.set_active(active);
|
||||
}
|
||||
}
|
||||
|
||||
/// Free Transform of a targeted unlinked layer mask, alpha channel or Quick Mask by itself: the
|
||||
@@ -440,6 +453,7 @@ fn begin_lone(
|
||||
above: None,
|
||||
styled: None,
|
||||
follow: None,
|
||||
committing: None,
|
||||
gesture: None,
|
||||
warp_drag: None,
|
||||
split_tool: None,
|
||||
@@ -501,6 +515,7 @@ fn begin_path(app: &mut PhotocraftApp, ctx: &egui::Context, params: serde_json::
|
||||
content_aware: None,
|
||||
above: None,
|
||||
follow: None,
|
||||
committing: None,
|
||||
});
|
||||
app.ui.transform = Some(TransformSession {
|
||||
session,
|
||||
@@ -574,6 +589,7 @@ pub fn begin_selection(app: &mut PhotocraftApp, ctx: &egui::Context) -> Result<(
|
||||
above: None,
|
||||
styled: None,
|
||||
follow: None,
|
||||
committing: None,
|
||||
gesture: None,
|
||||
warp_drag: None,
|
||||
split_tool: None,
|
||||
@@ -949,7 +965,13 @@ fn contains(l: &photocraft_doc::Layer, id: LayerId) -> bool {
|
||||
l.id == id || matches!(&l.content, LayerContent::Group(g) if g.children.iter().any(|c| contains(c, id)))
|
||||
}
|
||||
|
||||
/// Undo and Redo while the commit runs in the background.
|
||||
const STILL_APPLYING: &str = "Free Transform is still being applied";
|
||||
|
||||
pub fn commit(app: &mut PhotocraftApp) {
|
||||
if committing(app).is_some() {
|
||||
return;
|
||||
}
|
||||
let document = app.transform_preview.as_ref().map(|pv| pv.doc.id);
|
||||
if document.is_some_and(|id| app.session.active().is_none_or(|st| st.doc.id != id)) {
|
||||
cancel(app);
|
||||
@@ -957,7 +979,7 @@ pub fn commit(app: &mut PhotocraftApp) {
|
||||
}
|
||||
let type_layer = warps_type(app);
|
||||
let Some(t) = app.ui.transform.take() else { return };
|
||||
let preview = app.transform_preview.take();
|
||||
let mut preview = app.transform_preview.take();
|
||||
if let Some(options) = &t.content_aware {
|
||||
crate::cas_transform::commit(app, &t, options);
|
||||
return;
|
||||
@@ -1012,16 +1034,54 @@ pub fn commit(app: &mut PhotocraftApp) {
|
||||
}
|
||||
app.run("edit.transform", p)
|
||||
};
|
||||
// A large layer's warp runs in the background: the session stays until the job ends.
|
||||
if let Ok(v) = &r
|
||||
&& let Some(job) = v.get("pending").and_then(serde_json::Value::as_bool).filter(|p| *p).and(v.get("job")).and_then(serde_json::Value::as_u64)
|
||||
&& let Some(pv) = preview.as_mut()
|
||||
{
|
||||
pv.committing = Some(JobId(job));
|
||||
app.ui.transform = Some(t);
|
||||
app.transform_preview = preview;
|
||||
return;
|
||||
}
|
||||
match r {
|
||||
Ok(_) => {
|
||||
if let Some(made) = made {
|
||||
fold_made(app, made);
|
||||
}
|
||||
}
|
||||
Ok(_) => finish(app, document, LayerId(t.layer), made, true),
|
||||
Err(e) => keep_open(app, t, preview, e),
|
||||
}
|
||||
}
|
||||
|
||||
/// After the transform landed or not: fold the layer the session made into its step, or take it
|
||||
/// back.
|
||||
fn finish(app: &mut PhotocraftApp, document: Option<DocId>, layer: LayerId, made: Option<MadeLayer>, landed: bool) {
|
||||
let (Some(made), Some(document)) = (made, document) else { return };
|
||||
if landed {
|
||||
fold_made(app, document, made);
|
||||
} else {
|
||||
take_back_made(app, document, layer);
|
||||
}
|
||||
}
|
||||
|
||||
/// The job applying the transform, while it runs.
|
||||
pub(crate) fn committing(app: &PhotocraftApp) -> Option<JobId> {
|
||||
app.transform_preview.as_ref().and_then(|pv| pv.committing)
|
||||
}
|
||||
|
||||
/// A job ended (see [`crate::jobs_ui`]): when it was the commit, the session ends with it. Its
|
||||
/// status and errors are reported like any job's.
|
||||
pub fn on_job_event(app: &mut PhotocraftApp, e: &JobEvent) {
|
||||
if committing(app) != Some(e.id) {
|
||||
return;
|
||||
}
|
||||
let (Some(t), Some(mut pv)) = (app.ui.transform.take(), app.transform_preview.take()) else { return };
|
||||
// A refused commit (a result over the pixel budget, #3773) keeps the box open, as inline.
|
||||
if let JobOutcome::Failed(msg) = &e.outcome {
|
||||
pv.committing = None;
|
||||
keep_open(app, t, Some(pv), msg.clone());
|
||||
return;
|
||||
}
|
||||
finish(app, Some(pv.doc.id), LayerId(t.layer), t.made, matches!(e.outcome, JobOutcome::Done(_)));
|
||||
}
|
||||
|
||||
/// A commit the engine refused (a result over the pixel budget, #3773): the box stays open as it
|
||||
/// was, with the reason in the status bar, so the transform can be changed or cancelled. A copy or
|
||||
/// a placed layer it made goes only when it is cancelled.
|
||||
@@ -1038,6 +1098,9 @@ fn keep_open(app: &mut PhotocraftApp, t: TransformSession, preview: Option<Trans
|
||||
/// that tool.
|
||||
pub fn end_if_left(app: &mut PhotocraftApp) {
|
||||
let Some(t) = &app.ui.transform else { return };
|
||||
if committing(app).is_some() {
|
||||
return;
|
||||
}
|
||||
let Some(st) = app.session.active() else { return cancel(app) };
|
||||
|
||||
let layer = LayerId(t.layer);
|
||||
@@ -1093,6 +1156,11 @@ pub fn follow_edits(app: &mut PhotocraftApp) {
|
||||
}
|
||||
|
||||
pub fn cancel(app: &mut PhotocraftApp) {
|
||||
// Esc while the commit runs cancels the job; the session ends when it does.
|
||||
if let Some(job) = committing(app) {
|
||||
crate::jobs_ui::cancel(app, job);
|
||||
return;
|
||||
}
|
||||
let transform = app.ui.transform.take();
|
||||
let preview = app.transform_preview.take();
|
||||
if let (Some(t), Some(pv)) = (transform, preview)
|
||||
@@ -1123,6 +1191,9 @@ pub fn track_steps(app: &mut PhotocraftApp, ctx: &egui::Context) {
|
||||
/// Toggle Last State, which would change the document under the box), `None` for the rest.
|
||||
pub fn is_enabled(app: &PhotocraftApp, id: &str) -> Option<bool> {
|
||||
let t = app.ui.transform.as_ref()?;
|
||||
if committing(app).is_some() {
|
||||
return box_command(id).then_some(false);
|
||||
}
|
||||
let steps = app.transform_preview.as_ref().map(|pv| &pv.steps);
|
||||
match id {
|
||||
// Content-Aware Scale's box only scales: no switching it to another transform or Warp.
|
||||
@@ -1168,8 +1239,16 @@ fn box_preset(id: &str) -> Option<(f64, f64, f64)> {
|
||||
/// document under the box, is refused. Edit › Transform's Rotate and Flip turn the box itself, as
|
||||
/// in Photoshop, instead of the layer under it; committing stays with ↩ and the options bar.
|
||||
/// `None` for other commands or when not transforming.
|
||||
/// The commands the open box answers itself ([`is_enabled`], [`intercept`]).
|
||||
fn box_command(id: &str) -> bool {
|
||||
matches!(id, "edit.undo" | "edit.redo" | "edit.toggleLastState" | "edit.contentAwareScale" | "edit.puppetWarp") || box_preset(id).is_some()
|
||||
}
|
||||
|
||||
pub fn intercept(app: &mut PhotocraftApp, id: &str) -> Option<Result<serde_json::Value, String>> {
|
||||
let warping = app.ui.transform.as_ref()?.warp.is_some();
|
||||
if committing(app).is_some() {
|
||||
return box_command(id).then(|| Err(STILL_APPLYING.into()));
|
||||
}
|
||||
match id {
|
||||
"edit.undo" => Some(Ok(step(app, false))),
|
||||
"edit.redo" => Some(Ok(step(app, true))),
|
||||
@@ -1259,6 +1338,9 @@ fn accepts_outside_click(g: Gesture, end: [f64; 2], tol: f64) -> bool {
|
||||
/// Pointer input while transforming. Returns false when no transform is active.
|
||||
pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) -> bool {
|
||||
let Some(t) = app.ui.transform.clone() else { return false };
|
||||
if committing(app).is_some() {
|
||||
return true;
|
||||
}
|
||||
let tol = handle_tolerance(app);
|
||||
if t.warp.is_some() {
|
||||
warp_pointer(app, ev, tol, mods);
|
||||
@@ -1991,6 +2073,9 @@ pub fn draw_overlay(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform
|
||||
if let Some(a) = &pv.above {
|
||||
a.draw(painter, xf);
|
||||
}
|
||||
if pv.committing.is_some() {
|
||||
return;
|
||||
}
|
||||
let accent = crate::theme::Tokens::get(painter.ctx()).accent;
|
||||
if let (Some(path), Some(h)) = (&pv.path, Homography::rect_to_quad(t.rect, t.quad)) {
|
||||
crate::vector_ui::draw_outline(
|
||||
|
||||
Reference in new issue
Block a user