diff --git a/crates/automation/src/server.rs b/crates/automation/src/server.rs index e5a771c94..24da196bf 100644 --- a/crates/automation/src/server.rs +++ b/crates/automation/src/server.rs @@ -246,7 +246,8 @@ pub struct UiSetParams { /// color ("matchCanvas" or "custom"), custom_color ([r, g, b]), opacity (0..100), /// auto_adjust, classic_mode and auto_center_preview), shapeStroke (an object patching width, align, cap, join, miterLimit, /// dashes, dashOffset and opacity; null disables the stroke). Shape stroke fields use - /// `shape.edit` names and units; colour comes from `tools.setColors`. Other fields are an error. + /// `shape.edit` names and units; colour comes from `tools.setColors`. camTransformOnDrop (bool, + /// the Content-Aware Move tool's Transform On Drop). Other fields are an error. pub fields: Value, } diff --git a/crates/engine/src/retouch_cmds.rs b/crates/engine/src/retouch_cmds.rs index f93a4a7f3..9923f7a3d 100644 --- a/crates/engine/src/retouch_cmds.rs +++ b/crates/engine/src/retouch_cmds.rs @@ -1159,7 +1159,7 @@ pub fn specs() -> Vec { label: "Content-Aware Move", menu: &[], shortcut: None, - params: r#"{"offset":[dx,dy] (how far the selection was dragged),"mode":"move|extend"="move","structure":1..7=4 (3..7 copy the content exactly but for a band about a patch wide along its edge; 2 widens the band, 1 re-synthesises the content too),"color":0..10=0 (0 keeps the content's level; higher values bring it towards its new place, by up to about 0.04 × color),"sampleAllLayers":bool=false,"layer":id?=active,"target":"pixels"|"mask"|"quickMask"|{"channel":i}=Channels panel target} → {"damage","offset","mode"} (a background job; the selection moves with the content)"#, + params: r#"{"offset":[dx,dy] (how far the selection was dragged),"mode":"move|extend"="move","structure":1..7=4 (3..7 copy the content exactly but for a band about a patch wide along its edge; 2 widens the band, 1 re-synthesises the content too),"color":0..10=0 (0 keeps the content's level; higher values bring it towards its new place, by up to about 0.04 × color),"sampleAllLayers":bool=false,"scaleX":%=100,"scaleY":%=100 (Transform On Drop: 1..10000, negative flips),"rotate":deg=0 (Transform On Drop, clockwise; scale and rotation are about the selection's centre, before the offset; a transformed patch may reach past the canvas edge),"layer":id?=active,"target":"pixels"|"mask"|"quickMask"|{"channel":i}=Channels panel target} → {"damage","offset","mode","transformed"?} (a background job; the selection moves, and is transformed, with the content)"#, enabled: content_aware_move::enabled, run: content_aware_move::content_aware_move, journal: true, diff --git a/crates/engine/src/retouch_cmds/content_aware_move.rs b/crates/engine/src/retouch_cmds/content_aware_move.rs index aa320413f..120c8b7c0 100644 --- a/crates/engine/src/retouch_cmds/content_aware_move.rs +++ b/crates/engine/src/retouch_cmds/content_aware_move.rs @@ -22,8 +22,16 @@ //! completions and can take seconds on large selections, so the command runs as a background job //! (#210): with progress, cancellable, and the document unchanged until it applies. The Patch //! Tool's Content-Aware mode lands its content the same way ([`land`]). +//! +//! **Transform On Drop** (the options-bar checkbox): after the drop a transform box lets the user +//! scale and rotate the content before the blend is computed. `scaleX` / `scaleY` (%) and +//! `rotate` (degrees, clockwise) scale and turn the content about the centre of the selection's +//! bounds before `offset` moves it, as `select.transformSelection` does; the selection is +//! transformed with it. A transformed patch may reach past the canvas edge (Photoshop lets the +//! box go there): what lands outside is dropped. use photocraft_algo::content_aware::{FillOptions, color_amount, color_level, fill_with, level_transform}; +use photocraft_algo::transform::{Homography, Interp}; use super::patch::{coverage, offset, selection_area, target_layer}; use super::*; @@ -80,11 +88,111 @@ struct Plan { /// The selected area (where the content comes from). area: Rect, offset: (i32, i32), + /// Transform On Drop: where a source point lands (scale and rotation about the area's centre, + /// then the offset); `None` for a plain move by `offset`. + map: Option, + /// Where the content lands (the moved area, or the transformed one's bounds), on the canvas. + dest: Rect, mode: Mode, structure: u8, color: u8, } +/// Transform On Drop's `scaleX` / `scaleY` (%) and `rotate` (degrees): the map from a source point +/// to where it lands, about the centre of `area` and then moved by `offset`. `None` when the params +/// leave the content as it is (a plain move). +fn placement(p: &Value, area: Rect, (dx, dy): (i32, i32)) -> Result> { + let num = |k: &str, default: f64| -> Result { + match p.get(k) { + None | Some(Value::Null) => Ok(default), + Some(v) => v.as_f64().filter(|x| x.is_finite()).ok_or_else(|| bad(CMD, format!("`{k}` must be a finite number"))), + } + }; + let scale = |k: &str| -> Result { + let v = num(k, 100.0)?; + // 1% to 10000% either way (a negative scale flips): beyond that the content collapses to + // nothing or outgrows any selection the move can take. + if (1.0..=10_000.0).contains(&v.abs()) { Ok(v / 100.0) } else { Err(bad(CMD, format!("`{k}` must be 1..10000 % (or -10000..-1 to flip)"))) } + }; + let (sx, sy) = (scale("scaleX")?, scale("scaleY")?); + let deg = num("rotate", 0.0)?; + if deg.abs() > 36_000.0 { + return Err(bad(CMD, "`rotate` must be -36000..36000 degrees")); + } + let deg = deg.rem_euclid(360.0); + if sx == 1.0 && sy == 1.0 && deg == 0.0 { + return Ok(None); + } + let (cx, cy) = (f64::from(area.x0 + area.x1) / 2.0, f64::from(area.y0 + area.y1) / 2.0); + let t = |x: f64, y: f64| Homography([1.0, 0.0, x, 0.0, 1.0, y, 0.0, 0.0, 1.0]); + let (sa, ca) = deg.to_radians().sin_cos(); + let rot = Homography([ca, -sa, 0.0, sa, ca, 0.0, 0.0, 0.0, 1.0]); + let scale = Homography([sx, 0.0, 0.0, 0.0, sy, 0.0, 0.0, 0.0, 1.0]); + Ok(Some(t(cx + f64::from(dx), cy + f64::from(dy)).mul(&rot).mul(&scale).mul(&t(-cx, -cy)))) +} + +/// The pixel bounds of `r` mapped by `h`. +fn mapped_bounds(h: &Homography, r: Rect) -> Rect { + let corners = [(r.x0, r.y0), (r.x1, r.y0), (r.x1, r.y1), (r.x0, r.y1)].map(|(x, y)| h.apply(f64::from(x), f64::from(y))); + let (mut x0, mut y0, mut x1, mut y1) = (f64::INFINITY, f64::INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY); + for (x, y) in corners { + (x0, y0, x1, y1) = (x0.min(x), y0.min(y), x1.max(x), y1.max(y)); + } + if !(x0.is_finite() && y0.is_finite() && x1.is_finite() && y1.is_finite()) { + return Rect::EMPTY; + } + // Rounded inwards by a hair so an exact scale doesn't grow the bounds by a pixel. + let clamp = |v: f64| v.clamp(-1e8, 1e8) as i32; + Rect::new(clamp((x0 + 1e-6).floor()), clamp((y0 + 1e-6).floor()), clamp((x1 - 1e-6).ceil()), clamp((y1 - 1e-6).ceil())) +} + +/// `src` resampled (bilinear, edges clamped) through `inv` (destination → source) over `rect`: +/// the content as it lands. Works on float samples, so at any depth. +fn resample(src: &Region, inv: &Homography, rect: Rect) -> Region { + let (sw, sh, n) = (src.rect.width() as usize, src.rect.height() as usize, src.ch); + let mut out = Region { rect, ch: n, data: vec![0.0; rect.width() as usize * rect.height() as usize * n] }; + if sw == 0 || sh == 0 { + return out; + } + let px = |x: usize, y: usize, c: usize| src.data.get((y * sw + x) * n + c).copied().unwrap_or(0.0); + for (i, dst) in out.data.chunks_exact_mut(n).enumerate() { + let (qx, qy) = (rect.x0 + (i % rect.width() as usize) as i32, rect.y0 + (i / rect.width() as usize) as i32); + let (sx, sy) = inv.apply(f64::from(qx) + 0.5, f64::from(qy) + 0.5); + let fx = (sx - 0.5 - f64::from(src.rect.x0)).clamp(0.0, (sw - 1) as f64); + let fy = (sy - 0.5 - f64::from(src.rect.y0)).clamp(0.0, (sh - 1) as f64); + if !(fx.is_finite() && fy.is_finite()) { + continue; + } + let (x0, y0) = (fx.floor() as usize, fy.floor() as usize); + let (x1, y1) = ((x0 + 1).min(sw - 1), (y0 + 1).min(sh - 1)); + let (tx, ty) = ((fx - x0 as f64) as f32, (fy - y0 as f64) as f32); + for (c, d) in dst.iter_mut().enumerate() { + let top = px(x0, y0, c) * (1.0 - tx) + px(x1, y0, c) * tx; + let bottom = px(x0, y1, c) * (1.0 - tx) + px(x1, y1, c) * tx; + *d = top * (1.0 - ty) + bottom * ty; + } + } + out +} + +/// The selection's shape as it lands over `rect`: `cov` (coverage over `area`) sampled through +/// `inv` (destination → source), nearest, set wherever it is above zero (as [`mask`]). +fn landed_mask(cov: &[f32], area: Rect, inv: &Homography, rect: Rect) -> Vec { + let w = rect.width() as usize; + (0..w * rect.height() as usize) + .map(|i| { + let (qx, qy) = (rect.x0 + (i % w) as i32, rect.y0 + (i / w) as i32); + let (sx, sy) = inv.apply(f64::from(qx) + 0.5, f64::from(qy) + 0.5); + let (x, y) = (sx.floor(), sy.floor()); + if !(f64::from(area.x0)..f64::from(area.x1)).contains(&x) || !(f64::from(area.y0)..f64::from(area.y1)).contains(&y) { + return false; + } + let j = (y as i32 - area.y0) as usize * area.width() as usize + (x as i32 - area.x0) as usize; + cov.get(j).is_some_and(|c| *c > 0.0) + }) + .collect() +} + fn plan(s: &Session, p: &Value) -> Result { let mode = match string(p, "mode", "move") { "move" => Mode::Move, @@ -106,15 +214,35 @@ fn plan(s: &Session, p: &Value) -> Result { if pixels > MAX_PIXELS { return Err(bad(CMD, format!("the selection is too large to move ({pixels} pixels, at most {MAX_PIXELS})"))); } - if (dx, dy) == (0, 0) { - return Err(bad(CMD, "drag the selection to where the content should go")); - } - // Content dropped beyond the edge would be lost: keep it on the canvas, as the Patch Tool does. - if !canvas.contains_rect(&area.translate(dx, dy)) { - return Err(bad(CMD, "the moved selection must stay inside the canvas")); - } + let map = placement(p, area, (dx, dy))?; + let dest = match &map { + None => { + if (dx, dy) == (0, 0) { + return Err(bad(CMD, "drag the selection to where the content should go")); + } + // Content dropped beyond the edge would be lost: keep it on the canvas, as the Patch + // Tool does. + let dest = area.translate(dx, dy); + if !canvas.contains_rect(&dest) { + return Err(bad(CMD, "the moved selection must stay inside the canvas")); + } + dest + } + Some(h) => { + let landed = mapped_bounds(h, area); + let pixels = u64::from(landed.width()) * u64::from(landed.height()); + if pixels > MAX_PIXELS { + return Err(bad(CMD, format!("the transformed selection is too large to move ({pixels} pixels, at most {MAX_PIXELS})"))); + } + let dest = landed.intersect(&canvas); + if dest.is_empty() { + return Err(bad(CMD, "the transformed selection must land on the canvas")); + } + dest + } + }; let all_layers = flag(p, "sampleAllLayers", false) && targets_pixels(p); - Ok(Plan { target: Target { id, all_layers }, canvas, area, offset: (dx, dy), mode, structure, color }) + Ok(Plan { target: Target { id, all_layers }, canvas, area, offset: (dx, dy), map, dest, mode, structure, color }) } /// Width in pixels of the edge band re-synthesised at the new place, for Structure 1..7 and content @@ -288,20 +416,38 @@ pub(super) fn land( replace(doc, t, p, &buf, selected) } -/// The new target surface and the rectangle it changed: the content pasted at the offset, then -/// (`move`) its old place filled. Works on `doc`, a copy of the document. -fn run_move(doc: &mut Document, sel: &Surface, plan: &Plan, p: &Value, ctx: &crate::jobs::JobCtx, label: &str) -> Result<(Surface, Rect)> { +/// The new target surface, the rectangle it changed and (Transform On Drop) the transformed +/// selection: the content pasted at the offset, then (`move`) its old place filled. Works on `doc`, +/// a copy of the document. +fn run_move(doc: &mut Document, sel: &Surface, plan: &Plan, p: &Value, ctx: &crate::jobs::JobCtx, label: &str) -> Result<(Surface, Rect, Option)> { let (dx, dy) = plan.offset; let opts = fill_options(); let split = if plan.mode == Mode::Move { 0.4 } else { 1.0 }; let cancelled = |_| EngineError::Cancelled; + let inv = match &plan.map { + Some(h) => Some(h.inverse().ok_or_else(|| bad(CMD, "the transform collapses the selection"))?), + None => None, + }; + // The selection's coverage over the area, which the transformed shape is sampled from. + let cov = if inv.is_some() { coverage(sel, plan.area) } else { Vec::new() }; // 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.area.translate(dx, dy), plan.canvas); - let mut content = read(doc, plan.target, p, win.translate(-dx, -dy))?; - content.rect = win; - let moved = mask(sel, win.translate(-dx, -dy)); + let win = window(plan.dest, plan.canvas); + let (content, moved) = match &inv { + None => { + let mut content = read(doc, plan.target, p, win.translate(-dx, -dy))?; + content.rect = win; + (content, mask(sel, win.translate(-dx, -dy))) + } + 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 = if src.is_empty() { plan.area } else { src }; + (resample(&read(doc, plan.target, p, src)?, inv, win), landed_mask(&cov, plan.area, inv, win)) + } + }; let old_place = mask(sel, win); let mut damage = land(doc, plan.target, p, win, &moved, &content, &old_place, plan.structure, plan.color, (0.0, split), ctx, label)?; @@ -311,7 +457,10 @@ fn run_move(doc: &mut Document, sel: &Surface, plan: &Plan, p: &Value, ctx: &cra let win = window(plan.area, plan.canvas); let (w, h) = (win.width() as usize, win.height() as usize); let img = read(doc, plan.target, p, win)?; - let landed = mask(sel, win.translate(-dx, -dy)); + let landed = match &inv { + None => mask(sel, win.translate(-dx, -dy)), + Some(inv) => landed_mask(&cov, plan.area, inv, win), + }; let hole: Vec = mask(sel, win).iter().zip(&landed).map(|(s, l)| *s && !*l).collect(); if hole.iter().any(|h| *h) { let allowed: Vec = landed.iter().map(|l| !*l).collect(); @@ -320,7 +469,12 @@ fn run_move(doc: &mut Document, sel: &Surface, plan: &Plan, p: &Value, ctx: &cra } } let surf = crate::channel_cmds::target_surface(doc, plan.target.id, p)?.0.clone(); - Ok((surf, damage)) + // The selection follows the transformed content, as Select › Transform Selection moves it. + let moved_sel = match &plan.map { + Some(h) => Some(crate::transform_cmds::warp_gray(sel, h, Interp::Bilinear)?), + None => None, + }; + Ok((surf, damage, moved_sel)) } pub(super) fn content_aware_move(s: &mut Session, p: &Value) -> Result { @@ -342,12 +496,14 @@ pub(super) fn content_aware_move(s: &mut Session, p: &Value) -> Result { run_move(&mut work, sel, &plan, &p, ctx, label) } }, - move |s, (surf, damage)| { + move |s, (surf, damage, moved_sel)| { let (dx, dy) = plan.offset; s.edit(label, |doc, _| { *crate::channel_cmds::target_surface(doc, plan.target.id, &p)?.0 = surf; // The selection follows the content, ready for another drag. - if let Some(sel) = doc.selection.as_ref() { + if let Some(moved) = moved_sel { + doc.selection = Some(moved).filter(|s| !s.content_bounds().is_empty()); + } else if let Some(sel) = doc.selection.as_ref() { doc.selection = Some(crate::layer_multi_cmds::shift_surface(sel, dx, dy)); } Ok(()) @@ -356,7 +512,11 @@ pub(super) fn content_aware_move(s: &mut Session, p: &Value) -> Result { st.last_damage = Some(damage); } let mode = if plan.mode == Mode::Move { "move" } else { "extend" }; - Ok(json!({ "damage": damage_json(damage), "offset": [dx, dy], "mode": mode })) + let mut out = json!({ "damage": damage_json(damage), "offset": [dx, dy], "mode": mode }); + if plan.map.is_some() { + out["transformed"] = json!(true); + } + Ok(out) }, ) } diff --git a/crates/engine/src/retouch_cmds/tests.rs b/crates/engine/src/retouch_cmds/tests.rs index ca258e2e5..7104c69aa 100644 --- a/crates/engine/src/retouch_cmds/tests.rs +++ b/crates/engine/src/retouch_cmds/tests.rs @@ -917,6 +917,32 @@ fn content_aware_move_moves_the_object_and_fills_its_place_at_all_depths() { } } +#[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% + // width: it lands upright, 20 px tall, and the blend works around the turned content. + let bar = |x: i32, y: i32| if (20..36).contains(&x) && (30..36).contains(&y) { [0.05, 0.1, 0.9, 1.0] } else { texture(x, y) }; + for depth in [8, 32] { + let mut s = session(160, 64, depth, "rgb"); + paint_layer(&mut s, bar); + s.execute("select.rect", json!({"x": 10, "y": 15, "width": 36, "height": 36})).unwrap(); + let r = s.execute("paint.contentAwareMove", json!({"offset": [80, 0], "rotate": 90, "scaleX": 125})).unwrap(); + assert_eq!(r["transformed"], json!(true)); + for (x, y) in [(108, 24), (108, 33), (108, 42), (106, 30)] { + assert!(is_object(rgba(&s, x, y)), "depth {depth}: the turned, stretched bar covers {x},{y}: {:?}", rgba(&s, x, y)); + } + for (x, y) in [(101, 33), (115, 33)] { + assert!(!is_object(rgba(&s, x, y)), "depth {depth}: no unturned bar at {x},{y}"); + } + assert!(!is_object(rgba(&s, 28, 33)), "depth {depth}: the old place is filled"); + assert!(selected(&s, 108, 33) && !selected(&s, 28, 33), "depth {depth}: the selection followed"); + // One step: undo brings the bar and the selection back. + s.execute("edit.undo", json!({})).unwrap(); + assert!(is_object(rgba(&s, 28, 33)) && !is_object(rgba(&s, 108, 24)), "depth {depth}: undo"); + assert!(selected(&s, 28, 33), "depth {depth}: undo restores the selection"); + } +} + #[test] fn content_aware_extend_keeps_the_original() { let mut s = session(128, 64, 8, "rgb"); @@ -1054,10 +1080,21 @@ fn content_aware_move_fails_gracefully() { json!({"offset": [10, 0], "target": "mask"}), json!({"offset": [10, 0], "target": {"channel": 7}}), json!({"offset": [10, 0], "layer": 999}), + json!({"offset": [10, 0], "scaleX": 0}), + json!({"offset": [10, 0], "scaleY": 0.5}), + json!({"offset": [10, 0], "scaleX": 1e9}), + json!({"offset": [10, 0], "scaleX": "big"}), + json!({"offset": [10, 0], "rotate": 1e300}), + json!({"offset": [10, 0], "rotate": [45]}), + json!({"offset": [5000, 0], "scaleX": 200}), + json!({"offset": [0, 0], "scaleX": 100, "rotate": 360}), ] { assert!(s.execute("paint.contentAwareMove", p.clone()).is_err(), "{p}"); } assert!(s.execute("paint.contentAwareMove", json!({"offset": [10, 0], "structure": 7.0, "color": null})).is_ok()); + // Transform On Drop: scaling in place needs no drag; a big patch may reach past the edge. + assert!(s.execute("paint.contentAwareMove", json!({"offset": [0, 0], "scaleX": 150, "rotate": -30})).is_ok()); + assert!(s.execute("paint.contentAwareMove", json!({"offset": [20, 0], "scaleX": -10000, "scaleY": 10000})).is_ok()); } #[test] diff --git a/crates/ui-egui/src/cam_transform.rs b/crates/ui-egui/src/cam_transform.rs new file mode 100644 index 000000000..181519c03 --- /dev/null +++ b/crates/ui-egui/src/cam_transform.rs @@ -0,0 +1,220 @@ +//! Content-Aware Move › Transform On Drop (the options-bar checkbox, as in Photoshop): after the +//! drop a transform box sits on the moved patch. Its handles are the Free Transform box's +//! ([`crate::transform_tool::hit`], [`crate::transform_tool::apply_drag`]), limited to moving, +//! scaling and rotating; ↩, ✓ or a double-click computes the blend with the transformed patch +//! (`paint.contentAwareMove` with `scaleX`, `scaleY` and `rotate`), Esc or ⊘ drops the whole move. +//! The document is untouched until then, so the move stays one History step. + +use egui::{CursorIcon, Modifiers, Pos2}; +use photocraft_algo::transform::Homography; + +use crate::PhotocraftApp; +use crate::canvas::{ToolEvent, ViewXform}; +use crate::state::{CamTransform, Tool, TransformSession}; +use crate::transform_tool::{Gesture, Hit}; + +/// Where the patch lands: the drag offset (px) and the scale (%, per axis) and clockwise rotation +/// (degrees) about its centre, as `paint.contentAwareMove` takes them. +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) struct Placement { + pub offset: [i64; 2], + pub scale: [f64; 2], + pub rotate: f64, +} + +impl Placement { + /// The patch is only moved (or not even that). + pub fn is_plain_move(&self) -> bool { + self.scale == [100.0, 100.0] && self.rotate == 0.0 + } +} + +pub(crate) fn active(app: &PhotocraftApp) -> bool { + app.ui.cam_transform.is_some() +} + +/// Put the box on the selection dropped `offset` away. False when there is no selection to box. +pub(crate) fn begin(app: &mut PhotocraftApp, offset: [i32; 2]) -> bool { + let Some((document, revision, canvas, sel)) = + app.session.active().and_then(|st| Some((st.doc.id.0, st.revision, st.doc.bounds(), st.doc.selection.clone()?))) + else { + return false; + }; + let b = app.cached_bounds(u64::MAX - document, &sel).intersect(&canvas); + if b.is_empty() { + return false; + } + let r = [b.x0.saturating_add(offset[0]), b.y0.saturating_add(offset[1]), b.x1.saturating_add(offset[0]), b.y1.saturating_add(offset[1])].map(f64::from); + let frame = TransformSession { + session: app.ui.alloc_id(), + layer: 0, + rect: r, + quad: [[r[0], r[1]], [r[2], r[1]], [r[2], r[3]], [r[0], r[3]]], + pivot: [(r[0] + r[2]) / 2.0, (r[1] + r[3]) / 2.0], + interpolation: "bilinear".into(), + warp: None, + selection: false, + target: None, + path: None, + made: None, + mode: Default::default(), + }; + app.ui.cam_transform = Some(CamTransform { document, revision, offset, frame, gesture: None }); + app.ui.status = crate::i18n::fmt(tl!("Scale or rotate the moved area, then press {key}"), &[("key", &crate::shortcuts::pretty("Enter"))]); + app.ui.status_error = false; + true +} + +/// The box as `paint.contentAwareMove`'s params: its rect → quad map split into a scale along the +/// box's own axes, a rotation and the move of its centre. `None` for a collapsed or absurd box. +pub(crate) fn placement(t: &CamTransform) -> Option { + let [x0, y0, x1, y1] = t.frame.rect; + let (w, h) = (x1 - x0, y1 - y0); + if !(w > 0.0 && h > 0.0) { + return None; + } + let q = t.frame.quad; + // The box's x axis (scaled by sx, turned) and y axis (scaled by sy, a negative sy flips). + let u = [(q[1][0] - q[0][0]) / w, (q[1][1] - q[0][1]) / w]; + let v = [(q[3][0] - q[0][0]) / h, (q[3][1] - q[0][1]) / h]; + let angle = u[1].atan2(u[0]); + let (sn, cs) = angle.sin_cos(); + let (sx, sy) = (u[0].hypot(u[1]), v[1] * cs - v[0] * sn); + // Scale and rotation are about the selection's centre, then the offset moves it. + let centre = [(q[0][0] + q[2][0]) / 2.0, (q[0][1] + q[2][1]) / 2.0]; + let origin = [(x0 + x1) / 2.0 - f64::from(t.offset[0]), (y0 + y1) / 2.0 - f64::from(t.offset[1])]; + let off = [(centre[0] - origin[0]).round(), (centre[1] - origin[1]).round()]; + let round = |v: f64| { + let r = (v * 1e4).round() / 1e4; + if r == 0.0 { 0.0 } else { r } + }; + let (sx, sy, rotate) = (round(sx * 100.0), round(sy * 100.0), round(angle.to_degrees())); + let sane = |s: f64| (1.0..=10_000.0).contains(&s.abs()); + let ok = rotate.is_finite() && sane(sx) && sane(sy) && off.iter().all(|v| v.is_finite() && v.abs() < 1e7); + ok.then_some(Placement { offset: [off[0] as i64, off[1] as i64], scale: [sx, sy], rotate }) +} + +pub(crate) fn cancel(app: &mut PhotocraftApp) { + if app.ui.cam_transform.take().is_some() && !app.ui.status_error { + // The box's hint is done with. + app.ui.status.clear(); + } +} + +/// Drop the box when what it was made for changed: another tool, document or edit. +pub(crate) fn cancel_stale(app: &mut PhotocraftApp) { + let Some(t) = &app.ui.cam_transform else { return }; + let valid = app.ui.tool == Tool::ContentAwareMove + && app.session.active().is_some_and(|st| st.doc.id.0 == t.document && st.revision == t.revision && st.doc.selection.is_some()); + if !valid { + cancel(app); + } +} + +/// ↩ / ✓: the move with the transformed patch, as one background job and History step. +pub(crate) fn commit(app: &mut PhotocraftApp) { + cancel_stale(app); + let Some(t) = app.ui.cam_transform.clone() else { return }; + cancel(app); + let Some(place) = placement(&t) else { return }; + if place.offset == [0, 0] && place.is_plain_move() { + 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(); + } + if let Err(e) = app.run("paint.contentAwareMove", p) { + app.ui.status = e; + app.ui.status_error = true; + } +} + +/// Pointer input while the box is up: every press goes to it (the canvas is otherwise inert, as +/// during Free Transform). Returns false when there is no box. +pub(crate) fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: Modifiers) -> bool { + cancel_stale(app); + let tol = crate::transform_tool::handle_tolerance(app); + let legacy = app.session.prefs().general.use_legacy_free_transform; + let Some(t) = app.ui.cam_transform.as_mut() else { return false }; + match ev { + ToolEvent::Down { x, y, .. } => { + let p = [x, y]; + if !p.iter().all(|v| v.is_finite()) { + return true; + } + let mut h = crate::transform_tool::hit(&t.frame, p, tol); + // ⌥-click away from the handles moves the reference point there (and drags it). + if mods.alt && matches!(h, Hit::Inside | Hit::Outside) { + t.frame.pivot = p; + h = Hit::Pivot; + } + t.gesture = Some(Gesture { hit: h, start: p, quad0: t.frame.quad, pivot0: t.frame.pivot }); + } + ToolEvent::Move { x, y, .. } | ToolEvent::Up { x, y } => { + if let Some(g) = t.gesture + && [x, y].iter().all(|v| v.is_finite() && v.abs() <= 1e7) + { + let before = (t.frame.quad, t.frame.pivot); + // Move, scale and rotate only: ⌘ would skew or distort, which the blend can't take. + let plain = Modifiers { command: false, ctrl: false, mac_cmd: false, ..mods }; + crate::transform_tool::apply_drag(&mut t.frame, g, [x, y], crate::transform_tool::legacy_mods(legacy, g.hit, plain)); + if placement(t).is_none() { + (t.frame.quad, t.frame.pivot) = before; + } + } + if matches!(ev, ToolEvent::Up { .. }) { + t.gesture = None; + } + } + } + true +} + +/// The selection's outline where the box puts it, and the box. +pub(crate) fn draw(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform) { + let Some(t) = &app.ui.cam_transform else { return }; + if let Some((doc, _, segs)) = &app.outline_cache + && doc.0 == t.document + && let Some(h) = Homography::rect_to_quad(t.frame.rect, t.frame.quad) + { + let [ox, oy] = t.offset.map(f64::from); + let time = painter.ctx().input(|i| i.time); + crate::canvas::marching_ants_mapped(painter, segs, time, |p| { + let (x, y) = h.apply(f64::from(p[0]) + ox, f64::from(p[1]) + oy); + xf.to_screen(x as f32, y as f32) + }); + painter.ctx().request_repaint_after(std::time::Duration::from_millis(100)); + } + crate::type_transform::draw_box(painter, xf, &t.frame); +} + +/// The Free Transform box's cursors over the box's handles. +pub(crate) fn cursor(app: &PhotocraftApp, ctx: &egui::Context, xf: &ViewXform, p: Pos2) -> Option { + let t = app.ui.cam_transform.as_ref().filter(|_| app.ui.tool == Tool::ContentAwareMove)?; + let q = t.frame.quad; + let h = t.gesture.map_or_else(|| crate::transform_tool::hit(&t.frame, xf.to_doc(p), crate::transform_tool::handle_tolerance(app)), |g| g.hit); + let mid = |i: usize| -> Option<[f64; 2]> { + let (a, b) = (q.get(i % 4)?, q.get((i + 1) % 4)?); + Some([(a[0] + b[0]) / 2.0, (a[1] + b[1]) / 2.0]) + }; + Some(match h { + Hit::Corner(i) => { + let (a, b) = (q.get(i)?, q.get((i + 2) % 4)?); + crate::type_transform::resize_cursor([a[0] - b[0], a[1] - b[1]], xf.flip) + } + Hit::Edge(i) => { + let (a, b) = (mid(i)?, mid(i + 2)?); + crate::type_transform::resize_cursor([a[0] - b[0], a[1] - b[1]], xf.flip) + } + Hit::Outside => { + crate::type_transform::rotation_cursor(ctx, p); + CursorIcon::None + } + Hit::Pivot => CursorIcon::Crosshair, + Hit::Inside => CursorIcon::Move, + }) +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 86145f9ce..277035727 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -2714,7 +2714,8 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: app.perf.span("outline", crate::gpu_canvas::now_ms() - t0); app.outline_cache = Some((doc.id, key, std::sync::Arc::new(segs))); } - if let Some((_, _, segs)) = &app.outline_cache { + // Under Content-Aware Move's Transform On Drop box the ants are drawn where it puts them. + if let Some((_, _, segs)) = app.outline_cache.as_ref().filter(|_| !crate::cam_transform::active(app)) { let time = ui.input(|i| i.time); // A floating piece (and a selection being dragged) draws its ants where it is shown. let mut shown = xf; @@ -3173,6 +3174,9 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: if app.ui.transform.is_some() && response.double_clicked() { crate::transform_tool::commit(app); } + if crate::cam_transform::active(app) && response.double_clicked() { + crate::cam_transform::commit(app); + } // Crop: a double-click inside the frame commits it, as ↵ does (#1792). if tool == Tool::Crop && response.double_clicked() && response.interact_pointer_pos().is_some_and(|p| crate::crop_ui::commits_at(app, xf.to_doc(p))) { commit_crop(app); @@ -3248,6 +3252,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::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); @@ -3272,6 +3277,8 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: response.hover_pos().and_then(|p| crate::transform_tool::cursor(app, xf.to_doc(p), ui.input(|i| i.modifiers.alt || i.modifiers.command))) { ui.ctx().set_cursor_icon(c); + } else if let Some(c) = response.hover_pos().and_then(|p| crate::cam_transform::cursor(app, &ctx, &xf, p)) { + ui.ctx().set_cursor_icon(c); } else if let Some(c) = response.hover_pos().and_then(|p| transform_controls_rect(app, &xf).and_then(|r| transform_controls_cursor(r, p))) { // The Move tool draws handles without opening a Free Transform session. // Give them their resize/rotate cursors before the first pointer press (#2871). @@ -3593,14 +3600,20 @@ fn paint_mapped_quad(painter: &egui::Painter, xf: &ViewXform, size: [f32; 2], co /// Draw boundary segments as marching ants: white base, black dashes phased along x + y. fn marching_ants_segments(painter: &egui::Painter, xf: &ViewXform, segs: &[crate::outline::Segment], time: f64) { + marching_ants_mapped(painter, segs, time, |p| xf.to_screen(p[0] as f32, p[1] as f32)); +} + +/// [`marching_ants_segments`] with each outline point placed on screen by `to_screen` (a +/// transformed outline: Content-Aware Move's Transform On Drop). +pub(crate) fn marching_ants_mapped(painter: &egui::Painter, segs: &[crate::outline::Segment], time: f64, to_screen: impl Fn([i32; 2]) -> Pos2) { let dash = 4.0f32; let phase = ((time * 10.0) % (dash as f64 * 2.0)) as f32; let white = Stroke::new(1.0, Color32::WHITE); let black = Stroke::new(1.0, Color32::BLACK); let clip = painter.clip_rect(); for (a, b) in segs { - let pa = xf.to_screen(a[0] as f32, a[1] as f32); - let pb = xf.to_screen(b[0] as f32, b[1] as f32); + let pa = to_screen(*a); + let pb = to_screen(*b); if !clip.intersects(Rect::from_two_pos(pa, pb).expand(1.0)) { continue; } @@ -4259,6 +4272,10 @@ pub fn tool_event(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) if app.ui.transform.is_none() && crate::type_transform::pointer(app, ev, mods) { return; } + // Content-Aware Move's Transform On Drop box takes every press until ↩ or Esc. + if crate::cam_transform::pointer(app, ev, mods) { + return; + } // View › Snap / Snap To and smart guides (snap_ui.rs). let raw = ev; // The Move tool's marquee selecting layers takes its moves and release unsnapped (move_box.rs). diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 354f4d45d..b2a48cf6d 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -89,7 +89,7 @@ pub enum Outcome { /// field's value is validated before the first one is applied, so a typo, an unknown field, a /// bad value or a bad nested key can't reply with success while nothing — or only half of it — /// changed (#412). -pub const UI_SET_FIELDS: [&str; 33] = [ +pub const UI_SET_FIELDS: [&str; 34] = [ "tool", "panels", "dock", @@ -123,6 +123,7 @@ pub const UI_SET_FIELDS: [&str; 33] = [ "cropOverlayOrientation", "cropShield", "shapeStroke", + "camTransformOnDrop", ]; /// Most clicks one `ui.click` may queue (#982). Each click is a press and a release that the app @@ -449,6 +450,8 @@ fn dispatch(app: &mut PhotocraftApp, ctx: &egui::Context, req: &ControlRequest) None => None, }; let eyedropper_ring = bool_field(p, "eyedropperRing")?; + // Content-Aware Move's Transform On Drop checkbox. + let cam_transform_on_drop = bool_field(p, "camTransformOnDrop")?; // The Crop tool's overlay menu (#1919). let crop_overlay = match p.get("cropOverlay") { Some(v) => Some(v.as_str().and_then(crate::crop_overlay::CropOverlay::from_id).ok_or_else(|| { @@ -604,6 +607,9 @@ fn dispatch(app: &mut PhotocraftApp, ctx: &egui::Context, req: &ControlRequest) if let Some(ring) = eyedropper_ring { app.ui.tool_options.eyedropper_ring = ring; } + if let Some(on) = cam_transform_on_drop { + app.ui.tool_options.cam_transform_on_drop = on; + } if let Some(k) = crop_overlay { app.ui.tool_options.crop_overlay = k; } @@ -1176,6 +1182,7 @@ pub fn inspect(app: &PhotocraftApp, ctx: &egui::Context) -> Value { "magnetic": app.ui.magnetic, "textEdit": app.ui.text_edit, "typeTransform": app.ui.type_transform, + "camTransform": app.ui.cam_transform, "layerMenu": app.ui.layer_menu, "brushPicker": app.ui.brush_picker.map(|pos| json!({ "pos": pos, diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 07fc2f22e..0cf3e558f 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -2220,6 +2220,8 @@ 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 + Transform On Drop Transformovat po upuštění + Scale or rotate the moved area, then press {key} Změňte velikost nebo otočte přesunutou oblast a pak stiskněte {key} Now drag the selection onto another area Nyní přetáhněte výběr na jiné místo Edit Type Upravit text diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 1263fea6a..b9864db8e 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2248,6 +2248,8 @@ Grid: Raster: Highlight Clipping Warning (O) Lichter-Abschneidewarnung (O) Lasso around an area, then drag the selection Lasso um einen Bereich ziehen, dann die Auswahl verschieben + Transform On Drop Beim Ablegen transformieren + Scale or rotate the moved area, then press {key} Verschobenen Bereich skalieren oder drehen, dann {key} drücken Now drag the selection onto another area Jetzt die Auswahl auf einen anderen Bereich ziehen Patch: Korrektur: Shadow Clipping Warning (U) Tiefen-Abschneidewarnung (U) diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index b768f49df..f134be8a4 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -1656,6 +1656,8 @@ Click to apply, ⌥-click to clear overrides, double-click for Style Options Κλικ για εφαρμογή, ⌥-κλικ για εκκαθάριση παρακάμψεων, διπλό κλικ για επιλογές στυλ Click: corner · Drag: smooth · Click first point: close · ↩ finish · Esc cancel Κλικ: γωνία · Σύρσιμο: ομαλή καμπύλη · Κλικ στο πρώτο σημείο: κλείσιμο · ↩ ολοκλήρωση · Esc ακύρωση Lasso around an area, then drag the selection Σχεδιάστε επιλογή με το Lasso γύρω από μια περιοχή και σύρετέ την + Transform On Drop Μετασχηματισμός κατά την απόθεση + Scale or rotate the moved area, then press {key} Αλλάξτε μέγεθος ή περιστρέψτε τη μετακινημένη περιοχή και πατήστε {key} Now drag the selection onto another area Τώρα σύρετε την επιλογή σε άλλη περιοχή Rulers (⌘R) Χάρακες (⌘R) Show Guides (⌘;) Εμφάνιση οδηγών (⌘;) diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 0e7b9a351..eeb2c03be 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2291,6 +2291,8 @@ 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 + Transform On Drop Transformar al soltar + Scale or rotate the moved area, then press {key} Escale o gire el área movida y pulse {key} Now drag the selection onto another area Ahora arrastre la selección a otra área Edit Type Editar texto diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 406df970e..c72b68819 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -2638,6 +2638,8 @@ cameraRaw Blending Fusion Patch Tool Outil Pièce Patch: Pièce : Lasso around an area, then drag the selection Entourez une zone au lasso, puis faites glisser la sélection + Transform On Drop Transformer au dépôt + Scale or rotate the moved area, then press {key} Mettez à l'échelle ou faites pivoter la zone déplacée, puis appuyez sur {key} Now drag the selection onto another area Faites maintenant glisser la sélection vers une autre zone # Warp grid presets (#388) diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 6e0686214..25b9fdeb5 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -2612,6 +2612,8 @@ cameraRaw Blending Pembauran Patch Tool Alat Patch Patch: Patch: Lasso around an area, then drag the selection Lingkari area dengan lasso, lalu seret seleksinya + Transform On Drop Transformasi saat dilepas + Scale or rotate the moved area, then press {key} Skalakan atau putar area yang dipindah, lalu tekan {key} Now drag the selection onto another area Sekarang seret seleksi ke area lain Rendering Mode Mode Rendering Automatic (recommended) Otomatis (disarankan) diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 84b72cd63..b13ff561d 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -2298,6 +2298,8 @@ cameraRaw Amount Quantità Patch Tool Strumento Toppa Patch: Toppa: Lasso around an area, then drag the selection Seleziona un'area con lazo, poi trascina la selezione + Transform On Drop Trasforma al rilascio + Scale or rotate the moved area, then press {key} Ridimensiona o ruota l'area spostata, poi premi {key} Now drag the selection onto another area Ora trascina la selezione su un'altra area Edit Type Modifica testo diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index a1f5c3604..dd299a064 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -2192,6 +2192,8 @@ cameraRaw Color カラー Patch Tool パッチツール Patch: パッチ: Lasso around an area, then drag the selection 投げ縄で範囲を囲み、選択範囲をドラッグします + Transform On Drop ドロップ時に変形 + Scale or rotate the moved area, then press {key} 移動した範囲を拡大・縮小または回転し、{key} を押します Now drag the selection onto another area 選択範囲を別の場所へドラッグします Edit Type 文字を編集 diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 719237867..cd927f38b 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -2165,6 +2165,8 @@ cameraRaw Color 색상 Patch Tool 패치 도구 Patch: 패치: Lasso around an area, then drag the selection 올가미로 영역을 둘러싼 다음 선택 영역을 드래그하세요 + Transform On Drop 놓을 때 변형 + Scale or rotate the moved area, then press {key} 이동한 영역의 크기를 조절하거나 회전한 다음 {key} 키를 누르세요 Now drag the selection onto another area 이제 선택 영역을 다른 영역으로 드래그하세요 # Warp grid presets merged on main in #388. diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index cb5745516..e8531ac05 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -2229,6 +2229,8 @@ Grid: Raster: Highlight Clipping Warning (O) Waarschuwing afsnijden hooglichten (O) Lasso around an area, then drag the selection Trek een lasso rond een gebied en sleep daarna de selectie + Transform On Drop Transformeren bij neerzetten + Scale or rotate the moved area, then press {key} Schaal of draai het verplaatste gebied en druk daarna op {key} Patch: Lap: Shadow Clipping Warning (U) Waarschuwing afsnijden schaduwen (U) Show Lab Color Readouts Lab-kleurwaarden tonen diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index ef0ab773a..2e1506f84 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -2672,6 +2672,8 @@ cameraRaw Roughness Chropowatość Patch Tool Narzędzie Łatka Patch: Łatka: Lasso around an area, then drag the selection Zakreśl obszar lassem, a następnie przeciągnij zaznaczenie + Transform On Drop Przekształć po upuszczeniu + Scale or rotate the moved area, then press {key} Przeskaluj lub obróć przeniesiony obszar, a następnie naciśnij {key} Now drag the selection onto another area Teraz przeciągnij zaznaczenie na inny obszar Edit Type Edytuj tekst Make Default Ustaw jako domyślne diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index c0677b87a..6df8697dc 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2630,6 +2630,8 @@ cameraRaw Blending Mesclagem Patch Tool Ferramenta Correção Patch: Correção: Lasso around an area, then drag the selection Contorne uma área com o laço e arraste a seleção + Transform On Drop Transformar ao soltar + Scale or rotate the moved area, then press {key} Dimensione ou gire a área movida e pressione {key} Now drag the selection onto another area Agora arraste a seleção para outra área 3 x 3 3 × 3 4 x 4 4 × 4 diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 467d49639..8449dc879 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2491,6 +2491,8 @@ cameraRaw Roughness Неровность Patch Tool Заплатка Patch: Заплатка: Lasso around an area, then drag the selection Обведите область лассо, затем перетащите выделение + Transform On Drop Трансформировать при отпускании + Scale or rotate the moved area, then press {key} Масштабируйте или поверните перемещённую область, затем нажмите {key} Now drag the selection onto another area Теперь перетащите выделение на другую область Edit Type Редактировать текст diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index a74fe4fb5..c04c5d62b 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -2464,6 +2464,8 @@ cameraRaw Roughness Шорсткість Patch Tool Інструмент «Латка» Patch: Латка: Lasso around an area, then drag the selection Обведіть область ласо, потім перетягніть виділення + Transform On Drop Трансформувати після відпускання + Scale or rotate the moved area, then press {key} Змініть масштаб або поверніть переміщену область, потім натисніть {key} Edit Type Редагувати текст diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 5dd2d51cf..f1c45f6bd 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2081,6 +2081,8 @@ cameraRaw Blending 混合 Patch Tool 修补工具 Patch: 修补: Lasso around an area, then drag the selection 用套索圈出区域,然后拖动选区 + Transform On Drop 放下时变换 + Scale or rotate the moved area, then press {key} 缩放或旋转移动的区域,然后按 {key} Now drag the selection onto another area 现在将选区拖动到另一个区域 # Current shell labels and shortcut templates. Keep {key} platform-dependent. Add a mask (from the selection; {key} inverts) 添加蒙版 (基于选区;{key} 反相) diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 8f43be45f..0a1869387 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2198,6 +2198,8 @@ cameraRaw Blending 混合 Patch Tool 修補工具 Patch: 修補: Lasso around an area, then drag the selection 用套索圈出區域,然後拖曳選取範圍 + Transform On Drop 放下時變形 + Scale or rotate the moved area, then press {key} 縮放或旋轉移動的區域,然後按 {key} Now drag the selection onto another area 現在將選取範圍拖曳到另一個區域 Edit Type 編輯文字 diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 9cbb2b316..96c318e6d 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -32,6 +32,7 @@ pub mod brush_preview; pub mod brush_resize; pub mod brush_sections; pub mod brushes_tab; +mod cam_transform; mod camera_raw_detail_ui; mod camera_raw_preview_ui; mod camera_raw_scope_ui; @@ -990,6 +991,7 @@ impl PhotocraftApp { pub fn sync_views(&mut self) { type_transform::cancel_stale(self); + crate::cam_transform::cancel_stale(self); crate::lasso_ui::cancel_stale(self); crate::crop_ui::cancel_stale(self); let ids: Vec = self.session.documents().iter().map(|d| d.doc.id).collect(); diff --git a/crates/ui-egui/src/retouch_ui.rs b/crates/ui-egui/src/retouch_ui.rs index 5abd5c15b..37168ab4b 100644 --- a/crates/ui-egui/src/retouch_ui.rs +++ b/crates/ui-egui/src/retouch_ui.rs @@ -223,26 +223,35 @@ pub fn finish_patch(app: &mut PhotocraftApp, start: [f64; 2], end: [f64; 2]) { } /// Content-Aware Move Tool: the selection was dragged from `start` to `end`. The engine runs it as -/// a background job (progress dialog, Esc cancels); the selection follows the content. +/// a background job (progress dialog, Esc cancels); the selection follows the content. With +/// Transform On Drop a transform box comes up on the dropped patch first ([`crate::cam_transform`]). pub fn finish_content_aware_move(app: &mut PhotocraftApp, start: [f64; 2], end: [f64; 2]) { let off = patch_offset(app, start, end); if off == [0, 0] { patch_hint(app); return; } + if app.ui.tool_options.cam_transform_on_drop && crate::cam_transform::begin(app, off) { + return; + } + let p = content_aware_move_params(app, [i64::from(off[0]), i64::from(off[1])]); + if let Err(e) = app.run("paint.contentAwareMove", p) { + app.ui.status = e; + app.ui.status_error = true; + } +} + +/// `paint.contentAwareMove`'s params for a drag by `offset`, from the options bar. +pub fn content_aware_move_params(app: &PhotocraftApp, offset: [i64; 2]) -> Value { let o = &app.ui.tool_options; - let p = json!({ - "offset": off, + json!({ + "offset": offset, "mode": o.cam_mode, "structure": o.cam_structure.round().clamp(1.0, 7.0), "color": o.cam_color.round().clamp(0.0, 10.0), "sampleAllLayers": o.sample_all_layers, "target": crate::canvas::paint_target(app), - }); - if let Err(e) = app.run("paint.contentAwareMove", p) { - app.ui.status = e; - app.ui.status_error = true; - } + }) } /// Object Selection: the dragged rectangle. A click (or a sub-2 px nudge) cannot be a @@ -383,8 +392,24 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui, tool: Tool) -> bo opt(ui, tl!("Color:")); crate::widgets::value_field(ui, &mut o.cam_color, 0.0..=10.0, "", 40.0); crate::widgets::checkbox(ui, &mut o.sample_all_layers, tl!("Sample All Layers")); + crate::widgets::checkbox(ui, &mut o.cam_transform_on_drop, tl!("Transform On Drop")); crate::widgets::vline(ui, 22.0); - opt(ui, tl!("Lasso around an area, then drag the selection")); + if crate::cam_transform::active(app) { + // The box on the dropped patch: ✓ computes the move, ⊘ drops it (as ↩ / Esc). + let tip = crate::i18n::fmt(tl!("Commit transform ({key})"), &[("key", &crate::shortcuts::pretty("Enter"))]); + let commit = crate::icons::button(ui, "check", 26.0, false, &tip); + commit.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, "Commit transform")); + if commit.clicked() { + crate::cam_transform::commit(app); + } + let cancel = crate::icons::button(ui, "ban", 26.0, false, tl!("Cancel transform (Esc)")); + cancel.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, "Cancel transform")); + if cancel.clicked() { + crate::cam_transform::cancel(app); + } + } else { + opt(ui, tl!("Lasso around an area, then drag the selection")); + } } Tool::PatternStamp => { pattern_stamp_options(app, ui); @@ -804,6 +829,48 @@ mod tests { } } + #[test] + fn content_aware_move_transform_on_drop_turns_the_patch_on_enter() { + let mut app = app(); + app.run("paint.pencil", json!({"points": [[16, 30], [28, 30]], "size": 4, "color": "#0000ff"})).unwrap(); + let blue = |app: &PhotocraftApp, x: i32, y: i32| active(app).surface().unwrap().rgba(x, y)[0] < 0.5; + app.ui.tool = Tool::ContentAwareMove; + app.ui.tool_options.cam_transform_on_drop = true; + app.ui.tool_options.cam_structure = 7.0; + let m = egui::Modifiers::NONE; + let drag = |app: &mut PhotocraftApp, path: &[[f64; 2]]| { + let ([x, y], rest) = (path[0], &path[1..]); + tool_event(app, ToolEvent::Down { x, y, pressure: 1.0 }, m); + for &[x, y] in rest { + tool_event(app, ToolEvent::Move { x, y, pressure: 1.0 }, m); + } + let [x, y] = path[path.len() - 1]; + tool_event(app, ToolEvent::Up { x, y }, m); + }; + drag(&mut app, &[[6.0, 14.0], [38.0, 14.0], [38.0, 46.0], [6.0, 46.0]]); + let steps = app.session.active().unwrap().history.past_len(); + // The drop puts a box on the patch; nothing is computed yet. + drag(&mut app, &[[22.0, 30.0], [50.0, 30.0], [72.0, 30.0]]); + assert!(crate::cam_transform::active(&app), "{}", app.ui.status); + assert!(blue(&app, 22, 30) && !blue(&app, 72, 30), "the document waits for the commit"); + assert_eq!(app.session.active().unwrap().history.past_len(), steps); + // Dragging outside the box turns it 90° about its centre (72, 30). + drag(&mut app, &[[98.0, 55.0], [60.0, 58.0], [47.0, 56.0]]); + let place = crate::cam_transform::placement(app.ui.cam_transform.as_ref().unwrap()).unwrap(); + assert_eq!((place.offset, place.scale), ([50, 0], [100.0, 100.0])); + assert!((place.rotate - 90.0).abs() < 1e-3, "{place:?}"); + crate::cam_transform::commit(&mut app); + assert!(!crate::cam_transform::active(&app) && !app.ui.status_error, "{}", app.ui.status); + let (id, p) = app.session.journal.last().unwrap(); + assert_eq!((id.as_str(), &p["offset"]), ("paint.contentAwareMove", &json!([50, 0]))); + assert!((p["rotate"].as_f64().unwrap() - 90.0).abs() < 1e-3, "{p}"); + // The horizontal line lands upright, in one History step. + assert!(blue(&app, 72, 26) && blue(&app, 72, 34), "the turned line"); + assert!(!blue(&app, 66, 30) && !blue(&app, 78, 30), "no unturned line"); + assert!(!blue(&app, 22, 30), "the old place is filled"); + assert_eq!(app.session.active().unwrap().history.past_len(), steps + 1); + } + #[test] fn red_eye_click_corrects_a_pupil_and_errors_on_a_miss() { let mut app = app(); diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index ce21117b8..7436138d5 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -366,6 +366,17 @@ pub fn handle(app: &mut PhotocraftApp, ctx: &egui::Context) { return; } } + // Content-Aware Move's Transform On Drop box: ↩ computes the move, Esc drops it. + if crate::cam_transform::active(app) { + if ctx.input_mut(|i| i.consume_key(Modifiers::NONE, Key::Enter)) { + crate::cam_transform::commit(app); + return; + } + if ctx.input_mut(|i| i.consume_key(Modifiers::NONE, Key::Escape)) { + crate::cam_transform::cancel(app); + return; + } + } // Crop tool with a pending frame: arrows nudge it (⇧ ×10) and X swaps its orientation, before // the selection / layer nudge and the X colour swap (#1919). if focus == Focus::None && crate::crop_ui::keys(app, ctx) { diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 4cebc48d2..10bc9ec10 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -548,6 +548,8 @@ pub struct ToolOptions { pub cam_mode: String, pub cam_structure: f32, pub cam_color: f32, + /// Content-Aware Move: Transform On Drop (a transform box on the dropped patch). + pub cam_transform_on_drop: bool, /// Dodge/Burn: shadows | midtones | highlights, exposure %, protect tones. pub tone_range: String, pub exposure: f32, @@ -758,6 +760,7 @@ impl Default for ToolOptions { cam_mode: "move".into(), cam_structure: 4.0, cam_color: 0.0, + cam_transform_on_drop: false, tone_range: "midtones".into(), exposure: 50.0, protect_tones: true, @@ -926,6 +929,19 @@ pub struct TypeTransform { pub(crate) gesture: crate::transform_tool::Gesture, } +/// Content-Aware Move with Transform On Drop: the box on the dropped patch. The document is +/// unchanged until it is committed; this is view state. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct CamTransform { + pub document: u64, + pub revision: u64, + /// How far the selection was dragged; the box's `rect` is the selection's bounds there. + pub offset: [i32; 2], + pub frame: TransformSession, + #[serde(skip)] + pub(crate) gesture: Option, +} + /// View-menu overlays. #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(default)] @@ -985,6 +1001,9 @@ pub struct UiState { /// Reference point for the current type editing session (document coordinates). #[serde(skip)] pub type_transform_pivot: Option<[f64; 2]>, + /// Content-Aware Move's Transform On Drop box, waiting for ↩ / Esc. + #[serde(default, skip_deserializing)] + pub cam_transform: Option, /// Free Transform session, if any. #[serde(default)] pub transform: Option, @@ -1181,6 +1200,7 @@ impl Default for UiState { text_edit: None, type_transform: None, type_transform_pivot: None, + cam_transform: None, transform: None, symmetry_transform: None, mask_target: false, diff --git a/crates/ui-egui/src/transform_tool.rs b/crates/ui-egui/src/transform_tool.rs index 294e3d11f..ccde6b6a3 100644 --- a/crates/ui-egui/src/transform_tool.rs +++ b/crates/ui-egui/src/transform_tool.rs @@ -1209,7 +1209,7 @@ fn scale_not_distort(mode: TransformMode, hit: Hit, mut mods: egui::Modifiers) - /// Preferences › General › Use Legacy Free Transform, Photoshop's scaling before CC 2019: corner /// drags stretch freely and ⇧ keeps the proportions, edge drags stretch their one axis whatever /// the keys. The default is proportional for both, with ⇧ freeing them. -fn legacy_mods(legacy: bool, hit: Hit, mut mods: egui::Modifiers) -> egui::Modifiers { +pub(crate) fn legacy_mods(legacy: bool, hit: Hit, mut mods: egui::Modifiers) -> egui::Modifiers { if legacy && !mods.command { match hit { Hit::Corner(_) => mods.shift = !mods.shift, diff --git a/crates/ui-egui/src/type_transform.rs b/crates/ui-egui/src/type_transform.rs index a51c450eb..42c9cc8ee 100644 --- a/crates/ui-egui/src/type_transform.rs +++ b/crates/ui-egui/src/type_transform.rs @@ -322,6 +322,13 @@ pub(crate) fn draw(app: &mut PhotocraftApp, painter: &egui::Painter, xf: &ViewXf return false; } let Some(t) = frame(app) else { return false }; + draw_box(painter, xf, &t); + true +} + +/// A transform box: its outline, corner and edge handles and reference point. Shared with +/// Content-Aware Move's Transform On Drop. +pub(crate) fn draw_box(painter: &egui::Painter, xf: &ViewXform, t: &TransformSession) { let tokens = crate::theme::Tokens::get(painter.ctx()); let pts = t.quad.map(|p| xf.to_screen(p[0] as f32, p[1] as f32)); painter.add(egui::Shape::closed_line(pts.to_vec(), Stroke::new(3.0, tokens.shadow))); @@ -338,10 +345,9 @@ pub(crate) fn draw(app: &mut PhotocraftApp, painter: &egui::Painter, xf: &ViewXf for axis in [vec2(6.0, 0.0), vec2(0.0, 6.0)] { painter.line_segment([c - axis, c + axis], Stroke::new(1.0, tokens.accent)); } - true } -fn resize_cursor(d: [f64; 2], flip: bool) -> CursorIcon { +pub(crate) fn resize_cursor(d: [f64; 2], flip: bool) -> CursorIcon { let angle = d[1].atan2(if flip { -d[0] } else { d[0] }).rem_euclid(std::f64::consts::PI); match (angle / std::f64::consts::FRAC_PI_4).round() as u8 % 4 { 0 => CursorIcon::ResizeHorizontal, @@ -351,7 +357,7 @@ fn resize_cursor(d: [f64; 2], flip: bool) -> CursorIcon { } } -fn rotation_cursor(ctx: &egui::Context, p: Pos2) { +pub(crate) fn rotation_cursor(ctx: &egui::Context, p: Pos2) { let tokens = crate::theme::Tokens::get(ctx); let painter = ctx.layer_painter(egui::LayerId::new(egui::Order::Tooltip, egui::Id::new("type-rotate-cursor"))); let first = -std::f32::consts::FRAC_PI_4; diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 429c67cea..dc614bad6 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -49,6 +49,7 @@ The transport is `apps/photocraft/src/control_server.rs`, and the handlers are i - `typeTransform` is `null` outside a temporary Ctrl/Cmd Type-tool drag. During a drag it reports its source document/revision, original affine, oriented `frame` (layer, local bounds, document-space corners and pivot), and captured `gesture`. Drive it with `ui.pointer` and `command: true` at `down`; `move`/`up` may release the modifier. The document/history remain unchanged during preview, and `up` applies one `type.edit` within `textEdit`'s session. Pressing Escape during the drag cancels only the preview. This pointer state is never restored from saved UI state. - `ui.menu.invoke {id, params?, wait?}` / `ui.menu.list`: activate a menu item by id; list the menu tree. A menu item that starts a background job (a filter without a dialog, such as Blur More) replies with the job's result once it has been applied; with `"wait": false` the reply is `{job, pending: true}` at once. Window ends with the open documents as `window.document.` (tab order, the active one checked, #2991); invoking one brings that document to the front and replies `{document: index}` - `ui.set` also accepts `gradientBlendMode` (a layer blend mode name, such as `Difference`) and `gradientClassic` (boolean) for the Gradient tool options bar. +- `ui.set` also accepts `camTransformOnDrop` (boolean), the Content-Aware Move tool's Transform On Drop checkbox: a drop then puts a scale/rotate box on the patch (`ui.inspect` › `camTransform`: its document/revision, drag `offset` and `frame`), driven with `ui.pointer` (handles as Free Transform's; outside the box rotates), committed with `ui.key Enter` (one `paint.contentAwareMove` with `scaleX`, `scaleY`, `rotate`) or dropped with `Escape`. - Each painting tool keeps its own brush, as in Photoshop: `tools.setBrush` and `ui.set {brushSize}` change the active tool's brush, and switching tools swaps in that tool's own (size, hardness, mode, opacity, flow, dynamics, smoothing). A tool used for the first time starts from the brush in hand. The colours, the erase flag and the stroke seed stay shared. So `ui.set {tool: "eraser", brushSize: 12}` sets the Eraser's size, and `ui.set {tool: "brush"}` afterwards brings the Brush's own size back. - `ui.set` also accepts the Eyedropper options bar (#1649): `eyedropperSampleSize` (`"point"` or 1, 3, 5, 11, 31, 51, 101: the side of the averaged square, shared by the Eyedropper, a painting tool's Alt-click eyedropper, the Curves/Color Picker eyedroppers and the Info panel), `eyedropperSample` (`current`, `currentAndBelow`, `all`, `allNoAdjustments`, `currentAndBelowNoAdjustments`) and `eyedropperRing` (boolean, Show Sampling Ring). `ui.inspect` reports them under `toolOptions` (`eyedropper_size`, `eyedropper_sample`, `eyedropper_ring`). The engine command `document.sampleColor {x, y, size, sampleLayer}` returns the colour such a sample picks. - `ui.set` also accepts the Crop tool's overlay menu (#1919): `cropOverlay` (`thirds`, `grid`, `diagonal`, `triangle`, `goldenRatio`, `goldenSpiral`), `cropOverlayShow` (`auto`: only while the crop box is dragged, Photoshop's default; `always`; `never`) and `cropOverlayOrientation` (0..3; Triangle has two orientations and Golden Spiral four, the others ignore it). `ui.inspect` reports them under `toolOptions` (`crop_overlay`, `crop_overlay_show`, `crop_overlay_orientation`). In the app, O cycles the overlay and ⇧O its orientation while the Crop tool shows a box. diff --git a/docs/scorecard.md b/docs/scorecard.md index d71920d43..ab703533b 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -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; background job) + Tool::ContentAwareMove (J group: lasso, then drag). Missing: Transform On Drop, a live preview of the result (the drag shows 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). Missing: a live preview of the result (the drag and the box show the outline only) | | 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 | diff --git a/scorecard/tools.toml b/scorecard/tools.toml index 57f10e88e..6d5e67aed 100644 --- a/scorecard/tools.toml +++ b/scorecard/tools.toml @@ -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; background job) + Tool::ContentAwareMove (J group: lasso, then drag). Missing: Transform On Drop, a live preview of the result (the drag shows 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). Missing: a live preview of the result (the drag and the box show the outline only)" [[item]] id = "TOOL-213-5"