diff --git a/crates/engine/src/commands/file.rs b/crates/engine/src/commands/file.rs index 21b50171..14cbb758 100644 --- a/crates/engine/src/commands/file.rs +++ b/crates/engine/src/commands/file.rs @@ -217,7 +217,7 @@ pub(crate) fn import(s: &mut Session, p: &Value) -> Result { { 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}")); diff --git a/crates/engine/src/psd_import.rs b/crates/engine/src/psd_import.rs index 555dd97b..efd63a9d 100644 --- a/crates/engine/src/psd_import.rs +++ b/crates/engine/src/psd_import.rs @@ -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]); diff --git a/crates/host/tests/vector_import.rs b/crates/host/tests/vector_import.rs index df8cce21..d8082573 100644 --- a/crates/host/tests/vector_import.rs +++ b/crates/host/tests/vector_import.rs @@ -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)); +} diff --git a/crates/media/src/layered.rs b/crates/media/src/layered.rs index fd6479c2..02546611 100644 --- a/crates/media/src/layered.rs +++ b/crates/media/src/layered.rs @@ -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), diff --git a/crates/project/src/lib.rs b/crates/project/src/lib.rs index 7c508e94..9281b188 100644 --- a/crates/project/src/lib.rs +++ b/crates/project/src/lib.rs @@ -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, + /// 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 { diff --git a/crates/psd/README.md b/crates/psd/README.md index b0b19ba3..acbfe7a9 100644 --- a/crates/psd/README.md +++ b/crates/psd/README.md @@ -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)). diff --git a/crates/psd/src/descriptor.rs b/crates/psd/src/descriptor.rs index e121f823..d426f714 100644 --- a/crates/psd/src/descriptor.rs +++ b/crates/psd/src/descriptor.rs @@ -30,6 +30,9 @@ pub enum DValue { Class(String, String), Raw(Vec), Reference(Vec), + /// 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::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, 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()); diff --git a/crates/psd/src/lib.rs b/crates/psd/src/lib.rs index 61cadc7d..de3ec182 100644 --- a/crates/psd/src/lib.rs +++ b/crates/psd/src/lib.rs @@ -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, } 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()?; diff --git a/crates/psd/src/placed.rs b/crates/psd/src/placed.rs new file mode 100644 index 00000000..156a8e27 --- /dev/null +++ b/crates/psd/src/placed.rs @@ -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 { + 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 { + 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) -> 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"); + } +} diff --git a/crates/psd/src/write.rs b/crates/psd/src/write.rs index 160e81d3..cb3b2bf0 100644 --- a/crates/psd/src/write.rs +++ b/crates/psd/src/write.rs @@ -414,14 +414,53 @@ fn linked_block(files: &[(String, String, [u8; 4], Vec)]) -> Vec { /// `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) { + 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) -> ([u8; 4], Vec) { let list = |v: &[f64]| DValue::List(v.iter().map(|x| DValue::Double(*x)).collect()); let t: Vec = 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);