Content-Aware Move: Transform On Drop (#3071) (#3095)

* Content-Aware Move: Transform On Drop (#3071)

The Content-Aware Move tool gains Photoshop's Transform On Drop checkbox. With it on, the drop
puts a transform box on the moved patch (the Free Transform box's handles, limited to move,
scale and rotate); Enter, the check button or a double-click computes the content-aware blend
with the transformed patch, Esc or the cancel button drops the whole move. The document is
untouched until the commit, so the move stays one History step.

Engine: paint.contentAwareMove takes scaleX / scaleY (%, 1..10000, negative flips) and rotate
(degrees, clockwise) about the selection's centre before the offset, validated (Err, not a
panic, for non-finite, out-of-range or off-canvas placements). The content and the selection's
shape are resampled through the inverse map on float samples (any depth); the selection is
transformed with the content. A plain move keeps the old, exact code path.

UI: cam_transform.rs holds the box (UiState.cam_transform, view state); the option is remembered
in ToolOptions and settable with ui.set camTransformOnDrop; ui.inspect reports camTransform.

Scorecard TOOL-213-4 stays partial: no live preview of the blended result yet.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Tests: keep Transform On Drop cases in the Content-Aware Move test, not Patch (#3071)

paint.patch takes no scaleX/scaleY/rotate, so those cases (which only
paint.contentAwareMove rejects) failed patch_fails_gracefully. They are
already covered by content_aware_move_fails_gracefully.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Valentin KleinandClaude Opus 5.5 authored and GitHub committed 2026-10-11 18:44:10 -04:00
1 parent 8584e5dbf8
commit 661ba806d1
32 files changed
+622 -41

No files matched your search

+2 -1
View File
@@ -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,
}
+1 -1
View File
@@ -1159,7 +1159,7 @@ pub fn specs() -> Vec<CommandSpec> {
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,
@@ -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<Homography>,
/// 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<Option<Homography>> {
let num = |k: &str, default: f64| -> Result<f64> {
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<f64> {
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<bool> {
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<Plan> {
let mode = match string(p, "mode", "move") {
"move" => Mode::Move,
@@ -106,15 +214,35 @@ fn plan(s: &Session, p: &Value) -> Result<Plan> {
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<Surface>)> {
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<bool> = mask(sel, win).iter().zip(&landed).map(|(s, l)| *s && !*l).collect();
if hole.iter().any(|h| *h) {
let allowed: Vec<bool> = 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<Value> {
@@ -342,12 +496,14 @@ pub(super) fn content_aware_move(s: &mut Session, p: &Value) -> Result<Value> {
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<Value> {
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)
},
)
}
+37
View File
@@ -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]
+220
View File
@@ -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<Placement> {
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<CursorIcon> {
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,
})
}
+20 -3
View File
@@ -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).
+8 -1
View File
@@ -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,
+2
View File
@@ -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
Can't render this file because it contains an unexpected character in line 6 and column 47.
+2
View File
@@ -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)
Can't render this file because it contains an unexpected character in line 6 and column 47.
+2
View File
@@ -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 (⌘;) Εμφάνιση οδηγών (⌘;)
Can't render this file because it contains an unexpected character in line 1000 and column 18.
+2
View File
@@ -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
Can't render this file because it contains an unexpected character in line 4 and column 47.
+2
View File
@@ -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)
Can't render this file because it contains an unexpected character in line 6 and column 47.
+2
View File
@@ -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)
Can't render this file because it contains an unexpected character in line 4 and column 47.
+2
View File
@@ -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
1 # Italian (it) catalog. Columns, separated by a TAB: context <TAB> source <TAB> translation
2298
2299
2300
2301
2302
2303
2304
2305
+2
View File
@@ -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 文字を編集
Can't render this file because it contains an unexpected character in line 6 and column 47.
+2
View File
@@ -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.
1 # Korean (ko) catalog. UTF-8: context<TAB>English source<TAB>translation.
2165
2166 # Warp grid presets merged on main in #388.
2167
2168
2169
2170
2171 # Type layer thumbnails (#598)
2172
+2
View File
@@ -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
Can't render this file because it contains an unexpected character in line 8 and column 34.
+2
View File
@@ -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
Can't render this file because it contains an unexpected character in line 6 and column 47.
+2
View File
@@ -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
Can't render this file because it contains an unexpected character in line 6 and column 47.
+2
View File
@@ -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 Редактировать текст
Can't render this file because it contains an unexpected character in line 6 and column 47.
+2
View File
@@ -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 Редагувати текст
Can't render this file because it contains an unexpected character in line 1356 and column 18.
+2
View File
@@ -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} 反相)
1 # Simplified Chinese (zh-hans) catalog. UTF-8, TAB-separated columns:
2081 # Current shell labels and shortcut templates. Keep {key} platform-dependent.
2082
2083 # Current shell labels and shortcut templates. Keep {key} platform-dependent.
2084
2085
2086
2087
2088
+2
View File
@@ -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 編輯文字
Can't render this file because it contains an unexpected character in line 6 and column 47.
+2
View File
@@ -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<DocId> = self.session.documents().iter().map(|d| d.doc.id).collect();
+76 -9
View File
@@ -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();
+11
View File
@@ -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) {
+20
View File
@@ -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<crate::transform_tool::Gesture>,
}
/// 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<CamTransform>,
/// Free Transform session, if any.
#[serde(default)]
pub transform: Option<TransformSession>,
@@ -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,
+1 -1
View File
@@ -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,
+9 -3
View File
@@ -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;
+1
View File
@@ -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.<index>` (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.
+1 -1
View File
@@ -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 |
+1 -1
View File
@@ -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"