diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 26edba3ad0..8f3803e76a 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -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) | | 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 | | 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 | (`icons/.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 | (`icons/.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 | 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 | diff --git a/README.md b/README.md index 8c9ee0743b..a9a67182a5 100644 --- a/README.md +++ b/README.md @@ -178,8 +178,8 @@ Every screenshot here is the real app at work on public-domain art, rendered off
-

🧰 63 tools

- 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 +

🧰 64 tools

+ 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

🖌️ A real brush engine

diff --git a/assets/icons/perspective-crop.svg b/assets/icons/perspective-crop.svg new file mode 100644 index 0000000000..9a902947ce --- /dev/null +++ b/assets/icons/perspective-crop.svg @@ -0,0 +1,19 @@ + + + + + + + + diff --git a/crates/algo/src/transform.rs b/crates/algo/src/transform.rs index bb31945d5d..1dfe51a766 100644 --- a/crates/algo/src/transform.rs +++ b/crates/algo/src/transform.rs @@ -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 { + 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 { + 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 { + 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) -> Result { 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)>, 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) } diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index 7446677d3f..8cee834682 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -1153,6 +1153,7 @@ fn build() -> Vec { 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()); diff --git a/crates/engine/src/extra_cmds.rs b/crates/engine/src/extra_cmds.rs index 0a246168f2..f46c53e169 100644 --- a/crates/engine/src/extra_cmds.rs +++ b/crates/engine/src/extra_cmds.rs @@ -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 { +pub(crate) fn rasterize_all(s: &mut Session) -> Result { let ids: Vec = 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; diff --git a/crates/engine/src/image_cmds.rs b/crates/engine/src/image_cmds.rs index a4e040c04a..8ab6ad0ff8 100644 --- a/crates/engine/src/image_cmds.rs +++ b/crates/engine/src/image_cmds.rs @@ -181,7 +181,7 @@ fn image_size(s: &mut Session, p: &Value) -> Result { /// 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 { diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index c93d6fbc24..c35381be22 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -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; diff --git a/crates/engine/src/perspective_crop_cmds.rs b/crates/engine/src/perspective_crop_cmds.rs new file mode 100644 index 0000000000..c8881e6f70 --- /dev/null +++ b/crates/engine/src/perspective_crop_cmds.rs @@ -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) -> 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> { + 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 { + 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> { + 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 { + 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 { + 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 = 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 { + 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 { + 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)); + } +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 43194afc5d..d0a96d23e6 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -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 diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index e41e9c71dc..1dd80c7223 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -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, diff --git a/crates/ui-egui/src/hold_keys.rs b/crates/ui-egui/src/hold_keys.rs index 8b301e7dc7..57a7db4117 100644 --- a/crates/ui-egui/src/hold_keys.rs +++ b/crates/ui-egui/src/hold_keys.rs @@ -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 diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index c24028af23..ded6068e50 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 2c96f176d3..065314131b 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 4beef106a2..6df9f67ebb 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -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 Οριζόντιος προσανατολισμός diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 9fa4754bb7..34d8720156 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index adfdd2042a..98ce31f3f7 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 753d7b3884..4ef02c1bec 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index a1a5e5d0cb..4f6e3833fa 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index d058fdb17d..7083a779cf 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -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 横向きにする diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index c939db47ab..b40b9466d9 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -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 가로 방향으로 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index e097d4cdc8..10e108af68 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 85438ca729..8f82c364dd 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 4e7b2a93af..5d5cb7793d 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 3558fc5852..1f4066a66f 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -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 Сделать альбомной diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index f5aeab1362..706f278d2c 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -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 Зробити альбомною diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 7c2caf4a38..d0fa9b4a63 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -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 设为横向 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index df9287e619..16d5502873 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -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 設為橫向 diff --git a/crates/ui-egui/src/icon_data.rs b/crates/ui-egui/src/icon_data.rs index c4b4cef0a9..dc64bc5ad9 100644 --- a/crates/ui-egui/src/icon_data.rs +++ b/crates/ui-egui/src/icon_data.rs @@ -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")), diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index 79e1d1e67c..5df343c8ca 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -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", diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 9306046bb1..8756b9de18 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -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, /// 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)>, pub(crate) type_transform_preview: Option, @@ -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, diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 5ed4657757..1b3d9353ed 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -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`. diff --git a/crates/ui-egui/src/perspective_crop_ui.rs b/crates/ui-egui/src/perspective_crop_ui.rs new file mode 100644 index 0000000000..c44b54dc4a --- /dev/null +++ b/crates/ui-egui/src/perspective_crop_ui.rs @@ -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, + /// Corners clicked so far, before the fourth makes the quad. + pub clicks: Vec<[f64; 2]>, + /// The document the pending quad belongs to. + doc: Option, +} + +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::() / 4.0, points.iter().map(|p| p[1]).sum::() / 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 = 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 = 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]]); + } +} diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index 0c979a2f6e..622b2d4857 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -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; diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 2d9b9c5655..d3a75d37e5 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -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)); diff --git a/crates/ui-egui/src/tool_reset.rs b/crates/ui-egui/src/tool_reset.rs index 98435db163..89a1c0dc2e 100644 --- a/crates/ui-egui/src/tool_reset.rs +++ b/crates/ui-egui/src/tool_reset.rs @@ -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), diff --git a/docs/gaps.md b/docs/gaps.md index 6a46f7813e..d0419643d7 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -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 `` 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. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 55d8d3a6d1..ecf7f99f83 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -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 diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 4d958f0e22..38c3a58f20 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -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 | |