Tools: Perspective Crop (#1043) (#2864)

New Perspective Crop tool in the Crop flyout (C): drag a box or click four
corners, drag corners independently onto a skewed object, perspective grid
(Show Grid), Enter commits, Esc cancels. W/H/Resolution come from the Crop
tool's fields (Front Image, Clear), or the quad's mean side lengths.

Engine: image.perspectiveCrop {corners, width?, height?, resolution?,
interpolation?} maps the quad onto an upright canvas through the
quad-to-rectangle homography (Heckbert's square-to-quad map, inverted):
every raster layer, mask, alpha channel and the Quick Mask, one undo step.
The Background stays opaque over the background colour; type, shape, fill
and smart object layers are rasterized first; vector geometry follows the
nearest affine map. Non-convex, self-crossing, counter-clockwise or
degenerate quads are refused with an error.

algo: warp_surface_clipped bounds the per-tile warp by the new canvas even
when a layer reaches past the map's horizon; convex_quad, quad_size,
quad_to_rect.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Valentin Klein
2026-10-11 13:28:11 +02:00
committed by GitHub
parent 2e0c426692
commit 017d1aea1c
39 changed files with 874 additions and 35 deletions
+2 -1
View File
@@ -20,10 +20,11 @@ next to them.
| `crates/ui-egui/src/i18n/uk.tsv` | Ukrainian UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
| `assets/fonts/Inter-Regular.ttf`, `Inter-Medium.ttf`, `Inter-SemiBold.ttf` | Inter 4.001 (UI font) | The Inter Project Authors (Rasmus Andersson) | <https://github.com/rsms/inter> | SIL OFL 1.1, [`assets/fonts/OFL-Inter.txt`](assets/fonts/OFL-Inter.txt) |
| `assets/fonts/JetBrainsMono-Regular.ttf` | JetBrains Mono 2.305 (numeric font) | The JetBrains Mono Project Authors | <https://github.com/JetBrains/JetBrainsMono> | SIL OFL 1.1, [`assets/fonts/OFL-JetBrainsMono.txt`](assets/fonts/OFL-JetBrainsMono.txt) |
| `assets/icons/*.svg` except `slice-knife.svg`, `artboard.svg`, `type-mask.svg`, `type-mask-vertical.svg`, `clip-below.svg`, `clip-release.svg`, `layer-mask.svg`, `adjustment-layer.svg`, `brush-replace.svg`, `eraser-background.svg`, `eraser-magic.svg`, `lasso-magnetic.svg`, `direct-select.svg`, `pen-add-anchor.svg`, `pen-delete-anchor.svg`, `pen-freeform.svg`, `pen-curvature.svg`, `bandage-sparkle.svg`, `painting-symmetry.svg`, `symmetry-*.svg` and `marquee-{row,column}.svg` (112 files; `star-filled.svg` is `star.svg` with a solid fill) | Lucide icons | Lucide Icons and Contributors | <https://github.com/lucide-icons/lucide> (`icons/<name>.svg`; `align-*` are now named `text-align-*` upstream) | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
| `assets/icons/*.svg` except `slice-knife.svg`, `artboard.svg`, `perspective-crop.svg`, `type-mask.svg`, `type-mask-vertical.svg`, `clip-below.svg`, `clip-release.svg`, `layer-mask.svg`, `adjustment-layer.svg`, `brush-replace.svg`, `eraser-background.svg`, `eraser-magic.svg`, `lasso-magnetic.svg`, `direct-select.svg`, `pen-add-anchor.svg`, `pen-delete-anchor.svg`, `pen-freeform.svg`, `pen-curvature.svg`, `bandage-sparkle.svg`, `painting-symmetry.svg`, `symmetry-*.svg` and `marquee-{row,column}.svg` (112 files; `star-filled.svg` is `star.svg` with a solid fill) | Lucide icons | Lucide Icons and Contributors | <https://github.com/lucide-icons/lucide> (`icons/<name>.svg`; `align-*` are now named `text-align-*` upstream) | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
| `assets/icons/{check,chevron-down,chevron-right,chevron-up,chevrons-left,chevrons-right,circle,clock,compass,info,link,link-2,lock,minus,monitor,moon,move,navigation,plus,search,square,trash,triangle,type,x,zoom-in}.svg` | Lucide icons derived from Feather (subset of the row above) | Cole Bemis (Feather), Lucide Contributors | <https://github.com/feathericons/feather> via Lucide | MIT (Feather) and ISC (Lucide), [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
| `assets/icons/slice-knife.svg` | Slice tool glyph | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
| `assets/icons/artboard.svg` | Artboard tool glyph: a board with a tick at each end of its edges | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
| `assets/icons/perspective-crop.svg` | Perspective Crop tool glyph: a crop frame in perspective with its grid and crop-mark ticks | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
| `assets/icons/type-mask.svg`, `assets/icons/type-mask-vertical.svg` | Horizontal and Vertical Type Mask tool glyphs: a T drawn as marching ants (the vertical one beside a downward arrow), on Lucide's 24 px grid and stroke | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
| `assets/icons/marquee-row.svg`, `assets/icons/marquee-column.svg` | Single Row and Single Column Marquee tool glyphs: a dashed row (column) between two edge bars, drawn on Lucide's 24 px grid and stroke | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
| `assets/icons/clip-below.svg`, `assets/icons/clip-release.svg` | Clipping-mask cursors (⌥ over the line between two layers): a hooked arrow onto the layer below; the same with a small "no" badge to release | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
+2 -2
View File
@@ -178,8 +178,8 @@ Every screenshot here is the real app at work on public-domain art, rendered off
<table>
<tr>
<td width="33%" valign="top">
<h4>🧰 63 tools</h4>
Move · Artboard · Rectangular and Elliptical Marquee · Single Row Marquee · Single Column Marquee · Lasso · Polygonal Lasso · Magnetic Lasso · Object Selection · Quick Selection · Magic Wand · Crop · Slice · Slice Select · Eyedropper · Ruler · Note · Count · Remove · Spot Healing Brush · Healing Brush · Patch · Content-Aware Move · Red Eye · Brush · Pencil · Mixer Brush · Color Replacement · Clone Stamp · Pattern Stamp · History Brush · Eraser · Background Eraser · Magic Eraser · Gradient · Paint Bucket · Blur · Sharpen · Smudge · Dodge · Burn · Sponge · Pen · Freeform Pen · Curvature Pen · Add Anchor Point · Delete Anchor Point · Horizontal and Vertical Type · Horizontal and Vertical Type Mask · Path Selection · Direct Selection · Rectangle · Ellipse · Triangle · Polygon · Line · Custom Shape · Hand · Rotate View · Zoom
<h4>🧰 64 tools</h4>
Move · Artboard · Rectangular and Elliptical Marquee · Single Row Marquee · Single Column Marquee · Lasso · Polygonal Lasso · Magnetic Lasso · Object Selection · Quick Selection · Magic Wand · Crop · Perspective Crop · Slice · Slice Select · Eyedropper · Ruler · Note · Count · Remove · Spot Healing Brush · Healing Brush · Patch · Content-Aware Move · Red Eye · Brush · Pencil · Mixer Brush · Color Replacement · Clone Stamp · Pattern Stamp · History Brush · Eraser · Background Eraser · Magic Eraser · Gradient · Paint Bucket · Blur · Sharpen · Smudge · Dodge · Burn · Sponge · Pen · Freeform Pen · Curvature Pen · Add Anchor Point · Delete Anchor Point · Horizontal and Vertical Type · Horizontal and Vertical Type Mask · Path Selection · Direct Selection · Rectangle · Ellipse · Triangle · Polygon · Line · Custom Shape · Hand · Rotate View · Zoom
</td>
<td width="33%" valign="top">
<h4>🖌️ A real brush engine</h4>
+19
View File
@@ -0,0 +1,19 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<!-- PhotoCraft's own Perspective Crop tool glyph (not from an icon set): a crop frame seen in
perspective, with its grid and two crop-mark ticks. -->
<path d="M8 6h8l4 12H4z" />
<path d="M12 6v12" />
<path d="M6 12h12" />
<path d="M8 2v2" />
<path d="M20 22v-2" />
</svg>

After

Width:  |  Height:  |  Size: 493 B

+105 -17
View File
@@ -127,6 +127,56 @@ fn catmull_rom(t: f64) -> [f64; 4] {
/// The result has the same format as `src`, with alpha added if `src` had none; pixels outside
/// the warped quad are transparent.
pub fn warp_surface(src: &Surface, src_rect: Rect, h: &Homography, interp: Interp) -> Result<Surface, photocraft_raster::AllocationError> {
warp(src, src_rect, h, interp, None)
}
/// [`warp_surface`] keeping only the destination pixels inside `clip`. The work is bounded by
/// `clip` even when part of the source lies at or beyond the map's horizon (a Perspective Crop of a
/// layer that reaches far past the crop quad), which would make the full warped extent unbounded.
pub fn warp_surface_clipped(src: &Surface, src_rect: Rect, h: &Homography, interp: Interp, clip: Rect) -> Result<Surface, photocraft_raster::AllocationError> {
warp(src, src_rect, h, interp, Some(clip))
}
/// Is `q` (TL, TR, BR, BL) a usable Perspective Crop quad: finite, strictly convex with every
/// corner angle above ~0.06°, wound clockwise on screen (y down) and at least 1 px² in area?
pub fn convex_quad(q: &[[f64; 2]; 4]) -> bool {
if q.iter().flatten().any(|v| !v.is_finite()) {
return false;
}
let mut area2 = 0.0;
for i in 0..4 {
let (a, b, c) = (q[i], q[(i + 1) % 4], q[(i + 2) % 4]);
let (e1, e2) = ([b[0] - a[0], b[1] - a[1]], [c[0] - b[0], c[1] - b[1]]);
let cross = e1[0] * e2[1] - e1[1] * e2[0];
// sin(turn) = cross / (|e1| |e2|): positive (clockwise on screen) and not a straight angle.
if cross.is_nan() || cross <= 1e-3 * e1[0].hypot(e1[1]) * e2[0].hypot(e2[1]) {
return false;
}
area2 += a[0] * b[1] - b[0] * a[1];
}
area2.is_finite() && area2 >= 2.0
}
/// The output size a Perspective Crop of `q` (TL, TR, BR, BL) takes when none is given: the mean
/// length of the top and bottom edges by the mean length of the left and right edges.
pub fn quad_size(q: &[[f64; 2]; 4]) -> (f64, f64) {
let len = |a: [f64; 2], b: [f64; 2]| (b[0] - a[0]).hypot(b[1] - a[1]);
let [tl, tr, br, bl] = *q;
((len(tl, tr) + len(bl, br)) / 2.0, (len(tl, bl) + len(tr, br)) / 2.0)
}
/// The homography taking the quad `q` (TL, TR, BR, BL) onto the rectangle (0, 0)–(`w`, `h`), corner
/// to corner: the inverse of the unit-square-to-quad map (Heckbert, "Fundamentals of Texture
/// Mapping and Image Warping", 1989, §2.2.3) scaled to the rectangle. `None` when `q` isn't a
/// [`convex_quad`] or the size isn't positive.
pub fn quad_to_rect(q: &[[f64; 2]; 4], w: f64, h: f64) -> Option<Homography> {
if !convex_quad(q) || !(w.is_finite() && h.is_finite() && w > 0.0 && h > 0.0) {
return None;
}
Homography::rect_to_quad([0.0, 0.0, w, h], *q)?.inverse()
}
fn warp(src: &Surface, src_rect: Rect, h: &Homography, interp: Interp, clip: Option<Rect>) -> Result<Surface, photocraft_raster::AllocationError> {
let mut fmt = src.format();
let converted;
let mut src = if fmt.alpha {
@@ -137,13 +187,17 @@ pub fn warp_surface(src: &Surface, src_rect: Rect, h: &Homography, interp: Inter
&converted
};
let mut out = Surface::new(fmt);
if src_rect.is_empty() {
if src_rect.is_empty() || clip.is_some_and(|c| c.is_empty()) {
return Ok(out);
}
let mut h = *h;
let mut src_rect = src_rect;
// Pre-reduce for large downscales (bicubic alone aliases below ~50%).
let (cx, cy) = ((src_rect.x0 + src_rect.x1) as f64 / 2.0, (src_rect.y0 + src_rect.y1) as f64 / 2.0);
// Pre-reduce for large downscales (bicubic alone aliases below ~50%). With a clip, the scale
// that matters is the one at the source point landing in the clip's centre.
let (cx, cy) = match clip.and_then(|c| Some((c, h.inverse()?))) {
Some((c, inv)) => inv.apply((c.x0 as f64 + c.x1 as f64) / 2.0, (c.y0 as f64 + c.y1 as f64) / 2.0),
None => ((src_rect.x0 + src_rect.x1) as f64 / 2.0, (src_rect.y0 + src_rect.y1) as f64 / 2.0),
};
let scale = h.local_scale(cx, cy);
let reduced;
if interp != Interp::Nearest && scale < 0.5 && scale > 0.0 {
@@ -157,15 +211,32 @@ pub fn warp_surface(src: &Surface, src_rect: Rect, h: &Homography, interp: Inter
// Destination bounds: the warped corners (plus a pixel for filter support).
let corners = [(src_rect.x0, src_rect.y0), (src_rect.x1, src_rect.y0), (src_rect.x1, src_rect.y1), (src_rect.x0, src_rect.y1)]
.map(|(x, y)| h.apply(x as f64, y as f64));
if corners.iter().any(|c| !c.0.is_finite() || !c.1.is_finite()) {
// A sampled pixel's inverse w is 1 / (forward w at its source point). While the forward w keeps one sign over the source
// (plus the margin the sampler reads), every sampled pixel lies at least `w_lo` from the inverse horizon, so clipping a tile
// there keeps its footprint exact and bounded even when a steep Distort brings that horizon into the tile (#2271).
let (sx0, sy0, sx1, sy1) = (src_rect.x0 as f64 - 1.0, src_rect.y0 as f64 - 1.0, src_rect.x1 as f64 + 1.0, src_rect.y1 as f64 + 1.0);
let fwd_w = [(sx0, sy0), (sx1, sy0), (sx1, sy1), (sx0, sy1)].map(|(x, y)| h.0[6] * x + h.0[7] * y + h.0[8]);
let side = [1.0, -1.0].into_iter().find(|&s| fwd_w.iter().all(|&c| s * c > 0.0));
let w_lo = 1.0 / fwd_w.iter().fold(0.0f64, |m, c| m.max(c.abs()));
let finite = corners.iter().all(|c| c.0.is_finite() && c.1.is_finite());
let lim = 1 << 20;
let bounds = || {
let bx0 = corners.iter().map(|c| c.0).fold(f64::MAX, f64::min).floor().max(-(lim as f64)) as i32 - 1;
let by0 = corners.iter().map(|c| c.1).fold(f64::MAX, f64::min).floor().max(-(lim as f64)) as i32 - 1;
let bx1 = corners.iter().map(|c| c.0).fold(f64::MIN, f64::max).ceil().min(lim as f64) as i32 + 1;
let by1 = corners.iter().map(|c| c.1).fold(f64::MIN, f64::max).ceil().min(lim as f64) as i32 + 1;
Rect::new(bx0, by0, bx1, by1)
};
let dst = match clip {
// The warped corners only bound the result while the whole source is in front of the horizon.
Some(c) if finite && side.is_some() => bounds().intersect(&c),
Some(c) => c,
None if finite => bounds(),
None => return Ok(out),
};
if dst.is_empty() {
return Ok(out);
}
let lim = 1 << 20;
let bx0 = corners.iter().map(|c| c.0).fold(f64::MAX, f64::min).floor().max(-(lim as f64)) as i32 - 1;
let by0 = corners.iter().map(|c| c.1).fold(f64::MAX, f64::min).floor().max(-(lim as f64)) as i32 - 1;
let bx1 = corners.iter().map(|c| c.0).fold(f64::MIN, f64::max).ceil().min(lim as f64) as i32 + 1;
let by1 = corners.iter().map(|c| c.1).fold(f64::MIN, f64::max).ceil().min(lim as f64) as i32 + 1;
let dst = Rect::new(bx0, by0, bx1, by1);
let n = fmt.channels();
let a = n - 1;
let tile_count = dst.tiles().count();
@@ -174,13 +245,6 @@ pub fn warp_surface(src: &Surface, src_rect: Rect, h: &Homography, interp: Inter
tiles.try_reserve_exact(tile_count).map_err(|_| photocraft_raster::AllocationError::NotEnoughMemory { bytes: tile_bytes })?;
tiles.extend(dst.tiles().map(|tc| tc.rect().intersect(&dst)).filter(|r| !r.is_empty()));
let src_ref: &Surface = src;
// A sampled pixel's inverse w is 1 / (forward w at its source point). While the forward w keeps one sign over the source
// (plus the margin the sampler reads), every sampled pixel lies at least `w_lo` from the inverse horizon, so clipping a tile
// there keeps its footprint exact and bounded even when a steep Distort brings that horizon into the tile (#2271).
let (sx0, sy0, sx1, sy1) = (src_rect.x0 as f64 - 1.0, src_rect.y0 as f64 - 1.0, src_rect.x1 as f64 + 1.0, src_rect.y1 as f64 + 1.0);
let fwd_w = [(sx0, sy0), (sx1, sy0), (sx1, sy1), (sx0, sy1)].map(|(x, y)| h.0[6] * x + h.0[7] * y + h.0[8]);
let side = [1.0, -1.0].into_iter().find(|&s| fwd_w.iter().all(|&c| s * c > 0.0));
let w_lo = 1.0 / fwd_w.iter().fold(0.0f64, |m, c| m.max(c.abs()));
let work = |t: &Rect| -> Result<Option<(Rect, Vec<f32>)>, photocraft_raster::AllocationError> {
// Source footprint of this tile (inverse-mapped corners of its sampled part), padded for the filter.
let tc = [(t.x0, t.y0), (t.x1, t.y0), (t.x1, t.y1), (t.x0, t.y1)].map(|(x, y)| (x as f64, y as f64));
@@ -327,6 +391,30 @@ pub fn warp_surface(src: &Surface, src_rect: Rect, h: &Homography, interp: Inter
mod tests {
use super::*;
/// Perspective Crop's map lands each quad corner exactly on its rectangle corner; degenerate,
/// self-crossing, counter-clockwise and non-finite quads have none.
#[test]
fn quad_to_rect_maps_corners_and_refuses_bad_quads() {
let q = [[30.0, 12.0], [140.0, 25.0], [128.0, 110.0], [14.0, 95.0]];
let h = quad_to_rect(&q, 100.0, 80.0).unwrap();
for (c, want) in q.iter().zip([[0.0, 0.0], [100.0, 0.0], [100.0, 80.0], [0.0, 80.0]]) {
let (x, y) = h.apply(c[0], c[1]);
assert!((x - want[0]).abs() < 1e-9 && (y - want[1]).abs() < 1e-9, "{c:?} -> ({x}, {y})");
}
let (w, ht) = quad_size(&q);
assert!((w - 112.874).abs() < 1e-3 && (ht - 85.185).abs() < 1e-3, "{w} x {ht}");
for bad in [
[[0.0, 0.0], [50.0, 0.0], [100.0, 0.0], [0.0, 50.0]],
[[0.0, 0.0], [50.0, 50.0], [50.0, 0.0], [0.0, 50.0]],
[[0.0, 0.0], [0.0, 50.0], [50.0, 50.0], [50.0, 0.0]],
[[0.0, 0.0], [f64::NAN, 0.0], [50.0, 50.0], [0.0, 50.0]],
[[0.0, 0.0]; 4],
] {
assert!(quad_to_rect(&bad, 10.0, 10.0).is_none(), "{bad:?}");
}
assert!(quad_to_rect(&q, 0.0, 10.0).is_none());
}
fn rgba() -> Surface {
Surface::new(PixelFormat::RGBA8)
}
+1
View File
@@ -1153,6 +1153,7 @@ fn build() -> Vec<CommandSpec> {
v.extend(crate::retouch_cmds::specs());
v.extend(crate::redeye_cmds::specs());
v.extend(crate::image_cmds::specs());
v.extend(crate::perspective_crop_cmds::specs());
v.extend(crate::selection_cmds::specs());
v.extend(crate::magnetic_cmds::specs());
v.extend(crate::select_extra_cmds::specs());
+1 -1
View File
@@ -704,7 +704,7 @@ pub(crate) fn rasterize(s: &mut Session, p: &Value, only: Option<&'static str>)
Ok(json!({"layer": active.unwrap_or(first), "layers": done}))
}
fn rasterize_all(s: &mut Session) -> Result<Value> {
pub(crate) fn rasterize_all(s: &mut Session) -> Result<Value> {
let ids: Vec<LayerId> = s.active().map(|d| d.doc.walk().into_iter().map(|(_, _, l)| l.id).collect()).unwrap_or_default();
let key = step_key(s, "rasterizeAll");
let mut n = 0;
+1 -1
View File
@@ -181,7 +181,7 @@ fn image_size(s: &mut Session, p: &Value) -> Result<Value> {
/// Refuses to resample `doc` to `nw` x `nh` px when the raster alone would pass the budget, before
/// allocating any of it (#1544). `hint` ends the message with what the caller can do instead.
fn check_resample_budget(cmd: &str, doc: &Document, nw: u32, nh: u32, hint: &str) -> Result<()> {
pub(crate) fn check_resample_budget(cmd: &str, doc: &Document, nw: u32, nh: u32, hint: &str) -> Result<()> {
let bpp = doc.pixel_format().bytes_per_pixel().max(1) as u64;
let bytes = u64::from(nw).saturating_mul(u64::from(nh)).saturating_mul(bpp);
if bytes <= MAX_RESAMPLE_BYTES {
+1
View File
@@ -71,6 +71,7 @@ pub mod notes_cmds;
pub mod paint_cmds;
mod path_edit_cmds;
pub mod pattern_cmds;
pub mod perspective_crop_cmds;
pub mod photo_cmds;
pub mod pick_cmds;
mod pixels;
+321
View File
@@ -0,0 +1,321 @@
//! The Perspective Crop tool's commit: `image.perspectiveCrop`.
//!
//! Four corners (TL, TR, BR, BL in document px) mark a quad that is a rectangle seen in perspective
//! (a photographed document, a facade). The whole document is mapped through the homography taking
//! that quad onto an upright W x H rectangle, which becomes the new canvas, in one undo step:
//! every raster layer, layer mask, alpha channel and the Quick Mask is resampled through the map
//! (per destination tile, in parallel, bounded by the new canvas), the Background stays a
//! Background over the background colour where the quad reaches past the image, and pixels outside
//! the quad are deleted (Photoshop's Perspective Crop has no Delete Cropped Pixels option).
//!
//! Type, shape, fill and smart object layers are rasterized first: a projective map has no
//! editable equivalent for them. Vector masks, paths, notes, slices and guides follow the affine
//! map closest to the homography (exact when the quad is a parallelogram).
use photocraft_algo::transform::{Homography, Interp, convex_quad, quad_size, quad_to_rect, warp_surface_clipped};
use photocraft_color::PixelFormat;
use photocraft_doc::{Document, Layer, LayerContent, Size};
use photocraft_geom::{Affine, Rect};
use photocraft_raster::Surface;
use serde_json::{Value, json};
use crate::commands::CommandSpec;
use crate::{EngineError, Result, Session};
const CMD: &str = "image.perspectiveCrop";
/// The largest output side (the `file.new`, Canvas Size and Crop limit).
const MAX_SIDE: u32 = 300_000;
/// Corner coordinates beyond this are refused (far past any canvas).
const MAX_COORD: f64 = 1e7;
/// The highest resolution (ppi) the crop may set, as for Image Size.
const MAX_RESOLUTION: f64 = 10_000.0;
fn bad(msg: impl Into<String>) -> EngineError {
EngineError::BadParams { cmd: CMD.into(), msg: msg.into() }
}
fn has_doc(s: &Session) -> std::result::Result<(), String> {
s.active().map(|_| ()).ok_or_else(|| "no document open".into())
}
/// `corners`: four `[x, y]` points, TL, TR, BR, BL, forming a convex quad clockwise on screen.
fn corners(p: &Value) -> Result<[[f64; 2]; 4]> {
let shape = || bad("`corners` must be four [x, y] points: top-left, top-right, bottom-right, bottom-left");
let list = p.get("corners").and_then(Value::as_array).filter(|a| a.len() == 4).ok_or_else(shape)?;
let mut q = [[0.0; 2]; 4];
for (slot, c) in q.iter_mut().zip(list) {
let xy = c.as_array().filter(|a| a.len() == 2).ok_or_else(shape)?;
let (Some(x), Some(y)) = (xy.first().and_then(Value::as_f64), xy.get(1).and_then(Value::as_f64)) else { return Err(shape()) };
if !(x.is_finite() && y.is_finite()) || x.abs() > MAX_COORD || y.abs() > MAX_COORD {
return Err(bad(format!("corner [{x}, {y}] must be finite and within ±{MAX_COORD} px")));
}
*slot = [x, y];
}
if !convex_quad(&q) {
return Err(bad(
"the corners must form a convex quadrilateral at least 1 px in area, in order top-left, top-right, bottom-right, bottom-left (clockwise on screen)",
));
}
Ok(q)
}
/// Output size: `width`/`height` in whole px; one alone keeps the quad's proportions; neither
/// takes the quad's mean side lengths ([`quad_size`]).
fn out_size(p: &Value, q: &[[f64; 2]; 4]) -> Result<(u32, u32)> {
let side = |key: &str| -> Result<Option<f64>> {
match p.get(key) {
None | Some(Value::Null) => Ok(None),
Some(v) => v
.as_f64()
.filter(|n| n.is_finite() && (1.0..=f64::from(MAX_SIDE)).contains(&n.round()))
.map(|n| Some(n.round()))
.ok_or_else(|| bad(format!("`{key}` = {v} must be a number of pixels from 1 to {MAX_SIDE}"))),
}
};
let (qw, qh) = quad_size(q);
let (w, h) = match (side("width")?, side("height")?) {
(Some(w), Some(h)) => (w, h),
(Some(w), None) => (w, w * qh / qw),
(None, Some(h)) => (h * qw / qh, h),
(None, None) => (qw, qh),
};
let px = |v: f64| -> Result<u32> {
let v = v.round().max(1.0);
if !v.is_finite() || v > f64::from(MAX_SIDE) {
return Err(bad(format!("the output would be {v} px on a side; the limit is {MAX_SIDE}")));
}
Ok(v as u32)
};
Ok((px(w)?, px(h)?))
}
fn resolution(p: &Value) -> Result<Option<f32>> {
match p.get("resolution") {
None | Some(Value::Null) => Ok(None),
Some(v) => {
let r = v.as_f64().filter(|r| r.is_finite() && (1.0..=MAX_RESOLUTION).contains(r));
Ok(Some(r.ok_or_else(|| bad(format!("`resolution` = {v} must be a number of pixels per inch from 1 to {MAX_RESOLUTION}")))? as f32))
}
}
}
fn interpolation(p: &Value) -> Result<Interp> {
match p.get("interpolation") {
None | Some(Value::Null) => Ok(Interp::Bicubic),
Some(Value::String(s)) if ["nearest", "nearestNeighbor", "bilinear", "bicubic"].contains(&s.as_str()) => Ok(Interp::parse(s)),
Some(v) => Err(bad(format!("`interpolation` = {v} must be \"bicubic\", \"bilinear\" or \"nearest\""))),
}
}
/// The affine map closest to the crop for geometry that can't take a homography (vector masks,
/// paths, notes, guides): the quad's centroid goes to the output's centre and its mean horizontal
/// and vertical edges to the output's sides. Exact when the quad is a parallelogram.
fn nearest_affine(q: &[[f64; 2]; 4], w: f64, h: f64) -> Affine {
let [tl, tr, br, bl] = *q;
let u = [(tr[0] - tl[0] + br[0] - bl[0]) / 2.0, (tr[1] - tl[1] + br[1] - bl[1]) / 2.0];
let v = [(bl[0] - tl[0] + br[0] - tr[0]) / 2.0, (bl[1] - tl[1] + br[1] - tr[1]) / 2.0];
let det = u[0] * v[1] - v[0] * u[1];
if !det.is_finite() || det.abs() < 1e-12 {
return Affine::default();
}
let (m00, m01, m10, m11) = (w * v[1] / det, -w * v[0] / det, -h * u[1] / det, h * u[0] / det);
let c = [(tl[0] + tr[0] + br[0] + bl[0]) / 4.0, (tl[1] + tr[1] + br[1] + bl[1]) / 4.0];
Affine { m: [m00, m10, m01, m11, w / 2.0 - (m00 * c[0] + m01 * c[1]), h / 2.0 - (m10 * c[0] + m11 * c[1])] }
}
/// A one-channel surface (mask, alpha channel, Quick Mask) through `h`, kept inside `clip`; what
/// the map uncovers takes the surface's default (a mask's reveal or hide).
fn warp_gray(s: &Surface, h: &Homography, interp: Interp, clip: Rect) -> Result<Surface> {
const WHAT: &str = "a perspective crop of a mask or channel";
let default = s.default_pixel().first().copied().unwrap_or(0.0);
let fmt = s.format();
let mut out = Surface::with_default(fmt, &[default]);
let src = s.content_bounds();
if src.is_empty() {
return Ok(out);
}
// Content with alpha 1 over a transparent outside, so the edges blend into the default.
let v = crate::allocation::read_region(s, src, WHAT)?;
let mut lifted = crate::allocation::filled(v.len().checked_mul(2).ok_or_else(|| bad("the mask is too large"))?, 1.0, WHAT)?;
for (g, px) in v.iter().zip(lifted.as_chunks_mut::<2>().0) {
px[0] = *g;
}
let mut tmp = Surface::new(PixelFormat::new(fmt.mode, fmt.sample, true));
tmp.try_write_region(src, &lifted)?;
let warped = warp_surface_clipped(&tmp, src, h, interp, clip)?;
let b = warped.content_bounds().intersect(&clip);
if !b.is_empty() {
let px = crate::allocation::read_region(&warped, b, WHAT)?;
let flat: Vec<f32> = px.as_chunks::<2>().0.iter().map(|[g, a]| g * a + default * (1.0 - a)).collect();
out.try_write_region(b, &flat)?;
}
out.prune();
Ok(out)
}
/// Maps every layer's pixels and masks through `h` into `clip`.
fn warp_layers(layers: &mut [Layer], h: &Homography, interp: Interp, clip: Rect, bg: &[f32]) -> Result<()> {
for l in layers {
crate::allocation::checkpoint("a perspective crop")?;
let background = crate::extra_cmds::is_background(l);
match &mut l.content {
LayerContent::Group(g) => warp_layers(&mut g.children, h, interp, clip, bg)?,
LayerContent::Raster(surf) => {
let warped = warp_surface_clipped(surf, surf.content_bounds(), h, interp, clip)?;
*surf = if background {
// The Background stays opaque: the mapped pixels over the background colour.
let mut base = Surface::new(surf.format());
base.fill_rect(clip, bg);
crate::transform_cmds::composite_over(&mut base, &warped);
base.prune();
base
} else {
warped
};
}
// Adjustment layers have no pixels; the rest was rasterized before.
_ => {}
}
if let Some(m) = l.mask.as_mut() {
m.surface = warp_gray(&m.surface, h, interp, clip)?;
}
l.fill_cache = None;
}
Ok(())
}
fn apply(doc: &mut Document, q: &[[f64; 2]; 4], h: &Homography, size: (u32, u32), interp: Interp, bg: [f32; 4]) -> Result<()> {
let (w, ht) = size;
let clip = Rect::from_xywh(0, 0, w, ht);
let bg = photocraft_raster::from_rgba(&doc.pixel_format(), bg);
warp_layers(&mut doc.layers, h, interp, clip, &bg)?;
for ch in doc.channels.iter_mut().chain(doc.quick_mask.as_mut()) {
ch.surface = warp_gray(&ch.surface, h, interp, clip)?;
}
doc.selection = None;
crate::canvas_geom::transform_geometry(doc, &nearest_affine(q, f64::from(w), f64::from(ht)));
doc.size = Size::new(w, ht);
crate::canvas_geom::refresh(doc, crate::canvas_geom::Refresh::Shapes);
Ok(())
}
fn perspective_crop(s: &mut Session, p: &Value) -> Result<Value> {
let q = corners(p)?;
let (w, h) = out_size(p, &q)?;
let res = resolution(p)?;
let interp = interpolation(p)?;
let d = s.active().ok_or(EngineError::NoDocument)?;
crate::image_cmds::check_resample_budget(CMD, &d.doc, w, h, "")?;
let rasterize = d
.doc
.walk()
.iter()
.any(|(_, _, l)| matches!(l.content, LayerContent::Text(_) | LayerContent::Shape(_) | LayerContent::Fill(_) | LayerContent::Smart(_)));
let map = quad_to_rect(&q, f64::from(w), f64::from(h)).ok_or_else(|| bad("the corners don't define a perspective"))?;
let bg = s.tools.background;
crate::analysis_cmds::compound(s, "Perspective Crop", |s| {
if rasterize {
crate::extra_cmds::rasterize_all(s)?;
}
s.edit("Perspective Crop", |doc, _| {
apply(doc, &q, &map, (w, h), interp, bg)?;
if let Some(r) = res {
doc.resolution_dpi = r;
}
Ok(())
})
})?;
let dpi = s.active().map(|d| d.doc.resolution_dpi);
Ok(json!({"width": w, "height": h, "resolution": dpi, "corners": q}))
}
pub fn specs() -> Vec<CommandSpec> {
vec![CommandSpec {
id: CMD,
label: "Perspective Crop",
menu: &[],
shortcut: None,
params: r##"{"corners":[[x,y],[x,y],[x,y],[x,y]] (top-left, top-right, bottom-right, bottom-left; a convex quad, document px),"width":px,"height":px,"resolution":ppi,"interpolation":"bicubic|bilinear|nearest"="bicubic"} → {width,height,resolution,corners} (the quad is mapped onto an upright width x height canvas, all layers, one undo step; no width/height: the quad's mean side lengths, one alone keeps its proportions; type, shape, fill and smart object layers are rasterized)"##,
enabled: has_doc,
run: perspective_crop,
journal: true,
}]
}
#[cfg(test)]
mod tests {
use super::*;
use photocraft_color::{ColorMode, SampleType};
use photocraft_doc::Color;
/// A document whose Background shows a W x H ramp (red = u / W, green = v / H) seen in
/// perspective through the quad `q`: pixel (x, y) holds the ramp at the rectified point.
fn skewed_ramp(depth: SampleType, q: &[[f64; 2]; 4], w: f64, h: f64) -> Session {
let to_doc = Homography::rect_to_quad([0.0, 0.0, w, h], *q).unwrap();
let to_out = to_doc.inverse().unwrap();
let mut doc = Document::with_background("ramp", Size::new(160, 120), ColorMode::Rgb, depth, Color::WHITE);
let fmt = doc.pixel_format();
let mut px = Vec::new();
for y in 0..120 {
for x in 0..160 {
let (u, v) = to_out.apply(x as f64 + 0.5, y as f64 + 0.5);
px.extend(photocraft_raster::from_rgba(&fmt, [(u / w).clamp(0.0, 1.0) as f32, (v / h).clamp(0.0, 1.0) as f32, 0.5, 1.0]));
}
}
doc.layers[0].surface_mut().unwrap().write_region(Rect::from_xywh(0, 0, 160, 120), &px);
let mut s = Session::new();
s.add_document(doc, None);
s
}
/// The ramp comes out straight: every output pixel holds the ramp value of its own position
/// (within 1 px), at 8 and 16 bits, and one undo restores the document.
#[test]
fn perspective_crop_rectifies_a_skewed_ramp() {
let q = [[30.0, 12.0], [140.0, 25.0], [128.0, 110.0], [14.0, 95.0]];
let (w, h) = (100.0, 80.0);
for depth in [SampleType::U8, SampleType::U16] {
let mut s = skewed_ramp(depth, &q, w, h);
let r = s.execute(CMD, json!({"corners": q, "width": w, "height": h})).unwrap();
assert_eq!((r["width"].as_u64(), r["height"].as_u64()), (Some(100), Some(80)));
let doc = &s.active().unwrap().doc;
assert_eq!((doc.size.width, doc.size.height), (100, 80));
let surf = doc.layers[0].surface().unwrap();
let (fmt, n) = (surf.format(), surf.channels());
let out = surf.read_region(Rect::from_xywh(0, 0, 100, 80));
for y in (2..78).step_by(5) {
for x in (2..98).step_by(5) {
let i = (y * 100 + x) * n;
let p = photocraft_raster::to_rgba(&fmt, &out[i..i + n]);
let (u, v) = (p[0] as f64 * w, p[1] as f64 * h);
assert!((u - (x as f64 + 0.5)).abs() < 1.0 && (v - (y as f64 + 0.5)).abs() < 1.0, "{depth:?} ({x}, {y}) shows ({u:.2}, {v:.2})");
}
}
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(s.active().unwrap().doc.size, Size::new(160, 120), "one undo step");
}
}
/// Bad quads and params are refused with an error, and the document is untouched.
#[test]
fn perspective_crop_rejects_bad_quads() {
let mut s = skewed_ramp(SampleType::U8, &[[0.0, 0.0], [160.0, 0.0], [160.0, 120.0], [0.0, 120.0]], 160.0, 120.0);
for p in [
json!({}),
json!({"corners": [[0, 0], [10, 0], [10, 10]]}),
json!({"corners": [[0, 0], [50, 0], [100, 0], [0, 50]]}),
json!({"corners": [[0, 0], [50, 50], [50, 0], [0, 50]]}),
json!({"corners": [[0, 0], [0, 50], [50, 50], [50, 0]]}),
json!({"corners": [[0, 0], [0.5, 0], [0.5, 0.5], [0, 0.5]]}),
json!({"corners": [[0, 0], [1e300, 0], [50, 50], [0, 50]]}),
json!({"corners": [[0, 0], ["a", 0], [50, 50], [0, 50]]}),
json!({"corners": [[0, 0], [50, 0], [50, 50], [0, 50]], "width": 0}),
json!({"corners": [[0, 0], [50, 0], [50, 50], [0, 50]], "width": 1e9, "height": 10}),
json!({"corners": [[0, 0], [50, 0], [50, 50], [0, 50]], "resolution": -3}),
json!({"corners": [[0, 0], [50, 0], [50, 50], [0, 50]], "interpolation": 3}),
] {
assert!(s.execute(CMD, p.clone()).is_err(), "{p}");
}
assert_eq!(s.active().unwrap().doc.size, Size::new(160, 120));
}
}
+4
View File
@@ -3140,6 +3140,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::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);
crate::vector_ui::draw_overlay(app, &painter, &xf, &doc);
crate::symmetry_ui::draw(app, &painter, &xf);
@@ -4153,6 +4154,9 @@ pub fn tool_event(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers)
if crate::distort_ui::pointer(app, ev, mods) {
return;
}
if crate::perspective_crop_ui::pointer(app, ev) {
return;
}
// Window › Modifier Keys: sticky Shift/⌘/⌥ act as held keys.
let mods = crate::workspace_ui::sticky_mods(app, mods);
// ⌘⌥⌃-click with any tool selects the topmost layer with pixels there (quick_pick.rs); it
+1
View File
@@ -1135,6 +1135,7 @@ pub fn inspect(app: &PhotocraftApp, ctx: &egui::Context) -> Value {
"window": {"width": screen.width(), "height": screen.height(), "pixelsPerPoint": ctx.pixels_per_point()},
"tool": app.ui.tool,
"toolOptions": app.ui.tool_options,
"perspectiveCrop": app.ui.perspective_crop,
"strokeEditor": app.ui.stroke_editor,
"magnetic": app.ui.magnetic,
"textEdit": app.ui.text_edit,
+1
View File
@@ -77,6 +77,7 @@ pub fn cmd_moves(t: Tool) -> bool {
| Tool::RotateView
| Tool::Zoom
| Tool::Crop
| Tool::PerspectiveCrop
| Tool::Slice
| Tool::SliceSelect
| Tool::PathSelection
+3
View File
@@ -2490,6 +2490,9 @@ cameraData Lens Objektiv
Previous Document Předchozí dokument
W x H x Resolution Š x V x Rozlišení
Front Image Přední obraz
Perspective Crop Tool Nástroj perspektivní oříznutí
Show Grid Zobrazit mřížku
Drag a box or click four corners · drag the corners onto the edges of a skewed shape · {commit} commits · Esc cancels Táhněte rámeček nebo klikněte na čtyři rohy · přetáhněte rohy na okraje zkoseného tvaru · {commit} potvrdí · Esc zruší
Artboard Tool Nástroj kreslicí plocha
Make Portrait Na výšku
Make Landscape Na šířku
Can't render this file because it contains an unexpected character in line 6 and column 47.
+3
View File
@@ -2523,6 +2523,9 @@ cameraData Lens Objektiv
Previous Document Vorheriges Dokument
W x H x Resolution B x H x Auflösung
Front Image Vorderes Bild
Perspective Crop Tool Perspektivisches Freistellungswerkzeug
Show Grid Raster einblenden
Drag a box or click four corners · drag the corners onto the edges of a skewed shape · {commit} commits · Esc cancels Rahmen ziehen oder vier Ecken klicken · Ecken auf die Kanten einer schrägen Form ziehen · {commit} übernimmt · Esc bricht ab
Artboard Tool Zeichenbereich-Werkzeug
Make Portrait Hochformat
Make Landscape Querformat
Can't render this file because it contains an unexpected character in line 6 and column 47.
+2
View File
@@ -3587,6 +3587,8 @@ cameraData Lens Φακός
Previous Document Προηγούμενο έγγραφο
W x H x Resolution Π x Υ x Ανάλυση
Front Image Μπροστινή εικόνα
Perspective Crop Tool Περικοπή προοπτικής (Perspective Crop Tool)
Drag a box or click four corners · drag the corners onto the edges of a skewed shape · {commit} commits · Esc cancels Σύρετε ένα πλαίσιο ή κάντε κλικ σε τέσσερις γωνίες · σύρετε τις γωνίες στις άκρες ενός λοξού σχήματος · {commit} για εφαρμογή · Esc για ακύρωση
Artboard Tool Πίνακας σχεδίασης (Artboard Tool)
Make Portrait Κατακόρυφος προσανατολισμός
Make Landscape Οριζόντιος προσανατολισμός
Can't render this file because it contains an unexpected character in line 973 and column 18.
+3
View File
@@ -2564,6 +2564,9 @@ cameraData Lens Objetivo
Previous Document Documento anterior
W x H x Resolution An. x Al. x Resolución
Front Image Imagen frontal
Perspective Crop Tool Herramienta Recortar con perspectiva
Show Grid Mostrar cuadrícula
Drag a box or click four corners · drag the corners onto the edges of a skewed shape · {commit} commits · Esc cancels Arrastre un cuadro o haga clic en cuatro esquinas · arrastre las esquinas a los bordes de una forma inclinada · {commit} confirma · Esc cancela
Artboard Tool Herramienta Lienzo
Make Portrait Hacer vertical
Make Landscape Hacer horizontal
Can't render this file because it contains an unexpected character in line 4 and column 47.
+3
View File
@@ -2901,6 +2901,9 @@ cameraData Lens Objectif
Previous Document Document précédent
W x H x Resolution L x H x Résolution
Front Image Image de premier plan
Perspective Crop Tool Outil Recadrage perspective
Show Grid Afficher la grille
Drag a box or click four corners · drag the corners onto the edges of a skewed shape · {commit} commits · Esc cancels Tracez un cadre ou cliquez sur quatre coins · faites glisser les coins sur les bords d'une forme inclinée · {commit} valide · Échap annule
Artboard Tool Outil Plan de travail
Make Portrait Format portrait
Make Landscape Format paysage
Can't render this file because it contains an unexpected character in line 6 and column 47.
+3
View File
@@ -2888,6 +2888,9 @@ cameraData Lens Lensa
Previous Document Dokumen Sebelumnya
W x H x Resolution L x T x Resolusi
Front Image Gambar Depan
Perspective Crop Tool Alat Crop Perspektif
Show Grid Tampilkan Grid
Drag a box or click four corners · drag the corners onto the edges of a skewed shape · {commit} commits · Esc cancels Seret kotak atau klik empat sudut · seret sudut ke tepi bentuk yang miring · {commit} menerapkan · Esc membatalkan
Artboard Tool Alat Artboard
Make Portrait Jadikan Potret
Make Landscape Jadikan Lanskap
Can't render this file because it contains an unexpected character in line 4 and column 47.
+3
View File
@@ -2554,6 +2554,9 @@ cameraData Lens Obiettivo
Previous Document Documento precedente
W x H x Resolution L x A x Risoluzione
Front Image Immagine in primo piano
Perspective Crop Tool Strumento Taglierina prospettica
Show Grid Mostra griglia
Drag a box or click four corners · drag the corners onto the edges of a skewed shape · {commit} commits · Esc cancels Trascina un riquadro o fai clic su quattro angoli · trascina gli angoli sui bordi di una forma inclinata · {commit} conferma · Esc annulla
Artboard Tool Strumento Tavola da disegno
Make Portrait Rendi verticale
Make Landscape Rendi orizzontale
Can't render this file because it contains an unexpected character in line 2528 and column 53.
+3
View File
@@ -3502,6 +3502,9 @@ cameraData Lens レンズ
Previous Document 前のドキュメント
W x H x Resolution 幅 x 高さ x 解像度
Front Image 前面の画像
Perspective Crop Tool 遠近法の切り抜きツール
Show Grid グリッドを表示
Drag a box or click four corners · drag the corners onto the edges of a skewed shape · {commit} commits · Esc cancels ボックスをドラッグするか 4 つの角をクリック · 角を傾いた形の端までドラッグ · {commit} で確定 · Esc でキャンセル
Artboard Tool アートボードツール
Make Portrait 縦向きにする
Make Landscape 横向きにする
Can't render this file because it contains an unexpected character in line 6 and column 47.
+3
View File
@@ -3068,6 +3068,9 @@ cameraData Lens 렌즈
Previous Document 이전 문서
W x H x Resolution 폭 x 높이 x 해상도
Front Image 전면 이미지
Perspective Crop Tool 원근 자르기 도구
Show Grid 격자 표시
Drag a box or click four corners · drag the corners onto the edges of a skewed shape · {commit} commits · Esc cancels 상자를 드래그하거나 네 모서리를 클릭 · 모서리를 기울어진 모양의 가장자리로 드래그 · {commit} 적용 · Esc 취소
Artboard Tool 아트보드 도구
Make Portrait 세로 방향으로
Make Landscape 가로 방향으로
Can't render this file because it contains an unexpected character in line 3042 and column 53.
+2
View File
@@ -3655,6 +3655,8 @@ cameraData Lens Lens
Previous Document Vorig document
W x H x Resolution B x H x Resolutie
Front Image Voorste afbeelding
Perspective Crop Tool Gereedschap Perspectief bijsnijden
Drag a box or click four corners · drag the corners onto the edges of a skewed shape · {commit} commits · Esc cancels Sleep een kader of klik op vier hoeken · sleep de hoeken naar de randen van een scheve vorm · {commit} past toe · Esc annuleert
Artboard Tool Tekengebiedgereedschap
Make Portrait Staand maken
Make Landscape Liggend maken
Can't render this file because it contains an unexpected character in line 8 and column 34.
+3
View File
@@ -3503,6 +3503,9 @@ cameraData Lens Obiektyw
Previous Document Poprzedni dokument
W x H x Resolution Szer. x Wys. x Rozdzielczość
Front Image Obraz na wierzchu
Perspective Crop Tool Narzędzie Kadrowanie perspektywiczne
Show Grid Pokaż siatkę
Drag a box or click four corners · drag the corners onto the edges of a skewed shape · {commit} commits · Esc cancels Przeciągnij ramkę lub kliknij cztery narożniki · przeciągnij narożniki na krawędzie pochylonego kształtu · {commit} zatwierdza · Esc anuluje
Artboard Tool Narzędzie Obszar roboczy
Make Portrait Ustaw pionowo
Make Landscape Ustaw poziomo
Can't render this file because it contains an unexpected character in line 6 and column 47.
+3
View File
@@ -2909,6 +2909,9 @@ cameraData Lens Lente
Previous Document Documento anterior
W x H x Resolution L x A x Resolução
Front Image Imagem frontal
Perspective Crop Tool Ferramenta Corte demarcado em perspectiva
Show Grid Mostrar grade
Drag a box or click four corners · drag the corners onto the edges of a skewed shape · {commit} commits · Esc cancels Arraste uma caixa ou clique em quatro cantos · arraste os cantos até as bordas de uma forma inclinada · {commit} confirma · Esc cancela
Artboard Tool Ferramenta Prancheta
Make Portrait Tornar retrato
Make Landscape Tornar paisagem
Can't render this file because it contains an unexpected character in line 6 and column 47.
+3
View File
@@ -2830,6 +2830,9 @@ cameraData Lens Объектив
Previous Document Предыдущий документ
W x H x Resolution Ш x В x Разрешение
Front Image Изображение переднего плана
Perspective Crop Tool Кадрирование перспективы
Show Grid Показать сетку
Drag a box or click four corners · drag the corners onto the edges of a skewed shape · {commit} commits · Esc cancels Растяните рамку или щёлкните четыре угла · перетащите углы на края наклонной фигуры · {commit} применяет · Esc отменяет
Artboard Tool Монтажная область (инструмент)
Make Portrait Сделать книжной
Make Landscape Сделать альбомной
Can't render this file because it contains an unexpected character in line 6 and column 47.
+3
View File
@@ -3588,6 +3588,9 @@ cameraData Lens Об'єктив
Previous Document Попередній документ
W x H x Resolution Ш x В x Роздільна здатність
Front Image Зображення переднього плану
Perspective Crop Tool Інструмент «Перспективне кадрування»
Show Grid Показати сітку
Drag a box or click four corners · drag the corners onto the edges of a skewed shape · {commit} commits · Esc cancels Розтягніть рамку або клацніть чотири кути · перетягніть кути на краї похилої фігури · {commit} застосовує · Esc скасовує
Artboard Tool Інструмент «Монтажна область»
Make Portrait Зробити портретною
Make Landscape Зробити альбомною
Can't render this file because it contains an unexpected character in line 1330 and column 18.
+3
View File
@@ -2497,6 +2497,9 @@ cameraData Lens 镜头
Previous Document 上一个文档
W x H x Resolution 宽 x 高 x 分辨率
Front Image 前面的图像
Perspective Crop Tool 透视裁剪工具
Show Grid 显示网格
Drag a box or click four corners · drag the corners onto the edges of a skewed shape · {commit} commits · Esc cancels 拖动方框或单击四个角 · 将角拖到倾斜形状的边缘 · {commit} 确认 · Esc 取消
Artboard Tool 画板工具
Make Portrait 设为纵向
Make Landscape 设为横向
Can't render this file because it contains an unexpected character in line 2471 and column 53.
+3
View File
@@ -2467,6 +2467,9 @@ cameraData Lens 鏡頭
Previous Document 上一個文件
W x H x Resolution 寬 x 高 x 解析度
Front Image 前面的影像
Perspective Crop Tool 透視裁切工具
Show Grid 顯示格點
Drag a box or click four corners · drag the corners onto the edges of a skewed shape · {commit} commits · Esc cancels 拖曳方框或按一下四個角 · 將角拖曳到傾斜形狀的邊緣 · {commit} 確認 · Esc 取消
Artboard Tool 工作區域工具
Make Portrait 設為直向
Make Landscape 設為橫向
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
View File
@@ -89,6 +89,7 @@ pub static ICONS: &[(&str, &[u8])] = &[
("pen-tool", include_bytes!("../../../assets/icons/pen-tool.svg")),
("pencil", include_bytes!("../../../assets/icons/pencil.svg")),
("pentagon", include_bytes!("../../../assets/icons/pentagon.svg")),
("perspective-crop", include_bytes!("../../../assets/icons/perspective-crop.svg")),
("pipette", include_bytes!("../../../assets/icons/pipette.svg")),
("play", include_bytes!("../../../assets/icons/play.svg")),
("pause", include_bytes!("../../../assets/icons/pause.svg")),
+1
View File
@@ -106,6 +106,7 @@ pub fn tool_icon(t: Tool) -> &'static str {
Tool::MagneticLasso => "lasso-magnetic",
Tool::MagicWand => "wand-sparkles",
Tool::Crop => "crop",
Tool::PerspectiveCrop => "perspective-crop",
Tool::Slice => "slice-knife",
Tool::SliceSelect => "square-dashed-mouse-pointer",
Tool::Gradient => "blend",
+4
View File
@@ -122,6 +122,7 @@ pub mod panels;
pub mod parity;
pub mod patch_preview;
pub mod pattern_preview;
pub mod perspective_crop_ui;
pub mod perspective_ui;
pub mod pixel_grid;
pub mod plugin_ui;
@@ -606,6 +607,8 @@ pub struct PhotocraftApp {
pub(crate) layer_box: Option<move_box::LayerBox>,
/// Crop tool gesture in progress (see `crop_ui`).
pub(crate) crop: crop_ui::CropState,
/// Perspective Crop tool gesture and clicked corners (see `perspective_crop_ui`).
pub(crate) pcrop: perspective_crop_ui::PerspectiveCrop,
/// Type tool layout cache: ((doc, revision, layer), layout).
pub(crate) type_layout: Option<((u64, u64, u64), std::sync::Arc<photocraft_text::TextLayout>)>,
pub(crate) type_transform_preview: Option<type_transform::Preview>,
@@ -725,6 +728,7 @@ impl PhotocraftApp {
guide_drag: None,
layer_box: None,
crop: Default::default(),
pcrop: Default::default(),
hover_doc: None,
clone_preview: None,
info_sample: None,
+9 -1
View File
@@ -20,7 +20,7 @@ const TOOL_SECTIONS: &[&[&[Tool]]] = &[
&[Tool::RectMarquee, Tool::EllipseMarquee, Tool::SingleRowMarquee, Tool::SingleColumnMarquee],
&[Tool::Lasso, Tool::PolygonLasso, Tool::MagneticLasso],
&[Tool::ObjectSelection, Tool::QuickSelection, Tool::MagicWand],
&[Tool::Crop, Tool::Slice, Tool::SliceSelect],
&[Tool::Crop, Tool::PerspectiveCrop, Tool::Slice, Tool::SliceSelect],
&[Tool::Eyedropper, Tool::Ruler, Tool::Note, Tool::Count],
],
&[
@@ -1168,6 +1168,7 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
// Live mode: the options also change a selected gradient fill layer.
crate::gradient_ui::options_changed(app, &before);
}
Tool::PerspectiveCrop if t.pro => crate::perspective_crop_ui::options_bar(app, ui),
Tool::Crop if t.pro => {
let before_ratio = crate::crop_ui::preset_ratio(app);
let doc_size = app.session.active().map_or((0.0, 0.0), |s| (f64::from(s.doc.size.width), f64::from(s.doc.size.height)));
@@ -1377,6 +1378,13 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
],
),
),
Tool::PerspectiveCrop => hint(
ui,
&crate::i18n::fmt(
tl!("Drag a box or click four corners · drag the corners onto the edges of a skewed shape · {commit} commits · Esc cancels"),
&[("commit", &crate::shortcuts::pretty("Enter"))],
),
),
Tool::Gradient => hint(ui, tl!("Drag to draw a gradient")),
Tool::PaintBucket => hint(ui, tl!("Click to fill similar colours")),
// Retouching and smart-selection tools draw their bar in `retouch_ui::options_bar`.
+326
View File
@@ -0,0 +1,326 @@
//! The Perspective Crop tool (C, in the Crop flyout), as in Photoshop:
//!
//! - drag a box, or click four corners, to make a quad (`UiState::perspective_crop`, TL, TR, BR,
//! BL in document px; clicked corners are put in that order);
//! - drag a corner to move it alone, onto an edge of a skewed object (a photographed page, a
//! facade); drag inside the quad to move it;
//! - the quad shows its perspective grid (Show Grid) and turns red while it isn't a convex quad
//! wound like its box, which can't be committed;
//! - ↵ (or ✓) commits: `image.perspectiveCrop` maps the quad onto an upright rectangle, the whole
//! document in one undo step. Its size comes from the options bar's W x H (and the resolution),
//! which are the Crop tool's fields (`crop_size`), or with them empty from the quad's mean side
//! lengths. Esc (or ⊘) cancels.
//!
//! A pending quad belongs to the document it was drawn on, like the Crop tool's frame.
use egui::{Color32, Pos2, Stroke};
use photocraft_algo::transform::{Homography, convex_quad};
use photocraft_engine::prefs::Unit;
use serde_json::{Value, json};
use crate::PhotocraftApp;
use crate::canvas::{ToolEvent, ViewXform};
use crate::state::Tool;
/// A drag shorter than this (screen px) is a click: it places a corner.
const CLICK_PX: f64 = 3.0;
#[derive(Clone, Copy, Debug)]
pub enum Drag {
/// Drawing a box from `start`.
New { start: [f64; 2], cur: [f64; 2] },
/// Moving one corner; `offset` is the press's distance from it.
Corner { index: usize, offset: [f64; 2] },
/// Moving the whole quad.
Move { start: [f64; 2], quad: [[f64; 2]; 4] },
}
#[derive(Default)]
pub struct PerspectiveCrop {
pub drag: Option<Drag>,
/// Corners clicked so far, before the fourth makes the quad.
pub clicks: Vec<[f64; 2]>,
/// The document the pending quad belongs to.
doc: Option<photocraft_doc::DocId>,
}
fn active(app: &PhotocraftApp) -> bool {
app.ui.tool == Tool::PerspectiveCrop && app.session.active().is_some()
}
/// A quad or corners clicked or a drag in progress.
pub fn pending(app: &PhotocraftApp) -> bool {
app.ui.perspective_crop.is_some() || !app.pcrop.clicks.is_empty() || app.pcrop.drag.is_some()
}
pub fn cancel(app: &mut PhotocraftApp) {
app.ui.perspective_crop = None;
app.pcrop.drag = None;
app.pcrop.clicks.clear();
}
/// Another document became active: its pending quad is dropped.
fn cancel_stale(app: &mut PhotocraftApp) {
let doc = app.session.active().map(|st| st.doc.id);
if pending(app) && app.pcrop.doc.is_some() && app.pcrop.doc != doc {
cancel(app);
}
app.pcrop.doc = doc;
}
/// Four points in quad order: clockwise on screen around their centre, starting top-left.
pub fn ordered(points: [[f64; 2]; 4]) -> [[f64; 2]; 4] {
let c = [points.iter().map(|p| p[0]).sum::<f64>() / 4.0, points.iter().map(|p| p[1]).sum::<f64>() / 4.0];
let mut v = points;
// y grows downwards, so increasing atan2 runs clockwise on screen.
v.sort_by(|a, b| (a[1] - c[1]).atan2(a[0] - c[0]).total_cmp(&(b[1] - c[1]).atan2(b[0] - c[0])));
let first = (0..4).min_by(|&i, &j| (v[i][0] + v[i][1]).total_cmp(&(v[j][0] + v[j][1]))).unwrap_or(0);
v.rotate_left(first);
v
}
fn inside(q: &[[f64; 2]; 4], p: [f64; 2]) -> bool {
(0..4).all(|i| {
let (a, b) = (q[i], q[(i + 1) % 4]);
(b[0] - a[0]) * (p[1] - a[1]) - (b[1] - a[1]) * (p[0] - a[0]) >= 0.0
})
}
/// Pointer events (document px) with the tool. True when consumed.
pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent) -> bool {
if app.ui.tool != Tool::PerspectiveCrop {
app.pcrop.drag = None;
return false;
}
let p = match ev {
ToolEvent::Down { x, y, .. } | ToolEvent::Move { x, y, .. } | ToolEvent::Up { x, y } => [x, y],
};
if app.session.active().is_none() || !p[0].is_finite() || !p[1].is_finite() {
return true;
}
cancel_stale(app);
let tol = crate::distort_ui::tolerance(app);
match ev {
ToolEvent::Down { .. } => {
app.pcrop.drag = match app.ui.perspective_crop {
Some(q) => {
let near = (0..4)
.filter(|&i| (q[i][0] - p[0]).hypot(q[i][1] - p[1]) <= tol)
.min_by(|&i, &j| (q[i][0] - p[0]).hypot(q[i][1] - p[1]).total_cmp(&(q[j][0] - p[0]).hypot(q[j][1] - p[1])));
match near {
Some(index) => Some(Drag::Corner { index, offset: [p[0] - q[index][0], p[1] - q[index][1]] }),
None if inside(&q, p) => Some(Drag::Move { start: p, quad: q }),
None => None,
}
}
None => Some(Drag::New { start: p, cur: p }),
};
}
ToolEvent::Move { .. } => drag_to(app, p),
ToolEvent::Up { .. } => {
drag_to(app, p);
if let Some(Drag::New { start, cur }) = app.pcrop.drag {
let click = tol * CLICK_PX / 8.0;
if (cur[0] - start[0]).abs() <= click && (cur[1] - start[1]).abs() <= click {
app.pcrop.clicks.push(start);
if let [a, b, c, d] = app.pcrop.clicks[..] {
app.ui.perspective_crop = Some(ordered([a, b, c, d]));
app.pcrop.clicks.clear();
}
} else {
app.pcrop.clicks.clear();
let (x0, y0, x1, y1) = (start[0].min(cur[0]), start[1].min(cur[1]), start[0].max(cur[0]), start[1].max(cur[1]));
app.ui.perspective_crop = Some([[x0, y0], [x1, y0], [x1, y1], [x0, y1]]);
}
}
app.pcrop.drag = None;
}
}
true
}
fn drag_to(app: &mut PhotocraftApp, p: [f64; 2]) {
match app.pcrop.drag {
Some(Drag::New { start, .. }) => app.pcrop.drag = Some(Drag::New { start, cur: p }),
Some(Drag::Corner { index, offset }) => {
if let Some(c) = app.ui.perspective_crop.as_mut().and_then(|q| q.get_mut(index)) {
*c = [p[0] - offset[0], p[1] - offset[1]];
}
}
Some(Drag::Move { start, quad }) => {
let (dx, dy) = (p[0] - start[0], p[1] - start[1]);
app.ui.perspective_crop = Some(quad.map(|c| [c[0] + dx, c[1] + dy]));
}
None => {}
}
}
/// `image.perspectiveCrop`'s size params from W, H and the resolution (the Crop tool's fields):
/// lengths in physical units convert at the typed resolution, else the document's.
fn size_params(app: &PhotocraftApp, p: &mut Value) {
let o = &app.ui.tool_options;
let res = crate::crop_size::resolution_ppi(o);
let dpi = res.unwrap_or_else(|| app.session.active().map_or(72.0, |st| f64::from(st.doc.resolution_dpi)));
let px = |text: &str| {
let (v, u) = crate::crop_size::parse_length(text, Unit::Pixels)?;
let px = u.to_px(v, dpi, 0.0, 72.0).round();
(px.is_finite() && px >= 1.0).then_some(px)
};
if let Some(w) = px(&o.crop_width) {
p["width"] = json!(w);
}
if let Some(h) = px(&o.crop_height) {
p["height"] = json!(h);
}
if let Some(r) = res {
p["resolution"] = json!(r);
}
}
/// ↵: crops to the pending quad. An invalid quad stays for fixing, with the engine's reason.
pub fn commit(app: &mut PhotocraftApp) {
cancel_stale(app);
let Some(q) = app.ui.perspective_crop else { return };
let mut p = json!({"corners": q});
size_params(app, &mut p);
if app.run("image.perspectiveCrop", p).is_ok() {
cancel(app);
}
}
/// ↵ commits and Esc cancels while the tool has something pending. True when a key was taken.
pub fn keys(app: &mut PhotocraftApp, ctx: &egui::Context) -> bool {
if !active(app) || !pending(app) {
return false;
}
use egui::{Key, Modifiers};
if ctx.input_mut(|i| i.consume_key(Modifiers::NONE, Key::Enter)) {
commit(app);
return true;
}
if ctx.input_mut(|i| i.consume_key(Modifiers::NONE, Key::Escape)) {
cancel(app);
return true;
}
false
}
/// Grid lines per side for a quad about `side` screen px across (Photoshop's grid gets denser as
/// the quad grows).
fn grid_steps(side: f32) -> usize {
((side / 48.0).round() as usize).clamp(2, 12)
}
/// The quad, its perspective grid and corner handles; corners clicked so far; the box being drawn.
pub fn draw_overlay(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform) {
if app.ui.tool != Tool::PerspectiveCrop {
return;
}
let scr = |p: [f64; 2]| xf.to_screen(p[0] as f32, p[1] as f32);
let accent = crate::theme::Tokens::get(painter.ctx()).accent;
let quad = match app.pcrop.drag {
Some(Drag::New { start, cur }) => {
let (x0, y0, x1, y1) = (start[0].min(cur[0]), start[1].min(cur[1]), start[0].max(cur[0]), start[1].max(cur[1]));
(x1 > x0 && y1 > y0).then_some([[x0, y0], [x1, y0], [x1, y1], [x0, y1]])
}
_ => app.ui.perspective_crop,
};
if let Some(q) = quad.filter(|q| q.iter().flatten().all(|v| v.is_finite())) {
let ok = convex_quad(&q);
let color = if ok { accent } else { Color32::from_rgb(0xe5, 0x3e, 0x3e) };
let pts: Vec<Pos2> = q.iter().map(|c| scr(*c)).collect();
if ok
&& app.ui.tool_options.perspective_crop_grid
&& let Some(h) = Homography::rect_to_quad([0.0, 0.0, 1.0, 1.0], q)
{
let side = pts.iter().zip(pts.iter().skip(1)).map(|(a, b)| a.distance(*b)).fold(0.0, f32::max);
let n = grid_steps(side);
let thin = Stroke::new(0.75, Color32::from_rgba_unmultiplied(255, 255, 255, 150));
let at = |u: f64, v: f64| {
let (x, y) = h.apply(u, v);
scr([x, y])
};
for k in 1..n {
let t = k as f64 / n as f64;
painter.line_segment([at(t, 0.0), at(t, 1.0)], thin);
painter.line_segment([at(0.0, t), at(1.0, t)], thin);
}
}
painter.add(egui::Shape::closed_line(pts.clone(), Stroke::new(1.5, color)));
if !matches!(app.pcrop.drag, Some(Drag::New { .. })) {
for c in pts {
let r = egui::Rect::from_center_size(c, egui::vec2(8.0, 8.0));
painter.rect_filled(r, 0.0, Color32::WHITE);
painter.rect_stroke(r, 0.0, Stroke::new(1.25, color), egui::StrokeKind::Inside);
}
}
}
let clicks: Vec<Pos2> = app.pcrop.clicks.iter().map(|c| scr(*c)).collect();
if clicks.len() > 1 {
painter.add(egui::Shape::line(clicks.clone(), Stroke::new(1.5, accent)));
}
for c in clicks {
painter.circle_filled(c, 4.0, Color32::WHITE);
painter.circle_stroke(c, 4.0, Stroke::new(1.25, accent));
}
}
/// Options bar: W, ⇄, H, the resolution (the Crop tool's fields), Front Image, Clear, Show Grid,
/// and cancel / commit.
pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
crate::crop_size::fields(&mut app.ui.tool_options, ui);
if crate::widgets::secondary_button(ui, tl!("Front Image"), 0.0).clicked()
&& let Some((size, dpi)) = app.session.active().map(|st| (st.doc.size, f64::from(st.doc.resolution_dpi)))
{
let o = &mut app.ui.tool_options;
o.crop_width = crate::crop_size::format_length(f64::from(size.width), Unit::Pixels);
o.crop_height = crate::crop_size::format_length(f64::from(size.height), Unit::Pixels);
let res = if o.crop_resolution_unit == crate::crop_size::PX_PER_CM { dpi / 2.54 } else { dpi };
o.crop_resolution = crate::widgets::fmt_num2(res);
}
if crate::widgets::secondary_button(ui, tl!("Clear"), 0.0).clicked() {
crate::crop_size::clear(&mut app.ui.tool_options);
}
crate::widgets::vline(ui, 22.0);
crate::widgets::checkbox(ui, &mut app.ui.tool_options.perspective_crop_grid, tl!("Show Grid"));
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let commit_tip = crate::i18n::fmt(tl!("Commit current crop operation ({key})"), &[("key", &crate::shortcuts::pretty("Enter"))]);
if crate::icons::button(ui, "check", 26.0, false, &commit_tip).clicked() {
commit(app);
}
if crate::icons::button(ui, "ban", 26.0, false, tl!("Cancel current crop operation (Esc)")).clicked() {
cancel(app);
}
});
}
#[cfg(test)]
mod tests {
use super::*;
use photocraft_doc::{Color, ColorMode, Document, SampleType, Size};
/// A box drag makes the quad, corners drag alone, and ↵'s commit crops the document to the
/// quad's size; four clicks in any order make a quad in TL, TR, BR, BL order.
#[test]
fn drag_a_box_move_a_corner_and_commit() {
let doc = Document::with_background("p", Size::new(200, 150), ColorMode::Rgb, SampleType::U8, Color::WHITE);
let mut s = photocraft_engine::Session::new();
s.add_document(doc, None);
let mut app = PhotocraftApp::new(s, crate::Services::default());
app.ui.tool = Tool::PerspectiveCrop;
let ev = |app: &mut PhotocraftApp, e| assert!(pointer(app, e));
ev(&mut app, ToolEvent::Down { x: 20.0, y: 10.0, pressure: 1.0 });
ev(&mut app, ToolEvent::Move { x: 120.0, y: 90.0, pressure: 1.0 });
ev(&mut app, ToolEvent::Up { x: 120.0, y: 90.0 });
assert_eq!(app.ui.perspective_crop, Some([[20.0, 10.0], [120.0, 10.0], [120.0, 90.0], [20.0, 90.0]]));
ev(&mut app, ToolEvent::Down { x: 121.0, y: 89.0, pressure: 1.0 });
ev(&mut app, ToolEvent::Up { x: 141.0, y: 99.0 });
assert_eq!(app.ui.perspective_crop.unwrap()[2], [140.0, 100.0]);
commit(&mut app);
assert!(app.ui.perspective_crop.is_none());
let size = app.session.active().unwrap().doc.size;
assert_eq!((size.width, size.height), (110, 86), "the quad's mean side lengths");
assert_eq!(ordered([[10.0, 90.0], [100.0, 5.0], [0.0, 0.0], [90.0, 100.0]]), [[0.0, 0.0], [100.0, 5.0], [90.0, 100.0], [10.0, 90.0]]);
}
}
+4
View File
@@ -358,6 +358,10 @@ pub fn handle(app: &mut PhotocraftApp, ctx: &egui::Context) {
if focus == Focus::None && crate::crop_ui::keys(app, ctx) {
return;
}
// Perspective Crop with a pending quad: ↵ commits, Esc cancels.
if focus == Focus::None && crate::perspective_crop_ui::keys(app, ctx) {
return;
}
// Move tool / Free Transform: arrows nudge (⇧ ×10, ⌥ duplicates first).
if focus == Focus::None && crate::move_mods::arrow_keys(app, ctx) {
return;
+14 -3
View File
@@ -76,6 +76,7 @@ pub enum Tool {
MagneticLasso,
MagicWand,
Crop,
PerspectiveCrop,
Eyedropper,
Ruler,
Note,
@@ -132,7 +133,7 @@ pub enum Tool {
}
impl Tool {
pub const ALL: [Tool; 63] = [
pub const ALL: [Tool; 64] = [
Tool::Move,
Tool::Artboard,
Tool::RectMarquee,
@@ -144,6 +145,7 @@ impl Tool {
Tool::MagneticLasso,
Tool::MagicWand,
Tool::Crop,
Tool::PerspectiveCrop,
Tool::Eyedropper,
Tool::Ruler,
Tool::Note,
@@ -222,6 +224,7 @@ impl Tool {
Tool::MagneticLasso => "Magnetic Lasso Tool",
Tool::MagicWand => "Magic Wand Tool",
Tool::Crop => "Crop Tool",
Tool::PerspectiveCrop => "Perspective Crop Tool",
Tool::Slice => "Slice Tool",
Tool::SliceSelect => "Slice Select Tool",
Tool::Gradient => "Gradient Tool",
@@ -311,7 +314,7 @@ impl Tool {
Tool::Eyedropper | Tool::Ruler | Tool::Note | Tool::Count => 'I',
Tool::Lasso | Tool::PolygonLasso | Tool::MagneticLasso => 'L',
Tool::MagicWand => 'W',
Tool::Crop | Tool::Slice | Tool::SliceSelect => 'C',
Tool::Crop | Tool::PerspectiveCrop | Tool::Slice | Tool::SliceSelect => 'C',
Tool::Gradient | Tool::PaintBucket => 'G',
Tool::Type | Tool::VerticalType | Tool::HorizontalTypeMask | Tool::VerticalTypeMask => 'T',
Tool::Hand => 'H',
@@ -597,6 +600,9 @@ pub struct ToolOptions {
pub crop_resolution: String,
#[serde(default = "default_crop_resolution_unit")]
pub crop_resolution_unit: String,
/// Perspective Crop's Show Grid (`perspective_crop_ui`); it shares W, H and the resolution above.
#[serde(default = "yes")]
pub perspective_crop_grid: bool,
/// Crop overlay (#1919): the guide in the crop box, when it shows and its orientation
/// (Photoshop's defaults: Rule of Thirds, Auto Show Overlay). See `crop_overlay`.
#[serde(default)]
@@ -770,6 +776,7 @@ impl Default for ToolOptions {
crop_height: String::new(),
crop_resolution: String::new(),
crop_resolution_unit: default_crop_resolution_unit(),
perspective_crop_grid: true,
crop_overlay: Default::default(),
crop_overlay_show: Default::default(),
crop_overlay_orientation: 0,
@@ -1115,6 +1122,9 @@ pub struct UiState {
/// frame before the turn); 0 when upright. Committing passes it as `image.crop`'s `angle`.
#[serde(default)]
pub crop_angle: f64,
/// Perspective Crop quad being edited: TL, TR, BR, BL (document coordinates).
#[serde(default)]
pub perspective_crop: Option<[[f64; 2]; 4]>,
pub next_id: u64,
/// Last status message (errors from commands, hints).
pub status: String,
@@ -1206,6 +1216,7 @@ impl Default for UiState {
magnetic: Default::default(),
crop_rect: None,
crop_angle: 0.0,
perspective_crop: None,
next_id: 1,
status: String::new(),
status_error: false,
@@ -1282,7 +1293,7 @@ mod tests {
assert!(!Tool::RedEye.is_brushlike());
assert_eq!(Tool::from_name("patternStamp"), Some(Tool::PatternStamp));
assert_eq!(Tool::from_name("Pattern Stamp Tool"), Some(Tool::PatternStamp));
assert_eq!(Tool::ALL.len(), 63);
assert_eq!(Tool::ALL.len(), 64);
assert_eq!(Tool::from_name("Vertical Type Mask Tool"), Some(Tool::VerticalTypeMask));
assert_eq!(Tool::from_name("addAnchorPoint"), Some(Tool::AddAnchorPoint));
assert_eq!(Tool::from_name("Delete Anchor Point Tool"), Some(Tool::DeleteAnchorPoint));
+1
View File
@@ -139,6 +139,7 @@ pub fn reset_options(o: &mut ToolOptions, tool: Tool) {
crop_custom_ratio_width,
crop_custom_ratio_height,
),
Tool::PerspectiveCrop => reset!(crop_width, crop_height, crop_resolution, crop_resolution_unit, perspective_crop_grid),
Tool::Eyedropper => reset!(eyedropper_size, eyedropper_sample, eyedropper_ring),
Tool::Pencil => reset!(pencil_auto_erase),
Tool::BackgroundEraser => reset!(bg_sampling, bg_limits, bg_tolerance, bg_protect_fg),
+4 -4
View File
@@ -54,10 +54,10 @@ what an agent can't finish alone.
scripted check there) is the only real oracle.
### G6. Missing tools
- **Missing (8 of 68):** Perspective Crop, Frame, Color Sampler (#1046), Color
Replacement (engine command exists), Art History Brush, Add Anchor Point, Delete Anchor Point,
Convert Point (only as Pen + ⌥). Freeform Pen and Curvature Pen landed (#1054); Horizontal and
Vertical Type Mask landed (#1055, `type.createSelection`).
- **Missing (7 of 68):** Frame, Color Sampler (#1046), Color Replacement (engine command exists),
Art History Brush, Add Anchor Point, Delete Anchor Point, Convert Point (only as Pen + ⌥).
Freeform Pen and Curvature Pen landed (#1054); Horizontal and Vertical Type Mask landed (#1055,
`type.createSelection`); Perspective Crop landed (#1043, `image.perspectiveCrop`).
- **Evidence:** `Tool` enum (`crates/ui-egui/src/state.rs`) against the 67 `<tool>` entries of
Photoshop's `Default Keyboard Shortcuts.kys` plus Remove, Triangle and Curvature Pen.
- **Impact:** users look for them by name and shortcut; several are daily tools for designers.
+4 -4
View File
@@ -61,7 +61,7 @@ architecture and the PSD writer are the serial parts.
| Component | Weight | Ours / Photoshop | % | Kind |
|---|---:|---|---:|---|
| Menu commands | 30% | 628 / 628 catalog items dispatch a live command | 100% | measured (`cargo xtask parity`) |
| Toolbox tools | 15% | 56 / 68 (Photoshop's 67 `.kys` tools plus Remove, Triangle, Curvature Pen; minus legacy Rounded Rectangle and Targeted Adjustment) | 82% | measured (`Tool` enum in `ui-egui/src/state.rs`, `TOOL_SECTIONS` in `panels.rs`) |
| Toolbox tools | 15% | 57 / 68 (Photoshop's 67 `.kys` tools plus Remove, Triangle, Curvature Pen; minus legacy Rounded Rectangle and Targeted Adjustment) | 84% | measured (`Tool` enum in `ui-egui/src/state.rs`, `TOOL_SECTIONS` in `panels.rs`) |
| Panels | 10% | 30 / 35 Window-menu panels | 86% | measured (`view_cmds.rs`, panel modules) |
| Filters and adjustments | 10% | every Filter and Image › Adjustments item live; Filter Gallery 47 looks | 100% | measured (menu catalog) |
| File formats (usage-weighted) | 15% | 13 of 31 Photoshop formats supported in Photoshop's directions, 4 partial; the common ones (PSD/PSB, TIFF, JPEG, PNG, WebP) all present | ~80% (48% by count) | measured list, estimated weights ([file-format-parity.md](file-format-parity.md)) |
@@ -431,7 +431,7 @@ filled bar. This extends UI reachability and does not claim Photoshop-authored s
| Smart filters / text / effect shapes in PSDs | Measured on our Photoshop-authored set (https://github.com/storytold/photocraft-corpus, `corpus/photoshop`, 258 files): see the per-group floors in `crates/io/tests/corpus.rs` and `crates/engine/tests/photoshop_oracles.rs`. Smart objects and smart filters now survive PSD save and open (41 corpus files, 206 smart objects, round trip strict; Photoshop opens our exports with live filters); re-rendering Photoshop's smart filters with ours matches 5/30 (was 1/30) | low–medium | Remaining re-render gaps are filter maths (Gaussian/Motion Blur, Unsharp Mask, Emboss, Add Noise RNG) and bicubic placement. |
| Core editing (layers, masks, selections, adjustments, filters, transforms) | Broad engine coverage; many interaction bugs fixed after 0.2.0 (adjustment dialogs, Curves, crop, Move/Transform modifiers, gesture origin) | medium | Fixes not yet user-validated. |
| UI / UX polish | Shortcut audit 214 → 0 failures; dock, Layers rows and menus reworked; first visual-QA sweep found 14 defects (#147–#157). 2026-10-07: the keyboard-only shortcuts with no menu item (⌥[ ⌥] ⌥, ⌥. layer navigation, ⇧⌥[ ⇧⌥] to extend the selection, 1–0 for opacity and ⇧ for flow or fill, ⇧[ ⇧] hardness, ⌥⌘T to transform a copy and ⌥⇧⌘T to step and repeat), ⌥-click colour sampling with painting tools, double-click a Layers row for Layer Style, File › New from Clipboard, a centred main window and remembered Liquify settings (#352, #417, #350, #368, #419, #418). 2026-10-09: canvas zoom is physical at any display scale — 100% is one document pixel per physical display pixel, and every screen-space overlay, cursor, scrollbar, navigator and fit follows it (#1943) | low–medium | Needs recurring visual QA with realistic documents. |
| Tools | 2026-10-10: Pencil, Mixer Brush, Color Replacement, Patch, Content-Aware Move, Vertical Type, Pattern Stamp and Rotate View are toolbar tools. Remaining missing tools include Red Eye, Art History Brush, Color Sampler, Perspective Crop and Frame | low–medium | Patch has a live healing preview. Content-Aware Move is partial: Move/Extend, Structure/Color and Sample All Layers exist; Transform On Drop and a live result preview remain missing (`TOOL-213-4` in the scorecard). Magic/Background Eraser added; live gradients in progress (#180). Magnetic Lasso added 2026-10-08 (live-wire edge tracing, Width/Contrast/Frequency, `select.magneticLasso`). Pattern Stamp added 2026-10-08 (S flyout, `paint.patternStamp`, Aligned / Impressionist). Rotate View (2026-10-09) turns the canvas camera around its centre without rewriting pixels; Reset View and Match Rotation copy the angle. Crop rotation (2026-10-09, #1792): dragging outside the crop frame turns it (⇧ 15° steps, angle readout), and ↵ rotates the document and crops in one undo step (`image.crop` `angle`); the frame turns over the image as in Photoshop's Classic Mode, and the options-bar Straighten is still missing. |
| Tools | 2026-10-10: Pencil, Mixer Brush, Color Replacement, Patch, Content-Aware Move, Vertical Type, Pattern Stamp and Rotate View are toolbar tools. Remaining missing tools include Red Eye, Art History Brush, Color Sampler and Frame (Perspective Crop landed 2026-10-10, #1043: `image.perspectiveCrop`) | low–medium | Patch has a live healing preview. Content-Aware Move is partial: Move/Extend, Structure/Color and Sample All Layers exist; Transform On Drop and a live result preview remain missing (`TOOL-213-4` in the scorecard). Magic/Background Eraser added; live gradients in progress (#180). Magnetic Lasso added 2026-10-08 (live-wire edge tracing, Width/Contrast/Frequency, `select.magneticLasso`). Pattern Stamp added 2026-10-08 (S flyout, `paint.patternStamp`, Aligned / Impressionist). Rotate View (2026-10-09) turns the canvas camera around its centre without rewriting pixels; Reset View and Match Rotation copy the angle. Crop rotation (2026-10-09, #1792): dragging outside the crop frame turns it (⇧ 15° steps, angle readout), and ↵ rotates the document and crops in one undo step (`image.crop` `angle`); the frame turns over the image as in Photoshop's Classic Mode, and the options-bar Straighten is still missing. |
| Painting | Brush model and Brush Settings panel near Photoshop; .abr/.grd import; persistent presets; pen pressure/tilt on Windows, web, macOS and X11 | medium | Native Wayland pen input open (#79; a pen opens the window through Xwayland meanwhile); X11 pressure confirmed by a user, macOS not yet verified on tablet hardware. |
| Text / typography | Engine works; caret placement and size editing fixed; OpenType features, text-on-path editing, composer parity partial | medium-low | Measure with the Photoshop-authored set. |
| Colour management | Colour-managed canvas (document → monitor), embedded CMYK profiles, linear EXR/HDR, 16-bit float canvas | medium-high | Monitor profile follows only at launch. |
@@ -524,8 +524,8 @@ See [method and results](octagonal-mask-performance.md).
effects, composite with masks and adjustment layers, CMYK print prep…) scripted end to end and
checked against Photoshop's output on every build. Their pass rate becomes the headline parity
number.
4. **Tool coverage and workflow depth:** Pen variants (Direct Selection landed, #790; Add/Delete Anchor Point, #1044) and
Perspective Crop; finish Content-Aware Move's Transform On Drop and live result preview.
4. **Tool coverage and workflow depth:** Pen variants (Direct Selection landed, #790; Add/Delete Anchor Point, #1044);
finish Content-Aware Move's Transform On Drop and live result preview.
Pencil, Mixer Brush, Patch, Vertical Type, Pattern Stamp and Rotate View are already available on the toolbar.
5. **Complex-document performance** (#125/#128) and GPU tiling beyond the texture limit (#49).
6. **Recurring visual QA** (`cargo run -p photocraft-engine --example designer_psd`) and fast
+1 -1
View File
@@ -30,7 +30,7 @@ Triangle and Curvature Pen (minus legacy Rounded Rectangle and Targeted Adjustme
| Marquee | Rectangular, Elliptical, Single Row, Single Column | |
| Lasso | Lasso, Polygonal, Magnetic | |
| Selection | Object Selection, Quick Selection, Magic Wand | |
| Crop and slice | Crop, Slice, Slice Select | Perspective Crop |
| Crop and slice | Crop, Perspective Crop, Slice, Slice Select | |
| Frame | | Frame (`layer.new.frameFromLayers` exists) |
| Eyedropper | Eyedropper, Ruler, Note, Count | Color Sampler (#1046) |
| Retouch | Remove, Spot Healing, Healing, Patch, Content-Aware Move, Red Eye | |