mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-19 02:10:49 -05:00
* Crop tool: Content-Aware fill of the area a rotated or extended crop uncovers (#3823) image.crop gains contentAware (bool, default false). After a turned crop or one that extends past the canvas, the area the original image doesn't cover is filled from its surroundings on the Background (else the active pixel layer), in the crop's one undo step. Each connected piece (corner, edge strip) gets its own Content-Aware sampling window and samples only covered pixels; the patch search is Edit > Fill > Content-Aware's (fill_cmds::content_aware_native, shared). A crop that uncovers nothing is unchanged; one with nothing to sample from fails and rolls back. Crop options bar: Fill: Background (Default) / Content-Aware Fill (ToolOptions crop_content_aware, ui.set cropFill). Generative Expand is not offered. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * Crop: adapt to Document::background after main removed is_background Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * Crop: translate Background (Default) for Hungarian and Turkish Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * Fix the merge with main: UI_SET_FIELDS length, Serbian Background (Default) Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * Merge main: UI_SET_FIELDS counts both new fields --------- Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
34 files changed
+545
-23
No files matched your search
@@ -320,7 +320,8 @@ pub struct UiSetParams {
|
||||
/// grid, diagonal, triangle, goldenRatio, goldenSpiral), cropOverlayShow (auto, always, never),
|
||||
/// cropOverlayOrientation (0..3), cropShield (an object patching show_cropped_area, enabled,
|
||||
/// 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,
|
||||
/// auto_adjust, classic_mode and auto_center_preview), cropFill (background or contentAware,
|
||||
/// the Crop tool's Fill), 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`. camTransformOnDrop (bool,
|
||||
/// the Content-Aware Move tool's Transform On Drop). tileGrid (bool, a developer overlay of the
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
//! The Crop tool's Fill: Content-Aware Fill (#3823). After a rotated crop or one that extends past
|
||||
//! the canvas, the area the original image doesn't cover (the corners a rotation uncovers, the
|
||||
//! strips an extension adds) is filled from its surroundings instead of holding the background
|
||||
//! colour or transparency.
|
||||
//!
|
||||
//! Which layer: the Background when the document has one (the layer the crop filled with the
|
||||
//! background colour), otherwise the active pixel layer (where the crop left transparency). Other
|
||||
//! layers keep their transparency there, as after a plain crop. Each connected piece of the
|
||||
//! uncovered area (each corner, each edge strip) is filled on its own, with Photoshop's sampling
|
||||
//! window around that piece, and only pixels the image covers are sampled.
|
||||
|
||||
use photocraft_doc::{Document, LayerId};
|
||||
use photocraft_geom::Rect;
|
||||
use rayon::prelude::*;
|
||||
|
||||
use crate::{EngineError, Result};
|
||||
|
||||
/// Seed for the patch search, so a crop fills the same way every time.
|
||||
const SEED: u64 = 1;
|
||||
|
||||
/// The largest canvas (pixels) the uncovered area is worked out for: the Content-Aware window cap.
|
||||
const MAX_PIXELS: u64 = 400_000_000;
|
||||
|
||||
/// Where the cropped canvas came from in the original document.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub(crate) struct Coverage {
|
||||
/// Cropped canvas → original document: `[a, b, c, d, e, f]` with
|
||||
/// `x = a·u + b·v + e`, `y = c·u + d·v + f`.
|
||||
pub map: [f64; 6],
|
||||
/// The original canvas size (px).
|
||||
pub width: f64,
|
||||
pub height: f64,
|
||||
/// Whether the map rotates (its resampled edge is antialiased against the background colour).
|
||||
pub rotated: bool,
|
||||
}
|
||||
|
||||
impl Coverage {
|
||||
/// An unrotated crop to `r` (original document px).
|
||||
pub fn crop(r: Rect, width: u32, height: u32) -> Self {
|
||||
Coverage { map: [1.0, 0.0, 0.0, 1.0, f64::from(r.x0), f64::from(r.y0)], width: f64::from(width), height: f64::from(height), rotated: false }
|
||||
}
|
||||
|
||||
/// Whether the cropped canvas's pixel (u, v) is the original image's: its centre maps inside
|
||||
/// the original canvas by at least half the pixel's projected size (exact for an unrotated
|
||||
/// crop); a rotated crop also refills the one-pixel antialiased edge, which the resampling
|
||||
/// blended with the background colour.
|
||||
fn covered(&self, u: i32, v: i32, margin: f64) -> bool {
|
||||
let [a, b, c, d, e, f] = self.map;
|
||||
let (cu, cv) = (f64::from(u) + 0.5, f64::from(v) + 0.5);
|
||||
let (x, y) = (a * cu + b * cv + e, c * cu + d * cv + f);
|
||||
x >= margin && y >= margin && x <= self.width - margin && y <= self.height - margin
|
||||
}
|
||||
|
||||
fn margin(&self) -> f64 {
|
||||
let [a, b, ..] = self.map;
|
||||
0.5 * (a.abs() + b.abs()) + if self.rotated { 1.0 } else { 0.0 }
|
||||
}
|
||||
}
|
||||
|
||||
/// Labels of the uncovered pixels: 0 covered, `UNLABELLED` uncovered before labelling, else the
|
||||
/// component number (1-based).
|
||||
const UNLABELLED: u32 = u32::MAX;
|
||||
|
||||
/// Fills the area of the cropped `doc` that `cov` says the original image doesn't cover (see the
|
||||
/// module docs). Nothing uncovered: nothing changes. Errors leave `doc` to be rolled back by the
|
||||
/// caller's history step.
|
||||
pub(crate) fn fill_uncovered(doc: &mut Document, active: Option<LayerId>, cov: &Coverage) -> Result<()> {
|
||||
let canvas = doc.bounds();
|
||||
let (w, h) = (canvas.width(), canvas.height());
|
||||
if u64::from(w) * u64::from(h) > MAX_PIXELS {
|
||||
return Err(EngineError::Other(format!("the {w} x {h} crop is too large for Content-Aware fill")));
|
||||
}
|
||||
let (w, h) = (w as usize, h as usize);
|
||||
let len = w.checked_mul(h).ok_or_else(|| EngineError::Other("the crop is too large for Content-Aware fill".into()))?;
|
||||
if len == 0 {
|
||||
return Ok(());
|
||||
}
|
||||
let margin = cov.margin();
|
||||
let mut labels = crate::allocation::filled(len, 0u32, "Content-Aware crop fill")?;
|
||||
labels.par_chunks_mut(w).enumerate().for_each(|(y, row)| {
|
||||
for (x, l) in row.iter_mut().enumerate() {
|
||||
if !cov.covered(x as i32, y as i32, margin) {
|
||||
*l = UNLABELLED;
|
||||
}
|
||||
}
|
||||
});
|
||||
if !labels.contains(&UNLABELLED) {
|
||||
return Ok(());
|
||||
}
|
||||
if !labels.contains(&0) {
|
||||
return Err(EngineError::Other("Content-Aware fill needs some of the image inside the crop to sample from".into()));
|
||||
}
|
||||
let id = target_layer(doc, active)?;
|
||||
let pieces = label_components(&mut labels, w, h)?;
|
||||
let surf = doc.layer(id).and_then(|l| l.surface()).ok_or_else(|| EngineError::Other("Content-Aware fill needs a pixel layer".into()))?;
|
||||
let label = |x: i32, y: i32| -> u32 {
|
||||
if x < 0 || y < 0 {
|
||||
return 0;
|
||||
}
|
||||
(y as usize).checked_mul(w).and_then(|i| i.checked_add(x as usize)).and_then(|i| labels.get(i)).copied().unwrap_or(0)
|
||||
};
|
||||
let n = surf.format().channels().max(1);
|
||||
let filled: Vec<(Rect, Vec<f32>)> = pieces
|
||||
.par_iter()
|
||||
.enumerate()
|
||||
.map(|(k, bounds)| -> Result<(Rect, Vec<f32>)> {
|
||||
let k = k as u32 + 1;
|
||||
let (window, px) = crate::fill_cmds::content_aware_native(surf, *bounds, canvas, true, SEED, |x, y| label(x, y) == k, |x, y| label(x, y) == 0)?;
|
||||
// The piece's bounds, its pixels from the fill and the rest as they were.
|
||||
let mut out = surf.read_region(*bounds);
|
||||
let ww = window.width() as usize;
|
||||
for y in bounds.y0..bounds.y1 {
|
||||
for x in bounds.x0..bounds.x1 {
|
||||
if label(x, y) != k || !window.contains(x, y) {
|
||||
continue;
|
||||
}
|
||||
let src = ((y - window.y0) as usize * ww + (x - window.x0) as usize) * n;
|
||||
let dst = ((y - bounds.y0) as usize * bounds.width() as usize + (x - bounds.x0) as usize) * n;
|
||||
if let (Some(s), Some(d)) = (px.get(src..src + n), out.get_mut(dst..dst + n)) {
|
||||
d.copy_from_slice(s);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok((*bounds, out))
|
||||
})
|
||||
.collect::<Result<_>>()?;
|
||||
let surf = doc.layer_mut(id).and_then(|l| l.surface_mut()).ok_or_else(|| EngineError::Other("Content-Aware fill needs a pixel layer".into()))?;
|
||||
for (r, px) in filled {
|
||||
surf.write_region(r, &px);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The Background, else the active pixel layer.
|
||||
fn target_layer(doc: &Document, active: Option<LayerId>) -> Result<LayerId> {
|
||||
if let Some(bg) = doc.background() {
|
||||
return Ok(bg.id);
|
||||
}
|
||||
active
|
||||
.filter(|id| doc.layer(*id).is_some_and(|l| matches!(l.content, photocraft_doc::LayerContent::Raster(_))))
|
||||
.ok_or_else(|| EngineError::Other("Content-Aware crop fill needs a Background or an active pixel layer".into()))
|
||||
}
|
||||
|
||||
/// Numbers the 8-connected pieces of `UNLABELLED` pixels 1, 2, … and returns each one's bounds.
|
||||
fn label_components(labels: &mut [u32], w: usize, h: usize) -> Result<Vec<Rect>> {
|
||||
let mut pieces: Vec<Rect> = Vec::new();
|
||||
let mut stack: Vec<usize> = Vec::new();
|
||||
for start in 0..labels.len() {
|
||||
if labels.get(start) != Some(&UNLABELLED) {
|
||||
continue;
|
||||
}
|
||||
let k = u32::try_from(pieces.len() + 1).ok().filter(|k| *k < UNLABELLED).ok_or_else(|| EngineError::Other("too many uncovered areas".into()))?;
|
||||
let (mut x0, mut y0, mut x1, mut y1) = (usize::MAX, usize::MAX, 0usize, 0usize);
|
||||
if let Some(l) = labels.get_mut(start) {
|
||||
*l = k;
|
||||
}
|
||||
stack.push(start);
|
||||
while let Some(i) = stack.pop() {
|
||||
let (x, y) = (i % w, i / w);
|
||||
(x0, y0, x1, y1) = (x0.min(x), y0.min(y), x1.max(x), y1.max(y));
|
||||
for dy in -1i64..=1 {
|
||||
for dx in -1i64..=1 {
|
||||
let (nx, ny) = (x as i64 + dx, y as i64 + dy);
|
||||
if (dx, dy) == (0, 0) || nx < 0 || ny < 0 || nx >= w as i64 || ny >= h as i64 {
|
||||
continue;
|
||||
}
|
||||
let j = ny as usize * w + nx as usize;
|
||||
if let Some(l) = labels.get_mut(j)
|
||||
&& *l == UNLABELLED
|
||||
{
|
||||
*l = k;
|
||||
stack.push(j);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
pieces.push(Rect::new(x0 as i32, y0 as i32, x1 as i32 + 1, y1 as i32 + 1));
|
||||
}
|
||||
Ok(pieces)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn components_are_8_connected_with_their_bounds() {
|
||||
let (w, h) = (6, 4);
|
||||
let u = UNLABELLED;
|
||||
#[rustfmt::skip]
|
||||
let mut l = vec![
|
||||
u, 0, 0, 0, 0, u,
|
||||
0, u, 0, 0, 0, u,
|
||||
0, 0, 0, 0, 0, 0,
|
||||
0, 0, 0, u, u, 0,
|
||||
];
|
||||
let pieces = label_components(&mut l, w, h).unwrap();
|
||||
assert_eq!(pieces, vec![Rect::new(0, 0, 2, 2), Rect::new(5, 0, 6, 2), Rect::new(3, 3, 5, 4)]);
|
||||
assert_eq!(l[7], 1);
|
||||
assert_eq!(l[11], 2);
|
||||
assert_eq!(l[22], 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unrotated_coverage_is_exact() {
|
||||
let c = Coverage::crop(Rect::new(-2, 0, 8, 4), 5, 4);
|
||||
let m = c.margin();
|
||||
assert!(!c.covered(1, 0, m), "x = -1");
|
||||
assert!(c.covered(2, 0, m), "x = 0");
|
||||
assert!(c.covered(6, 0, m), "x = 4");
|
||||
assert!(!c.covered(7, 0, m), "x = 5");
|
||||
}
|
||||
}
|
||||
@@ -235,24 +235,47 @@ pub(crate) fn blend_color_mask(
|
||||
/// The selection filled from its surroundings (`photocraft_algo::content_aware`), as straight
|
||||
/// RGBA over the sampling window. Only the selected pixels are used.
|
||||
fn content_aware(surf: &Surface, sel: &Surface, canvas: Rect, color_adaptation: bool, seed: u64) -> Result<Source> {
|
||||
use photocraft_algo::content_aware::{FillOptions, color_level, fill, rotation_level};
|
||||
let hole_bounds = sel.content_bounds().intersect(&canvas);
|
||||
if hole_bounds.is_empty() {
|
||||
return Err(EngineError::Other("the selection is outside the canvas".into()));
|
||||
}
|
||||
let window = sampling_window(hole_bounds, canvas);
|
||||
let is_hole = |x: i32, y: i32| sel.sample_channel(x, y, 0) > 0.0;
|
||||
let (window, filled) = content_aware_native(surf, hole_bounds, canvas, color_adaptation, seed, is_hole, |_, _| true)?;
|
||||
let fmt = surf.format();
|
||||
let n = fmt.channels();
|
||||
let px = filled.chunks_exact(fmt.channels().max(1)).map(|c| to_rgba(&fmt, c)).collect();
|
||||
Ok(Source::Pixels(window, px))
|
||||
}
|
||||
|
||||
/// The pixels `is_hole` picks inside `hole_bounds` filled from their surroundings
|
||||
/// (`photocraft_algo::content_aware`): Photoshop's sampling window around `hole_bounds`, sampling
|
||||
/// only the non-hole pixels `usable` allows. Returns the window and its pixels in the surface's
|
||||
/// own channels (the hole filled, the rest as read). Shared by Edit › Fill › Content-Aware and the
|
||||
/// Crop tool's Content-Aware fill (#3823).
|
||||
pub(crate) fn content_aware_native(
|
||||
surf: &Surface,
|
||||
hole_bounds: Rect,
|
||||
canvas: Rect,
|
||||
color_adaptation: bool,
|
||||
seed: u64,
|
||||
is_hole: impl Fn(i32, i32) -> bool,
|
||||
usable: impl Fn(i32, i32) -> bool,
|
||||
) -> Result<(Rect, Vec<f32>)> {
|
||||
use photocraft_algo::content_aware::{FillOptions, color_level, fill, rotation_level};
|
||||
let window = sampling_window(hole_bounds, canvas);
|
||||
let n = surf.format().channels();
|
||||
let (w, h) = window_size(window)?;
|
||||
let img = surf.read_region(window);
|
||||
let mut hole = vec![false; w * h];
|
||||
for (i, hv) in hole.iter_mut().enumerate() {
|
||||
*hv = sel.sample_channel(window.x0 + (i % w) as i32, window.y0 + (i / w) as i32, 0) > 0.0;
|
||||
let len = w.checked_mul(h).ok_or_else(|| EngineError::Other("the area is too large for Content-Aware fill".into()))?;
|
||||
let mut hole = crate::allocation::filled(len, false, "Content-Aware fill")?;
|
||||
let mut source = crate::allocation::filled(len, false, "Content-Aware fill")?;
|
||||
for (i, (hv, sv)) in hole.iter_mut().zip(source.iter_mut()).enumerate() {
|
||||
let (x, y) = (window.x0 + (i % w) as i32, window.y0 + (i / w) as i32);
|
||||
*hv = is_hole(x, y);
|
||||
*sv = !*hv && usable(x, y);
|
||||
}
|
||||
if hole.iter().all(|h| *h) {
|
||||
return Err(EngineError::Other("Content-Aware fill needs pixels outside the selection to sample from".into()));
|
||||
if !source.iter().any(|s| *s) {
|
||||
return Err(EngineError::Other("Content-Aware fill needs pixels outside the area to fill to sample from".into()));
|
||||
}
|
||||
let source = vec![true; w * h];
|
||||
let opts = FillOptions {
|
||||
color_adaptation: color_level(if color_adaptation { "default" } else { "none" }),
|
||||
rotations: rotation_level("none"),
|
||||
@@ -260,12 +283,10 @@ fn content_aware(surf: &Surface, sel: &Surface, canvas: Rect, color_adaptation:
|
||||
mirror: false,
|
||||
seed,
|
||||
};
|
||||
if img.len() != w * h * n {
|
||||
if img.len() != len * n {
|
||||
return Err(EngineError::Other("could not read the layer pixels".into()));
|
||||
}
|
||||
let filled = fill(w, h, n, &img, &hole, &source, &opts);
|
||||
let px = filled.chunks_exact(n.max(1)).map(|c| to_rgba(&fmt, c)).collect();
|
||||
Ok(Source::Pixels(window, px))
|
||||
Ok((window, fill(w, h, n, &img, &hole, &source, &opts)))
|
||||
}
|
||||
|
||||
/// Photoshop's Content-Aware sampling window around the selection bounds `hole`
|
||||
|
||||
@@ -428,6 +428,12 @@ const MAX_CROP_SIDE: i32 = 300_000;
|
||||
/// Image → Crop (to the selection bounds).
|
||||
fn crop(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let delete = p.get("deleteCroppedPixels").and_then(Value::as_bool).unwrap_or(true);
|
||||
// The Crop tool's Fill: Content-Aware Fill of what the crop uncovers (#3823).
|
||||
let content_aware = match p.get("contentAware") {
|
||||
None | Some(Value::Null) => false,
|
||||
Some(Value::Bool(b)) => *b,
|
||||
Some(v) => return Err(bad("image.crop", format!("`contentAware` must be true or false, not {v}"))),
|
||||
};
|
||||
// Values beyond i32 would wrap through the narrowing casts into a rectangle unrelated to
|
||||
// the numbers passed; reject them (see `commands::int_i32`).
|
||||
let explicit = match (
|
||||
@@ -459,7 +465,7 @@ fn crop(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
if let Some(a) = crop_angle(p)? {
|
||||
let rect = explicit.ok_or_else(|| bad("image.crop", "an `angle` needs an explicit `x`, `y`, `width` and `height`"))?;
|
||||
return rotated_crop(s, rect, a, delete, &target);
|
||||
return rotated_crop(s, rect, a, delete, content_aware, &target);
|
||||
}
|
||||
// An explicit rectangle (the Crop tool) may extend past the canvas; a selection crop is clamped.
|
||||
let r = explicit
|
||||
@@ -470,8 +476,12 @@ fn crop(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
s.set_sampler_map(photocraft_geom::Affine::translate(-f64::from(r.x0), -f64::from(r.y0)));
|
||||
let extension = s.tools.background;
|
||||
s.edit("Crop", |doc, _| {
|
||||
s.edit("Crop", |doc, active| {
|
||||
let original = doc.size;
|
||||
crop_doc(doc, "image.crop", r, delete, extension)?;
|
||||
if content_aware {
|
||||
crate::crop_fill::fill_uncovered(doc, *active, &crate::crop_fill::Coverage::crop(r, original.width, original.height))?;
|
||||
}
|
||||
doc.selection = None;
|
||||
target.apply(doc);
|
||||
Ok(())
|
||||
@@ -579,8 +589,9 @@ fn crop_angle(p: &Value) -> Result<Option<f64>> {
|
||||
/// Arbitrary: the canvas grows to fit, the Background fills what the rotation uncovers with the
|
||||
/// background colour, other layers keep transparency there and every pixel of theirs), which makes
|
||||
/// the frame upright, then cropped to it (`deleteCroppedPixels` as for an unrotated crop), all in
|
||||
/// one undo step.
|
||||
fn rotated_crop(s: &mut Session, r: Rect, angle: f64, delete: bool, crop_target: &CropTarget) -> Result<Value> {
|
||||
/// one undo step. With `content_aware`, the corners the rotation uncovers (and any area past the
|
||||
/// rotated image) are then filled from their surroundings, in the same step (#3823).
|
||||
fn rotated_crop(s: &mut Session, r: Rect, angle: f64, delete: bool, content_aware: bool, crop_target: &CropTarget) -> Result<Value> {
|
||||
const CMD: &str = "image.crop";
|
||||
let size = s.active().ok_or(EngineError::NoDocument)?.doc.size;
|
||||
let (w, h) = (f64::from(r.width()), f64::from(r.height()));
|
||||
@@ -601,13 +612,26 @@ fn rotated_crop(s: &mut Session, r: Rect, angle: f64, delete: bool, crop_target:
|
||||
let end = |o: i32, len: u32| i32::try_from(len).ok().and_then(|l| o.checked_add(l));
|
||||
let (Some(x1), Some(y1)) = (end(x, r.width()), end(y, r.height())) else { return Err(far()) };
|
||||
let target = Rect::new(x, y, x1, y1);
|
||||
// The cropped canvas back to the original document: undo the crop's offset, then the rotation
|
||||
// (x = c·(x' − nx) + s·(y' − ny) + cx, y = −s·(x' − nx) + c·(y' − ny) + cy).
|
||||
let (ox, oy) = (f64::from(target.x0) - f64::from(new.width) / 2.0, f64::from(target.y0) - f64::from(new.height) / 2.0);
|
||||
let (ocx, ocy) = (f64::from(size.width) / 2.0, f64::from(size.height) / 2.0);
|
||||
let coverage = crate::crop_fill::Coverage {
|
||||
map: [cs, sn, -sn, cs, cs * ox + sn * oy + ocx, -sn * ox + cs * oy + ocy],
|
||||
width: f64::from(size.width),
|
||||
height: f64::from(size.height),
|
||||
rotated: true,
|
||||
};
|
||||
crate::analysis_cmds::compound(s, "Crop", |s| {
|
||||
crate::mode_cmds::rotate_arbitrary(s, &json!({"angle": angle, "direction": "ccw"}))?;
|
||||
// Color Sampler points follow the crop's origin shift too (the rotation moved them above).
|
||||
s.set_sampler_map(photocraft_geom::Affine::translate(-f64::from(target.x0), -f64::from(target.y0)));
|
||||
let extension = s.tools.background;
|
||||
s.edit("Crop", |doc, _| {
|
||||
s.edit("Crop", |doc, active| {
|
||||
crop_doc(doc, CMD, target, delete, extension)?;
|
||||
if content_aware {
|
||||
crate::crop_fill::fill_uncovered(doc, *active, &coverage)?;
|
||||
}
|
||||
doc.selection = None;
|
||||
crop_target.apply(doc);
|
||||
Ok(())
|
||||
@@ -747,7 +771,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"image.crop",
|
||||
"Crop",
|
||||
["Image"],
|
||||
r##"{"x":px,"y":px,"width":px,"height":px,"angle":-3600..3600=0,"deleteCroppedPixels":bool=true,"targetWidth":px,"targetHeight":px,"resolution":ppi,"resample":"bicubic|bilinear|nearest|lanczos|preserveDetails"="bicubic"} → {x,y,width,height,documentWidth,documentHeight,resolution} (targetWidth/targetHeight, both or neither: the cropped area is resampled to exactly that size, as Image Size, in the same undo step; resolution: the document's new ppi)"##,
|
||||
r##"{"x":px,"y":px,"width":px,"height":px,"angle":-3600..3600=0,"deleteCroppedPixels":bool=true,"contentAware":bool=false,"targetWidth":px,"targetHeight":px,"resolution":ppi,"resample":"bicubic|bilinear|nearest|lanczos|preserveDetails"="bicubic"} → {x,y,width,height,documentWidth,documentHeight,resolution} (targetWidth/targetHeight, both or neither: the cropped area is resampled to exactly that size, as Image Size, in the same undo step; resolution: the document's new ppi; contentAware: the Crop tool's Fill › Content-Aware Fill, the area a rotated or extending crop uncovers filled from its surroundings on the Background, else the active pixel layer)"##,
|
||||
has_doc,
|
||||
crop
|
||||
),
|
||||
|
||||
@@ -30,6 +30,7 @@ pub mod color_cmds;
|
||||
pub mod color_to_alpha_cmds;
|
||||
pub mod commands;
|
||||
pub mod comps_cmds;
|
||||
mod crop_fill;
|
||||
pub mod cutout_cmds;
|
||||
pub mod display_color;
|
||||
pub mod distort_cmds;
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
//! Crop tool › Fill: Content-Aware Fill (#3823). A rotated crop, or one that extends past the
|
||||
//! canvas, fills the area the image doesn't cover from its surroundings instead of the background
|
||||
//! colour, in the crop's one undo step; an unrotated crop inside the canvas is unaffected.
|
||||
|
||||
use photocraft_engine::Session;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
const MODES: [(&str, u32); 5] = [("rgb", 8), ("rgb", 16), ("rgb", 32), ("grayscale", 16), ("cmyk", 8)];
|
||||
|
||||
/// A 64 × 48 document whose Background is a light green, with black as the background colour (what
|
||||
/// a plain crop puts in the uncovered area).
|
||||
fn doc(mode: &str, depth: u32, background: &str) -> Session {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 64, "height": 48, "mode": mode, "depth": depth, "background": background})).unwrap();
|
||||
s.execute("tools.setColors", json!({"foreground": "#c0e0a0", "background": "#000000"})).unwrap();
|
||||
s.execute("edit.fill", json!({"contents": "foreground"})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn layer(s: &Session) -> &photocraft_raster::Surface {
|
||||
let d = &s.active().unwrap().doc;
|
||||
d.layers[0].surface().unwrap()
|
||||
}
|
||||
|
||||
fn size(s: &Session) -> (u32, u32) {
|
||||
let d = &s.active().unwrap().doc;
|
||||
(d.size.width, d.size.height)
|
||||
}
|
||||
|
||||
fn close(a: [f32; 4], b: [f32; 4], tol: f32) -> bool {
|
||||
a.iter().zip(b).all(|(x, y)| (x - y).abs() < tol)
|
||||
}
|
||||
|
||||
/// Every pixel of the cropped layer is the image's colour: no black (or transparent) area left.
|
||||
fn assert_all_image(s: &Session, want: [f32; 4], what: &str) {
|
||||
let (w, h) = size(s);
|
||||
let surf = layer(s);
|
||||
for y in 0..h as i32 {
|
||||
for x in 0..w as i32 {
|
||||
let px = surf.rgba(x, y);
|
||||
assert!(close(px, want, 0.06), "{what}: ({x}, {y}) is {px:?}, want {want:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn crop(s: &mut Session, mut p: Value, content_aware: bool) -> photocraft_engine::Result<Value> {
|
||||
p["contentAware"] = json!(content_aware);
|
||||
s.execute("image.crop", p)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rotated_crop_fills_the_corners_in_every_mode() {
|
||||
for (mode, depth) in MODES {
|
||||
let frame = json!({"x": 0, "y": 0, "width": 64, "height": 48, "angle": 10});
|
||||
let mut plain = doc(mode, depth, "white");
|
||||
let want = layer(&plain).rgba(32, 24);
|
||||
crop(&mut plain, frame.clone(), false).unwrap();
|
||||
assert!(close(layer(&plain).rgba(0, 0), [0.0, 0.0, 0.0, 1.0], 0.06), "{mode} {depth}: a plain rotated crop leaves black corners");
|
||||
|
||||
let mut s = doc(mode, depth, "white");
|
||||
let past = s.active().unwrap().history.past_len();
|
||||
crop(&mut s, frame, true).unwrap();
|
||||
assert_eq!(size(&s), (64, 48), "{mode} {depth}");
|
||||
assert_eq!(s.active().unwrap().history.past_len(), past + 1, "{mode} {depth}: one undo step");
|
||||
assert_all_image(&s, want, &format!("{mode} {depth} rotated"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extending_crop_fills_the_added_canvas_in_every_mode() {
|
||||
for (mode, depth) in MODES {
|
||||
let frame = json!({"x": -8, "y": -6, "width": 80, "height": 60});
|
||||
let mut plain = doc(mode, depth, "white");
|
||||
let want = layer(&plain).rgba(32, 24);
|
||||
crop(&mut plain, frame.clone(), false).unwrap();
|
||||
assert!(close(layer(&plain).rgba(0, 0), [0.0, 0.0, 0.0, 1.0], 0.06), "{mode} {depth}");
|
||||
|
||||
let mut s = doc(mode, depth, "white");
|
||||
crop(&mut s, frame, true).unwrap();
|
||||
assert_eq!(size(&s), (80, 60), "{mode} {depth}");
|
||||
assert_all_image(&s, want, &format!("{mode} {depth} extended"));
|
||||
}
|
||||
}
|
||||
|
||||
/// Without a Background the active pixel layer is filled: no transparent strip is left.
|
||||
#[test]
|
||||
fn extending_crop_fills_the_active_layer_without_a_background() {
|
||||
let mut s = doc("rgb", 16, "transparent");
|
||||
let want = layer(&s).rgba(32, 24);
|
||||
crop(&mut s, json!({"x": 0, "y": -10, "width": 64, "height": 58}), true).unwrap();
|
||||
assert_all_image(&s, want, "transparent document");
|
||||
}
|
||||
|
||||
/// Undo takes the crop and the fill back in one step.
|
||||
#[test]
|
||||
fn undo_restores_crop_and_fill_in_one_step() {
|
||||
let mut s = doc("rgb", 8, "white");
|
||||
s.execute("tools.setColors", json!({"foreground": "#ff0000", "background": "#000000"})).unwrap();
|
||||
s.execute("select.rect", json!({"x": 0, "y": 0, "width": 8, "height": 8})).unwrap();
|
||||
s.execute("edit.fill", json!({"contents": "foreground"})).unwrap();
|
||||
s.execute("select.deselect", json!({})).unwrap();
|
||||
let before = s.active().unwrap().doc.clone();
|
||||
crop(&mut s, json!({"x": -4, "y": -4, "width": 40, "height": 40, "angle": -6}), true).unwrap();
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(size(&s), (64, 48));
|
||||
let after = layer(&s);
|
||||
let orig = before.layers[0].surface().unwrap();
|
||||
for y in 0..48 {
|
||||
for x in 0..64 {
|
||||
assert_eq!(after.rgba(x, y), orig.rgba(x, y), "({x}, {y})");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `contentAware: false` (or absent), and a crop that uncovers nothing, are the plain crop.
|
||||
#[test]
|
||||
fn nothing_uncovered_or_off_is_the_plain_crop() {
|
||||
for (frame, ca) in [
|
||||
(json!({"x": 5, "y": 4, "width": 30, "height": 20}), true),
|
||||
(json!({"x": -5, "y": -4, "width": 80, "height": 60}), false),
|
||||
(json!({"x": 20, "y": 15, "width": 20, "height": 14, "angle": 4}), true),
|
||||
] {
|
||||
let mut a = doc("rgb", 16, "white");
|
||||
let mut b = doc("rgb", 16, "white");
|
||||
a.execute("image.crop", frame.clone()).unwrap();
|
||||
crop(&mut b, frame.clone(), ca).unwrap();
|
||||
assert_eq!(size(&a), size(&b), "{frame}");
|
||||
let (w, h) = size(&a);
|
||||
for y in 0..h as i32 {
|
||||
for x in 0..w as i32 {
|
||||
assert_eq!(layer(&a).rgba(x, y), layer(&b).rgba(x, y), "{frame} ({x}, {y})");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Bad params and an unfillable crop fail without changing the document.
|
||||
#[test]
|
||||
fn bad_params_and_unfillable_crops_fail_cleanly() {
|
||||
let frame = json!({"x": -4, "y": 0, "width": 70, "height": 48});
|
||||
for v in [json!("yes"), json!(1), json!([true]), json!({"on": true})] {
|
||||
let mut s = doc("rgb", 8, "white");
|
||||
let past = s.active().unwrap().history.past_len();
|
||||
let mut p = frame.clone();
|
||||
p["contentAware"] = v.clone();
|
||||
assert!(s.execute("image.crop", p).is_err(), "{v}");
|
||||
assert_eq!(size(&s), (64, 48), "{v}");
|
||||
assert_eq!(s.active().unwrap().history.past_len(), past, "{v}");
|
||||
}
|
||||
// Entirely off the canvas: nothing of the image to sample from (both paths).
|
||||
for p in [json!({"x": 500, "y": 500, "width": 20, "height": 20}), json!({"x": 500, "y": 500, "width": 20, "height": 20, "angle": 30})] {
|
||||
let mut s = doc("rgb", 8, "white");
|
||||
let past = s.active().unwrap().history.past_len();
|
||||
let err = crop(&mut s, p.clone(), true).unwrap_err();
|
||||
assert!(err.to_string().contains("sample"), "{p}: {err}");
|
||||
assert_eq!(size(&s), (64, 48), "{p}");
|
||||
assert_eq!(s.active().unwrap().history.past_len(), past, "{p}");
|
||||
}
|
||||
}
|
||||
@@ -5798,6 +5798,11 @@ pub fn commit_crop(app: &mut PhotocraftApp) {
|
||||
let (w, h) = ((r[2] - r[0]).round().max(1.0), (r[3] - r[1]).round().max(1.0));
|
||||
let delete = app.ui.tool_options.crop_delete;
|
||||
let mut p = json!({"x": x, "y": y, "width": w, "height": h, "deleteCroppedPixels": delete});
|
||||
// Fill › Content-Aware Fill (#3823): what a turned or extending frame uncovers is filled from
|
||||
// its surroundings instead of the background colour.
|
||||
if app.ui.tool_options.crop_content_aware {
|
||||
p["contentAware"] = json!(true);
|
||||
}
|
||||
// A turned frame (#1792): the document turns with it, then is cropped to it. (The untouched
|
||||
// default frame above is never turned: turning it makes it a real frame.)
|
||||
let angle = crate::crop_ui::angle(app);
|
||||
|
||||
@@ -131,7 +131,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; 36] = [
|
||||
pub const UI_SET_FIELDS: [&str; 37] = [
|
||||
"tool",
|
||||
"panels",
|
||||
"dock",
|
||||
@@ -164,6 +164,7 @@ pub const UI_SET_FIELDS: [&str; 36] = [
|
||||
"cropOverlayShow",
|
||||
"cropOverlayOrientation",
|
||||
"cropShield",
|
||||
"cropFill",
|
||||
"shapeStroke",
|
||||
"camTransformOnDrop",
|
||||
"tileGrid",
|
||||
@@ -534,6 +535,15 @@ fn dispatch(app: &mut PhotocraftApp, ctx: &egui::Context, req: &ControlRequest)
|
||||
Some(_) => return Err("cropOverlayOrientation must be 0, 1, 2 or 3".into()),
|
||||
None => None,
|
||||
};
|
||||
// The Crop tool's Fill (#3823): "background" (Background (Default)) or "contentAware".
|
||||
let crop_content_aware = match p.get("cropFill") {
|
||||
Some(v) => Some(match v.as_str() {
|
||||
Some("background") => false,
|
||||
Some("contentAware") => true,
|
||||
_ => return Err("cropFill must be \"background\" or \"contentAware\"".into()),
|
||||
}),
|
||||
None => None,
|
||||
};
|
||||
// The Crop tool's gear menu: Show Cropped Area and the shield (#1919).
|
||||
let crop_shield = merged_object(&app.ui.tool_options.crop_shield, p.get("cropShield"), "cropShield")?;
|
||||
if crop_shield.as_ref().is_some_and(|s| !(0.0..=100.0).contains(&s.opacity)) {
|
||||
@@ -680,6 +690,9 @@ fn dispatch(app: &mut PhotocraftApp, ctx: &egui::Context, req: &ControlRequest)
|
||||
if let Some(k) = crop_overlay {
|
||||
app.ui.tool_options.crop_overlay = k;
|
||||
}
|
||||
if let Some(on) = crop_content_aware {
|
||||
app.ui.tool_options.crop_content_aware = on;
|
||||
}
|
||||
if let Some(v) = crop_overlay_show {
|
||||
app.ui.tool_options.crop_overlay_show = v;
|
||||
}
|
||||
@@ -2027,6 +2040,22 @@ mod tests {
|
||||
assert_eq!(Tool::ColorSampler.key(), 'I');
|
||||
}
|
||||
|
||||
/// #3823: the Crop tool's Fill over the control channel, reported by `ui.inspect`.
|
||||
#[test]
|
||||
fn ui_set_drives_the_crop_fill() {
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
|
||||
let ctx = egui::Context::default();
|
||||
assert_eq!(call(&mut app, &ctx, "ui.set", json!({"tool": "crop", "cropFill": "contentAware"}))["ok"], true);
|
||||
assert!(app.ui.tool_options.crop_content_aware);
|
||||
assert_eq!(call(&mut app, &ctx, "ui.inspect", json!({}))["result"]["toolOptions"]["crop_content_aware"], true);
|
||||
for bad in [json!("generativeExpand"), json!(true), json!(1)] {
|
||||
assert_eq!(call(&mut app, &ctx, "ui.set", json!({"cropFill": bad}))["ok"], false, "{bad}");
|
||||
assert!(app.ui.tool_options.crop_content_aware, "{bad}: unchanged");
|
||||
}
|
||||
assert_eq!(call(&mut app, &ctx, "ui.set", json!({"cropFill": "background"}))["ok"], true);
|
||||
assert!(!app.ui.tool_options.crop_content_aware);
|
||||
}
|
||||
|
||||
/// #1919: the Crop tool's overlay menu over the control channel, reported by `ui.inspect`.
|
||||
#[test]
|
||||
fn ui_set_drives_the_crop_overlay_options() {
|
||||
|
||||
@@ -1276,6 +1276,29 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Fill › Content-Aware Fill (#3823): ↵ on an extending frame fills the added canvas from the
|
||||
/// image instead of the background colour; Background (Default) keeps the colour.
|
||||
#[test]
|
||||
fn commit_passes_the_fill_choice() {
|
||||
for content_aware in [false, true] {
|
||||
let mut app = app(SampleType::U16);
|
||||
app.run("tools.setColors", json!({"foreground": "#808080", "background": "#000000"})).unwrap();
|
||||
app.run("edit.fill", json!({"contents": "foreground"})).unwrap();
|
||||
app.ui.tool_options.crop_content_aware = content_aware;
|
||||
app.ui.crop_rect = Some([-10.0, -10.0, 210.0, 110.0]);
|
||||
app.crop.default_frame = false;
|
||||
crate::canvas::commit_crop(&mut app);
|
||||
let st = app.session.active().unwrap();
|
||||
assert_eq!((st.doc.size.width, st.doc.size.height), (220, 120));
|
||||
let corner = st.doc.layers[0].surface().unwrap().rgba(0, 0);
|
||||
if content_aware {
|
||||
assert!((corner[0] - 0.5).abs() < 0.05, "filled from the grey image: {corner:?}");
|
||||
} else {
|
||||
assert!(corner[0] < 0.05, "the background colour: {corner:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn moving_the_frame_snaps_its_edges() {
|
||||
let mut app = app(SampleType::U8);
|
||||
|
||||
@@ -3746,6 +3746,7 @@ navigator Zoom Zvětšení
|
||||
Action playback stopped Přehrávání akce zastaveno
|
||||
Step {step} is waiting for its dialog Krok {step} čeká na svůj dialog
|
||||
@plural {n} action|{n} actions {n} akce|{n} akce|{n} akcí
|
||||
Background (Default) Pozadí (výchozí)
|
||||
# Batch / Image Processor
|
||||
Include All Subfolders Zahrnout všechny podsložky
|
||||
Auto Add/Delete Automaticky přidat/odstranit
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -3136,6 +3136,8 @@ curves Smooth Glätten
|
||||
Action playback stopped Aktionswiedergabe angehalten
|
||||
Step {step} is waiting for its dialog Schritt {step} wartet auf seinen Dialog
|
||||
@plural {n} action|{n} actions {n} Aktion|{n} Aktionen
|
||||
Background (Default) Hintergrund (Standard)
|
||||
Content-Aware Fill Inhaltsbasiert füllen
|
||||
# Batch / Image Processor
|
||||
Include All Subfolders Alle Unterordner einschließen
|
||||
Auto Add/Delete Autom. hinzuf./löschen
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -3836,6 +3836,7 @@ curves Smooth Εξομάλυνση
|
||||
Action playback stopped Η αναπαραγωγή της ενέργειας σταμάτησε
|
||||
Step {step} is waiting for its dialog Το βήμα {step} περιμένει το παράθυρο διαλόγου του
|
||||
@plural {n} action|{n} actions {n} ενέργεια|{n} ενέργειες
|
||||
Background (Default) Φόντο (προεπιλογή)
|
||||
# Batch / Image Processor
|
||||
Include All Subfolders Συμπερίληψη όλων των υποφακέλων
|
||||
Auto Add/Delete Αυτόματη προσθήκη/διαγραφή
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 1029 and column 18.
|
@@ -2823,6 +2823,8 @@ curves Smooth Suavizar
|
||||
Action playback stopped Reproducción de la acción detenida
|
||||
Step {step} is waiting for its dialog El paso {step} está esperando su cuadro de diálogo
|
||||
@plural {n} action|{n} actions {n} acción|{n} acciones
|
||||
Background (Default) Fondo (predeterminado)
|
||||
Content-Aware Fill Relleno según el contenido
|
||||
# Batch / Image Processor
|
||||
Include All Subfolders Incluir todas las subcarpetas
|
||||
Auto Add/Delete Añadir/eliminar automáticamente
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 4 and column 47.
|
@@ -3140,6 +3140,8 @@ curves Smooth Lisser
|
||||
Action playback stopped Exécution de l’action arrêtée
|
||||
Step {step} is waiting for its dialog L’étape {step} attend sa boîte de dialogue
|
||||
@plural {n} action|{n} actions {n} action|{n} actions
|
||||
Background (Default) Arrière-plan (par défaut)
|
||||
Content-Aware Fill Remplissage d'après le contenu
|
||||
# Batch / Image Processor
|
||||
Include All Subfolders Inclure tous les sous-dossiers
|
||||
Auto Add/Delete Ajout/suppr. auto
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -3125,6 +3125,7 @@ cameraRaw Yellow Sárga
|
||||
Concise Tömör
|
||||
Constrain proportions Arányok megtartása
|
||||
Constraints Megkötések
|
||||
Background (Default) Háttér (alapértelmezett)
|
||||
Content-Aware Fill Tartalomérzékeny kitöltés
|
||||
Content-Aware Move Tool Tartalomérzékeny mozgatás
|
||||
Contract Szűkítés
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -3127,6 +3127,8 @@ curves Smooth Haluskan
|
||||
Action playback stopped Pemutaran tindakan dihentikan
|
||||
Step {step} is waiting for its dialog Langkah {step} menunggu dialognya
|
||||
@plural {n} action|{n} actions {n} tindakan
|
||||
Background (Default) Background (Default)
|
||||
Content-Aware Fill Isi Content-Aware
|
||||
# Batch / Image Processor
|
||||
Include All Subfolders Sertakan semua subfolder
|
||||
Auto Add/Delete Tambah/Hapus Otomatis
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 4 and column 47.
|
@@ -4102,6 +4102,7 @@ navigator Zoom Zoom
|
||||
Action playback stopped Esecuzione dell’azione interrotta
|
||||
Step {step} is waiting for its dialog Il passaggio {step} attende la sua finestra di dialogo
|
||||
@plural {n} action|{n} actions {n} azione|{n} azioni
|
||||
Background (Default) Sfondo (predefinito)
|
||||
# Batch / Image Processor
|
||||
Include All Subfolders Includi tutte le sottocartelle
|
||||
Auto Add/Delete Aggiungi/elimina automaticamente
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2649 and column 53.
|
@@ -3746,6 +3746,7 @@ curves Smooth スムーズ
|
||||
Action playback stopped アクションの再生を停止しました
|
||||
Step {step} is waiting for its dialog ステップ {step} はダイアログを待っています
|
||||
@plural {n} action|{n} actions アクション {n} 個
|
||||
Background (Default) 背景(既定)
|
||||
# Batch / Image Processor
|
||||
Include All Subfolders すべてのサブフォルダーを含める
|
||||
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -3313,6 +3313,7 @@ curves Smooth 매끄럽게
|
||||
Action playback stopped 액션 재생이 중지되었습니다
|
||||
Step {step} is waiting for its dialog {step}단계가 대화 상자를 기다리고 있습니다
|
||||
@plural {n} action|{n} actions 액션 {n}개
|
||||
Background (Default) 배경(기본값)
|
||||
# Batch / Image Processor
|
||||
Include All Subfolders 모든 하위 폴더 포함
|
||||
Auto Add/Delete 자동 추가/삭제
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 3161 and column 53.
|
@@ -3898,6 +3898,7 @@ curves Smooth Vloeiend maken
|
||||
Action playback stopped Afspelen van de handeling gestopt
|
||||
Step {step} is waiting for its dialog Stap {step} wacht op zijn dialoogvenster
|
||||
@plural {n} action|{n} actions {n} handeling|{n} handelingen
|
||||
Background (Default) Achtergrond (standaard)
|
||||
# Batch / Image Processor
|
||||
Include All Subfolders Alle submappen opnemen
|
||||
Auto Add/Delete Automatisch toevoegen/verwijderen
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 8 and column 34.
|
@@ -3736,6 +3736,7 @@ curves Smooth Wygładź
|
||||
Action playback stopped Odtwarzanie operacji zatrzymane
|
||||
Step {step} is waiting for its dialog Krok {step} czeka na swoje okno dialogowe
|
||||
@plural {n} action|{n} actions {n} operacja|{n} operacje|{n} operacji
|
||||
Background (Default) Tło (domyślne)
|
||||
# Batch / Image Processor
|
||||
Include All Subfolders Uwzględnij wszystkie podfoldery
|
||||
Auto Add/Delete Automatyczne dodawanie/usuwanie
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -3928,6 +3928,7 @@ layerLabel No Color Sem cor
|
||||
Action playback stopped Reprodução da ação interrompida
|
||||
Step {step} is waiting for its dialog A etapa {step} está aguardando sua caixa de diálogo
|
||||
@plural {n} action|{n} actions {n} ação|{n} ações
|
||||
Background (Default) Plano de fundo (padrão)
|
||||
# Batch / Image Processor
|
||||
Include All Subfolders Incluir todas as subpastas
|
||||
Auto Add/Delete Adicionar/excluir automaticamente
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -3085,6 +3085,8 @@ curves Smooth Сгладить
|
||||
Action playback stopped Воспроизведение операции остановлено
|
||||
Step {step} is waiting for its dialog Шаг {step} ожидает своего диалога
|
||||
@plural {n} action|{n} actions {n} операция|{n} операции|{n} операций
|
||||
Background (Default) Фон (по умолчанию)
|
||||
Content-Aware Fill Заливка с учётом содержимого
|
||||
# Batch / Image Processor
|
||||
Include All Subfolders Включить все вложенные папки
|
||||
Auto Add/Delete Автодобавление/удаление
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -3731,6 +3731,7 @@ layerLabel No Color Bez boje
|
||||
Color Settings Podešavanja boja
|
||||
Color Table Tabela boja
|
||||
Conditional Mode Change Uslovna promena režima
|
||||
Background (Default) Pozadina (podrazumevano)
|
||||
Content-Aware Fill Popuna prema sadržaju
|
||||
Convert to Linked Pretvori u povezani
|
||||
Convert to Profile Pretvori u profil
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -3289,6 +3289,7 @@ cameraRaw Color Renk
|
||||
Compose Oluştur
|
||||
Condensed Yoğunlaştırılmış
|
||||
Conditional Mode Change Koşullu Mod Değişikliği
|
||||
Background (Default) Arka plan (varsayılan)
|
||||
Content-Aware Fill İçeriğe Uygun Dolgu
|
||||
Convert Frames > Video Layers Çerçeveleri Dönüştür > Video Katmanları
|
||||
Convert to Linked Bağlantılıya Dönüştür
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 8 and column 34.
|
@@ -3837,6 +3837,7 @@ curves Smooth Згладити
|
||||
Action playback stopped Відтворення операції зупинено
|
||||
Step {step} is waiting for its dialog Крок {step} очікує на свій діалог
|
||||
@plural {n} action|{n} actions {n} операція|{n} операції|{n} операцій
|
||||
Background (Default) Фон (типовий)
|
||||
# Batch / Image Processor
|
||||
Include All Subfolders Включити всі вкладені папки
|
||||
Auto Add/Delete Автододавання/видалення
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 1386 and column 18.
|
@@ -3620,6 +3620,7 @@ curves Smooth Làm mượt
|
||||
Action playback stopped Đã dừng phát hành động
|
||||
Step {step} is waiting for its dialog Bước {step} đang chờ hộp thoại của nó
|
||||
@plural {n} action|{n} actions {n} hành động
|
||||
Background (Default) Nền (mặc định)
|
||||
Presentation Trình bày
|
||||
# Batch / Image Processor
|
||||
Include All Subfolders Bao gồm tất cả thư mục con
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -2748,6 +2748,8 @@ curves Smooth 平滑
|
||||
Action playback stopped 动作播放已停止
|
||||
Step {step} is waiting for its dialog 步骤 {step} 正在等待其对话框
|
||||
@plural {n} action|{n} actions {n} 个动作
|
||||
Background (Default) 背景(默认)
|
||||
Content-Aware Fill 内容识别填充
|
||||
# Batch / Image Processor
|
||||
Include All Subfolders 包含所有子文件夹
|
||||
Auto Add/Delete 自动添加/删除
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2594 and column 53.
|
@@ -2733,6 +2733,8 @@ curves Smooth 平滑
|
||||
Action playback stopped 動作播放已停止
|
||||
Step {step} is waiting for its dialog 步驟 {step} 正在等待其對話框
|
||||
@plural {n} action|{n} actions {n} 個動作
|
||||
Background (Default) 背景(預設)
|
||||
Content-Aware Fill 內容感知填滿
|
||||
# Batch / Image Processor
|
||||
Include All Subfolders 包含所有子資料夾
|
||||
Auto Add/Delete 自動增加/刪除
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -1295,6 +1295,16 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
|
||||
crate::crop_overlay::options_button(o, ui);
|
||||
let pick_shield_color = crate::crop_shield::options_button(&mut o.crop_shield, ui);
|
||||
widgets::checkbox(ui, &mut o.crop_delete, tl!("Delete Cropped Pixels"));
|
||||
// Fill: what a turned or extending crop puts where the image doesn't reach
|
||||
// (#3823). Generative Expand is not offered.
|
||||
ui.label(tl!("Fill:"));
|
||||
widgets::dropdown(
|
||||
ui,
|
||||
"crop-fill",
|
||||
&mut o.crop_content_aware,
|
||||
&[(false, tl!("Background (Default)")), (true, tl!("Content-Aware Fill"))],
|
||||
150.0,
|
||||
);
|
||||
if crate::crop_ui::preset_ratio(app) != before_ratio {
|
||||
crate::crop_ui::apply_ratio(app);
|
||||
}
|
||||
|
||||
@@ -624,6 +624,10 @@ pub struct ToolOptions {
|
||||
pub crop_custom_ratio_height: f32,
|
||||
#[serde(default = "yes")]
|
||||
pub crop_delete: bool,
|
||||
/// Crop options bar › Fill: false = Background (Default), true = Content-Aware Fill (the area a
|
||||
/// rotated or extending crop uncovers is filled from its surroundings, #3823).
|
||||
#[serde(default)]
|
||||
pub crop_content_aware: bool,
|
||||
/// Crop W x H x Resolution (`crop_ratio` = "whr", #2443): width and height as typed lengths
|
||||
/// with their unit ("4 in", "1024 px"; empty = unset), the resolution ("" = the document's)
|
||||
/// and its unit ("px/in" or "px/cm"). See `crop_size`.
|
||||
@@ -823,6 +827,7 @@ impl Default for ToolOptions {
|
||||
crop_custom_ratio_width: 0.0,
|
||||
crop_custom_ratio_height: 0.0,
|
||||
crop_delete: true,
|
||||
crop_content_aware: false,
|
||||
crop_width: String::new(),
|
||||
crop_height: String::new(),
|
||||
crop_resolution: String::new(),
|
||||
|
||||
@@ -130,6 +130,7 @@ pub fn reset_options(o: &mut ToolOptions, tool: Tool) {
|
||||
Tool::Crop => reset!(
|
||||
crop_ratio,
|
||||
crop_delete,
|
||||
crop_content_aware,
|
||||
crop_width,
|
||||
crop_height,
|
||||
crop_resolution,
|
||||
|
||||
@@ -57,6 +57,7 @@ The transport is `apps/photocraft/src/control_server.rs`, and the handlers are i
|
||||
- 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.
|
||||
- `ui.set` also accepts `cropFill` (`background`, Photoshop's Background (Default), or `contentAware`), the Crop tool's Fill dropdown (#3823): with `contentAware`, committing a turned or extending frame passes `contentAware: true` to `image.crop`, which fills the area the image doesn't cover from its surroundings. `ui.inspect` reports it as `toolOptions.crop_content_aware`.
|
||||
Both gradient modes use the current preset, including its opacity stops. Select `Foreground to Transparent` through `gradient.presets.select`, or pass custom `stops` and `transparency: [[0,100],[1,0]]` (location 0..1, opacity 0..100). Use `applyToLayer: false` to edit only the tool preset. Classic gradient drags dispatch `paint.gradient` without overriding them.
|
||||
- `ui.set` also accepts the Crop tool's gear menu (#1919) as `cropShield`, an object patching any of `show_cropped_area` (Show Cropped Area; off hides everything outside the box behind the canvas colour, H in the app), `enabled` (Enable Crop Shield), `color` (`matchCanvas`, the pasteboard colour, or `custom`), `custom_color` (`[r, g, b]`, 0..255), `opacity` (0..100, default 75), `auto_adjust` (Auto Adjust Opacity: half the opacity while the box is dragged), `classic_mode` (Use Classic Mode, default off: the box turns and moves over the image; off is Photoshop's default mode, where the box stays upright and the view turns and pans the image behind it while the Crop tool shows a frame, P in the app; in both modes picking the tool turns a rotated view upright about its centre, Classic Mode keeping the camera upright with the frame drawn turned, and the view stays upright after a commit or cancel; Classic Mode greys out Show Cropped Area, which then acts as on, and Auto Center Preview) and `auto_center_preview` (default on: in default mode a box being resized keeps its centre where it is on screen, also after a pan, a drawn one moves to the middle of the canvas, and X re-centres it; a move never re-centres the box). `ui.inspect` reports it as `toolOptions.crop_shield`. The pending frame (`crop_rect`, `crop_angle`: its turn on the image) and the `image.crop` a commit runs are the same in both modes.
|
||||
- `ui.dialog.open {kind, fields?}` (kinds `newDocument`, `about`, `layerStyle {effect?}`, `colorPicker {target: foreground|background}`, `command {command}`, `newCropPreset` / `deleteCropPreset`: the Crop tool's New / Delete Crop Preset… dialogs, fields `name` and `selected: [names]`; crop presets are `crop` tool presets, so `tool.presets.new/list/edit {"tool":"crop"}` drive them too) / `ui.dialog.set {dialog, field, value}` / `ui.dialog.confirm {dialog, wait?}` / `ui.dialog.cancel {dialog}`. Like `ui.menu.invoke`, `ui.dialog.confirm` waits for a background job its command starts (a filter dialog's OK) and replies with the result; `"wait": false` replies `{job, pending: true}` at once
|
||||
|
||||
+4
-2
@@ -1,6 +1,6 @@
|
||||
# UI parity: tools, handles, modifiers and feel
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (on-canvas corner radius widgets, TOOL-214-12; Curvature Pen edits the active path; Clip Thumbnails to Layer Bounds; Pen ⇧ 45°; last selected tool restored at launch; Layers panel filter-type dropdown with Kind, Name and Mode, #5048; Free Transform box and marching ants follow the selection, #5297; History: the trash deletes the selected snapshot, rows drag onto the trash, #5324; smart filter reordering by drag; smart object perspective row corrected to done, #426; Free Transform H/V skew fields; toolbar tool shortcuts can be reassigned; ⇧-dragged guides land on ruler ticks; layer and selection keys; tool keys; spring-loaded tool letters; type formatting keys; Pen Auto Add/Delete, #4144; smart filter rows; stale missing-tool rows corrected: Color Replacement, Red Eye and the Add/Delete Anchor Point pair are live toolbar tools, Frame landed; no percentage changed) · **Target:** Adobe Photoshop 2026
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (Crop tool Content-Aware Fill; on-canvas corner radius widgets, TOOL-214-12; Curvature Pen edits the active path; Clip Thumbnails to Layer Bounds; Pen ⇧ 45°; last selected tool restored at launch; Layers panel filter-type dropdown with Kind, Name and Mode, #5048; Free Transform box and marching ants follow the selection, #5297; History: the trash deletes the selected snapshot, rows drag onto the trash, #5324; smart filter reordering by drag; smart object perspective row corrected to done, #426; Free Transform H/V skew fields; toolbar tool shortcuts can be reassigned; ⇧-dragged guides land on ruler ticks; layer and selection keys; tool keys; spring-loaded tool letters; type formatting keys; Pen Auto Add/Delete, #4144; smart filter rows; stale missing-tool rows corrected: Color Replacement, Red Eye and the Add/Delete Anchor Point pair are live toolbar tools, Frame landed; no percentage changed) · **Target:** Adobe Photoshop 2026
|
||||
|
||||
How PhotoCraft's direct manipulation compares with Photoshop 2026 (27.11.0): the toolbox, the
|
||||
handles and modifier keys of each gesture, nudging, snapping, numeric precision, navigation,
|
||||
@@ -142,7 +142,8 @@ Triangle and Curvature Pen (minus legacy Rounded Rectangle and Targeted Adjustme
|
||||
| Default mode: the image turns and moves behind an upright box; Auto Center Preview; Use Classic Mode | done | TOOL-214-23 |
|
||||
| W × H × Resolution with units, built-in size presets, Front Image | done | TOOL-214-9 (`crop_size.rs`) |
|
||||
| Saved custom crop presets | missing | TOOL-214-9 |
|
||||
| Content-Aware crop fill, Delete Cropped Pixels off | partial | |
|
||||
| Fill: Content-Aware Fill: a turned or extending crop fills the uncovered corners and strips from the image (each piece with its own sampling window), on the Background or else the active pixel layer, in the crop's one undo step; Generative Expand not offered | done | `tests/crop_content_aware.rs`, `crop_ui.rs::commit_passes_the_fill_choice` (#3823, 2026-10-12) |
|
||||
| Delete Cropped Pixels off | partial | |
|
||||
|
||||
## Snapping, guides, rulers, precision
|
||||
|
||||
@@ -240,6 +241,7 @@ Paragraph; the rest open as in-app floating windows.
|
||||
| 2026-10-12 | minor | Toolbar tool shortcuts can be reassigned, removed and reset in Keyboard Shortcuts (#4086); capture, restart, group cycling, conflict and text-focus regressions |
|
||||
| 2026-10-12 | minor | Smart filter rows can be selected, and dragged (⌥: copied) onto another smart object or onto Delete (#4271) |
|
||||
| 2026-10-12 | minor | Free Transform options bar: H and V skew fields; a sheared box reads as skew instead of a taller H |
|
||||
| 2026-10-12 | minor | Crop tool Fill › Content-Aware Fill done (#3823); Delete Cropped Pixels off split into its own row, still partial |
|
||||
| 2026-10-12 | minor | Layers panel: Clip Thumbnails to Layer Bounds frames each layer's pixels instead of the canvas (#4281) |
|
||||
| 2026-10-13 | minor | Smart filter rows can be dragged up or down their stack to reorder the filters |
|
||||
| 2026-10-13 | minor | On-canvas corner radius widgets on live rectangles (TOOL-214-12) |
|
||||
|
||||
Reference in new issue
Block a user