M13.6: PSD smart objects apply perspective quads and placed-layer warps

nonAffineTransform and the warp descriptor (named styles with bend / distortion, custom
envelope quilt meshes via ObAr meshPoints) are baked as placed over the placed bounds
(SourceLayer::placed), at up to 4x resolution.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-03 07:21:00 -04:00
parent 7d944ca494
commit 7b0a449c5f
10 changed files with 578 additions and 13 deletions

View File

@ -217,7 +217,7 @@ pub(crate) fn import(s: &mut Session, p: &Value) -> Result<Value> {
{
match s.services.read_file(path).ok().and_then(|b| effectcraft_psd::Psd::parse(b).ok()).and_then(|d| find_psd_layer(&d, sel)) {
Some((index, name)) => {
f.layer = Some(effectcraft_project::SourceLayer { index: index as u32, name, layer_size: false, embedded: None })
f.layer = Some(effectcraft_project::SourceLayer { index: index as u32, name, layer_size: false, embedded: None, placed: false })
}
None => {
errors.push(format!("{path}: no layer {sel}"));

View File

@ -158,7 +158,7 @@ fn footage_item(proj: &mut Project, cx: &mut Ctx, index: usize, name: &str) -> (
missing: false,
sequence: vec![],
color_profile: None,
layer: Some(SourceLayer { index: index as u32, name: name.to_string(), layer_size: retain && !l.rect.is_empty(), embedded: None }),
layer: Some(SourceLayer { index: index as u32, name: name.to_string(), layer_size: retain && !l.rect.is_empty(), embedded: None, placed: false }),
..Default::default()
};
let id = proj.add_item(&format!("{name}/{file}"), Label::Lavender, Some(cx.folder), ItemKind::Footage(f));
@ -191,8 +191,20 @@ fn smart_object_layer(proj: &mut Project, cx: &mut Ctx, comp: &Comp, index: usiz
let pl = &cx.psd.layers[index];
let so = pl.smart_object.clone()?;
let data = cx.psd.linked_data(&so.uuid)?;
let (w, h) = embedded_size(&so, data).filter(|s| s.0 > 0 && s.1 > 0)?;
let (cw, ch) = embedded_size(&so, data).filter(|s| s.0 > 0 && s.1 > 0)?;
let file = cx.psd.linked.iter().find(|f| f.uuid == so.uuid).map(|f| f.name.clone()).unwrap_or_default();
// Perspective quads and warps are baked over the placed bounds, at up to 4× the document
// resolution when the embedded content has that much detail.
let bake = so.needs_bake();
let (w, h, k, bounds) = if bake {
let bb = so.placed_bounds(cw as f64, ch as f64);
let area = ((bb[2] - bb[0]) * (bb[3] - bb[1])).max(1.0);
let k = ((cw as f64 * ch as f64) / area).sqrt().clamp(1.0, 4.0);
let (w, h) = so.placed_size(cw as f64, ch as f64, k);
(w, h, k, bb)
} else {
(cw, ch, 1.0, [0.0; 4])
};
let f = Footage {
path: cx.path.to_string(),
kind: FootageKind::Still,
@ -204,13 +216,20 @@ fn smart_object_layer(proj: &mut Project, cx: &mut Ctx, comp: &Comp, index: usiz
alpha: AlphaMode::Straight,
loop_count: 1,
codec: if effectcraft_psd::is_psd(data) { "PSD".into() } else { "PSD (embedded image)".into() },
layer: Some(SourceLayer { index: index as u32, name: name.to_string(), layer_size: true, embedded: Some(so.uuid.clone()) }),
layer: Some(SourceLayer { index: index as u32, name: name.to_string(), layer_size: true, embedded: Some(so.uuid.clone()), placed: bake }),
..Default::default()
};
let label = if file.is_empty() { name.to_string() } else { file };
let id = proj.add_item(&format!("{label} (Smart Object)"), Label::Lavender, Some(cx.folder), ItemKind::Footage(f));
cx.items.push(id);
let mut l = build::layer(proj, comp, name, LayerSource::Footage { item: id }, (w, h), None);
if bake {
// The baked pixels cover the placed bounds: centred there, scaled back to 1×.
let c = [bounds[0] + w as f64 / k / 2.0, bounds[1] + h as f64 / k / 2.0];
set_vec2(&mut l, "transform/position", c);
set_vec2(&mut l, "transform/scale", [100.0 / k, 100.0 / k]);
return Some(l);
}
let (c, sx, sy, rot) = so.placement(w as f64, h as f64);
set_vec2(&mut l, "transform/position", c);
set_vec2(&mut l, "transform/scale", [sx * 100.0, sy * 100.0]);

View File

@ -405,3 +405,59 @@ fn pdf_pages_text_and_clips_to_masks() {
let comp = s.project.comp(ItemId(r["comps"][0].as_u64().unwrap())).unwrap();
assert_eq!((comp.width, comp.height), (120, 80));
}
#[test]
fn psd_smart_objects_with_perspective_and_warp_bake_as_placed() {
let mut inner = WDoc::new(20, 10);
inner.layers =
vec![WLayer::solid("Red", Rect::new(0, 0, 10, 10), [1.0, 0.0, 0.0, 1.0]), WLayer::solid("Green", Rect::new(10, 0, 10, 10), [0.0, 1.0, 0.0, 1.0])];
let inner_bytes = write(&inner);
let mut d = WDoc::new(64, 48);
// A perspective trapezoid, and an affine placement with an Arc warp.
let trap = [[20.0, 4.0], [44.0, 10.0], [44.0, 38.0], [20.0, 44.0]];
let warp = warp_descriptor("warpArc", 50.0, 0.0, 0.0, [0.0, 0.0, 20.0, 10.0], None);
d.layers = vec![
WLayer::solid("Back", Rect::new(0, 0, 64, 48), [0.0, 0.0, 0.0, 1.0]),
WLayer::solid("Pinned", Rect::new(20, 4, 24, 40), [0.5, 0.5, 0.5, 1.0]).with_block(smart_object_block_warped("so-1", trap, [20.0, 10.0], None)),
];
d.linked = vec![("so-1".into(), "inner.psd".into(), *b"8BPS", inner_bytes.clone())];
let mut d2 = WDoc::new(64, 48);
d2.layers = vec![
WLayer::solid("Back", Rect::new(0, 0, 64, 48), [0.0, 0.0, 0.0, 1.0]),
WLayer::solid("Arc", Rect::new(20, 10, 40, 20), [0.5, 0.5, 0.5, 1.0]).with_block(smart_object_block_warped(
"so-2",
[[20.0, 10.0], [60.0, 10.0], [60.0, 30.0], [20.0, 30.0]],
[20.0, 10.0],
Some(warp),
)),
];
d2.linked = vec![("so-2".into(), "inner.psd".into(), *b"8BPS", inner_bytes)];
let parsed = effectcraft_psd::Psd::parse(write(&d2)).unwrap();
let so = parsed.layers.iter().find_map(|l| l.smart_object.clone()).unwrap();
assert_eq!(so.warp.as_ref().map(|w| w.style.as_str()), Some("warpArc"));
let mut s = effectcraft_host::session();
let render = |s: &mut effectcraft_engine::Session, doc: &WDoc, file: &str| {
let path = tmp(file);
std::fs::write(&path, write(doc)).unwrap();
let r = s.execute_checked("file.import", json!({"paths": [path], "importAs": "composition"})).unwrap();
assert_eq!(r["errors"], json!([]), "{r}");
let cid = ItemId(r["comps"][0].as_u64().unwrap());
let placed = s.project.comp(cid).unwrap().layers.iter().any(|l| {
let effectcraft_project::LayerSource::Footage { item } = l.source else { return false };
matches!(&s.project.item(item).unwrap().kind, effectcraft_project::ItemKind::Footage(f) if f.layer.as_ref().is_some_and(|x| x.placed))
});
assert!(placed, "{file}: baked as placed");
s.render(cid, effectcraft_time::Tick::ZERO, RenderOpts::default())
};
let img = render(&mut s, &d, "pinned.psd");
let (red, green) = (img.get(24, 24), img.get(40, 24));
assert!(red[0] > 0.9 && red[1] < 0.1, "{red:?}");
assert!(green[1] > 0.9 && green[0] < 0.1, "{green:?}");
// Above the slanted top edge (y = 4 + 6·(x − 20)/24): background.
assert!(img.get(42, 6)[0] < 0.05 && img.get(42, 6)[1] < 0.05, "{:?}", img.get(42, 6));
let img = render(&mut s, &d2, "arc.psd");
// The arc keeps the top centre and pulls the corners down and out.
let top = img.get(40, 11);
assert!(top[0] > 0.5 || top[1] > 0.5, "top centre covered {top:?}");
assert!(img.get(21, 11)[0] < 0.05 && img.get(21, 11)[1] < 0.05, "corner region empty {:?}", img.get(21, 11));
}

View File

@ -75,13 +75,26 @@ pub(crate) fn decode(path: &str, bytes: &[u8], footage: &Footage, op: AlphaOp) -
// A smart object's embedded file: an embedded document's merged image, or an image.
if let Some(uuid) = footage.layer.as_ref().and_then(|l| l.embedded.as_deref()) {
let data = psd.linked_data(uuid).ok_or_else(|| MediaError::Decode(format!("{path}: no embedded file {uuid}")))?;
if effectcraft_psd::is_psd(data) {
let px = if effectcraft_psd::is_psd(data) {
let inner = effectcraft_psd::Psd::parse(data.to_vec()).map_err(|e| MediaError::Decode(format!("{path} (smart object): {e}")))?;
let px = inner.composite().map_err(|e| MediaError::Decode(format!("{path} (smart object): {e}")))?;
return Ok(Some(straight_to_image(px.width, px.height, &px.data, op)));
inner.composite().map_err(|e| MediaError::Decode(format!("{path} (smart object): {e}")))?
} else {
let img = image::load_from_memory(data).map_err(|e| MediaError::Decode(format!("{path} (smart object): {e}")))?;
let rgba = img.to_rgba32f();
effectcraft_psd::Pixels { width: rgba.width(), height: rgba.height(), data: rgba.pixels().map(|p| p.0).collect() }
};
// A perspective quad or a warp: baked as placed.
let l = footage.layer.as_ref().expect("embedded implies a layer");
if l.placed
&& let Some(so) = psd.layers.get(l.index as usize).and_then(|pl| pl.smart_object.as_ref())
{
let bb = so.placed_bounds(px.width as f64, px.height as f64);
let k = footage.width as f64 / (bb[2] - bb[0]).max(1e-9);
if let Some((baked, _)) = so.render_placed(&px, k, Some((footage.width, footage.height))) {
return Ok(Some(straight_to_image(baked.width, baked.height, &baked.data, op)));
}
}
let img = image::load_from_memory(data).map_err(|e| MediaError::Decode(format!("{path} (smart object): {e}")))?;
return Ok(Some(crate::convert::dynamic_to_image(&img, op)));
return Ok(Some(straight_to_image(px.width, px.height, &px.data, op)));
}
let px = match &footage.layer {
Some(l) => psd.layer_pixels(l.index as usize, !l.layer_size),

View File

@ -848,6 +848,10 @@ pub struct SourceLayer {
/// is the footage instead of the layer's pixels.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub embedded: Option<String>,
/// A smart object with a perspective quad or a warp: the footage is its embedded file baked
/// as placed (warped and pinned to its corners) over the placed bounds in document space.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub placed: bool,
}
fn one() -> u32 {

View File

@ -16,6 +16,12 @@ Bitmap, Grayscale, Indexed, RGB, CMYK (naive conversion) and Lab colour modes.
Text engine data (`EngineData`) is read best-effort for the font, size, fill colour and
justification of the first style run. Smart objects import as footage of their embedded file
(a Photoshop document's merged image, or an image) placed by the smart object's corner quad
(position, scale, rotation; perspective and warps are not applied). Externally linked files
(position, scale, rotation). A non-affine quad (`nonAffineTransform`: perspective) or a placed
layer warp (`warp`: the named styles with bend and horizontal / vertical distortion, and custom
envelope warps whose quilt mesh of bicubic Bezier patches is read from `meshPoints` (`ObAr`)
and `quiltSliceX` / `quiltSliceY`) is baked as placed over the placed bounds, at up to 4x the
document resolution ([`placed`](src/placed.rs)). Custom meshes are evaluated exactly; the
named styles' curves are not published, so they are clean-room geometric definitions matching
each style by behaviour. Externally linked files
fall back to the layer's rendered pixels. No Adobe code, sample files or assets were used; test
fixtures are generated in-test by the writer in this crate ([`write`](src/write.rs)).

View File

@ -30,6 +30,9 @@ pub enum DValue {
Class(String, String),
Raw(Vec<u8>),
Reference(Vec<RefItem>),
/// An object array (`ObAr`): `count` objects of one class stored column-wise, each item a
/// list of `count` values (unit floats).
ObjectArray(u32, Descriptor),
}
/// An item of a reference (`obj `).
@ -161,6 +164,10 @@ fn read_value(r: &mut Reader, ty: &[u8; 4], depth: usize) -> Result<DValue> {
}
DValue::List(v)
}
b"ObAr" => {
let n = r.u32()?;
DValue::ObjectArray(n, read_descriptor(r)?)
}
b"doub" => DValue::Double(r.f64()?),
b"UntF" => {
let unit = String::from_utf8_lossy(&r.tag()?).into_owned();
@ -280,6 +287,11 @@ fn write_value(out: &mut Vec<u8>, v: &DValue) {
write_value(out, x);
}
}
DValue::ObjectArray(n, d) => {
out.extend_from_slice(b"ObAr");
put_u32(out, *n);
write_descriptor(out, d);
}
DValue::Double(x) => {
out.extend_from_slice(b"doub");
out.extend_from_slice(&x.to_be_bytes());

View File

@ -9,6 +9,7 @@
pub mod descriptor;
pub mod engine_data;
pub mod placed;
mod read;
pub mod write;
@ -214,6 +215,8 @@ pub struct SmartObject {
pub quad: [[f64; 2]; 4],
/// The content's size in pixels (`Sz `), when recorded.
pub size: Option<[f64; 2]>,
/// The placed layer's warp (`warp`), when it has one ([`placed`]).
pub warp: Option<placed::Warp>,
}
impl SmartObject {
@ -665,8 +668,11 @@ impl Psd {
}
};
let t = nums("Trnf").filter(|t| t.len() == 8).unwrap_or_else(|| vec![0.0; 8]);
// The non-affine transform when present (perspective), else the transform.
let t = nums("nonAffineTransform").filter(|t| t.len() == 8).unwrap_or(t);
let size = d.obj("Sz ").and_then(|s| Some([s.num("Wdth")?, s.num("Hght")?]));
l.smart_object = Some(SmartObject { uuid, quad: [[t[0], t[1]], [t[2], t[3]], [t[4], t[5]], [t[6], t[7]]], size });
let warp = d.obj("warp").and_then(placed::Warp::read);
l.smart_object = Some(SmartObject { uuid, quad: [[t[0], t[1]], [t[2], t[3]], [t[4], t[5]], [t[6], t[7]]], size, warp });
}
b"PlLd" if l.smart_object.is_none() => {
let _id = r.tag()?;
@ -681,7 +687,7 @@ impl Psd {
for v in &mut t {
*v = r.f64()?;
}
l.smart_object = Some(SmartObject { uuid, quad: [[t[0], t[1]], [t[2], t[3]], [t[4], t[5]], [t[6], t[7]]], size: None });
l.smart_object = Some(SmartObject { uuid, quad: [[t[0], t[1]], [t[2], t[3]], [t[4], t[5]], [t[6], t[7]]], size: None, warp: None });
}
b"vmsk" | b"vsms" => {
let _ver = r.u32()?;

410
crates/psd/src/placed.rs Normal file
View File

@ -0,0 +1,410 @@
//! 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");
}
}

View File

@ -414,14 +414,53 @@ fn linked_block(files: &[(String, String, [u8; 4], Vec<u8>)]) -> Vec<u8> {
/// `SoLd`: a placed layer showing linked file `uuid` (content `size` pixels) with its corners at
/// `quad` (top-left, top-right, bottom-right, bottom-left).
pub fn smart_object_block(uuid: &str, quad: [[f64; 2]; 4], size: [f64; 2]) -> ([u8; 4], Vec<u8>) {
smart_object_block_warped(uuid, quad, size, None)
}
/// A `warp` descriptor: a named style (bend and distortions in %) over `bounds` (left, top,
/// right, bottom), or with `mesh` (16 points, content pixels) a custom envelope.
pub fn warp_descriptor(style: &str, bend: f64, h: f64, v: f64, bounds: [f64; 4], mesh: Option<&[[f64; 2]]>) -> Descriptor {
let mut d = Descriptor::new("warp")
.with("warpStyle", DValue::Enum("warpStyle".into(), style.into()))
.with("warpValue", DValue::Double(bend))
.with("warpPerspective", DValue::Double(h))
.with("warpPerspectiveOther", DValue::Double(v))
.with("warpRotate", DValue::Enum("Ornt".into(), "Hrzn".into()))
.with(
"bounds",
DValue::Descriptor(
Descriptor::new("Rctn")
.with("Top ", DValue::UnitFloat("#Pxl".into(), bounds[1]))
.with("Left", DValue::UnitFloat("#Pxl".into(), bounds[0]))
.with("Btom", DValue::UnitFloat("#Pxl".into(), bounds[3]))
.with("Rght", DValue::UnitFloat("#Pxl".into(), bounds[2])),
),
)
.with("uOrder", DValue::Integer(4))
.with("vOrder", DValue::Integer(4));
if let Some(m) = mesh {
let pts = Descriptor::new("rationalPoint")
.with("Hrzn", DValue::UnitFloats("#Pxl".into(), m.iter().map(|p| p[0]).collect()))
.with("Vrtc", DValue::UnitFloats("#Pxl".into(), m.iter().map(|p| p[1]).collect()));
d = d
.with("customEnvelopeWarp", DValue::Descriptor(Descriptor::new("customEnvelopeWarp").with("meshPoints", DValue::ObjectArray(m.len() as u32, pts))));
}
d
}
/// `SoLd` with an optional warp; `quad` may be non-affine (perspective).
pub fn smart_object_block_warped(uuid: &str, quad: [[f64; 2]; 4], size: [f64; 2], warp: Option<Descriptor>) -> ([u8; 4], Vec<u8>) {
let list = |v: &[f64]| DValue::List(v.iter().map(|x| DValue::Double(*x)).collect());
let t: Vec<f64> = quad.iter().flat_map(|p| [p[0], p[1]]).collect();
let d = Descriptor::new("null")
let mut d = Descriptor::new("null")
.with("Idnt", DValue::Text(uuid.to_string()))
.with("placed", DValue::Text(uuid.to_string()))
.with("Trnf", list(&t))
.with("nonAffineTransform", list(&t))
.with("Sz ", DValue::Descriptor(Descriptor::new("Pnt ").with("Wdth", DValue::Double(size[0])).with("Hght", DValue::Double(size[1]))));
if let Some(w) = warp {
d = d.with("warp", DValue::Descriptor(w));
}
let mut out = b"soLD".to_vec();
u32b(&mut out, 4);
u32b(&mut out, 16);