mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-14 19:26:51 -04:00
* 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:
1 parent
8584e5dbf8
commit
661ba806d1
32 files changed
+622
-41
No files matched your search
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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).
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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
|
||||
|
||||
|
||||
|
@@ -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.
|
@@ -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.
|
||||
|
||||
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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} 反相)
|
||||
|
||||
|
@@ -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.
|
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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 |
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user