From d709fd81a1a05a62b602f96ef2f6c68f35b32b67 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 20:33:52 +0000 Subject: [PATCH 1/4] =?UTF-8?q?Warp:=20Photoshop's=20Cylinder=20warp=20as?= =?UTF-8?q?=20a=204=20=C3=97=201=20patch=20mesh=20(#4112)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Transform Warp's Cylinder (`warpCylinder`) stores seven `warpValues` instead of a bend. WarpStyle gains Cylinder (parsed, named, not one of the fifteen bend presets and not listed in the Warp menus yet), and Warp::cylinder builds its mesh: four patches across (back, front left, front right, back) and one down, in the warp's value space like a custom mesh, so map, to_mesh and is_identity treat it as one. Warp keeps the values for writing them back (serde: omitted when empty). The model and its table of shape factors were measured against Photoshop 2026's renders by the reporter of #4112 (not from Adobe's code). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01EuC9R3K5QeMBrDkixa1jnt --- crates/geom/src/warp.rs | 226 ++++++++++++++++++++++++++++++++++++++-- 1 file changed, 216 insertions(+), 10 deletions(-) diff --git a/crates/geom/src/warp.rs b/crates/geom/src/warp.rs index 9c8f138747..cd4979d775 100644 --- a/crates/geom/src/warp.rs +++ b/crates/geom/src/warp.rs @@ -9,6 +9,9 @@ //! - **Custom meshes** ([`BezierMesh`]): a grid of bicubic Bezier patches. The default is one //! patch (a 4×4 control grid); split warps add patch rows/columns by exact de Casteljau //! subdivision, so splitting never changes the shape. +//! - **Cylinder** ([`WarpStyle::Cylinder`], Transform Warp only): seven `warpValues` that +//! Photoshop turns into a 4 × 1 patch mesh wrapping the contents round a cylinder; see +//! [`cylinder_mesh`]. //! //! Pure geometry: resampling lives in `photocraft-algo::warp`. @@ -38,6 +41,10 @@ pub enum WarpStyle { Inflate, Squeeze, Twist, + /// Edit › Transform › Warp's Cylinder (`warpCylinder`): not one of the fifteen bend presets + /// (Warp Text doesn't have it), it takes seven values instead of a bend; see + /// [`Warp::cylinder`]. + Cylinder, } impl WarpStyle { @@ -60,7 +67,8 @@ impl WarpStyle { WarpStyle::Twist, ]; - /// Every style (none, custom, presets). + /// The styles the Warp menus list: none, custom and the presets. [`WarpStyle::Cylinder`] is + /// read from PSDs and taken by commands, but has no editor yet. pub fn all() -> impl Iterator { [WarpStyle::None, WarpStyle::Custom].into_iter().chain(Self::PRESETS) } @@ -85,6 +93,7 @@ impl WarpStyle { WarpStyle::Inflate => "warpInflate", WarpStyle::Squeeze => "warpSqueeze", WarpStyle::Twist => "warpTwist", + WarpStyle::Cylinder => "warpCylinder", } } @@ -109,6 +118,7 @@ impl WarpStyle { "Fisheye" => "fisheye", "Inflate" => "inflate", "Squeeze" => "squeeze", + "Cylinder" => "cylinder", _ => "twist", } } @@ -133,17 +143,19 @@ impl WarpStyle { WarpStyle::Inflate => "Inflate", WarpStyle::Squeeze => "Squeeze", WarpStyle::Twist => "Twist", + WarpStyle::Cylinder => "Cylinder", } } /// Parses a short id, a PSD name or a label (case-insensitive, spaces ignored). pub fn parse(s: &str) -> Option { let k: String = s.chars().filter(|c| !c.is_whitespace()).collect::().to_ascii_lowercase(); - Self::all().find(|w| w.id().to_ascii_lowercase() == k || w.psd_name().to_ascii_lowercase() == k) + Self::all().chain([WarpStyle::Cylinder]).find(|w| w.id().to_ascii_lowercase() == k || w.psd_name().to_ascii_lowercase() == k) } + /// One of the fifteen bend presets ([`Self::PRESETS`]). pub fn is_preset(self) -> bool { - !matches!(self, WarpStyle::None | WarpStyle::Custom) + Self::PRESETS.contains(&self) } } @@ -357,7 +369,7 @@ fn preset_grid(style: WarpStyle, b: f64, hd: f64, vd: f64, vertical: bool, aspec g[2][2][1] -= a; } } - WarpStyle::None | WarpStyle::Custom => {} + WarpStyle::None | WarpStyle::Custom | WarpStyle::Cylinder => {} } // Vertical distortion: the top and bottom rows scale about their end points' midpoint (by // 1 ∓ vd) and the middle rows follow by the blend of those two moves. @@ -412,6 +424,101 @@ fn split_cubic(p: [[f64; 2]; 4], t: f64) -> [[f64; 2]; 7] { [p[0], a, d, f, e, c, p[3]] } +/// Photoshop's Cylinder warp as a Bezier mesh in the `warp` descriptor's value space (the space +/// of its `bounds`), from the seven `warpValues` `v` of a horizontal cylinder with no +/// perspective. Like a custom warp's mesh, its control-point box is what the placement quad +/// frames. `None` unless there are seven finite values with the right silhouette right of the +/// left one. +/// +/// The model was measured against Photoshop 2026's renders of a UV grid (issue #4112: within +/// 0.3 px vertically and 1.2 px horizontally at the 99th percentile), not taken from Adobe's code: +/// - `v[0]`, `v[2]`: the left and right silhouettes. Half axis `a = (v[2] − v[0]) / 2`. +/// - `v[3]`, `v[1]`: the top and bottom edges at the silhouettes (the centre lines of the upper +/// and lower ellipses); `v[4]`, `v[5]`: those ellipses' depth in units of `a`. +/// - `v[6]` (0..1): how far the contents wrap round. The outer `uL` of the contents on each side +/// is on the back of the cylinder and collapses onto the silhouette: 1/6 at 0, 0.006 at 1. +/// +/// Four patches across (`us = [0, uL, 0.5, 1 − uL, 1]`: back, front left, front right, back) and +/// one down; control rows are linear between the top and bottom edge. +pub fn cylinder_mesh(v: &[f64]) -> Option { + let v: [f64; 7] = v.try_into().ok()?; + if !v.iter().all(|x| x.is_finite()) || v[2] <= v[0] { + return None; + } + let a = (v[2] - v[0]) / 2.0; + let cx = (v[0] + v[2]) / 2.0; + let [ul, kx, ky, my] = cylinder_shape(v[6]); + let s = [-1.0, -1.0, -1.0, -1.0, -1.0, -kx, 0.0, kx, 1.0, 1.0, 1.0, 1.0, 1.0]; + let e = [0.0, 0.0, 0.0, 0.0, ky, my, 1.0, my, ky, 0.0, 0.0, 0.0, 0.0]; + let mut points = Vec::with_capacity(13 * 4); + for r in 0..4 { + let t = f64::from(r) / 3.0; + for (sk, ek) in s.iter().zip(e) { + let (top, bottom) = (v[3] + v[4] * a * ek, v[1] + v[5] * a * ek); + points.push([cx + a * sk, top + (bottom - top) * t]); + } + } + let mesh = BezierMesh { us: vec![0.0, ul, 0.5, 1.0 - ul, 1.0], vs: vec![0.0, 1.0], points }; + mesh.is_valid().then_some(mesh) +} + +/// `[uL, kx, ky, my]` for a cylinder's wrap `g` (`warpValues[6]`, clamped to 0..1): the share of +/// the contents on the back on each side, then the front patches' inner x control point (in +/// units of the half axis) and their ellipse factors. Linear between the measured rows. +fn cylinder_shape(g: f64) -> [f64; 4] { + let n = CYLINDER_SHAPE.len() - 1; + let x = if g.is_finite() { g.clamp(0.0, 1.0) * n as f64 } else { 0.0 }; + let i = (x.floor() as usize).min(n - 1); + let (a, b, t) = (CYLINDER_SHAPE[i], CYLINDER_SHAPE[i + 1], x - i as f64); + std::array::from_fn(|k| a[k] + (b[k] - a[k]) * t) +} + +/// [`cylinder_shape`]'s rows for `g` = 0, 0.025 … 1, measured from Photoshop's renders (#4112): +/// no closed form was found. +const CYLINDER_SHAPE: [[f64; 4]; 41] = [ + [0.166169, 0.655404, 0.382374, 1.003104], + [0.157304, 0.650228, 0.388326, 1.002685], + [0.148438, 0.645374, 0.394211, 1.007918], + [0.140060, 0.639652, 0.401415, 1.006938], + [0.131885, 0.634064, 0.408465, 1.006069], + [0.123875, 0.628845, 0.414586, 1.005743], + [0.116162, 0.623565, 0.421033, 1.005233], + [0.108674, 0.618563, 0.426905, 1.004990], + [0.101198, 0.614439, 0.431943, 1.010787], + [0.094322, 0.609611, 0.437749, 1.010560], + [0.087757, 0.604929, 0.443406, 1.010361], + [0.081514, 0.600390, 0.448907, 1.010190], + [0.075599, 0.596012, 0.454288, 1.010019], + [0.069994, 0.591836, 0.459420, 1.009888], + [0.064709, 0.587844, 0.464361, 1.009768], + [0.059740, 0.584026, 0.469109, 1.009669], + [0.055072, 0.580427, 0.473609, 1.009588], + [0.050712, 0.576990, 0.477904, 1.009528], + [0.046635, 0.573760, 0.481968, 1.009477], + [0.042824, 0.570758, 0.485764, 1.009440], + [0.039295, 0.567910, 0.489377, 1.009411], + [0.036023, 0.565229, 0.492800, 1.009382], + [0.032681, 0.563478, 0.495519, 1.015285], + [0.029877, 0.561152, 0.498513, 1.015270], + [0.027282, 0.559012, 0.501313, 1.015245], + [0.024894, 0.557004, 0.503913, 1.015241], + [0.022693, 0.555163, 0.506331, 1.015225], + [0.020668, 0.553463, 0.508575, 1.015213], + [0.018816, 0.551877, 0.510651, 1.015217], + [0.017106, 0.550434, 0.512556, 1.015214], + [0.015542, 0.549100, 0.514319, 1.015213], + [0.014115, 0.547868, 0.515936, 1.015223], + [0.012806, 0.546743, 0.517432, 1.015223], + [0.011600, 0.545730, 0.518775, 1.015228], + [0.010506, 0.544788, 0.520034, 1.015230], + [0.009510, 0.543920, 0.521179, 1.015238], + [0.008600, 0.543134, 0.522224, 1.015244], + [0.007763, 0.542420, 0.523173, 1.015251], + [0.007006, 0.541757, 0.524065, 1.015252], + [0.006315, 0.541164, 0.524867, 1.015252], + [0.005686, 0.540608, 0.525602, 1.015261], +]; + /// Inverse of the Bernstein interpolation matrix at t = 0, 1/3, 2/3, 1 (rows: control point, /// columns: sample). Exact rational values. const BERN_INV: [[f64; 4]; 4] = [[1.0, 0.0, 0.0, 0.0], [-5.0 / 6.0, 3.0, -1.5, 1.0 / 3.0], [1.0 / 3.0, -1.5, 3.0, -5.0 / 6.0], [0.0, 0.0, 0.0, 1.0]]; @@ -917,15 +1024,18 @@ pub struct Warp { pub vertical: bool, /// The source box `[x0, y0, x1, y1]` the warp is defined over. pub bounds: [f64; 4], - /// Control mesh for [`WarpStyle::Custom`] (output coordinates). + /// Control mesh for [`WarpStyle::Custom`] and [`WarpStyle::Cylinder`] (output coordinates). #[serde(default)] pub mesh: Option, + /// A cylinder's seven `warpValues` (see [`Warp::cylinder`]); empty for other styles. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub values: Vec, } impl Warp { /// No warp over `bounds`. pub fn none(bounds: [f64; 4]) -> Warp { - Warp { style: WarpStyle::None, bend: 0.0, h_distort: 0.0, v_distort: 0.0, vertical: false, bounds, mesh: None } + Warp { style: WarpStyle::None, bend: 0.0, h_distort: 0.0, v_distort: 0.0, vertical: false, bounds, mesh: None, values: Vec::new() } } /// A preset style. @@ -938,6 +1048,13 @@ impl Warp { Warp { style: WarpStyle::Custom, mesh: Some(mesh), ..Warp::none(bounds) } } + /// A horizontal Cylinder warp over `bounds` from its seven `warpValues` (see + /// [`cylinder_mesh`]). `None` when they don't describe one. + pub fn cylinder(values: &[f64], bounds: [f64; 4]) -> Option { + let mesh = cylinder_mesh(values)?; + Some(Warp { style: WarpStyle::Cylinder, mesh: Some(mesh), values: values.to_vec(), ..Warp::none(bounds) }) + } + fn style_warp(&self) -> Option { StyleWarp::new(self.style, self.bend, self.h_distort, self.v_distort, self.vertical, self.bounds) } @@ -946,7 +1063,7 @@ impl Warp { pub fn is_identity(&self) -> bool { match self.style { WarpStyle::None => true, - WarpStyle::Custom => match &self.mesh { + WarpStyle::Custom | WarpStyle::Cylinder => match &self.mesh { None => true, Some(m) => { let id = BezierMesh::fit(&|s, t| self.box_point(s, t), m.us.clone(), m.vs.clone()); @@ -966,7 +1083,7 @@ impl Warp { pub fn map(&self, x: f64, y: f64) -> (f64, f64) { match self.style { WarpStyle::None => (x, y), - WarpStyle::Custom => match &self.mesh { + WarpStyle::Custom | WarpStyle::Cylinder => match &self.mesh { Some(m) => { let b = self.bounds; let (w, h) = (b[2] - b[0], b[3] - b[1]); @@ -986,9 +1103,9 @@ impl Warp { } /// The warp as a control mesh (a preset is its own Bezier patch split into `cols × rows`; - /// custom warps return their own mesh). + /// custom and cylinder warps return their own mesh). pub fn to_mesh(&self, cols: usize, rows: usize) -> BezierMesh { - if let (WarpStyle::Custom, Some(m)) = (self.style, &self.mesh) { + if let (WarpStyle::Custom | WarpStyle::Cylinder, Some(m)) = (self.style, &self.mesh) { return m.clone(); } let (cols, rows) = (cols.clamp(1, 64), rows.clamp(1, 64)); @@ -1364,4 +1481,93 @@ mod tests { let ob = Warp::none(B).output_bounds(); assert!((ob[0] - 10.0).abs() < 1e-9 && (ob[3] - 70.0).abs() < 1e-9); } + + /// A cylinder over a 1000 × 1000 source like #4112's: silhouettes at x 100 and 900, edges at + /// y 200 (top) and 800 (bottom), ellipses 0.3 deep, half wrapped round. + const CYL: [f64; 7] = [100.0, 800.0, 900.0, 200.0, 0.3, 0.3, 0.5]; + + #[test] + fn cylinder_style_is_read_and_named_but_not_a_bend_preset() { + for s in ["cylinder", "warpCylinder", "Cylinder"] { + assert_eq!(WarpStyle::parse(s), Some(WarpStyle::Cylinder), "{s}"); + } + assert_eq!(WarpStyle::Cylinder.psd_name(), "warpCylinder"); + assert_eq!(WarpStyle::Cylinder.id(), "cylinder"); + assert!(!WarpStyle::Cylinder.is_preset()); + assert!(StyleWarp::new(WarpStyle::Cylinder, 50.0, 0.0, 0.0, false, B).is_none()); + // The Warp menus list what they can edit. + assert!(WarpStyle::all().all(|s| s != WarpStyle::Cylinder)); + assert_eq!(WarpStyle::all().filter(|s| s.is_preset()).count(), 15); + } + + #[test] + fn cylinder_values_that_describe_no_cylinder_are_refused() { + assert!(cylinder_mesh(&CYL[..6]).is_none(), "six values"); + assert!(cylinder_mesh(&[CYL.as_slice(), &[0.0]].concat()).is_none(), "eight values"); + for i in 0..7 { + for bad in [f64::NAN, f64::INFINITY] { + let mut v = CYL; + v[i] = bad; + assert!(cylinder_mesh(&v).is_none(), "value {i} = {bad}"); + } + } + let mut v = CYL; + v[2] = v[0]; + assert!(Warp::cylinder(&v, [0.0, 0.0, 1000.0, 1000.0]).is_none(), "no width"); + v[2] = 50.0; + assert!(Warp::cylinder(&v, [0.0, 0.0, 1000.0, 1000.0]).is_none(), "right silhouette left of the left one"); + } + + #[test] + fn cylinder_wraps_the_contents_between_its_silhouettes() { + let w = Warp::cylinder(&CYL, [0.0, 0.0, 1000.0, 1000.0]).unwrap(); + assert_eq!(w.style, WarpStyle::Cylinder); + assert_eq!(w.values, CYL); + assert!(!w.is_identity()); + let m = w.mesh.as_ref().unwrap(); + assert_eq!((m.nx(), m.ny()), (13, 4)); + let [ul, ..] = cylinder_shape(0.5); + assert!((m.us[1] - ul).abs() < 1e-12 && (m.us[3] - (1.0 - ul)).abs() < 1e-12); + // The middle of the contents is at the front: centred, on the ellipses' near side. + assert!(close(w.map(500.0, 0.0), (500.0, 200.0 + 0.3 * 400.0), 1e-9)); + assert!(close(w.map(500.0, 1000.0), (500.0, 800.0 + 0.3 * 400.0), 1e-9)); + // What wraps round the back lies on the silhouettes, at the edges' ends. + for x in [0.0, ul * 500.0, ul * 1000.0] { + assert!(close(w.map(x, 0.0), (100.0, 200.0), 1e-9), "{x}"); + assert!(close(w.map(1000.0 - x, 1000.0), (900.0, 800.0), 1e-9), "{x}"); + } + // Across the front x only grows, and the two halves mirror each other. + let mut last = f64::MIN; + for i in 0..=200 { + let x = 1000.0 * (ul + (1.0 - 2.0 * ul) * f64::from(i) / 200.0); + let (px, py) = w.map(x, 400.0); + assert!(px >= last - 1e-9, "x {x}"); + last = px; + let (qx, qy) = w.map(1000.0 - x, 400.0); + assert!(close((px - 500.0, py), (500.0 - qx, qy), 1e-9), "x {x}"); + } + // Every point stays inside the control-point box the placement frames. + let cb = m.control_bounds(); + let ob = w.output_bounds(); + assert!(ob[0] >= cb[0] - 1e-9 && ob[1] >= cb[1] - 1e-9 && ob[2] <= cb[2] + 1e-9 && ob[3] <= cb[3] + 1e-9); + assert!((cb[0] - 100.0).abs() < 1e-9 && (cb[2] - 900.0).abs() < 1e-9); + // The box's bottom is the lower ellipse's deepest control point (`my` > 1). + assert!((cb[3] - (800.0 + 0.3 * 400.0 * cylinder_shape(0.5)[3])).abs() < 1e-9); + assert_eq!(w.to_mesh(4, 4), *m, "a cylinder is its own mesh"); + } + + #[test] + fn cylinder_shape_follows_the_measured_rows() { + assert_eq!(cylinder_shape(0.0), CYLINDER_SHAPE[0]); + assert_eq!(cylinder_shape(1.0), CYLINDER_SHAPE[40]); + assert_eq!(cylinder_shape(-3.0), CYLINDER_SHAPE[0]); + assert_eq!(cylinder_shape(7.0), CYLINDER_SHAPE[40]); + assert_eq!(cylinder_shape(f64::NAN), CYLINDER_SHAPE[0]); + let mid = cylinder_shape(0.0125); + for k in 0..4 { + assert!((mid[k] - (CYLINDER_SHAPE[0][k] + CYLINDER_SHAPE[1][k]) / 2.0).abs() < 1e-12); + } + // The more it wraps, the less of the contents is on the back. + assert!(CYLINDER_SHAPE.windows(2).all(|r| r[1][0] < r[0][0])); + } } From a4531d3122d0fef445f335000db651aa413d2a9d Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 20:48:54 +0000 Subject: [PATCH 2/4] PSD: read and write Photoshop's Cylinder warp on placed layers (#4112) A `warpCylinder` placed layer was read as having no warp: it showed Photoshop's cached pixels on open, but Replace Contents, Relink and Edit Contents drew the contents flat, and a regenerated SoLd wrote warpNone. - Import reads the seven `warpValues` (a list of doubles or unit floats) of a horizontal cylinder without perspective into Warp::cylinder. Vertical cylinders and ones with perspective, never measured, stay unreadable as before, as do broken values. - Like a custom warp's (#3677), the cylinder's control-point box is what the placement quad frames, for affine corners and, new for this style, projective ones (`nonAffineTransform`): perspective_frame and corners_perspective_in. Custom warps keep the source rect there. - Export writes `warpCylinder` with its values back and frames the same box, so a moved or replaced layer reopens where it was. An unchanged layer is still written verbatim. Tests: synthetic SoLd parsing (both value encodings, the refused variants), the quad framing the control box (affine and projective), SoLd and PlLd round trips, and a PSD export/import whose re-render hides the contents wrapped round the back (fails before the change). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01EuC9R3K5QeMBrDkixa1jnt --- crates/io/src/blocks.rs | 175 ++++++++++++++++++++++++++++--- crates/io/src/smart_map.rs | 62 +++++++++-- crates/io/tests/cylinder_warp.rs | 123 ++++++++++++++++++++++ 3 files changed, 342 insertions(+), 18 deletions(-) create mode 100644 crates/io/tests/cylinder_warp.rs diff --git a/crates/io/src/blocks.rs b/crates/io/src/blocks.rs index 87fda102fe..a54e07fee7 100644 --- a/crates/io/src/blocks.rs +++ b/crates/io/src/blocks.rs @@ -526,7 +526,8 @@ pub(crate) fn with_pattern_placement(mut d: Descriptor, angle: f32, link: bool, } /// The warp of a placed layer (`SoLd`/`SoLE` `warp` descriptor): style, bend, distortions, -/// axis, bounds and, for `warpCustom`, the 4 × 4 `customEnvelopeWarp` mesh. Read-only: the raw +/// axis, bounds and, for `warpCustom`, the 4 × 4 `customEnvelopeWarp` mesh, for `warpCylinder` +/// its seven `warpValues`. Read-only: the raw /// block is what gets written back. `None` when absent or `warpNone` with no mesh. A `PlLd` /// record keeps its warp elsewhere: see [`parse_plld_warp`]. pub fn parse_placed_warp(key: &[u8; 4], data: &[u8]) -> Option { @@ -539,7 +540,8 @@ pub fn parse_placed_warp(key: &[u8; 4], data: &[u8]) -> Option Option> { use photocraft_geom::warp::{BezierMesh, Warp, WarpStyle}; let style = enum_of(w, "warpStyle").and_then(|v| WarpStyle::parse(&String::from_utf8_lossy(v)))?; @@ -553,6 +555,17 @@ fn warp_of(w: &Descriptor) -> Option> { warp.h_distort = num(w.get("warpPerspective")).unwrap_or(0.0); warp.v_distort = num(w.get("warpPerspectiveOther")).unwrap_or(0.0); warp.vertical = enum_of(w, "warpRotate") == Some(b"Vrtc"); + if style == WarpStyle::Cylinder { + if warp.vertical || warp.h_distort != 0.0 || warp.v_distort != 0.0 { + return None; + } + let values: Vec = match w.get("warpValues")? { + Value::List(l) => l.iter().map(|v| num(Some(v))).collect::>()?, + Value::UnitFloats { values, .. } => values.clone(), + _ => return None, + }; + return Some(Some(Warp::cylinder(&values, bounds)?)); + } if style == WarpStyle::Custom { let env = get_desc(w, "customEnvelopeWarp")?; let Some(Value::ObjectArray(arr)) = env.get("meshPoints") else { return None }; @@ -593,12 +606,17 @@ pub(crate) fn parse_prefix_versioned(data: &[u8]) -> Option { } /// The source-space box Photoshop maps onto a placed layer's `Trnf` quad: the bounding box of a -/// single-patch custom warp's 16 control points, which may spill past the source, else the source -/// rect `0..w × 0..h`. Mapping the source rect instead counts the overhang twice and stretches the -/// placement. A degenerate or non-finite box falls back to the source rect. +/// single-patch custom warp's 16 control points or of a cylinder's mesh (#4112), which may spill +/// past the source, else the source rect `0..w × 0..h`. Mapping the source rect instead counts the +/// overhang twice and stretches the placement. A degenerate or non-finite box falls back to the +/// source rect. pub(crate) fn placed_frame(warp: Option<&photocraft_geom::warp::Warp>, w: f64, h: f64) -> [f64; 4] { + use photocraft_geom::warp::WarpStyle; let rect = [0.0, 0.0, w, h]; - let Some(m) = warp.filter(|wp| wp.style == photocraft_geom::warp::WarpStyle::Custom).and_then(|wp| wp.mesh.as_ref()).filter(|m| m.points.len() == 16) + let Some(m) = warp + .and_then(|wp| wp.mesh.as_ref().map(|m| (wp.style, m))) + .filter(|(style, m)| (*style == WarpStyle::Custom && m.points.len() == 16) || *style == WarpStyle::Cylinder) + .map(|(_, m)| m) else { return rect; }; @@ -608,6 +626,16 @@ pub(crate) fn placed_frame(warp: Option<&photocraft_geom::warp::Warp>, w: f64, h if ok { b } else { rect } } +/// The source-space box a projective (Distort / Perspective) placement maps onto its corners: a +/// cylinder's control-point box, as for an affine placement (#4112), else the source rect. A +/// custom warp keeps the source rect here: there is no Photoshop reference for that combination. +pub(crate) fn perspective_frame(warp: Option<&photocraft_geom::warp::Warp>, w: f64, h: f64) -> [f64; 4] { + match warp { + Some(wp) if wp.style == photocraft_geom::warp::WarpStyle::Cylinder => placed_frame(warp, w, h), + _ => [0.0, 0.0, w, h], + } +} + /// Smart object identifier and transform from `SoLd`/`SoLE` (best effort). A `PlLd` block /// stores no source size, so its transform needs the embedded file: see [`parse_plld`]. pub fn parse_smart(key: &[u8; 4], data: &[u8]) -> (String, Affine) { @@ -647,15 +675,15 @@ pub fn corners_affine(p: &[f64; 8], frame: [f64; 4]) -> Option { /// for a source of `size` warped by `warp`: the inverse of [`crate::smart_map::plld_bytes`]'s /// quad. As for `SoLd`'s `Trnf` (#3677), affine corners frame a single-patch custom warp's /// control-point box (which may spill past the source), else the source rect; projective corners -/// frame the source rect, as `parse_smart_perspective` reads them. `PlLd` stores no source size: +/// frame [`perspective_frame`], as `parse_smart_perspective` reads them. `PlLd` stores no source size: /// without the source file (`None`) the layer is taken as placed at 100 %, the edge lengths being /// its size (a custom warp's box needs no size); position and rotation stay right, only Replace /// Contents and re-renders depend on the scale. Import and export (which keeps an unedited record /// verbatim) both place a `PlLd` layer with this. pub fn plld_transform(p: &[f64; 8], size: Option<(f64, f64)>, warp: Option<&photocraft_geom::warp::Warp>) -> (Affine, Option<[f64; 9]>) { let (w, h) = size.unwrap_or_else(|| ((p[2] - p[0]).hypot(p[3] - p[1]), (p[6] - p[0]).hypot(p[7] - p[1]))); - let perspective = corners_perspective([[p[0], p[1]], [p[2], p[3]], [p[4], p[5]], [p[6], p[7]]], (w, h)); - let frame = if perspective.is_some() { [0.0, 0.0, w, h] } else { placed_frame(warp, w, h) }; + let perspective = corners_perspective_in([[p[0], p[1]], [p[2], p[3]], [p[4], p[5]], [p[6], p[7]]], perspective_frame(warp, w, h)); + let frame = if perspective.is_some() { perspective_frame(warp, w, h) } else { placed_frame(warp, w, h) }; (corners_affine(p, frame).unwrap_or(Affine::IDENTITY), perspective) } @@ -730,18 +758,26 @@ pub fn parse_smart_perspective(key: &[u8; 4], data: &[u8]) -> Option<[f64; 9]> { (p.len() == 8 && p.iter().all(|v| v.is_finite())).then(|| [[p[0], p[1]], [p[2], p[3]], [p[4], p[5]], [p[6], p[7]]]) }; let q = quad("nonAffineTransform").or_else(|| quad("Trnf"))?; - corners_perspective(q, (w, h)) + let warp = get_desc(&d, "warp").and_then(warp_of).flatten(); + corners_perspective_in(q, perspective_frame(warp.as_ref(), w, h)) } /// The projective placement of a `w` × `h` source onto corners `q` that aren't a parallelogram; /// `None` for affine corners (see [`corners_affine`]) or an empty size. pub fn corners_perspective(q: [[f64; 2]; 4], (w, h): (f64, f64)) -> Option<[f64; 9]> { + corners_perspective_in(q, [0.0, 0.0, w, h]) +} + +/// [`corners_perspective`] for the source-space box `frame` (left, top, right, bottom; see +/// [`perspective_frame`]). +pub(crate) fn corners_perspective_in(q: [[f64; 2]; 4], frame: [f64; 4]) -> Option<[f64; 9]> { // A parallelogram (top-left + bottom-right = top-right + bottom-left) is affine. let tol = 1e-6 * (1.0 + q.iter().flatten().fold(0.0f64, |m, v| m.max(v.abs()))); - if (0..2).all(|i| (q[0][i] + q[2][i] - q[1][i] - q[3][i]).abs() <= tol) || w <= 0.0 || h <= 0.0 { + let ok = frame.iter().all(|v| v.is_finite()) && frame[2] > frame[0] && frame[3] > frame[1]; + if (0..2).all(|i| (q[0][i] + q[2][i] - q[1][i] - q[3][i]).abs() <= tol) || !ok { return None; } - photocraft_algo::transform::Homography::rect_to_quad([0.0, 0.0, w, h], q).map(|m| m.0) + photocraft_algo::transform::Homography::rect_to_quad(frame, q).map(|m| m.0) } /// `masterFXSwitch` from an `lfx2` block (defaults to `true`). @@ -1239,6 +1275,121 @@ mod more_tests { assert_eq!(placed_frame(None, 50.0, 25.0), [0.0, 0.0, 50.0, 25.0]); } + /// A `SoLd` placed on `quad` (`Trnf` and `nonAffineTransform`) for a 1000 × 1000 source, + /// warped by `warp`. + fn sold_placed(quad: [f64; 8], warp: Descriptor) -> Vec { + let list = Value::List(quad.iter().map(|v| Value::Double(*v)).collect()); + let d = Descriptor::new("null") + .with("Trnf", list.clone()) + .with("nonAffineTransform", list) + .with("Sz ", Value::Descriptor(Descriptor::new("Pnt ").with("Wdth", Value::Double(1000.0)).with("Hght", Value::Double(1000.0)))) + .with("warp", Value::Descriptor(warp)); + let mut data = b"soLD".to_vec(); + data.extend_from_slice(&4u32.to_be_bytes()); + data.extend(VersionedDescriptor::new(d).to_bytes()); + data + } + + /// A `warpCylinder` descriptor as #4112 describes it: horizontal, no perspective, seven + /// `warpValues` in the space of `bounds`. + fn cylinder_desc(values: Value) -> Descriptor { + let e = |t: &str, v: &str| Value::Enumerated { type_id: Id::new(t), value: Id::new(v) }; + let bounds = Descriptor::new("classFloatRect") + .with("Top ", Value::Double(0.0)) + .with("Left", Value::Double(0.0)) + .with("Btom", Value::Double(1000.0)) + .with("Rght", Value::Double(1000.0)); + Descriptor::new("warp") + .with("warpStyle", e("warpStyle", "warpCylinder")) + .with("warpValue", Value::Double(0.0)) + .with("warpValues", values) + .with("warpPerspective", Value::Double(0.0)) + .with("warpPerspectiveOther", Value::Double(0.0)) + .with("warpRotate", e("Ornt", "Hrzn")) + .with("bounds", Value::Descriptor(bounds)) + } + + const CYL: [f64; 7] = [100.0, 800.0, 900.0, 200.0, 0.3, 0.3, 0.5]; + + fn doubles(v: &[f64]) -> Value { + Value::List(v.iter().map(|x| Value::Double(*x)).collect()) + } + + /// #4112: a `warpCylinder` placed layer comes in as the cylinder its seven values describe, + /// instead of no warp (which re-rendered the contents flat). + #[test] + fn placed_cylinder_warp_is_read() { + use photocraft_geom::warp::{Warp, WarpStyle}; + let quad = [0.0, 0.0, 800.0, 0.0, 800.0, 800.0, 0.0, 800.0]; + let want = Warp::cylinder(&CYL, [0.0, 0.0, 1000.0, 1000.0]).unwrap(); + let w = parse_placed_warp(b"SoLd", &sold_placed(quad, cylinder_desc(doubles(&CYL)))).expect("a cylinder"); + assert_eq!(w, want); + assert_eq!(w.style, WarpStyle::Cylinder); + // Values written as one unit-float list read the same. + let floats = Value::UnitFloats { unit: *b"#Pxl", values: CYL.to_vec() }; + assert_eq!(parse_placed_warp(b"SoLE", &sold_placed(quad, cylinder_desc(floats))), Some(want)); + } + + /// Cylinders nobody has measured (vertical, with perspective) and broken values stay + /// unreadable, as every cylinder was before: the layer keeps the pixels Photoshop stored. + #[test] + fn placed_cylinder_warps_that_cant_be_read() { + let quad = [0.0, 0.0, 800.0, 0.0, 800.0, 800.0, 0.0, 800.0]; + let read = |d: Descriptor| parse_placed_warp(b"SoLd", &sold_placed(quad, d)); + let e = |t: &str, v: &str| Value::Enumerated { type_id: Id::new(t), value: Id::new(v) }; + let with = |key: &str, v: Value| { + let mut d = cylinder_desc(doubles(&CYL)); + d.items.iter_mut().filter(|(k, _)| k.is(key)).for_each(|(_, old)| *old = v.clone()); + d + }; + assert!(read(with("warpRotate", e("Ornt", "Vrtc"))).is_none(), "vertical"); + assert!(read(with("warpPerspective", Value::Double(20.0))).is_none(), "perspective"); + assert!(read(with("warpPerspectiveOther", Value::Double(-5.0))).is_none(), "the other perspective"); + assert!(read(cylinder_desc(doubles(&CYL[..6]))).is_none(), "six values"); + assert!(read(cylinder_desc(Value::List(vec![Value::Text(UnicodeString::new_nul("x")); 7]))).is_none(), "not numbers"); + assert!(read(cylinder_desc(Value::Double(1.0))).is_none(), "one number"); + let mut flipped = CYL; + flipped.swap(0, 2); + assert!(read(cylinder_desc(doubles(&flipped))).is_none(), "silhouettes swapped"); + let mut no_values = cylinder_desc(doubles(&CYL)); + no_values.items.retain(|(k, _)| !k.is("warpValues")); + assert!(read(no_values).is_none(), "no warpValues"); + } + + /// Like a custom warp's (#3677), a cylinder's control-point box is what the placement quad + /// frames, for affine corners and for projective ones (`nonAffineTransform`). + #[test] + fn placed_cylinder_quad_frames_its_control_box() { + use photocraft_geom::warp::Warp; + let cb = Warp::cylinder(&CYL, [0.0, 0.0, 1000.0, 1000.0]).unwrap().mesh.unwrap().control_bounds(); + // Affine: the box lands on the quad. + let quad = [10.0, 20.0, 410.0, 20.0, 410.0, 520.0, 10.0, 520.0]; + let (_, a) = parse_smart(b"SoLd", &sold_placed(quad, cylinder_desc(doubles(&CYL)))); + let [m0, m1, m2, m3, m4, m5] = a.m; + let map = |x: f64, y: f64| (m0 * x + m2 * y + m4, m1 * x + m3 * y + m5); + for ((x, y), (qx, qy)) in + [(cb[0], cb[1]), (cb[2], cb[1]), (cb[2], cb[3]), (cb[0], cb[3])].into_iter().zip(quad.as_chunks::<2>().0.iter().map(|p| (p[0], p[1]))) + { + let (px, py) = map(x, y); + assert!((px - qx).abs() < 1e-9 && (py - qy).abs() < 1e-9, "{x},{y} → {px},{py}, not {qx},{qy}"); + } + assert!(parse_smart_perspective(b"SoLd", &sold_placed(quad, cylinder_desc(doubles(&CYL)))).is_none(), "affine"); + // Projective: the same box lands on the four corners. + let quad = [10.0, 20.0, 410.0, 60.0, 400.0, 480.0, 30.0, 520.0]; + let h = photocraft_algo::transform::Homography(parse_smart_perspective(b"SoLd", &sold_placed(quad, cylinder_desc(doubles(&CYL)))).expect("projective")); + for ((x, y), (qx, qy)) in + [(cb[0], cb[1]), (cb[2], cb[1]), (cb[2], cb[3]), (cb[0], cb[3])].into_iter().zip(quad.as_chunks::<2>().0.iter().map(|p| (p[0], p[1]))) + { + let (px, py) = h.apply(x, y); + assert!((px - qx).abs() < 1e-6 && (py - qy).abs() < 1e-6, "{x},{y} → {px},{py}, not {qx},{qy}"); + } + // Without a warp a projective quad still frames the source rect. + let none = Descriptor::new("warp").with("warpStyle", Value::Enumerated { type_id: Id::new("warpStyle"), value: Id::new("warpNone") }); + let h = photocraft_algo::transform::Homography(parse_smart_perspective(b"SoLd", &sold_placed(quad, none)).expect("projective")); + let (px, py) = h.apply(1000.0, 1000.0); + assert!((px - 400.0).abs() < 1e-6 && (py - 480.0).abs() < 1e-6); + } + #[test] fn effects_switch() { let mut lfx = vec![0, 0, 0, 0]; diff --git a/crates/io/src/smart_map.rs b/crates/io/src/smart_map.rs index 6eeda61a13..4f46a7bbd5 100644 --- a/crates/io/src/smart_map.rs +++ b/crates/io/src/smart_map.rs @@ -692,7 +692,8 @@ pub struct PlacedSpec<'a> { /// Where the frame corners land (top-left, top-right, bottom-right, bottom-left), as x, y pairs. /// The frame is [`crate::blocks::placed_frame`] for an affine placement (the inverse of -/// `parse_smart`), the source rect for a projective one (as `parse_smart_perspective` reads it). +/// `parse_smart`), [`crate::blocks::perspective_frame`] for a projective one (as +/// `parse_smart_perspective` reads it). fn quad_points(s: &PlacedSpec<'_>) -> [f64; 8] { let (w, h) = s.size; let [a, b, c, d, e, f] = s.transform.m; @@ -700,7 +701,7 @@ fn quad_points(s: &PlacedSpec<'_>) -> [f64; 8] { Some(p) => photocraft_algo::transform::Homography(*p).apply(x, y), None => (a * x + c * y + e, b * x + d * y + f), }; - let [x0, y0, x1, y1] = if s.perspective.is_some() { [0.0, 0.0, w, h] } else { crate::blocks::placed_frame(s.warp, w, h) }; + let [x0, y0, x1, y1] = if s.perspective.is_some() { crate::blocks::perspective_frame(s.warp, w, h) } else { crate::blocks::placed_frame(s.warp, w, h) }; let mut out = [0.0; 8]; for (i, (x, y)) in [(x0, y0), (x1, y0), (x1, y1), (x0, y1)].into_iter().enumerate() { (out[2 * i], out[2 * i + 1]) = map(x, y); @@ -726,7 +727,7 @@ fn enumv(t: &str, v: &str) -> Value { } /// The `warp` descriptor (also the `PlLd` warp). Multi-patch meshes have no 1 × 1 envelope and -/// are written as no warp, with a message. +/// are written as no warp, with a message. A cylinder is written as its seven `warpValues`. fn warp_desc(w: Option<&Warp>, size: (f64, f64), warnings: &mut Vec) -> Value { let none = Warp::none([0.0, 0.0, size.0, size.1]); let mut w = w.unwrap_or(&none).clone(); @@ -745,9 +746,11 @@ fn warp_desc(w: Option<&Warp>, size: (f64, f64), warnings: &mut Vec) -> .with("Left", Value::Double(x0)) .with("Btom", Value::Double(y1)) .with("Rght", Value::Double(x1)); - let mut d = Descriptor::new("warp") - .with("warpStyle", enumv("warpStyle", w.style.psd_name())) - .with("warpValue", Value::Double(w.bend)) + let mut d = Descriptor::new("warp").with("warpStyle", enumv("warpStyle", w.style.psd_name())).with("warpValue", Value::Double(w.bend)); + if w.style == WarpStyle::Cylinder { + d = d.with("warpValues", Value::List(w.values.iter().copied().map(Value::Double).collect())); + } + let mut d = d .with("warpPerspective", Value::Double(w.h_distort)) .with("warpPerspectiveOther", Value::Double(w.v_distort)) .with("warpRotate", enumv("Ornt", if w.vertical { "Vrtc" } else { "Hrzn" })) @@ -1424,6 +1427,53 @@ mod tests { assert!(!quad_moved(&p.descriptor, &spec)); } + /// #4112: a regenerated `SoLd` (after Replace Contents, a move…) writes a cylinder back as + /// `warpCylinder` and its seven values, not as no warp, and frames its control-point box with + /// the quad, affine or projective, so import places it where it was. + #[test] + fn cylinder_warp_round_trips_through_sold() { + let v = [100.0, 800.0, 900.0, 200.0, 0.3, 0.3, 0.5]; + let warp = Warp::cylinder(&v, [0.0, 0.0, 1000.0, 1000.0]).unwrap(); + let transform = Affine { m: [0.5, 0.0, 0.0, 0.5, 40.0, 30.0] }; + let mut spec = + PlacedSpec { idnt: "i", placed: "p", transform, perspective: None, size: (1000.0, 1000.0), dpi: 72.0, warp: Some(&warp), filter_fx: None }; + let mut warnings = Vec::new(); + let sold = sold_bytes(None, &spec, &mut warnings); + assert!(warnings.is_empty(), "{warnings:?}"); + let p = parse_sold(&sold).expect("parses"); + let Some(Value::Descriptor(w)) = p.descriptor.get("warp") else { panic!("no warp") }; + assert_eq!(crate::blocks::enum_of(w, "warpStyle"), Some(b"warpCylinder".as_slice())); + let Some(Value::List(values)) = w.get("warpValues") else { panic!("no warpValues") }; + assert_eq!(values.iter().filter_map(|x| num(Some(x))).collect::>(), v); + assert!(w.get("customEnvelopeWarp").is_none()); + assert_eq!(crate::blocks::parse_placed_warp(b"SoLd", &sold), Some(warp.clone())); + for (a, b) in p.transform.m.iter().zip(transform.m) { + assert!((a - b).abs() < 1e-9, "{:?}", p.transform); + } + // The quad is the image of the control-point box, not of the source rect. + let cb = warp.mesh.as_ref().unwrap().control_bounds(); + let Some(Value::List(q)) = p.descriptor.get("Trnf") else { panic!("no Trnf") }; + let q: Vec = q.iter().filter_map(|x| num(Some(x))).collect(); + let want = [cb[0], cb[1], cb[2], cb[1], cb[2], cb[3], cb[0], cb[3]].map(|c| 0.5 * c); + for (i, (a, b)) in q.iter().zip(want).enumerate() { + let shift = if i % 2 == 0 { 40.0 } else { 30.0 }; + assert!((a - (b + shift)).abs() < 1e-9, "corner {i}: {q:?}"); + } + assert!(!quad_moved(&p.descriptor, &spec)); + // A projective placement comes back as the same map. + let h = photocraft_algo::transform::Homography::rect_to_quad(cb, [[10.0, 20.0], [410.0, 60.0], [400.0, 480.0], [30.0, 520.0]]).unwrap(); + spec.perspective = Some(h.0); + let sold = sold_bytes(None, &spec, &mut warnings); + let back = crate::blocks::parse_smart_perspective(b"SoLd", &sold).expect("projective"); + let n = |m: [f64; 9]| m.map(|x| x / m[8]); + for (a, b) in n(back).iter().zip(n(h.0)) { + assert!((a - b).abs() < 1e-9 * (1.0 + b.abs()), "{back:?} vs {:?}", h.0); + } + let plld = plld_bytes(&spec, &mut warnings); + assert_eq!(crate::blocks::parse_plld_warp(&plld), crate::blocks::PlldWarp::Warp(warp)); + assert!(warnings.is_empty(), "{warnings:?}"); + } + #[test] fn hex_and_uuid() { assert_eq!(from_hex(&to_hex(&[0, 1, 0xab, 0xff])), Some(vec![0, 1, 0xab, 0xff])); diff --git a/crates/io/tests/cylinder_warp.rs b/crates/io/tests/cylinder_warp.rs new file mode 100644 index 0000000000..be75e31505 --- /dev/null +++ b/crates/io/tests/cylinder_warp.rs @@ -0,0 +1,123 @@ +//! Smart objects warped with Photoshop's Cylinder (#4112) through PSD persistence and a re-render +//! from their contents, as Replace Contents, Relink and Edit Contents do. + +use std::sync::Arc; + +use photocraft_color::{ColorMode, SampleType}; +use photocraft_doc::{Document, Layer, LayerContent, SmartObject, SmartSource}; +use photocraft_geom::warp::{Warp, WarpStyle}; +use photocraft_geom::{Affine, Rect, Size}; +use photocraft_io::{ExportOptions, export, import, smart_map}; +use photocraft_psd::PsdFile; +use photocraft_raster::Surface; + +/// Silhouettes at x 10 and 90, edges at y 20 and 80, ellipses 0.3 deep, wrapped as little as +/// Photoshop allows (a sixth of the contents on the back on each side). +const VALUES: [f64; 7] = [10.0, 80.0, 90.0, 20.0, 0.3, 0.3, 0.0]; + +/// A 100 × 100 source: a red band at the left and a blue band at the right (both on the back of +/// the cylinder), green in between. +fn contents() -> Surface { + let mut s = Surface::new(photocraft_color::PixelFormat::new(ColorMode::Rgb, SampleType::U8, true)); + s.fill_rect(Rect::new(0, 0, 100, 100), &[0.0, 1.0, 0.0, 1.0]); + s.fill_rect(Rect::new(0, 0, 5, 100), &[1.0, 0.0, 0.0, 1.0]); + s.fill_rect(Rect::new(95, 0, 100, 100), &[0.0, 0.0, 1.0, 1.0]); + s +} + +fn document(transform: Affine, perspective: Option<[f64; 9]>) -> (Document, Warp) { + let pixels = contents(); + let mut source = Document::new("label", Size::new(100, 100), ColorMode::Rgb, SampleType::U8); + source.layers.push(Layer::new("art", LayerContent::Raster(pixels.clone()))); + let bytes = photocraft_format::save_to_bytes(&source, &Default::default()).unwrap(); + let warp = Warp::cylinder(&VALUES, [0.0, 0.0, 100.0, 100.0]).unwrap(); + let mut sm = SmartObject::new(SmartSource::Embedded { file_name: "label.pcraft".into(), bytes: Arc::new(bytes) }, transform, None); + sm.warp = Some(warp.clone()); + sm.perspective = perspective; + sm.cache = Some(render(&sm)); + let mut doc = Document::new("jar", Size::new(160, 160), ColorMode::Rgb, SampleType::U8); + doc.layers.push(Layer::new("label", LayerContent::Smart(sm))); + (doc, warp) +} + +/// The smart object drawn from its contents, as the engine re-renders it. +fn render(sm: &SmartObject) -> Surface { + let src = contents(); + match &sm.perspective { + Some(p) => { + photocraft_algo::warp::place_source_projective(&src, Rect::new(0, 0, 100, 100), &photocraft_algo::transform::Homography(*p), sm.warp.as_ref()) + } + None => photocraft_algo::warp::place_source(&src, Rect::new(0, 0, 100, 100), &sm.transform, sm.warp.as_ref()), + } + .unwrap() +} + +fn smart(doc: &Document) -> &SmartObject { + let LayerContent::Smart(sm) = &doc.layers[0].content else { panic!("not a smart object") }; + sm +} + +fn round_trip(doc: &Document) -> (PsdFile, Document) { + let out = export(doc, "psd", &ExportOptions::default()).unwrap(); + assert!(!out.warnings.iter().any(|w| w.contains("warp") || w.contains("written as pixels")), "{:?}", out.warnings); + (PsdFile::from_bytes(&out.bytes).unwrap(), import("psd", &out.bytes).unwrap().document) +} + +/// Opaque pixels of the render that are mostly `channel` (0 red, 1 green, 2 blue). +fn count(s: &Surface, channel: usize) -> usize { + let r = s.content_bounds(); + s.read_region(r).chunks_exact(4).filter(|p| p[3] > 0.5 && p[channel] > 0.5 && (0..3).all(|c| c == channel || p[c] < 0.5)).count() +} + +/// Before #4112 a cylinder was read as no warp: re-rendering the contents drew them flat, the +/// red and blue bands that wrap round the back included. Now the PSD keeps the cylinder and the +/// re-render hides the back and centres the front between the silhouettes. +#[test] +fn a_cylinder_survives_psd_and_wraps_the_contents_again() { + let transform = Affine { m: [1.0, 0.0, 0.0, 1.0, 30.0, 20.0] }; + let (doc, warp) = document(transform, None); + let (file, back) = round_trip(&doc); + let layer = file.layers().iter().find(|l| l.name() == "label").unwrap(); + let sold = smart_map::parse_sold(&layer.block(b"SoLd").unwrap().data).unwrap(); + let Some(photocraft_psd::descriptor::Value::Descriptor(w)) = sold.descriptor.get("warp") else { panic!("no warp") }; + assert!(matches!(w.get("warpStyle"), Some(photocraft_psd::descriptor::Value::Enumerated { value, .. }) if value.as_bytes() == b"warpCylinder")); + let sm = smart(&back); + assert_eq!(sm.warp.as_ref().map(|w| w.style), Some(WarpStyle::Cylinder)); + assert_eq!(sm.warp.as_ref(), Some(&warp)); + for (a, b) in sm.transform.m.iter().zip(transform.m) { + assert!((a - b).abs() < 1e-9, "{:?}", sm.transform); + } + let out = render(sm); + assert_eq!((count(&out, 0), count(&out, 2)), (0, 0), "the bands on the back are hidden"); + assert!(count(&out, 1) > 3000, "the front is drawn"); + // Between the silhouettes (x 10 and 90, shifted by 30) and no further. + let b = out.content_bounds(); + assert!(b.x0 >= 39 && b.x1 <= 121, "{b:?}"); + // The middle of the contents is at the front, centred. + let mid = out.pixel(80, 60); + assert!(mid[1] > 0.9 && mid[3] > 0.9, "{mid:?}"); + // Saved again unchanged, the block is written as it was read. + let (again, _) = round_trip(&back); + let layer2 = again.layers().iter().find(|l| l.name() == "label").unwrap(); + assert_eq!(layer2.block(b"SoLd").unwrap().data, layer.block(b"SoLd").unwrap().data); +} + +/// Mockups usually place the label in perspective (`nonAffineTransform`): that placement and +/// the cylinder both come back. +#[test] +fn a_cylinder_in_perspective_survives_psd() { + let cb = Warp::cylinder(&VALUES, [0.0, 0.0, 100.0, 100.0]).unwrap().mesh.unwrap().control_bounds(); + let h = photocraft_algo::transform::Homography::rect_to_quad(cb, [[20.0, 20.0], [140.0, 30.0], [135.0, 150.0], [25.0, 140.0]]).unwrap(); + let (doc, warp) = document(Affine::IDENTITY, Some(h.0)); + let (_, back) = round_trip(&doc); + let sm = smart(&back); + assert_eq!(sm.warp.as_ref(), Some(&warp)); + let p = sm.perspective.expect("still projective"); + let n = |m: [f64; 9]| m.map(|v| v / m[8]); + for (a, b) in n(p).iter().zip(n(h.0)) { + assert!((a - b).abs() < 1e-9 * (1.0 + b.abs()), "{p:?} vs {:?}", h.0); + } + let out = render(sm); + assert_eq!((count(&out, 0), count(&out, 2)), (0, 0)); + assert!(count(&out, 1) > 3000); +} From 0981fd79d32e6147d86f7b3c25378782894704b5 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 20:59:02 +0000 Subject: [PATCH 3/4] Warp commands take a cylinder; docs for #4112 - `edit.transform.warp` / `layer.smartObjects.warp`: `style: "cylinder"` with its seven `values` (document px; depths in half axes), or a full `warp` object whose mesh is rebuilt from its values. Anything that isn't a horizontal cylinder without distortion is refused, never a silent no-op; on type it is refused like a custom mesh. - On a smart object the cylinder is stored in source space: still a cylinder (so it goes to PSD as one) when the placement only scales and moves, its mesh as a custom warp otherwise. - docs: file-format-parity (PSD row, history) and gaps G1. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01EuC9R3K5QeMBrDkixa1jnt --- crates/engine/src/warp_cmds.rs | 135 ++++++++++++++++++++++++++++++++- crates/io/src/blocks.rs | 15 ++-- crates/io/src/smart_map.rs | 2 +- docs/file-format-parity.md | 5 +- docs/gaps.md | 7 +- 5 files changed, 148 insertions(+), 16 deletions(-) diff --git a/crates/engine/src/warp_cmds.rs b/crates/engine/src/warp_cmds.rs index 5142b0a940..87811f7346 100644 --- a/crates/engine/src/warp_cmds.rs +++ b/crates/engine/src/warp_cmds.rs @@ -71,10 +71,14 @@ pub fn smart_warp_doc_space(layer: &Layer) -> Option { } /// Parses the warp params: a full `"warp"` object, or `"style"` (+ `"bend"`, `"hDistort"`, -/// `"vDistort"`, `"vertical"`), `"mesh"` and `"grid"` over `rect`. +/// `"vDistort"`, `"vertical"`; `"values"` for a cylinder), `"mesh"` and `"grid"` over `rect`. pub fn warp_from_params(cmd: &str, p: &Value, rect: [f64; 4]) -> Result { if let Some(w) = p.get("warp") { - let w: Warp = serde_json::from_value(w.clone()).map_err(|e| bad(cmd, format!("bad `warp`: {e}")))?; + let mut w: Warp = serde_json::from_value(w.clone()).map_err(|e| bad(cmd, format!("bad `warp`: {e}")))?; + // A cylinder is its values: the mesh is rebuilt from them, whatever was passed. + if w.style == WarpStyle::Cylinder { + w = Warp::cylinder(&w.values, w.bounds).ok_or_else(|| bad(cmd, CYLINDER_VALUES))?; + } if w.mesh.as_ref().is_some_and(|m| !m.is_valid()) { return Err(bad(cmd, "malformed warp mesh")); } @@ -90,6 +94,13 @@ pub fn warp_from_params(cmd: &str, p: &Value, rect: [f64; 4]) -> Result { w.h_distort = num("hDistort", 0.0); w.v_distort = num("vDistort", 0.0); w.vertical = p.get("vertical").and_then(Value::as_bool).unwrap_or(false); + if style == WarpStyle::Cylinder { + if w.vertical || w.h_distort != 0.0 || w.v_distort != 0.0 { + return Err(bad(cmd, "a cylinder is horizontal, with no hDistort or vDistort")); + } + let values: Option> = p.get("values").and_then(Value::as_array).and_then(|a| a.iter().map(Value::as_f64).collect()); + return values.and_then(|v| Warp::cylinder(&v, rect)).ok_or_else(|| bad(cmd, CYLINDER_VALUES)); + } if style == WarpStyle::Custom { let mesh = match p.get("mesh") { Some(m) => { @@ -110,6 +121,30 @@ pub fn warp_from_params(cmd: &str, p: &Value, rect: [f64; 4]) -> Result { Ok(w) } +const CYLINDER_VALUES: &str = + "a cylinder needs `values`: seven numbers [left, bottom, right, top, top depth, bottom depth, wrap 0..1], the right silhouette right of the left one"; + +/// A document-space cylinder `w` stored on a smart object, whose placement's inverse is `inv` and +/// whose warp box `w.bounds` lands on `sb` in source space. A placement that only scales and +/// moves keeps it a cylinder (written to PSD as one): the silhouettes and edges map, and the +/// ellipses' depth, in units of the half axis, follows the change of aspect. Otherwise (rotated, +/// skewed, flipped, in perspective) it becomes its mesh, a custom warp. +fn cylinder_in_source(w: &Warp, inv: &Homography, sb: [f64; 4]) -> Option { + let m = inv.0; + let axis_aligned = m[8] != 0.0 && m[1] == 0.0 && m[3] == 0.0 && m[6] == 0.0 && m[7] == 0.0; + let (sx, sy) = (m[0] / m[8], m[4] / m[8]); + if axis_aligned && sx > 0.0 && sy > 0.0 && w.values.len() == 7 { + let v = &w.values; + let (x, y) = (|v: f64| sx * v + m[2] / m[8], |v: f64| sy * v + m[5] / m[8]); + let k = sy / sx; + if let Some(c) = Warp::cylinder(&[x(v[0]), y(v[1]), x(v[2]), y(v[3]), v[4] * k, v[5] * k, v[6]], sb) { + return Some(c); + } + } + let mesh = w.to_mesh(1, 1).map_points(|p| hom_apply(inv, p)); + Some(Warp::custom(mesh, sb)) +} + /// A Background is turned into a normal layer by the caller first /// ([`crate::extra_cmds::background_to_layer`]). fn warp_layer(doc_sel: Option<&Surface>, group: Locks, l: &mut Layer, w: &Warp, rect: Rect, interp: Interp, frames: Option<&Frames>) -> Result<()> { @@ -146,6 +181,7 @@ fn warp_layer(doc_sel: Option<&Surface>, group: Locks, l: &mut Layer, w: &Warp, None => Some(Warp::custom(mesh, sb)), } } + WarpStyle::Cylinder => cylinder_in_source(w, &inv, sb), _ => Some(Warp { bounds: sb, mesh: None, ..w.clone() }), }; sm.warp = src_warp.filter(|w| !w.is_identity()); @@ -302,6 +338,7 @@ fn type_warp(cmd: &str, w: &Warp) -> Result Ok(None), WarpStyle::Custom if w.is_identity() => Ok(None), WarpStyle::Custom => Err(bad(cmd, "type can only take a warp style (arc, flag, …); a custom mesh needs rasterized type")), + WarpStyle::Cylinder => Err(bad(cmd, "type can only take a warp style (arc, flag, …); a cylinder needs rasterized type")), s => Ok(Some(photocraft_doc::text::TextWarp { style: s.psd_name().into(), value: clamp(w.bend), @@ -546,7 +583,7 @@ fn grid(s: &mut Session, p: &Value) -> Result { } pub fn specs() -> Vec { - const P: &str = r##"{"layer":id?,"rect":[x0,y0,x1,y1]? (warp box; default = layer content ∩ selection),"style":"custom|none|arc|arcLower|arcUpper|arch|bulge|shellLower|shellUpper|flag|wave|fish|rise|fisheye|inflate|squeeze|twist","bend":%=50,"hDistort":%,"vDistort":%,"vertical":bool,"mesh":{"us":[0,…,1],"vs":[0,…,1],"points":[[x,y]…]} ((3c+1)×(3r+1) control points, row-major, document px; on a smart object `us`/`vs` span the source contents, so points equal to the placement are an identity warp even when it is skewed),"grid":[cols,rows]?,"warp":{full warp object}?,"interpolation":"bicubic|bilinear|nearest"} — on a type layer a style (or none) sets its Warp Text and the type stays editable"##; + const P: &str = r##"{"layer":id?,"rect":[x0,y0,x1,y1]? (warp box; default = layer content ∩ selection),"style":"custom|none|arc|arcLower|arcUpper|arch|bulge|shellLower|shellUpper|flag|wave|fish|rise|fisheye|inflate|squeeze|twist|cylinder","bend":%=50,"hDistort":%,"vDistort":%,"vertical":bool,"values":[left,bottom,right,top,topDepth,bottomDepth,wrap]? (cylinder: silhouettes and edges in document px, the ellipses' depths in half-axis units, wrap 0..1; horizontal only),"mesh":{"us":[0,…,1],"vs":[0,…,1],"points":[[x,y]…]} ((3c+1)×(3r+1) control points, row-major, document px; on a smart object `us`/`vs` span the source contents, so points equal to the placement are an identity warp even when it is skewed),"grid":[cols,rows]?,"warp":{full warp object}?,"interpolation":"bicubic|bilinear|nearest"} — on a type layer a style (or none) sets its Warp Text and the type stays editable"##; const S: &str = r##"{"warp":{…}? (the warp being edited; returned split),"rect":[x0,y0,x1,y1]?,"at":[x,y]? (document point; default = middle of the patch)} — without `warp`, edits the active smart object's warp. A 1×1 custom mesh is divided at the thirds first (the guides drawn on the default grid), so splitWarpCrosswise on an identity custom warp returns a 4×4 mesh (knots at 0, 1/3, 1/2, 2/3 and 1), not a 2×2. A preset is fitted as one patch and split without that step."##; const G: &str = r##"{"warp":{…}? (the warp being edited; returned with the new mesh),"rect":[x0,y0,x1,y1]?,"size":n|"default"|"3"|"4"|"5" (square patches, 1..=64; 1 and "default" are the single-patch grid)} — without `warp`/`style`/`mesh`, edits the active smart object's warp. The surface is kept and the new lines are real splits at i/n."##; vec![ @@ -668,6 +705,98 @@ mod tests { assert!(s.execute("edit.transform.warp", json!({"warp": Warp::custom(mesh, [0.0, 0.0, 10.0, 10.0])})).is_err()); } + /// The seven values of a cylinder over the session's painted box (x 10..60, y 10..40). + const CYL: [f64; 7] = [10.0, 40.0, 60.0, 10.0, 0.3, 0.3, 0.5]; + + /// `style: "cylinder"` takes its seven values (#4112); anything that isn't a horizontal + /// cylinder is refused, never a silent no-op, and the layer is left alone. + #[test] + fn cylinder_style_needs_its_values() { + let mut s = session(8); + let before = active_surface(&s); + for p in [ + json!({"style": "cylinder"}), + json!({"style": "cylinder", "values": [10, 40, 60, 10, 0.3, 0.3]}), + json!({"style": "cylinder", "values": [60, 40, 10, 10, 0.3, 0.3, 0.5]}), + json!({"style": "cylinder", "values": [10, 40, 60, 10, 0.3, 0.3, "x"]}), + json!({"style": "cylinder", "values": "seven"}), + json!({"style": "cylinder", "values": CYL, "vertical": true}), + json!({"style": "cylinder", "values": CYL, "hDistort": 10}), + json!({"warp": {"style": "cylinder", "bounds": [10.0, 10.0, 60.0, 40.0]}}), + ] { + let e = s.execute("edit.transform.warp", p.clone()).unwrap_err().to_string(); + assert!(e.contains("cylinder"), "{p}: {e}"); + } + assert_eq!(active_surface(&s), before); + // On type it is refused like a custom mesh: Warp Text has no cylinder. + s.execute("type.create", json!({"x": 10, "y": 50, "text": "Hi"})).unwrap(); + assert!(s.execute("edit.transform.warp", json!({"style": "cylinder", "values": CYL})).is_err()); + } + + #[test] + fn cylinder_warps_pixels_at_every_depth() { + for depth in [8u64, 16, 32] { + let mut s = session(depth); + let r = s.execute("edit.transform.warp", json!({"style": "cylinder", "values": CYL})).unwrap(); + assert_eq!(r["changed"], true, "{depth}"); + let after = active_surface(&s); + let b = after.content_bounds(); + // Between the silhouettes, the edges bowed down by the ellipses. + assert!(b.x0 >= 9 && b.x1 <= 61 && b.y0 >= 9 && b.y1 > 40, "{depth}: {b:?}"); + // The same cylinder as a full warp object (its mesh rebuilt from the values) agrees. + let mut t = session(depth); + let w = Warp::cylinder(&CYL, [10.0, 10.0, 60.0, 40.0]).unwrap(); + let mut garbled = w.clone(); + garbled.mesh = None; + t.execute("edit.transform.warp", json!({"warp": garbled})).unwrap(); + assert_eq!(active_surface(&t), after, "{depth}"); + } + } + + /// On a smart object the cylinder is stored in source space: still a cylinder (so it goes to + /// PSD as one) when the placement only scales and moves, its mesh when it rotates. + #[test] + fn smart_object_cylinder_is_stored_in_source_space() { + let stored = |s: &Session| { + let st = s.active().unwrap(); + let LayerContent::Smart(sm) = &st.doc.layer(st.active_layer.unwrap()).unwrap().content else { panic!("smart") }; + sm.warp.clone().unwrap() + }; + let mut s = session(8); + s.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap(); + s.execute("layer.smartObjects.warp", json!({"style": "cylinder", "values": CYL, "rect": [10, 10, 60, 40]})).unwrap(); + let w = stored(&s); + assert_eq!(w.style, WarpStyle::Cylinder); + let LayerContent::Smart(sm) = &s.active().unwrap().doc.layers[1].content else { panic!("smart") }; + let inv = crate::smart_cmds::placement(sm).inverse().unwrap(); + let src = |x: f64, y: f64| inv.apply(x, y); + let ((l, t), (r, bt)) = (src(10.0, 10.0), src(60.0, 40.0)); + for (a, b) in w.values.iter().zip([l, bt, r, t, 0.3, 0.3, 0.5]) { + assert!((a - b).abs() < 1e-9, "{:?}", w.values); + } + // Twice as wide: the silhouettes double, the ellipses' depth (in half axes) halves. + let mut s = session(8); + s.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap(); + s.execute("edit.transform", json!({"scaleX": 200, "scaleY": 100})).unwrap(); + let wide = [-15.0, 40.0, 85.0, 10.0, 0.3, 0.3, 0.5]; + s.execute("layer.smartObjects.warp", json!({"style": "cylinder", "values": wide, "rect": [-15, 10, 85, 40]})).unwrap(); + let w = stored(&s); + assert_eq!(w.style, WarpStyle::Cylinder); + assert!((w.values[4] - 0.6).abs() < 1e-9 && (w.values[5] - 0.6).abs() < 1e-9, "{:?}", w.values); + // The silhouettes land on the source's own edges (its contents span the scaled box). + let LayerContent::Smart(sm) = &s.active().unwrap().doc.layers[1].content else { panic!("smart") }; + let inv = crate::smart_cmds::placement(sm).inverse().unwrap(); + assert!((w.values[0] - inv.apply(-15.0, 10.0).0).abs() < 1e-9 && (w.values[2] - inv.apply(85.0, 10.0).0).abs() < 1e-9, "{:?}", w.values); + // Rotated: a custom mesh with the same shape. + let mut s = session(8); + s.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap(); + s.execute("edit.transform", json!({"rotate": 30})).unwrap(); + s.execute("layer.smartObjects.warp", json!({"style": "cylinder", "values": CYL, "rect": [10, 10, 60, 40]})).unwrap(); + let w = stored(&s); + assert_eq!(w.style, WarpStyle::Custom); + assert_eq!(w.mesh.as_ref().map(|m| (m.nx(), m.ny())), Some((13, 4))); + } + #[test] fn identity_and_bend_zero_are_no_ops_at_every_depth() { for depth in [8u64, 16, 32] { diff --git a/crates/io/src/blocks.rs b/crates/io/src/blocks.rs index a54e07fee7..087f2121d2 100644 --- a/crates/io/src/blocks.rs +++ b/crates/io/src/blocks.rs @@ -527,9 +527,8 @@ pub(crate) fn with_pattern_placement(mut d: Descriptor, angle: f32, link: bool, /// The warp of a placed layer (`SoLd`/`SoLE` `warp` descriptor): style, bend, distortions, /// axis, bounds and, for `warpCustom`, the 4 × 4 `customEnvelopeWarp` mesh, for `warpCylinder` -/// its seven `warpValues`. Read-only: the raw -/// block is what gets written back. `None` when absent or `warpNone` with no mesh. A `PlLd` -/// record keeps its warp elsewhere: see [`parse_plld_warp`]. +/// its seven `warpValues`. Read-only: the raw block is what gets written back. `None` when absent +/// or `warpNone` with no mesh. A `PlLd` record keeps its warp elsewhere: see [`parse_plld_warp`]. pub fn parse_placed_warp(key: &[u8; 4], data: &[u8]) -> Option { if key != b"SoLd" && key != b"SoLE" { return None; @@ -675,11 +674,11 @@ pub fn corners_affine(p: &[f64; 8], frame: [f64; 4]) -> Option { /// for a source of `size` warped by `warp`: the inverse of [`crate::smart_map::plld_bytes`]'s /// quad. As for `SoLd`'s `Trnf` (#3677), affine corners frame a single-patch custom warp's /// control-point box (which may spill past the source), else the source rect; projective corners -/// frame [`perspective_frame`], as `parse_smart_perspective` reads them. `PlLd` stores no source size: -/// without the source file (`None`) the layer is taken as placed at 100 %, the edge lengths being -/// its size (a custom warp's box needs no size); position and rotation stay right, only Replace -/// Contents and re-renders depend on the scale. Import and export (which keeps an unedited record -/// verbatim) both place a `PlLd` layer with this. +/// frame [`perspective_frame`], as `parse_smart_perspective` reads them. `PlLd` stores no source +/// size: without the source file (`None`) the layer is taken as placed at 100 %, the edge lengths +/// being its size (a custom warp's box needs no size); position and rotation stay right, only +/// Replace Contents and re-renders depend on the scale. Import and export (which keeps an unedited +/// record verbatim) both place a `PlLd` layer with this. pub fn plld_transform(p: &[f64; 8], size: Option<(f64, f64)>, warp: Option<&photocraft_geom::warp::Warp>) -> (Affine, Option<[f64; 9]>) { let (w, h) = size.unwrap_or_else(|| ((p[2] - p[0]).hypot(p[3] - p[1]), (p[6] - p[0]).hypot(p[7] - p[1]))); let perspective = corners_perspective_in([[p[0], p[1]], [p[2], p[3]], [p[4], p[5]], [p[6], p[7]]], perspective_frame(warp, w, h)); diff --git a/crates/io/src/smart_map.rs b/crates/io/src/smart_map.rs index 4f46a7bbd5..4e7f955208 100644 --- a/crates/io/src/smart_map.rs +++ b/crates/io/src/smart_map.rs @@ -750,7 +750,7 @@ fn warp_desc(w: Option<&Warp>, size: (f64, f64), warnings: &mut Vec) -> if w.style == WarpStyle::Cylinder { d = d.with("warpValues", Value::List(w.values.iter().copied().map(Value::Double).collect())); } - let mut d = d + d = d .with("warpPerspective", Value::Double(w.h_distort)) .with("warpPerspectiveOther", Value::Double(w.v_distort)) .with("warpRotate", enumv("Ornt", if w.vertical { "Vrtc" } else { "Hrzn" })) diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 2651f4c4d1..3349852b38 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity -> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-13 · **Change:** minor (CMYK Hue/Saturation and Color Balance keep the black ink; CMYK Posterize on the stored values, psd-tools oracle floor 242/309; CMYK adjustment layers composite on the document's inks; Photoshop-oracle floor 165/258; CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; modern Brightness/Contrast's brightness curve; Photoshop-oracle floor 151/258; Lab documents composite on their stored L, a, b; Photoshop-oracle floor 152/258; Levels on whole levels at every step with Photoshop's shadow toe; Photoshop-oracle floor 146/258, psd-tools 238/309; 32-bit adjustments unclipped; Photoshop-oracle floor 149/258; mask feathers blur with their exact variance; Threshold splits on Photoshop's luminosity; a pass-through clipping base with an adjustment renders isolated; psd-tools oracle floor 240/309; 16-bit Posterize as Photoshop bins it; psd-tools oracle floor 241/309; Gradient Map luminosity; Photoshop-oracle floor 147/258; PSD: `Mt16`/`Mt32` merged transparency read; palette PNGs open in Indexed Color mode, #4804; earlier: CMYK Invert, Posterize and Threshold on the inks; 16-bit Lab Invert; Photoshop-oracle floor 145/258; `.atn` import maps 112 events; DDS textures open, #1327; DPX read and write, #1049; #2465 fixed: CS2 placed layers with only a `PlLd` block open their contents; Photoshop Actions `.atn` read and write; `.shc` contour files read and write; earlier the same day: indexed BMP palette mode; Save keeps a JPEG's quality and a WebP's encoding) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-14 · **Change:** minor (smart objects keep Photoshop's Cylinder warp through re-renders and saves, #4112; CMYK Hue/Saturation and Color Balance keep the black ink; CMYK Posterize on the stored values, psd-tools oracle floor 242/309; CMYK adjustment layers composite on the document's inks; Photoshop-oracle floor 165/258; CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; modern Brightness/Contrast's brightness curve; Photoshop-oracle floor 151/258; Lab documents composite on their stored L, a, b; Photoshop-oracle floor 152/258; Levels on whole levels at every step with Photoshop's shadow toe; Photoshop-oracle floor 146/258, psd-tools 238/309; 32-bit adjustments unclipped; Photoshop-oracle floor 149/258; mask feathers blur with their exact variance; Threshold splits on Photoshop's luminosity; a pass-through clipping base with an adjustment renders isolated; psd-tools oracle floor 240/309; 16-bit Posterize as Photoshop bins it; psd-tools oracle floor 241/309; Gradient Map luminosity; Photoshop-oracle floor 147/258; PSD: `Mt16`/`Mt32` merged transparency read; palette PNGs open in Indexed Color mode, #4804; earlier: CMYK Invert, Posterize and Threshold on the inks; 16-bit Lab Invert; Photoshop-oracle floor 145/258; `.atn` import maps 112 events; DDS textures open, #1327; DPX read and write, #1049; #2465 fixed: CS2 placed layers with only a `PlLd` block open their contents; Photoshop Actions `.atn` read and write; `.shc` contour files read and write; earlier the same day: indexed BMP palette mode; Save keeps a JPEG's quality and a WebP's encoding) · **Target:** Adobe Photoshop 2026 Every format Photoshop 2026 (27.11.0) reads or writes, with PhotoCraft's support, fidelity and tests. Photoshop's list comes from its `Info.plist` `CFBundleDocumentTypes`, the PiPL read/write @@ -21,7 +21,7 @@ XL are missing (gap G10). Remaining: 300–500 h including PSD fidelity. | Format | Photoshop | PhotoCraft read | PhotoCraft write | Fidelity and tests | |---|---|---|---|---| -| PSD | R/W | yes | yes | 1/8/16/32-bit; Bitmap, Gray, Indexed, RGB, CMYK, Multichannel, Lab, Duotone; layers, groups, masks, vector masks, adjustment and fill layers, effects, text, shapes, smart objects (SoLd/lnk2; CS2 PlLd/lnkD read with their warp, kept as is or re-saved as SoLd once edited), smart filters (14 mapped plus Filter Gallery with Chrome, rest verbatim), slices, comps, Advanced Blending. Oracle floors: Photoshop-authored 185/258, io 148/170, psd-tools 249/309; round trip ≥ 169/170, 308/309, 258/256. Known failures: #1281, #2469 (#2374, text layers, closed 2026-10-10; #2465, CS2 `PlLd`-only smart objects, fixed 2026-10-12) | +| PSD | R/W | yes | yes | 1/8/16/32-bit; Bitmap, Gray, Indexed, RGB, CMYK, Multichannel, Lab, Duotone; layers, groups, masks, vector masks, adjustment and fill layers, effects, text, shapes, smart objects (SoLd/lnk2 with their warp: the bend presets, a custom mesh and, since #4112, Cylinder; CS2 PlLd/lnkD read with their warp, kept as is or re-saved as SoLd once edited), smart filters (14 mapped plus Filter Gallery with Chrome, rest verbatim), slices, comps, Advanced Blending. Oracle floors: Photoshop-authored 185/258, io 148/170, psd-tools 249/309; round trip ≥ 169/170, 308/309, 258/256. Known failures: #1281, #2469 (#2374, text layers, closed 2026-10-10; #2465, CS2 `PlLd`-only smart objects, fixed 2026-10-12) | | PSB | R/W | yes | yes | Auto-switches above 2 GB (REL-212-2); 4–5 GB fixtures generated (FILE-216-2); > 4 GB fails on Windows (#375) | | Cloud PSD (.psdc) | R/W | no | no | Out of scope (Adobe cloud) | | TIFF | R/W | yes | yes | 8/16/32f, Gray/RGB/CMYK, BigTIFF, every IFD, banded parallel decode; layered TIFF both byte orders (FILE-215-7: extra alpha channels not written) | @@ -142,6 +142,7 @@ remaining gaps in #1606 stay tracked with per-sample corpus ceilings. | Date | Change | Summary | |---|---|---| +| 2026-10-14 | minor | PSD smart objects warped with Transform Warp's Cylinder (`warpCylinder`, seven `warpValues`) are read and written: Replace Contents, Relink and Edit Contents re-render them wrapped instead of flat, and a regenerated `SoLd` no longer drops the warp (#4112). Vertical cylinders and ones with perspective are still unread | | 2026-10-14 | minor | Corpus floors raised to the measured results of the CMYK, Lab, 32-bit, Levels, Threshold, Posterize, feather and pass-through fixes landed together: Photoshop-authored 165 → 185 of 258, io 146 → 148 of 170, psd-tools 242 → 249 of 309 | | 2026-10-13 | minor | CMYK Hue/Saturation (not Colorize) and Color Balance work on C, M and Y as inverted RGB with black kept, on the inks for display, export and destructive edits; cmyk8/16 hue-saturation 100% → 0.4–0.7% bad pixels, color-balance 74% → 25% | | 2026-10-13 | minor | CMYK Posterize works on the stored values (1 − ink), as Photoshop does: psd-tools posterize_cmyk passes (13.8 % → 0 of pixels off on the ink path); psd-tools floor → 242 of 309 | diff --git a/docs/gaps.md b/docs/gaps.md index 52b467b2dd..52edd1cd7f 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where PhotoCraft falls short of Photoshop -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (G1: CMYK Hue/Saturation and Color Balance keep the black ink; G1: CMYK Posterize on the stored values, psd-tools oracle floor 242/309; G1: CMYK adjustment layers composite on the document's inks, Photoshop-oracle floor 165/258; G1: CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; G1: modern Brightness/Contrast's brightness curve matches Photoshop, Photoshop-oracle floor 151/258; G1: Lab documents composite on their stored L, a, b, Photoshop-oracle floor 152/258; G1: Levels on whole levels at every step, Photoshop-oracle floor 146/258, psd-tools 238/309; G1: 32-bit Brightness/Contrast legacy, Channel Mixer, Photo Filter and Black & White tint unclipped as in Photoshop, Photoshop-oracle floor 149/258; G1: mask feathers blur with their exact variance, psd-tools oracle floor 240/309; G1: Threshold splits on Photoshop's luminosity, psd-tools oracle floor 241/309; G1: pass-through clipping base with an adjustment renders isolated, psd-tools oracle floor 240/309; G1: 16-bit Posterize as Photoshop bins it, psd-tools oracle floor 241/309; G1: Gradient Map driven by the W3C luminosity, Photoshop-oracle floor 147/258; G7: Field Blur's on-canvas pins, #3373; G4: Hue Jitter amount track, #3825 partial; G4: minimum field interaction, #3825 partial; G1: CMYK Invert, Posterize and Threshold on the inks, 16-bit Lab Invert; Photoshop-oracle floor 145/258; G11: `.atn` import maps 112 events: Select and Mask, Filter Gallery Spatter, Ripple, Crystallize, Pattern Fill layers, mask targeting, Step Backward, nudges in points, Bitmap mode; G5: Free Transform H/V skew fields; G9: toolbar tool shortcut editing; refreshed against main: G2, G5, G7, G9, G10, G13, G14, G17 items that landed; G10: DPX read and write, #1049; G4: checkbox-only Brush Settings rows; G11: Load/Save Actions `.atn` landed, AUTO-219-2 partial; G5: hold-a-key spring-loaded tools landed, #2672; G1: #2465 fixed; stale missing-tool rows corrected: Color Replacement, Red Eye and Add/Delete Anchor Point are live tools, so only Art History Brush is missing; no estimate changed) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (G1: smart objects keep Photoshop's Cylinder warp, #4112; G1: CMYK Hue/Saturation and Color Balance keep the black ink; G1: CMYK Posterize on the stored values, psd-tools oracle floor 242/309; G1: CMYK adjustment layers composite on the document's inks, Photoshop-oracle floor 165/258; G1: CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; G1: modern Brightness/Contrast's brightness curve matches Photoshop, Photoshop-oracle floor 151/258; G1: Lab documents composite on their stored L, a, b, Photoshop-oracle floor 152/258; G1: Levels on whole levels at every step, Photoshop-oracle floor 146/258, psd-tools 238/309; G1: 32-bit Brightness/Contrast legacy, Channel Mixer, Photo Filter and Black & White tint unclipped as in Photoshop, Photoshop-oracle floor 149/258; G1: mask feathers blur with their exact variance, psd-tools oracle floor 240/309; G1: Threshold splits on Photoshop's luminosity, psd-tools oracle floor 241/309; G1: pass-through clipping base with an adjustment renders isolated, psd-tools oracle floor 240/309; G1: 16-bit Posterize as Photoshop bins it, psd-tools oracle floor 241/309; G1: Gradient Map driven by the W3C luminosity, Photoshop-oracle floor 147/258; G7: Field Blur's on-canvas pins, #3373; G4: Hue Jitter amount track, #3825 partial; G4: minimum field interaction, #3825 partial; G1: CMYK Invert, Posterize and Threshold on the inks, 16-bit Lab Invert; Photoshop-oracle floor 145/258; G11: `.atn` import maps 112 events: Select and Mask, Filter Gallery Spatter, Ripple, Crystallize, Pattern Fill layers, mask targeting, Step Backward, nudges in points, Bitmap mode; G5: Free Transform H/V skew fields; G9: toolbar tool shortcut editing; refreshed against main: G2, G5, G7, G9, G10, G13, G14, G17 items that landed; G10: DPX read and write, #1049; G4: checkbox-only Brush Settings rows; G11: Load/Save Actions `.atn` landed, AUTO-219-2 partial; G5: hold-a-key spring-loaded tools landed, #2672; G1: #2465 fixed; stale missing-tool rows corrected: Color Replacement, Red Eye and Add/Delete Anchor Point are live tools, so only Art History Brush is missing; no estimate changed) · **Target:** Adobe Photoshop 2026 Every known shortfall against Photoshop 2026 (27.11.0), one entry each, ranked by user impact on the way to beta. This is the work list agents pick from: take the highest-ranked entry that @@ -48,7 +48,9 @@ what an agent can't finish alone. re-saved unmodified would not reopen (#2374, closed 2026-10-10), Photoshop 26.6 cannot reopen our files (#1281), "PSD not opening in Photoshop" (#2469), a CS2-era smart object unavailable (#2465, fixed 2026-10-12), PSB smart object blank (#1764), perspective placed layers flattened (#579, fixed by #426), PSB > 4 GB fails on Windows (#375). - No test opens our exports in a third-party editor (FILE-216-4). + No test opens our exports in a third-party editor (FILE-216-4). Mockups whose smart object + uses Transform Warp's Cylinder re-rendered flat after Replace Contents (#4112, fixed 2026-10-14; + vertical cylinders and ones with perspective are still unread). - **Impact:** PSD is the format professionals exchange. Beta requires it to be reliable. - **Estimate:** 150–250 h. **Human:** opening exports in Photoshop on the owner's Mac (or a scripted check there) is the only real oracle. @@ -263,6 +265,7 @@ what an agent can't finish alone. | Date | Change | Summary | |---|---|---| +| 2026-10-14 | minor | G1: PSD smart objects keep Transform Warp's Cylinder through Replace Contents, Relink, Edit Contents and re-saves (#4112) | | 2026-10-14 | minor | Corpus floors raised to the measured results of the CMYK, Lab, 32-bit, Levels, Threshold, Posterize, feather and pass-through fixes landed together: Photoshop-authored 165 → 185 of 258, io 146 → 148 of 170, psd-tools 242 → 249 of 309 | | 2026-10-13 | minor | G1: CMYK Hue/Saturation and Color Balance work on C, M and Y as inverted RGB with black kept, as Photoshop does; cmyk hue-saturation 100% → 0.4–0.7% bad pixels, color-balance 74% → 25%; floor unchanged | | 2026-10-13 | minor | G1: CMYK Posterize works on the stored values (1 − ink); psd-tools posterize_cmyk passes; psd-tools floor → 242 of 309 | From 63958b07abb73013a7d7bf4fcf950405a9a5efd0 Mon Sep 17 00:00:00 2001 From: David Mann Date: Wed, 21 Oct 2026 00:23:02 +0200 Subject: [PATCH 4/4] Integration: keep an untouched cylinder on Warp mode's Enter (#5769) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Warp mode shows a smart object's stored cylinder as its 13 x 4 mesh (smart_warp_doc_space), and Enter commits that mesh as edit.transform.warp. The cylinder was then stored as a custom warp, and the next PSD save wrote no warp ("custom warp with more than one patch was written without its warp"). On main the same steps kept Photoshop's warpCylinder (unread, so the SoLd stayed verbatim). Committing exactly what Warp mode shows (the same mesh over the same box) now changes nothing and adds no history step; a moved point or another box still makes it a custom warp. The transform box then treats a `changed: false` commit like an untouched box: a ⌥⌘T copy stays its own step instead of being folded into the step before it (that also held for an unchanged Warp Text on a type copy). Also: the cylinder_warp test takes `as_chunks` (clippy's chunks_exact_to_as_chunks on the current toolchain). Co-authored-by: alvarohn <32224796+alvarohn@users.noreply.github.com> Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/warp_cmds.rs | 66 ++++++++++++++++++++++++++++ crates/io/tests/cylinder_warp.rs | 2 +- crates/ui-egui/src/transform_tool.rs | 32 ++++++++++++++ 3 files changed, 99 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/warp_cmds.rs b/crates/engine/src/warp_cmds.rs index 3d44a8c34b..2428976713 100644 --- a/crates/engine/src/warp_cmds.rs +++ b/crates/engine/src/warp_cmds.rs @@ -145,6 +145,22 @@ fn cylinder_in_source(w: &Warp, inv: &Homography, sb: [f64; 4]) -> Option Some(Warp::custom(mesh, sb)) } +/// Whether the custom warp `w` is exactly what Warp mode shows for `layer`'s stored cylinder +/// ([`smart_warp_doc_space`]: its mesh over the same box), so committing it untouched changes +/// nothing. Stored as a custom warp instead, the cylinder would be lost, as a PSD keeps it only +/// as `warpCylinder` (#4112). +fn is_stored_cylinder(layer: &Layer, w: &Warp) -> bool { + let LayerContent::Smart(sm) = &layer.content else { return false }; + if w.style != WarpStyle::Custom || sm.warp.as_ref().is_none_or(|s| s.style != WarpStyle::Cylinder) { + return false; + } + let (Some(shown), Some(m)) = (smart_warp_doc_space(layer), w.mesh.as_ref()) else { return false }; + let Some(o) = shown.mesh.as_ref() else { return false }; + let tol = 1e-6 * (1.0 + o.points.iter().flatten().chain(&shown.bounds).fold(0.0f64, |a, v| a.max(v.abs()))); + let near = |a: &[f64], b: &[f64]| a.len() == b.len() && a.iter().zip(b).all(|(x, y)| (x - y).abs() <= tol); + o.us == m.us && o.vs == m.vs && near(&shown.bounds, &w.bounds) && near(o.points.as_flattened(), m.points.as_flattened()) +} + /// A Background is turned into a normal layer by the caller first /// ([`crate::extra_cmds::background_to_layer`]). fn warp_layer(doc_sel: Option<&Surface>, group: Locks, l: &mut Layer, w: &Warp, rect: Rect, interp: Interp, frames: Option<&Frames>) -> Result<()> { @@ -385,6 +401,10 @@ fn apply(s: &mut Session, p: &Value, cmd: &str) -> Result { if w.is_identity() && !is_smart { return Ok(json!({"layer": id.0, "changed": false})); } + // An untouched cylinder (Warp mode, then Enter) stays as it is, with no history step. + if p.get("mesh").is_none() && s.active().and_then(|d| d.doc.layer(id)).is_some_and(|l| is_stored_cylinder(l, &w)) { + return Ok(json!({"layer": id.0, "changed": false})); + } let interp = Interp::parse(p.get("interpolation").and_then(Value::as_str).unwrap_or("bicubic")); let content_knots = p.get("mesh").is_some() && p.get("warp").is_none(); s.edit("Warp", |doc, _| { @@ -800,6 +820,52 @@ mod tests { assert_eq!(w.mesh.as_ref().map(|m| (m.nx(), m.ny())), Some((13, 4))); } + /// Warp mode shows a stored cylinder as its mesh, and Enter commits that mesh untouched: the + /// smart object keeps its cylinder (else the next PSD save writes no warp), while a moved + /// point still makes it a custom warp. + #[test] + fn untouched_cylinder_committed_from_warp_mode_stays_a_cylinder() { + for scale in [100, 150] { + let mut s = session(8); + s.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap(); + s.execute("edit.transform", json!({"scaleX": scale, "scaleY": 100})).unwrap(); + let id = s.active().unwrap().active_layer.unwrap(); + let rect = warp_bounds(&s.active().unwrap().doc, s.active().unwrap().doc.layer(id).unwrap()); + let [x0, x1] = [f64::from(rect.x0), f64::from(rect.x1)]; + let values = [x0, 40.0, x1, 10.0, 0.3, 0.3, 0.5]; + s.execute("layer.smartObjects.warp", json!({"style": "cylinder", "values": values, "rect": [rect.x0, 10, rect.x1, 40]})).unwrap(); + let stored = |s: &Session| { + let st = s.active().unwrap(); + let LayerContent::Smart(sm) = &st.doc.layer(id).unwrap().content else { panic!("smart") }; + sm.warp.clone().unwrap() + }; + let cylinder = stored(&s); + assert_eq!(cylinder.style, WarpStyle::Cylinder, "{scale}"); + let shown = smart_warp_doc_space(s.active().unwrap().doc.layer(id).unwrap()).unwrap(); + assert_eq!(shown.style, WarpStyle::Custom); + s.execute("select.all", json!({})).unwrap(); + let (sel, steps) = (s.active().unwrap().doc.selection.clone(), s.active().unwrap().history.past_len()); + let r = s.execute("edit.transform.warp", json!({"layer": id.0, "rect": shown.bounds, "warp": shown, "interpolation": "bicubic"})).unwrap(); + assert_eq!(r["changed"], false, "{scale}"); + assert_eq!(stored(&s), cylinder, "{scale}: untouched"); + let st = s.active().unwrap(); + assert!(st.doc.selection == sel && st.history.past_len() == steps, "{scale}: no step, the selection stays"); + // The same mesh over another box is another warp. + let mut wider = shown.clone(); + wider.bounds[2] += 10.0; + s.execute("edit.transform.warp", json!({"layer": id.0, "rect": wider.bounds, "warp": wider})).unwrap(); + assert_eq!(stored(&s).style, WarpStyle::Custom, "{scale}: other box"); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(stored(&s), cylinder); + let mut moved = shown.clone(); + if let Some(m) = moved.mesh.as_mut() { + m.points[20][1] += 5.0; + } + s.execute("edit.transform.warp", json!({"layer": id.0, "rect": moved.bounds, "warp": moved})).unwrap(); + assert_eq!(stored(&s).style, WarpStyle::Custom, "{scale}: edited"); + } + } + #[test] fn identity_and_bend_zero_are_no_ops_at_every_depth() { for depth in [8u64, 16, 32] { diff --git a/crates/io/tests/cylinder_warp.rs b/crates/io/tests/cylinder_warp.rs index be75e31505..8ed1caa916 100644 --- a/crates/io/tests/cylinder_warp.rs +++ b/crates/io/tests/cylinder_warp.rs @@ -66,7 +66,7 @@ fn round_trip(doc: &Document) -> (PsdFile, Document) { /// Opaque pixels of the render that are mostly `channel` (0 red, 1 green, 2 blue). fn count(s: &Surface, channel: usize) -> usize { let r = s.content_bounds(); - s.read_region(r).chunks_exact(4).filter(|p| p[3] > 0.5 && p[channel] > 0.5 && (0..3).all(|c| c == channel || p[c] < 0.5)).count() + s.read_region(r).as_chunks::<4>().0.iter().filter(|p| p[3] > 0.5 && p[channel] > 0.5 && (0..3).all(|c| c == channel || p[c] < 0.5)).count() } /// Before #4112 a cylinder was read as no warp: re-rendering the contents drew them flat, the diff --git a/crates/ui-egui/src/transform_tool.rs b/crates/ui-egui/src/transform_tool.rs index 7ae223029d..a0bd3520c7 100644 --- a/crates/ui-egui/src/transform_tool.rs +++ b/crates/ui-egui/src/transform_tool.rs @@ -1093,6 +1093,9 @@ pub fn commit(app: &mut PhotocraftApp) { return; } match r { + // Nothing changed (an untouched cylinder or Warp Text): no step to fold a copy into, so + // it stays its own step, as for an untouched box. + Ok(v) if v.get("changed") == Some(&json!(false)) => {} Ok(_) => finish(app, document, LayerId(t.layer), made, true), Err(e) => keep_open(app, t, preview, e), } @@ -4124,6 +4127,35 @@ mod tests { assert_eq!(app.session.active().unwrap().doc.layers.len(), layers); } + /// #4112: ⌥⌘T on a cylinder smart object, Warp mode, Enter with nothing touched: the copy + /// keeps the cylinder and stays its own step (no warp step to fold into), so one undo takes + /// back the copy and nothing before it. + #[test] + fn untouched_warp_of_a_cylinder_copy_keeps_its_own_step() { + let ctx = egui::Context::default(); + let mut app = app_with_square(64, photocraft_geom::Rect::new(8, 8, 24, 24)); + app.run("layer.smartObjects.convertToSmartObject", json!({})).unwrap(); + app.run("layer.smartObjects.warp", json!({"style": "cylinder", "values": [8, 24, 24, 8, 0.3, 0.3, 0.5], "rect": [8, 8, 24, 24]})).unwrap(); + let warp = |app: &PhotocraftApp, i: usize| match &app.session.active().unwrap().doc.layers[i].content { + photocraft_doc::LayerContent::Smart(sm) => sm.warp.as_ref().map(|w| w.style), + _ => None, + }; + let (layers, steps) = (app.session.active().unwrap().doc.layers.len(), app.session.active().unwrap().history.past_len()); + assert_eq!(warp(&app, layers - 1), Some(WarpStyle::Cylinder)); + crate::menus::invoke(&mut app, &ctx, "edit.freeTransformCopy", json!({})).unwrap(); + enter_warp(&mut app, &ctx); + assert!(app.ui.transform.as_ref().unwrap().warp.is_some(), "Warp mode shows the cylinder"); + commit(&mut app); + assert!(!app.ui.status_error, "{}", app.ui.status); + let st = app.session.active().unwrap(); + assert_eq!((st.doc.layers.len(), st.history.past_len()), (layers + 1, steps + 1)); + assert_eq!(warp(&app, layers), Some(WarpStyle::Cylinder), "the copy is still a cylinder"); + app.session.undo(); + let st = app.session.active().unwrap(); + assert_eq!((st.doc.layers.len(), st.history.past_len()), (layers, steps), "one undo takes back the copy only"); + assert_eq!(warp(&app, layers - 1), Some(WarpStyle::Cylinder)); + } + /// #1099: cancel, frame cleanup and a commit before the next frame all belong to the /// original document, including after tab reordering and with shared layer IDs. #[test]