Edit › Transform › Distort / Perspective and Warp on a shape layer let the
user edit the box or mesh and then refused at commit ("needs a rasterized
shape"). Photoshop maps the shape's path and keeps it a shape.
- photocraft-vector: new `map` module. `map_points` sends every anchor and
control point through a map (Distort / Perspective: exact for straight
segments, exact end tangents for curves). `map_path` follows a curved map
(Warp), halving each segment until the mapped control points follow the
mapped curve within a tolerance (at most 64 pieces a segment, bounded in
total); straight segments whose image is straight stay straight.
- engine: `transform_layer` and `warp_layer` map a shape's path instead of
refusing; a live rectangle/ellipse/polygon/line becomes a plain path. A
linked vector mask now follows Distort / Perspective / Warp too. A map that
sends a point to infinity is an error, not a corrupt path.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
f435eb0b55
commit
8458cf7095
|
|
@ -247,12 +247,11 @@ pub(crate) fn transform_layer(doc_sel: Option<&Surface>, group: Locks, l: &mut L
|
|||
};
|
||||
t.transform = a.mul(&t.transform);
|
||||
}
|
||||
LayerContent::Shape(sh) => {
|
||||
let Some(a) = affine else {
|
||||
return Err(EngineError::Other("Distort and Perspective need a rasterized shape (Layer › Rasterize › Shape)".into()));
|
||||
};
|
||||
crate::vector_cmds::transform_shape(sh, &a);
|
||||
}
|
||||
LayerContent::Shape(sh) => match affine {
|
||||
Some(a) => crate::vector_cmds::transform_shape(sh, &a),
|
||||
// Distort and Perspective map the path and keep the shape (#3009).
|
||||
None => crate::vector_cmds::map_shape(sh, Some(h), None)?,
|
||||
},
|
||||
LayerContent::Smart(sm) => {
|
||||
// Smart objects keep the transform and re-render from their source afterwards
|
||||
// (`refresh_text`), so repeated transforms don't degrade the pixels. Distort and
|
||||
|
|
@ -297,9 +296,11 @@ pub(crate) fn transform_layer(doc_sel: Option<&Surface>, group: Locks, l: &mut L
|
|||
}
|
||||
if let Some(vm) = l.vector_mask.as_mut()
|
||||
&& vm.linked
|
||||
&& let Some(a) = affine
|
||||
{
|
||||
vm.path = vm.path.transform(&a);
|
||||
vm.path = match affine {
|
||||
Some(a) => vm.path.transform(&a),
|
||||
None => crate::vector_cmds::map_path(&vm.path, Some(h), None)?,
|
||||
};
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -634,7 +635,24 @@ mod tests {
|
|||
assert_eq!(active_bounds(&s), Rect::new(40, 15, 80, 35));
|
||||
s.execute("edit.transform", json!({"matrix": [2, 0, 0, 2, -40, -15]})).unwrap();
|
||||
assert_eq!(active_bounds(&s), Rect::new(40, 15, 120, 55));
|
||||
assert!(s.execute("edit.transform", json!({"quad": [[40, 15], [120, 15], [130, 55], [30, 55]]})).is_err());
|
||||
// Distort maps the path and keeps a live shape (#3009): the rectangle's corners go to the quad.
|
||||
let quad = [[40.0, 15.0], [120.0, 15.0], [130.0, 55.0], [30.0, 55.0]];
|
||||
s.execute("edit.transform", json!({ "quad": quad })).unwrap();
|
||||
let st = s.active().unwrap();
|
||||
let LayerContent::Shape(sh) = &st.doc.layer(st.active_layer.unwrap()).unwrap().content else { panic!("still a shape layer") };
|
||||
let mut anchors: Vec<[f64; 2]> = sh.path.subpaths[0].knots.iter().map(|k| [k.anchor.x, k.anchor.y]).collect();
|
||||
let mut want = quad.to_vec();
|
||||
anchors.sort_by(|a, b| a.partial_cmp(b).unwrap());
|
||||
want.sort_by(|a, b| a.partial_cmp(b).unwrap());
|
||||
assert!(anchors.iter().zip(&want).all(|(a, b)| (a[0] - b[0]).abs() < 1e-6 && (a[1] - b[1]).abs() < 1e-6), "{anchors:?}");
|
||||
assert!(sh.live.is_none(), "the live rectangle is now a path");
|
||||
assert_eq!(active_bounds(&s), Rect::new(30, 15, 130, 55));
|
||||
// Warp bends the path too: the arc adds anchors along the curved edges.
|
||||
s.execute("edit.transform.warp", json!({"style": "arc", "bend": 50})).unwrap();
|
||||
let st = s.active().unwrap();
|
||||
let LayerContent::Shape(sh) = &st.doc.layer(st.active_layer.unwrap()).unwrap().content else { panic!("still a shape layer") };
|
||||
assert!(sh.path.subpaths[0].knots.len() > 4, "{}", sh.path.subpaths[0].knots.len());
|
||||
assert!(!active_bounds(&s).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -547,6 +547,46 @@ pub fn transform_shape(sh: &mut ShapeLayer, a: &Affine) {
|
|||
sh.psd_raw = None;
|
||||
}
|
||||
|
||||
/// Is every anchor and control point of `path` a finite point?
|
||||
fn finite_path(path: &Path) -> bool {
|
||||
path.subpaths.iter().flat_map(|s| &s.knots).all(|k| [k.anchor, k.in_ctrl, k.out_ctrl].iter().all(|p| p.x.is_finite() && p.y.is_finite()))
|
||||
}
|
||||
|
||||
/// `path` through Distort / Perspective (`h`, a projective map of its points: exact for straight
|
||||
/// segments, the end tangents of curves exact) or, with `warp`, Warp (split where the warp bends
|
||||
/// it). Refused when the map sends a point to infinity (past a perspective's horizon).
|
||||
pub(crate) fn map_path(path: &Path, h: Option<&photocraft_algo::transform::Homography>, warp: Option<&photocraft_geom::warp::Warp>) -> Result<Path> {
|
||||
let out = match (h, warp) {
|
||||
(_, Some(w)) => vector::map::map_path(
|
||||
path,
|
||||
|p| {
|
||||
let (x, y) = w.map(p.x, p.y);
|
||||
Point::new(x, y)
|
||||
},
|
||||
0.1,
|
||||
),
|
||||
(Some(h), None) => vector::map::map_points(path, |p| {
|
||||
let (x, y) = h.apply(p.x, p.y);
|
||||
Point::new(x, y)
|
||||
}),
|
||||
(None, None) => path.clone(),
|
||||
};
|
||||
if !finite_path(&out) {
|
||||
return Err(EngineError::Other("the distortion sends part of the path out of bounds".into()));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Distort, Perspective or Warp of a shape layer (#3009): the shape stays a shape, its path
|
||||
/// mapped ([`map_path`]), as in Photoshop. A live rectangle, ellipse, polygon or line becomes a
|
||||
/// plain path, as its parameters can't describe the distorted outline.
|
||||
pub(crate) fn map_shape(sh: &mut ShapeLayer, h: Option<&photocraft_algo::transform::Homography>, warp: Option<&photocraft_geom::warp::Warp>) -> Result<()> {
|
||||
sh.path = map_path(&sh.path, h, warp)?;
|
||||
sh.live = None;
|
||||
sh.psd_raw = None;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn shape_info(s: &Session, id: LayerId) -> Result<Value> {
|
||||
let d = s.active().ok_or(EngineError::NoDocument)?;
|
||||
let l = d.doc.layer(id).ok_or(EngineError::NoLayer(id))?;
|
||||
|
|
|
|||
|
|
@ -128,7 +128,8 @@ fn warp_layer(doc_sel: Option<&Surface>, group: Locks, l: &mut Layer, w: &Warp,
|
|||
}
|
||||
}
|
||||
LayerContent::Text(_) => return Err(EngineError::Other("Warp needs rasterized type (or use Type › Warp Text)".into())),
|
||||
LayerContent::Shape(_) => return Err(EngineError::Other("Warp needs a rasterized shape (Layer › Rasterize › Shape)".into())),
|
||||
// The path is warped and the shape stays a shape (#3009).
|
||||
LayerContent::Shape(sh) => crate::vector_cmds::map_shape(sh, None, Some(w))?,
|
||||
LayerContent::Smart(sm) => {
|
||||
// Store in source space: undo the placement (affine, or Distort / Perspective) around the warp.
|
||||
let inv = crate::smart_cmds::placement(sm).inverse().ok_or_else(|| EngineError::Other("the smart object's transform is degenerate".into()))?;
|
||||
|
|
@ -178,6 +179,11 @@ fn warp_layer(doc_sel: Option<&Surface>, group: Locks, l: &mut Layer, w: &Warp,
|
|||
{
|
||||
m.surface = warp_mesh_gray(&m.surface, &map, interp);
|
||||
}
|
||||
if let Some(vm) = l.vector_mask.as_mut()
|
||||
&& vm.linked
|
||||
{
|
||||
vm.path = crate::vector_cmds::map_path(&vm.path, None, Some(w))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
//!
|
||||
//! * [`edit`]: point-level path editing (Direct Selection, Convert Point) and hit testing.
|
||||
//! * [`flatten`]: cubic Bézier flattening with a distance tolerance.
|
||||
//! * [`map`]: non-affine maps of paths (Distort, Perspective, Warp).
|
||||
//! * [`raster`]: exact area-coverage scanline rasterizer (non-zero / even-odd per component,
|
||||
//! boolean combination of components, inversion).
|
||||
//! * [`stroke`]: polygon stroking with miter/round/bevel joins, butt/round/square caps, dashes.
|
||||
|
|
@ -20,6 +21,7 @@
|
|||
|
||||
pub mod edit;
|
||||
pub mod flatten;
|
||||
pub mod map;
|
||||
pub mod raster;
|
||||
pub mod shapes;
|
||||
pub mod stroke;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,176 @@
|
|||
//! Non-affine maps of paths, so shape layers and vector masks stay vectors through Edit ›
|
||||
//! Transform › Distort, Perspective and Warp (#3009).
|
||||
//!
|
||||
//! * [`map_points`] sends every anchor and control point through the map and keeps the knots as
|
||||
//! they are: what a projective map (Distort, Perspective) needs. Straight segments stay exactly
|
||||
//! straight; a curve keeps its exact end tangents and approximates the (rational) image between.
|
||||
//! * [`map_path`] follows a curved map (Warp): it splits each segment where the mapped control
|
||||
//! points stray from the mapped curve by more than a tolerance, so the result adds anchors where
|
||||
//! the warp bends it.
|
||||
|
||||
use photocraft_doc::{Knot, Path, Subpath};
|
||||
use photocraft_geom::Point;
|
||||
|
||||
/// Every anchor and control point of `path` through `f` (no subdivision).
|
||||
pub fn map_points(path: &Path, f: impl Fn(Point) -> Point) -> Path {
|
||||
let mut out = path.clone();
|
||||
for s in &mut out.subpaths {
|
||||
for k in &mut s.knots {
|
||||
*k = Knot { anchor: f(k.anchor), in_ctrl: f(k.in_ctrl), out_ctrl: f(k.out_ctrl), smooth: k.smooth };
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Pieces a path may be split into, in total: bounds the work and memory on hostile input.
|
||||
const MAX_PIECES: usize = 1 << 20;
|
||||
/// Deepest split of one segment (2^6 = 64 pieces).
|
||||
const MAX_DEPTH: u32 = 6;
|
||||
|
||||
/// `path` through a smooth, possibly non-linear map `f`: each segment is split in halves until its
|
||||
/// mapped control points follow the mapped curve within `tol` px (at most 64 pieces a segment).
|
||||
/// A straight segment whose image is straight stays a straight segment.
|
||||
pub fn map_path(path: &Path, f: impl Fn(Point) -> Point, tol: f64) -> Path {
|
||||
let tol = if tol.is_finite() && tol > 0.0 { tol } else { 0.1 };
|
||||
let segments: usize = path.subpaths.iter().map(|s| s.knots.len()).sum();
|
||||
// Fewer splits for huge paths, so the result stays bounded.
|
||||
let mut depth = MAX_DEPTH;
|
||||
while depth > 0 && segments.saturating_mul(1 << depth) > MAX_PIECES {
|
||||
depth -= 1;
|
||||
}
|
||||
let mut out = path.clone();
|
||||
out.subpaths = path.subpaths.iter().map(|s| map_subpath(s, &f, tol, depth)).collect();
|
||||
out
|
||||
}
|
||||
|
||||
fn map_subpath(s: &Subpath, f: &impl Fn(Point) -> Point, tol: f64, depth: u32) -> Subpath {
|
||||
let n = s.knots.len();
|
||||
let segs = s.segments();
|
||||
if segs.is_empty() {
|
||||
return map_points(&Path::new(vec![s.clone()]), f).subpaths.pop().unwrap_or_default();
|
||||
}
|
||||
// The mapped pieces of each segment (segment i runs from knot i to knot i + 1).
|
||||
let pieces: Vec<Vec<[Point; 4]>> = segs.iter().map(|seg| map_segment(seg, f, tol, depth)).collect();
|
||||
let mut knots = Vec::with_capacity(pieces.iter().map(Vec::len).sum::<usize>() + 1);
|
||||
for (i, k) in s.knots.iter().enumerate() {
|
||||
let incoming = if i > 0 {
|
||||
pieces.get(i - 1)
|
||||
} else if s.closed {
|
||||
pieces.get(n - 1)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let in_ctrl = incoming.and_then(|p| p.last()).map_or_else(|| f(k.in_ctrl), |p| p[2]);
|
||||
let outgoing = pieces.get(i);
|
||||
let out_ctrl = outgoing.and_then(|p| p.first()).map_or_else(|| f(k.out_ctrl), |p| p[1]);
|
||||
knots.push(Knot { anchor: f(k.anchor), in_ctrl, out_ctrl, smooth: k.smooth });
|
||||
// The anchors the split added inside segment i: smooth, as the curve runs through them.
|
||||
if let Some(p) = outgoing {
|
||||
for w in p.windows(2) {
|
||||
knots.push(Knot { anchor: w[1][0], in_ctrl: w[0][2], out_ctrl: w[1][1], smooth: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
Subpath { closed: s.closed, knots, op: s.op }
|
||||
}
|
||||
|
||||
fn lerp(a: Point, b: Point, t: f64) -> Point {
|
||||
Point::new(a.x + (b.x - a.x) * t, a.y + (b.y - a.y) * t)
|
||||
}
|
||||
|
||||
fn dist(a: Point, b: Point) -> f64 {
|
||||
(a.x - b.x).hypot(a.y - b.y)
|
||||
}
|
||||
|
||||
fn eval(s: &[Point; 4], t: f64) -> Point {
|
||||
let (ab, bc, cd) = (lerp(s[0], s[1], t), lerp(s[1], s[2], t), lerp(s[2], s[3], t));
|
||||
lerp(lerp(ab, bc, t), lerp(bc, cd, t), t)
|
||||
}
|
||||
|
||||
/// De Casteljau split at `t = ½`.
|
||||
fn halves(s: &[Point; 4]) -> ([Point; 4], [Point; 4]) {
|
||||
let (ab, bc, cd) = (lerp(s[0], s[1], 0.5), lerp(s[1], s[2], 0.5), lerp(s[2], s[3], 0.5));
|
||||
let (abc, bcd) = (lerp(ab, bc, 0.5), lerp(bc, cd, 0.5));
|
||||
let m = lerp(abc, bcd, 0.5);
|
||||
([s[0], ab, abc, m], [m, bcd, cd, s[3]])
|
||||
}
|
||||
|
||||
/// Distance from `p` to the line through `a` and `b` (to `a` when they coincide).
|
||||
fn off_line(p: Point, a: Point, b: Point) -> f64 {
|
||||
let l = dist(a, b);
|
||||
if l < 1e-12 { dist(p, a) } else { ((b.x - a.x) * (a.y - p.y) - (a.x - p.x) * (b.y - a.y)).abs() / l }
|
||||
}
|
||||
|
||||
const PROBES: [f64; 3] = [0.25, 0.5, 0.75];
|
||||
|
||||
fn map_segment(seg: &[Point; 4], f: &impl Fn(Point) -> Point, tol: f64, depth: u32) -> Vec<[Point; 4]> {
|
||||
// A straight segment (retracted handles) whose image is straight stays one.
|
||||
if seg[1] == seg[0] && seg[2] == seg[3] {
|
||||
let (a, b) = (f(seg[0]), f(seg[3]));
|
||||
if PROBES.iter().all(|&t| off_line(f(lerp(seg[0], seg[3], t)), a, b) <= tol) {
|
||||
return vec![[a, a, b, b]];
|
||||
}
|
||||
// Curved by the map: the same line as an evenly parametrized cubic, then split.
|
||||
let line = [seg[0], lerp(seg[0], seg[3], 1.0 / 3.0), lerp(seg[0], seg[3], 2.0 / 3.0), seg[3]];
|
||||
let mut out = Vec::new();
|
||||
split_mapped(&line, f, tol, depth, &mut out);
|
||||
return out;
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
split_mapped(seg, f, tol, depth, &mut out);
|
||||
out
|
||||
}
|
||||
|
||||
fn split_mapped(seg: &[Point; 4], f: &impl Fn(Point) -> Point, tol: f64, depth: u32, out: &mut Vec<[Point; 4]>) {
|
||||
let mapped = seg.map(f);
|
||||
let fits = PROBES.iter().all(|&t| dist(f(eval(seg, t)), eval(&mapped, t)) <= tol);
|
||||
if fits || depth == 0 {
|
||||
out.push(mapped);
|
||||
return;
|
||||
}
|
||||
let (a, b) = halves(seg);
|
||||
split_mapped(&a, f, tol, depth - 1, out);
|
||||
split_mapped(&b, f, tol, depth - 1, out);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn square() -> Path {
|
||||
Path::new(vec![Subpath::polygon(&[(0.0, 0.0), (10.0, 0.0), (10.0, 10.0), (0.0, 10.0)])])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn map_points_moves_every_point_and_keeps_the_knots() {
|
||||
let p = map_points(&square(), |q| Point::new(q.x * 2.0, q.y + q.x));
|
||||
let k = &p.subpaths[0].knots;
|
||||
assert_eq!(k.len(), 4);
|
||||
assert_eq!(k[2].anchor, Point::new(20.0, 20.0));
|
||||
assert_eq!(k[2].in_ctrl, k[2].anchor);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn map_path_keeps_lines_that_stay_straight_and_splits_bent_ones() {
|
||||
// Linear map: no splits, straight segments stay straight.
|
||||
let p = map_path(&square(), |q| Point::new(q.x + q.y * 0.5, q.y), 0.1);
|
||||
assert_eq!(p.subpaths[0].knots.len(), 4);
|
||||
assert!(p.subpaths[0].closed);
|
||||
// A bend: the top edge (y = 0) arcs, so it gains anchors that lie on the mapped curve.
|
||||
let bend = |q: Point| Point::new(q.x, q.y - (q.x - 5.0).powi(2) * 0.2);
|
||||
let p = map_path(&square(), bend, 0.01);
|
||||
let k = &p.subpaths[0].knots;
|
||||
assert!(k.len() > 4, "{}", k.len());
|
||||
assert_eq!(k[0].anchor, bend(Point::new(0.0, 0.0)));
|
||||
for s in p.subpaths[0].segments() {
|
||||
let mid = eval(&s, 0.5);
|
||||
// Every point on the top edge maps to y = -(x - 5)² / 5.
|
||||
if s[0].y <= 0.0 && s[3].y <= 0.0 {
|
||||
assert!((mid.y + (mid.x - 5.0).powi(2) * 0.2).abs() < 0.05, "{mid:?}");
|
||||
}
|
||||
}
|
||||
// The vertical sides are only shifted, so they stay single straight segments.
|
||||
let right = k.iter().position(|k| k.anchor == bend(Point::new(10.0, 0.0))).unwrap();
|
||||
assert_eq!(k[right + 1].anchor, bend(Point::new(10.0, 10.0)));
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue