Merge PR #5769: PSD smart objects: keep Photoshop's Cylinder warp when re-rendered (#4112)

Integration: resolve docs conflicts (file-format-parity, gaps status lines) with main
This commit is contained in:
David Mann
2026-10-21 23:24:11 +02:00
8 changed files with 823 additions and 44 deletions
+198 -3
View File
@@ -71,10 +71,14 @@ pub fn smart_warp_doc_space(layer: &Layer) -> Option<Warp> {
}
/// 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<Warp> {
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<Warp> {
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<Vec<f64>> = 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,46 @@ pub fn warp_from_params(cmd: &str, p: &Value, rect: [f64; 4]) -> Result<Warp> {
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<Warp> {
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))
}
/// 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<()> {
@@ -149,6 +200,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());
@@ -305,6 +357,7 @@ fn type_warp(cmd: &str, w: &Warp) -> Result<Option<photocraft_doc::text::TextWar
WarpStyle::None => 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),
@@ -348,6 +401,10 @@ fn apply(s: &mut Session, p: &Value, cmd: &str) -> Result<Value> {
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, _| {
@@ -549,7 +606,7 @@ fn grid(s: &mut Session, p: &Value) -> Result<Value> {
}
pub fn specs() -> Vec<CommandSpec> {
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![
@@ -671,6 +728,144 @@ 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)));
}
/// 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] {
+216 -10
View File
@@ -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<Item = WarpStyle> {
[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<WarpStyle> {
let k: String = s.chars().filter(|c| !c.is_whitespace()).collect::<String>().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<BezierMesh> {
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<BezierMesh>,
/// A cylinder's seven `warpValues` (see [`Warp::cylinder`]); empty for other styles.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub values: Vec<f64>,
}
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<Warp> {
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> {
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]));
}
}
+190 -19
View File
@@ -568,9 +568,10 @@ pub(crate) fn with_pattern_placement(mut d: Descriptor, angle: f32, link: bool,
/// The warp of a placed layer (`SoLd`/`SoLE` `quiltWarp` or `warp` descriptor): style, bend,
/// distortions, axis, bounds and, for `warpCustom`, the `customEnvelopeWarp` mesh: one 4 × 4
/// patch, or the multi-patch grid Photoshop's split warp writes as `quiltWarp`. 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`].
/// patch, or the multi-patch grid Photoshop's split warp writes as `quiltWarp`; 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<photocraft_geom::warp::Warp> {
if key != b"SoLd" && key != b"SoLE" {
return None;
@@ -583,7 +584,8 @@ pub fn parse_placed_warp(key: &[u8; 4], data: &[u8]) -> Option<photocraft_geom::
/// A `warp` or `quiltWarp` descriptor (the `SoLd` key, or the whole descriptor after a `PlLd`
/// record's corners) as a warp: `Some(None)` for no warp (`warpNone`, or a warp that moves
/// nothing), `None` when it can't be read (an unknown style, missing bounds, a mesh that is
/// neither one 4 × 4 patch nor a consistent quilt grid).
/// neither one 4 × 4 patch nor a consistent quilt grid, a cylinder that isn't a horizontal one
/// without perspective, the only kind measured).
pub(crate) fn warp_of(w: &Descriptor) -> Option<Option<photocraft_geom::warp::Warp>> {
use photocraft_geom::warp::{BezierMesh, Warp, WarpStyle};
let style = enum_of(w, "warpStyle").and_then(|v| WarpStyle::parse(&String::from_utf8_lossy(v)))?;
@@ -597,6 +599,17 @@ pub(crate) fn warp_of(w: &Descriptor) -> Option<Option<photocraft_geom::warp::Wa
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<f64> = match w.get("warpValues")? {
Value::List(l) => l.iter().map(|v| num(Some(v))).collect::<Option<_>>()?,
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 };
@@ -702,12 +715,14 @@ pub(crate) fn parse_prefix_versioned(data: &[u8]) -> Option<Descriptor> {
}
/// The source-space box Photoshop maps onto a placed layer's `Trnf` quad: the bounding box of a
/// custom warp's control points (one patch or a split grid), 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.
/// custom warp's control points (one patch or a split grid) 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.is_valid()) else {
let Some(m) = warp.filter(|wp| matches!(wp.style, WarpStyle::Custom | WarpStyle::Cylinder)).and_then(|wp| wp.mesh.as_ref()).filter(|m| m.is_valid()) else {
return rect;
};
let inf = f64::INFINITY;
@@ -716,6 +731,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) {
@@ -753,17 +778,17 @@ pub fn corners_affine(p: &[f64; 8], frame: [f64; 4]) -> Option<Affine> {
/// The transform and (Distort/Perspective) projective placement of a `PlLd` record's corners `p`
/// 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
/// quad. As for `SoLd`'s `Trnf` (#3677), affine corners frame a custom warp's or a cylinder'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:
/// 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([[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)
}
@@ -838,18 +863,27 @@ 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))
// The warp `parse_placed_warp` reads: a split warp's `quiltWarp` comes first.
let warp = get_desc(&d, "quiltWarp").or_else(|| 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`).
@@ -1517,6 +1551,143 @@ 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<u8> {
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())
}
/// A projective placement is framed by the warp `parse_placed_warp` imports: an unreadable
/// `quiltWarp` next to a cylinder under `warp` imports no warp, so the source rect is framed.
#[test]
fn perspective_frame_follows_the_warp_parse_placed_warp_reads() {
let quad = [10.0, 20.0, 410.0, 60.0, 400.0, 480.0, 30.0, 520.0];
let list = Value::List(quad.iter().map(|v| Value::Double(*v)).collect());
let unreadable =
Descriptor::new("quiltWarp").with("warpStyle", Value::Enumerated { type_id: Id::new("warpStyle"), value: Id::new("warpSomethingNew") });
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("quiltWarp", Value::Descriptor(unreadable))
.with("warp", Value::Descriptor(cylinder_desc(doubles(&CYL))));
let mut data = b"soLD".to_vec();
data.extend_from_slice(&4u32.to_be_bytes());
data.extend(VersionedDescriptor::new(d).to_bytes());
assert_eq!(parse_placed_warp(b"SoLd", &data), None);
let h = photocraft_algo::transform::Homography(parse_smart_perspective(b"SoLd", &data).expect("projective"));
let (px, py) = h.apply(1000.0, 1000.0);
assert!((px - 400.0).abs() < 1e-6 && (py - 480.0).abs() < 1e-6, "{px},{py}");
}
/// #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];
+56 -8
View File
@@ -1127,8 +1127,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`] of `warp`, the warp the block stores, for an
/// affine placement (the inverse of `parse_smart`), the source rect for a projective one (as
/// `parse_smart_perspective` reads it).
/// affine placement (the inverse of `parse_smart`), [`crate::blocks::perspective_frame`] of it
/// for a projective one (as `parse_smart_perspective` reads it).
fn quad_points(s: &PlacedSpec<'_>, warp: Option<&Warp>) -> [f64; 8] {
let (w, h) = s.size;
let [a, b, c, d, e, f] = s.transform.m;
@@ -1136,7 +1136,7 @@ fn quad_points(s: &PlacedSpec<'_>, warp: Option<&Warp>) -> [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(warp, w, h) };
let [x0, y0, x1, y1] = if s.perspective.is_some() { crate::blocks::perspective_frame(warp, w, h) } else { crate::blocks::placed_frame(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);
@@ -1187,10 +1187,10 @@ fn warp_head(class: &str, w: &Warp) -> Descriptor {
.with("Left", Value::Double(x0))
.with("Btom", Value::Double(y1))
.with("Rght", Value::Double(x1));
Descriptor::new(class)
.with("warpStyle", enumv("warpStyle", w.style.psd_name()))
.with("warpValue", Value::Double(w.bend))
.with("warpPerspective", Value::Double(w.h_distort))
let d = Descriptor::new(class).with("warpStyle", enumv("warpStyle", w.style.psd_name())).with("warpValue", Value::Double(w.bend));
// A cylinder is stored as its seven `warpValues` (#4112).
let d = if w.style == WarpStyle::Cylinder { d.with("warpValues", Value::List(w.values.iter().copied().map(Value::Double).collect())) } else { 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" }))
.with("bounds", Value::Descriptor(bounds))
@@ -1208,7 +1208,8 @@ fn mesh_points(m: &BezierMesh) -> Value {
/// The `warp` descriptor (also the `PlLd` warp). A split warp is written as no warp: `SoLd`
/// keeps it in `quiltWarp` ([`quilt_desc`]), next to this `warpNone`, as Photoshop does. A
/// custom warp without a usable mesh is written as no warp, with a message.
/// custom warp without a usable mesh is 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<String>) -> Value {
let none = Warp::none([0.0, 0.0, size.0, size.1]);
let mut w = w.unwrap_or(&none).clone();
@@ -3327,6 +3328,53 @@ mod tests {
assert!(!quad_moved(&p.descriptor, &spec, spec.warp));
}
/// #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::<Vec<_>>(), 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<f64> = 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, spec.warp));
// 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]));
+123
View File
@@ -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).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
/// 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);
}
+32
View File
@@ -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]
+3 -2
View File
@@ -1,6 +1,6 @@
# File-format parity
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-15 · **Change:** minor (DDS textures save: uncompressed or BC1–BC3, optional mip chain, DDS Options in Save As (#1327); PSD smart filters: 41 filters (27 more: Blur, Surface Blur, Smart Sharpen, Diffuse, Wind, Offset, Mezzotint and others) plus Hue/Saturation and Perspective Warp mapped both ways, #6296, #6298, #6300, #6302, #6304, #6314, #6316, #6267, #6268, FILE-215-2; large-file fixtures: a 20 MP layered PSD, a 150-layer layout, a 2 GB PSD and a 4.4 GB PSB generated at test time and round-tripped, FILE-216-2 done, #5107; earlier: PNG `sTER` stereo pairing kept on save, #6063; PDF write one flattened page via Save As / Save a Copy / CLI, #3401; CMYK Invert, Posterize and Threshold on the inks; smart filter masks on PSD open and save; Photoshop-oracle floor 188/258; lossy DNG, compression 34892, decodes with its levels and LinearizationTable applied to the stored 8-bit samples, FILE-215-11; 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-15 · **Change:** minor (DDS textures save: uncompressed or BC1–BC3, optional mip chain, DDS Options in Save As (#1327); PSD smart filters: 41 filters (27 more: Blur, Surface Blur, Smart Sharpen, Diffuse, Wind, Offset, Mezzotint and others) plus Hue/Saturation and Perspective Warp mapped both ways, #6296, #6298, #6300, #6302, #6304, #6314, #6316, #6267, #6268, FILE-215-2; large-file fixtures: a 20 MP layered PSD, a 150-layer layout, a 2 GB PSD and a 4.4 GB PSB generated at test time and round-tripped, FILE-216-2 done, #5107; earlier: smart objects keep Photoshop's Cylinder warp through re-renders and saves, #4112; PNG `sTER` stereo pairing kept on save, #6063; PDF write one flattened page via Save As / Save a Copy / CLI, #3401; CMYK Invert, Posterize and Threshold on the inks; smart filter masks on PSD open and save; Photoshop-oracle floor 188/258; lossy DNG, compression 34892, decodes with its levels and LinearizationTable applied to the stored 8-bit samples, FILE-215-11; 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 (41 mapped plus Hue/Saturation, Perspective Warp, Filter Gallery with Chrome and a Camera Raw subset, rest verbatim), slices, comps, Advanced Blending. Oracle floors: Photoshop-authored 188/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 (41 mapped plus Hue/Saturation, Perspective Warp, Filter Gallery with Chrome and a Camera Raw subset, rest verbatim), slices, comps, Advanced Blending. Oracle floors: Photoshop-authored 188/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); generated 20 MP, 150-layer, 2 GB PSD and 4.4 GB PSB fixtures opened, saved and re-opened by io tests/large_files.rs (FILE-216-2, 2026-10-13; the 4.4 GB PSB opens on Windows); > 4 GB from the app's File › Open 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) |
@@ -154,6 +154,7 @@ remaining gaps in #1606 stay tracked with per-sample corpus ceilings.
| 2026-10-15 | minor | PSD smart filters: Mezzotint (`Mztn`, `MztT` enum, all 10 types) and Color Halftone (`ClrH`, `Rds `/`Ang1`–`Ang4` longs) open as editable filters and save in Photoshop's shape, measured in Photoshop (FILE-215-2); Mezzotint's PhotoCraft-only seed is not saved |
| 2026-10-15 | minor | PNG: a stereo PNG's `sTER` pairing (PNG Extensions 1.4.0 §3.6) is read on open and written back on save, so the file still says it is a stereo pair (#6063) |
| 2026-10-15 | minor | PSD guides: image resource 1092 (guides v2) gives guides their own colours on import; 1092 and 1097 (guide GUIDs) are written back only while they still describe the document's guides, so an edit no longer leaves Photoshop a stale second guide list (#5966) |
| 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 | PSD smart filter masks: Photoshop stores a smart object's filtered pixels without its filter mask and mixes the unfiltered ones (`FEid`) back in; import now mixes them and export writes them that way, so sf-filter-mask-hard, -gradient and sf-stack-blend-and-mask pass; Photoshop-oracle floor 185 → 188 of 258 |
| 2026-10-13 | minor | Lossy DNG (compression 34892) decodes: 8-bit baseline JPEG tiles and strips, CFA and LinearRaw, partial edge tiles; the stored values take the normal levels and LinearizationTable (FILE-215-11, lossy DNG part) |
| 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 |
+5 -2
View File
@@ -1,6 +1,6 @@
# Where PhotoCraft falls short of Photoshop
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** minor (G9: Preferences › File Handling › Save in Background takes effect, #6207, 24 of 169 preferences do nothing; G7: Content-Aware Move previews every selection the command takes, coarse then exact, TOOL-213-4 done, #6680; G4: Pattern Stamp and Background Eraser draw live, TOOL-213-7; G9: Animated Zoom pref read: holding the Zoom tool zooms continuously, #6210, prefs that do nothing 25 of 166; G2: a cancelled 24 MP Gaussian Blur job stops in at most 44 ms, P25 under its 200 ms budget, #6205; G1: generated large-file fixtures, a 4.38 GB PSB opens on Windows through io, FILE-216-2 done, #5107; G9: fill and adjustment layers copy and paste whole, #2344; G1: smart filter masks show on PSD open, Photoshop-oracle floor 188/258; G10: DPX read and write, #1049, Cineon read; 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-18 · **Change:** minor (G1: smart objects keep Photoshop's Cylinder warp, #4112; G9: Preferences › File Handling › Save in Background takes effect, #6207, 24 of 169 preferences do nothing; G7: Content-Aware Move previews every selection the command takes, coarse then exact, TOOL-213-4 done, #6680; G4: Pattern Stamp and Background Eraser draw live, TOOL-213-7; G9: Animated Zoom pref read: holding the Zoom tool zooms continuously, #6210, prefs that do nothing 25 of 166; G2: a cancelled 24 MP Gaussian Blur job stops in at most 44 ms, P25 under its 200 ms budget, #6205; G1: generated large-file fixtures, a 4.38 GB PSB opens on Windows through io, FILE-216-2 done, #5107; G9: fill and adjustment layers copy and paste whole, #2344; G1: smart filter masks show on PSD open, Photoshop-oracle floor 188/258; G10: DPX read and write, #1049, Cineon read; 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
@@ -53,7 +53,9 @@ what an agent can't finish alone.
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; a generated 4.38 GB PSB opens, saves and re-opens
through io on Windows, io tests/large_files.rs, 2026-10-13).
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.
@@ -293,6 +295,7 @@ what an agent can't finish alone.
| 2026-10-15 | minor | G9: Animated Zoom preference takes effect (hold the Zoom tool to zoom continuously, #6210); prefs that do nothing 25 of 166; no estimate changed |
| 2026-10-16 | minor | G7: Content-Aware Move's live preview covers every selection the command takes (over 256 × 256: a coarse result solved scaled down, then the exact one); TOOL-213-4 done (#6680) |
| 2026-10-15 | minor | G9: Save in Background off saves in the foreground under a modal progress dialog (#6207); preferences that do nothing 25 → 24 of 169 |
| 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 | G1: a PSD smart object's filter mask shows when the file opens (Photoshop stores the filtered pixels unmasked); export stores them that way too; Photoshop-oracle floor 185 → 188 of 258 |
| 2026-10-13 | minor | G14: D90 image area measured (4288 × 2848 at (10, 10)); gap remains open |
| 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 |