mirror of
https://github.com/storytold/photocraft.git
synced 2026-11-02 18:13:39 +00:00
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:
+2
-1
@@ -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 |
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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")),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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]]);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
@@ -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 | |
|
||||
|
||||
Reference in New Issue
Block a user