effectcraft/crates/psd/src/placed.rs

411 lines
19 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! Placed-layer (smart object) geometry: the non-affine corner quad (a projective map of the
//! content rectangle) and the placed-layer warp (`warp` in `SoLd`: the named warp styles with
//! bend and horizontal / vertical distortion, and custom envelope warps whose quilt mesh is a
//! grid of bicubic Bézier patches), and a renderer that bakes both into document-space pixels.
//!
//! The custom mesh is evaluated exactly (bicubic Bézier patches over `meshPoints`, split by
//! `quiltSliceX` / `quiltSliceY`). Photoshop does not publish the curves of its named styles;
//! the styles here are clean-room geometric definitions of what each name describes (Arc,
//! Arch, Bulge, Shell, Flag, Wave, Fish, Rise, Fisheye, Inflate, Squeeze, Twist), matched by
//! behaviour, not numerically.
use crate::descriptor::{DValue, Descriptor};
use crate::{Pixels, SmartObject};
/// A placed layer's warp.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Warp {
/// `warpNone`, `warpArc`, `warpArcLower`, `warpArcUpper`, `warpArch`, `warpBulge`,
/// `warpShellLower`, `warpShellUpper`, `warpFlag`, `warpWave`, `warpFish`, `warpRise`,
/// `warpFisheye`, `warpInflate`, `warpSqueeze`, `warpTwist`, `warpCustom`.
pub style: String,
/// Bend, −100..100 (%).
pub bend: f64,
/// Horizontal and vertical distortion, −100..100 (%).
pub horizontal: f64,
pub vertical: f64,
/// The style is applied rotated a quarter turn (`warpRotate` = vertical).
pub rotate_vertical: bool,
/// The warped area in content pixels: left, top, right, bottom.
pub bounds: [f64; 4],
/// Custom envelope: patch columns / rows and the (3·cols+1)×(3·rows+1) control points
/// (content pixels, row-major).
pub cols: usize,
pub rows: usize,
pub mesh: Vec<[f64; 2]>,
}
fn floats(v: Option<&DValue>) -> Vec<f64> {
match v {
Some(DValue::UnitFloats(_, v)) => v.clone(),
Some(DValue::List(l)) => l
.iter()
.filter_map(|x| match x {
DValue::Double(v) | DValue::UnitFloat(_, v) => Some(*v),
DValue::Integer(i) => Some(*i as f64),
_ => None,
})
.collect(),
_ => vec![],
}
}
impl Warp {
/// Read a `warp` descriptor; `None` for no warp.
pub fn read(d: &Descriptor) -> Option<Warp> {
let style = d.enum_value("warpStyle").unwrap_or("warpNone").to_string();
let b = d.obj("bounds");
let bounds = b
.map(|b| [b.num("Left").unwrap_or(0.0), b.num("Top ").unwrap_or(0.0), b.num("Rght").unwrap_or(0.0), b.num("Btom").unwrap_or(0.0)])
.unwrap_or([0.0; 4]);
let mut w = Warp {
bend: d.num("warpValue").unwrap_or(0.0),
horizontal: d.num("warpPerspective").unwrap_or(0.0),
vertical: d.num("warpPerspectiveOther").unwrap_or(0.0),
rotate_vertical: d.enum_value("warpRotate") == Some("Vrtc"),
bounds,
cols: 1,
rows: 1,
mesh: vec![],
style,
};
if let Some(env) = d.obj("customEnvelopeWarp") {
let cols = floats(env.get("quiltSliceX")).len().saturating_sub(1).max(1);
let rows = floats(env.get("quiltSliceY")).len().saturating_sub(1).max(1);
if let Some(DValue::ObjectArray(_, pts)) = env.get("meshPoints") {
let xs = floats(pts.get("Hrzn"));
let ys = floats(pts.get("Vrtc"));
let n = (3 * cols + 1) * (3 * rows + 1);
if xs.len() == n && ys.len() == n {
w.cols = cols;
w.rows = rows;
w.mesh = xs.into_iter().zip(ys).map(|(x, y)| [x, y]).collect();
}
}
}
let identity = w.style == "warpNone" || (w.style != "warpCustom" && w.bend == 0.0 && w.horizontal == 0.0 && w.vertical == 0.0);
let custom_ok = w.style != "warpCustom" || !w.mesh.is_empty();
(!identity && custom_ok).then_some(w)
}
/// The warp of a content point (content pixels → content pixels).
pub fn apply(&self, p: [f64; 2], w: f64, h: f64) -> [f64; 2] {
let [l, t, r, b] = if self.bounds[2] > self.bounds[0] && self.bounds[3] > self.bounds[1] { self.bounds } else { [0.0, 0.0, w, h] };
let (bw, bh) = (r - l, b - t);
let (u, v) = ((p[0] - l) / bw, (p[1] - t) / bh);
if self.style == "warpCustom" {
return self.mesh_point(u.clamp(0.0, 1.0), v.clamp(0.0, 1.0));
}
// Styles work in a centred frame (pixels), optionally rotated a quarter turn.
let (x, y, fw, fh) = if self.rotate_vertical { (bh * (v - 0.5), -bw * (u - 0.5), bh, bw) } else { (bw * (u - 0.5), bh * (v - 0.5), bw, bh) };
let [mut x2, mut y2] = style_map(&self.style, self.bend / 100.0, x, y, fw, fh);
// Distortions: a perspective-like taper across the width (horizontal) or height.
let (hd, vd) = if self.rotate_vertical { (self.vertical / 100.0, self.horizontal / 100.0) } else { (self.horizontal / 100.0, self.vertical / 100.0) };
let s = (x2 / (fw / 2.0)).clamp(-1.5, 1.5);
let tt = (y2 / (fh / 2.0)).clamp(-1.5, 1.5);
y2 *= 1.0 + hd * s;
x2 *= 1.0 + vd * tt;
let (ox, oy) = if self.rotate_vertical { (-y2, x2) } else { (x2, y2) };
[l + bw / 2.0 + ox, t + bh / 2.0 + oy]
}
/// A point of the custom quilt mesh at (u, v) ∈ [0, 1]².
fn mesh_point(&self, u: f64, v: f64) -> [f64; 2] {
let (cols, rows) = (self.cols, self.rows);
let pu = (u * cols as f64).min(cols as f64 - 1e-9);
let pv = (v * rows as f64).min(rows as f64 - 1e-9);
let (ci, cj) = (pu.floor() as usize, pv.floor() as usize);
let (fu, fv) = (pu - ci as f64, pv - cj as f64);
let stride = 3 * cols + 1;
let bern = |t: f64| {
let s = 1.0 - t;
[s * s * s, 3.0 * s * s * t, 3.0 * s * t * t, t * t * t]
};
let (bu, bv) = (bern(fu), bern(fv));
let mut out = [0.0; 2];
for j in 0..4 {
for i in 0..4 {
let q = self.mesh[(3 * cj + j) * stride + 3 * ci + i];
let k = bu[i] * bv[j];
out[0] += q[0] * k;
out[1] += q[1] * k;
}
}
out
}
}
/// A named style in a centred frame `fw`×`fh` (y down), bend `b` ∈ [−1, 1].
fn style_map(style: &str, b: f64, x: f64, y: f64, fw: f64, fh: f64) -> [f64; 2] {
use std::f64::consts::PI;
let (hw, hh) = (fw / 2.0, fh / 2.0);
let s = x / hw; // −1..1 across
let t = y / hh; // −1..1 down
// Concentric arcs: the midline bends through an angle of b·π, rows keep their distance.
let arc = |x: f64, y: f64| -> [f64; 2] {
let theta = b * PI;
if theta.abs() < 1e-9 {
return [x, y];
}
let r = fw / theta;
let phi = x / hw * theta / 2.0;
let rr = r - y;
// Arc centre below the shape for upward bends; keep the midline's centre fixed.
[rr * phi.sin(), r - rr * phi.cos()]
};
let bump = 1.0 - s * s;
match style {
"warpArc" => arc(x, y),
"warpArcLower" => {
let a = arc(x, y);
let k = (t + 1.0) / 2.0;
[x + (a[0] - x) * k, y + (a[1] - y) * k]
}
"warpArcUpper" => {
let a = arc(x, y);
let k = (1.0 - t) / 2.0;
[x + (a[0] - x) * k, y + (a[1] - y) * k]
}
"warpArch" => [x, y - b * hh * bump],
"warpBulge" => [x, y + t * b * hh * bump],
"warpShellLower" => [x * (1.0 - b * 0.25 * (1.0 - t) / 2.0), y + (t + 1.0) / 2.0 * b * hh * bump],
"warpShellUpper" => [x * (1.0 - b * 0.25 * (t + 1.0) / 2.0), y - (1.0 - t) / 2.0 * b * hh * bump],
"warpFlag" => [x, y - b * hh * 0.5 * (PI * s).sin()],
"warpWave" => [x + b * hw * 0.1 * (PI * t).sin(), y - b * hh * 0.5 * (PI * s).sin() * (1.0 - 0.5 * t)],
"warpFish" => [x, y + t * b * hh * (PI * (s + 1.0) / 2.0).sin() * (1.0 - 0.5 * (s + 1.0) / 2.0)],
"warpRise" => [x, y - b * hh * (PI * s / 2.0).sin()],
"warpFisheye" | "warpInflate" => {
let k = if style == "warpFisheye" { (1.0 - (s * s + t * t) / 2.0).max(0.0) } else { 1.0 };
[x * (1.0 + b * 0.5 * k * (1.0 - t * t)), y * (1.0 + b * 0.5 * k * bump)]
}
"warpSqueeze" => [x * (1.0 - b * 0.5 * (1.0 - t * t)), y * (1.0 + b * 0.5 * bump)],
"warpTwist" => {
let r = ((s * s + t * t) / 2.0).sqrt().min(1.0);
let a = b * PI / 2.0 * (1.0 - r);
let (sn, cs) = a.sin_cos();
[x * cs - y * sn, x * sn + y * cs]
}
_ => [x, y],
}
}
/// The projective map taking the unit square's corners (0,0), (1,0), (1,1), (0,1) to `q`
/// (Heckbert's square-to-quad), as a 3×3 matrix applied to (u, v, 1).
fn square_to_quad(q: [[f64; 2]; 4]) -> [[f64; 3]; 3] {
let [p0, p1, p2, p3] = q;
let sx = p0[0] - p1[0] + p2[0] - p3[0];
let sy = p0[1] - p1[1] + p2[1] - p3[1];
if sx.abs() < 1e-12 && sy.abs() < 1e-12 {
return [[p1[0] - p0[0], p2[0] - p1[0], p0[0]], [p1[1] - p0[1], p2[1] - p1[1], p0[1]], [0.0, 0.0, 1.0]];
}
let (dx1, dx2) = (p1[0] - p2[0], p3[0] - p2[0]);
let (dy1, dy2) = (p1[1] - p2[1], p3[1] - p2[1]);
let den = dx1 * dy2 - dx2 * dy1;
let g = (sx * dy2 - dx2 * sy) / den;
let h = (dx1 * sy - sx * dy1) / den;
[[p1[0] - p0[0] + g * p1[0], p3[0] - p0[0] + h * p3[0], p0[0]], [p1[1] - p0[1] + g * p1[1], p3[1] - p0[1] + h * p3[1], p0[1]], [g, h, 1.0]]
}
impl SmartObject {
/// Whether the corner quad is a parallelogram (an affine placement).
pub fn is_affine(&self) -> bool {
let q = self.quad;
let ex = q[0][0] + q[2][0] - q[1][0] - q[3][0];
let ey = q[0][1] + q[2][1] - q[1][1] - q[3][1];
ex.abs() < 1e-3 && ey.abs() < 1e-3
}
/// Whether placing needs baking (a perspective quad or a warp).
pub fn needs_bake(&self) -> bool {
!self.is_affine() || self.warp.is_some()
}
/// A content point (pixels of a `w`×`h` content) in document pixels: the warp, then the quad.
pub fn map(&self, p: [f64; 2], w: f64, h: f64) -> [f64; 2] {
let p = match &self.warp {
Some(wp) => wp.apply(p, w, h),
None => p,
};
let m = square_to_quad(self.quad);
let (u, v) = (p[0] / w.max(1e-9), p[1] / h.max(1e-9));
let z = m[2][0] * u + m[2][1] * v + m[2][2];
let z = if z.abs() < 1e-12 { 1e-12 } else { z };
[(m[0][0] * u + m[0][1] * v + m[0][2]) / z, (m[1][0] * u + m[1][1] * v + m[1][2]) / z]
}
/// The document-space bounds (x0, y0, x1, y1) of the placed (warped) content.
pub fn placed_bounds(&self, w: f64, h: f64) -> [f64; 4] {
let n = 32;
let mut b = [f64::MAX, f64::MAX, f64::MIN, f64::MIN];
for j in 0..=n {
for i in 0..=n {
let p = self.map([i as f64 / n as f64 * w, j as f64 / n as f64 * h], w, h);
b = [b[0].min(p[0]), b[1].min(p[1]), b[2].max(p[0]), b[3].max(p[1])];
}
}
[b[0].floor(), b[1].floor(), b[2].ceil(), b[3].ceil()]
}
/// The baked size for a `w`×`h` content at `k` pixels per document pixel.
pub fn placed_size(&self, w: f64, h: f64, k: f64) -> (u32, u32) {
let bb = self.placed_bounds(w, h);
(((bb[2] - bb[0]) * k).ceil().clamp(1.0, 16384.0) as u32, ((bb[3] - bb[1]) * k).ceil().clamp(1.0, 16384.0) as u32)
}
/// Bake the content (straight RGBA) as placed: warped and mapped by the quad, rendered at
/// `k` pixels per document pixel (into `size` pixels when given; see [`Self::placed_size`]).
/// Returns the pixels and their document-space origin.
pub fn render_placed(&self, content: &Pixels, k: f64, size: Option<(u32, u32)>) -> Option<(Pixels, [f64; 2])> {
let (cw, ch) = (content.width as f64, content.height as f64);
if content.width == 0 || content.height == 0 {
return None;
}
let bb = self.placed_bounds(cw, ch);
let (ow, oh) = match size {
Some((w, h)) => (w.max(1), h.max(1)),
None => self.placed_size(cw, ch, k),
};
let (ow, oh) = (ow as usize, oh as usize);
// A forward grid, filled triangle by triangle with inverse barycentric sampling.
let n = 48usize;
let mut dst = Vec::with_capacity((n + 1) * (n + 1));
let mut src = Vec::with_capacity((n + 1) * (n + 1));
for j in 0..=n {
for i in 0..=n {
let s = [i as f64 / n as f64 * cw, j as f64 / n as f64 * ch];
let d = self.map(s, cw, ch);
dst.push([(d[0] - bb[0]) * k, (d[1] - bb[1]) * k]);
src.push(s);
}
}
let mut out = vec![[0.0f32; 4]; ow * oh];
let sample = |x: f64, y: f64| -> [f32; 4] {
let (fx, fy) = ((x - 0.5).clamp(0.0, cw - 1.0), (y - 0.5).clamp(0.0, ch - 1.0));
let (x0, y0) = (fx.floor() as usize, fy.floor() as usize);
let (x1, y1) = ((x0 + 1).min(content.width as usize - 1), (y0 + 1).min(content.height as usize - 1));
let (a, b) = ((fx - x0 as f64) as f32, (fy - y0 as f64) as f32);
let at = |x: usize, y: usize| {
let p = content.data[y * content.width as usize + x];
[p[0] * p[3], p[1] * p[3], p[2] * p[3], p[3]]
};
let (p00, p10, p01, p11) = (at(x0, y0), at(x1, y0), at(x0, y1), at(x1, y1));
let mut o = [0.0; 4];
for c in 0..4 {
o[c] = (p00[c] * (1.0 - a) + p10[c] * a) * (1.0 - b) + (p01[c] * (1.0 - a) + p11[c] * a) * b;
}
o
};
let idx = |i: usize, j: usize| j * (n + 1) + i;
for j in 0..n {
for i in 0..n {
for tri in [[idx(i, j), idx(i + 1, j), idx(i + 1, j + 1)], [idx(i, j), idx(i + 1, j + 1), idx(i, j + 1)]] {
let [a, b, c] = tri.map(|t| dst[t]);
let den = (b[1] - c[1]) * (a[0] - c[0]) + (c[0] - b[0]) * (a[1] - c[1]);
if den.abs() < 1e-12 {
continue;
}
let x0 = a[0].min(b[0]).min(c[0]).floor().max(0.0) as usize;
let x1 = (a[0].max(b[0]).max(c[0]).ceil() as usize).min(ow);
let y0 = a[1].min(b[1]).min(c[1]).floor().max(0.0) as usize;
let y1 = (a[1].max(b[1]).max(c[1]).ceil() as usize).min(oh);
for y in y0..y1 {
for x in x0..x1 {
let (px, py) = (x as f64 + 0.5, y as f64 + 0.5);
let l1 = ((b[1] - c[1]) * (px - c[0]) + (c[0] - b[0]) * (py - c[1])) / den;
let l2 = ((c[1] - a[1]) * (px - c[0]) + (a[0] - c[0]) * (py - c[1])) / den;
let l3 = 1.0 - l1 - l2;
if l1 < -1e-9 || l2 < -1e-9 || l3 < -1e-9 {
continue;
}
let [sa, sb, sc] = tri.map(|t| src[t]);
let sx = sa[0] * l1 + sb[0] * l2 + sc[0] * l3;
let sy = sa[1] * l1 + sb[1] * l2 + sc[1] * l3;
out[y * ow + x] = sample(sx, sy);
}
}
}
}
}
// Back to straight alpha.
for p in &mut out {
if p[3] > 0.0 {
for c in 0..3 {
p[c] /= p[3];
}
}
}
Some((Pixels { width: ow as u32, height: oh as u32, data: out }, [bb[0], bb[1]]))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn so(quad: [[f64; 2]; 4], warp: Option<Warp>) -> SmartObject {
SmartObject { uuid: "x".into(), quad, size: Some([20.0, 10.0]), warp }
}
#[test]
fn perspective_quad_maps_corners_and_is_projective() {
let q = [[10.0, 10.0], [50.0, 0.0], [50.0, 40.0], [10.0, 30.0]];
let s = so(q, None);
assert!(!s.is_affine() && s.needs_bake());
for (c, want) in [([0.0, 0.0], q[0]), ([20.0, 0.0], q[1]), ([20.0, 10.0], q[2]), ([0.0, 10.0], q[3])] {
let p = s.map(c, 20.0, 10.0);
assert!((p[0] - want[0]).abs() < 1e-9 && (p[1] - want[1]).abs() < 1e-9, "{c:?} → {p:?}");
}
// Projective, not bilinear: the content centre is where the diagonals cross.
let c = s.map([10.0, 5.0], 20.0, 10.0);
assert!((c[0] - 30.0).abs() > 0.5, "{c:?}");
let affine = so([[0.0, 0.0], [20.0, 0.0], [20.0, 10.0], [0.0, 10.0]], None);
assert!(affine.is_affine() && !affine.needs_bake());
}
#[test]
fn custom_mesh_and_styles_warp() {
// An identity 4×4 mesh shifted 3 px right in its interior row: interior points move.
let mut mesh = vec![];
for j in 0..4 {
for i in 0..4 {
mesh.push([i as f64 * 20.0 / 3.0 + if j == 1 || j == 2 { 3.0 } else { 0.0 }, j as f64 * 10.0 / 3.0]);
}
}
let w = Warp { style: "warpCustom".into(), bounds: [0.0, 0.0, 20.0, 10.0], cols: 1, rows: 1, mesh, ..Default::default() };
let p = w.apply([10.0, 5.0], 20.0, 10.0);
assert!((p[0] - 12.25).abs() < 1e-9 && (p[1] - 5.0).abs() < 1e-9, "{p:?}");
assert_eq!(w.apply([0.0, 0.0], 20.0, 10.0), [0.0, 0.0]);
// Arc: the top centre rises, the bottom centre too (concentric arcs); Bulge: top up,
// bottom down; Flag keeps the centre.
let style = |s: &str| Warp { style: s.into(), bend: 50.0, bounds: [0.0, 0.0, 200.0, 100.0], ..Default::default() };
let top = style("warpArc").apply([100.0, 0.0], 200.0, 100.0);
let corner = style("warpArc").apply([0.0, 0.0], 200.0, 100.0);
assert!(top[1] < corner[1], "arc {top:?} {corner:?}");
let b = style("warpBulge");
assert!(b.apply([100.0, 0.0], 200.0, 100.0)[1] < 0.0 && b.apply([100.0, 100.0], 200.0, 100.0)[1] > 100.0);
assert!((style("warpFlag").apply([100.0, 50.0], 200.0, 100.0)[1] - 50.0).abs() < 1e-9);
// Vertical rotation swaps the roles of the axes.
let mut v = style("warpBulge");
v.rotate_vertical = true;
let l = v.apply([0.0, 50.0], 200.0, 100.0);
assert!(l[0] < 0.0 && (l[1] - 50.0).abs() < 1e-9, "{l:?}");
// Horizontal distortion: the right side grows taller.
let d = Warp { style: "warpArc".into(), horizontal: 50.0, bounds: [0.0, 0.0, 200.0, 100.0], ..Default::default() };
assert!(d.apply([200.0, 0.0], 200.0, 100.0)[1] < d.apply([0.0, 0.0], 200.0, 100.0)[1]);
}
#[test]
fn render_placed_bakes_a_perspective_quad() {
// 2×1 content: red | green, pinned to a trapezoid.
let content = Pixels { width: 2, height: 1, data: vec![[1.0, 0.0, 0.0, 1.0], [0.0, 1.0, 0.0, 1.0]] };
let s = SmartObject { uuid: "x".into(), quad: [[0.0, 0.0], [40.0, 5.0], [40.0, 15.0], [0.0, 20.0]], size: None, warp: None };
let (px, origin) = s.render_placed(&content, 1.0, None).unwrap();
assert_eq!(origin, [0.0, 0.0]);
assert_eq!((px.width, px.height), (40, 20));
let at = |x: usize, y: usize| px.data[y * 40 + x];
assert!(at(5, 10)[0] > 0.9 && at(5, 10)[3] > 0.99, "{:?}", at(5, 10));
assert!(at(35, 10)[1] > 0.9, "{:?}", at(35, 10));
assert_eq!(at(38, 1)[3], 0.0, "outside the trapezoid");
}
}