collective

Browse / github.com/storytold/photocraft / patch

Warp mode: edit Photoshop's Cylinder warp with handles and fields (#4112)

Warp mode: edit Photoshop's Cylinder warp with handles and fields (#4112) Follows up #5769 (#4112). That PR read, wrote and re-rendered Photoshop's Cylinder warp (`warpCylinder`) but left it out of the Warp menu: there was no editor for it. This adds one, so a label can be wrapped round a can, jar or bottle in Warp mode, as in Photoshop's Edit › Transform › Warp › Cylinder. ## Changes - **ui-egui, `transform_tool/cylinder.rs`:** the Warp menu lists Cylinder after the presets (not on type: Warp Text has none). Picking it starts a cylinder fitted to the box (silhouettes on its sides, top edge on its top, the lower ellipse's front at its bottom, 15 % deep, half wrappe

Status
open
Score
0.00
Adopters
0 other operators
Author
TimothyBritt (imported from pull request #9798, open)
Base
7ac7b8b106acccf6826d45ed5c9e481dfe341035
Size
+638 −48 in 12 files
Review
pending
Shared
2026-10-28
Id
97d11185c5e55cdae3b9ae85

How it went

No other operator has reported using it yet.

Diff

raw · crates/engine/src/cmd/warp_cmds.rs crates/engine/src/cmd/warp_cmds/tests.rs crates/geom/src/warp.rs crates/ui-egui/src/tools/transform_tool.rs crates/ui-egui/src/tools/transform_tool/cylinder.rs crates/ui-egui/src/tools/transform_tool/draw.rs crates/ui-egui/src/tools/transform_tool/options_bar.rs crates/ui-egui/src/tools/transform_tool/pointer.rs crates/ui-egui/src/tools/transform_tool/tests.rs crates/ui-egui/src/tools/transform_tool/warp.rs docs/gaps.md docs/ui-parity.md

diff --git a/crates/engine/src/cmd/warp_cmds.rs b/crates/engine/src/cmd/warp_cmds.rs--- a/crates/engine/src/cmd/warp_cmds.rs+++ b/crates/engine/src/cmd/warp_cmds.rs@@ -59,14 +59,20 @@ pub fn warp_bounds(doc: &Document, layer: &Layer) -> Rect {     crate::transform_cmds::transform_bounds(doc, layer) } -/// A smart object's stored warp expressed in document space (for editing), if it has one.+/// A smart object's stored warp expressed in document space (for editing), if it has one. A+/// cylinder under a placement that only scales and moves stays a cylinder, with its seven values+/// in document space, so Warp mode shows its own handles; under any other placement it is its+/// mesh, a custom warp, like every other stored warp. pub fn smart_warp_doc_space(layer: &Layer) -> Option<Warp> {     let LayerContent::Smart(sm) = &layer.content else { return None };     let w = sm.warp.as_ref()?;     let t = crate::smart_cmds::placement(sm);-    let mesh = w.to_mesh(1, 1).map_points(|p| hom_apply(&t, p));     let corners = [[w.bounds[0], w.bounds[1]], [w.bounds[2], w.bounds[1]], [w.bounds[2], w.bounds[3]], [w.bounds[0], w.bounds[3]]].map(|p| hom_apply(&t, p));     let b = corners.iter().fold([f64::MAX, f64::MAX, f64::MIN, f64::MIN], |b, p| [b[0].min(p[0]), b[1].min(p[1]), b[2].max(p[0]), b[3].max(p[1])]);+    if let Some(c) = cylinder_through(w, &t, b) {+        return Some(c);+    }+    let mesh = w.to_mesh(1, 1).map_points(|p| hom_apply(&t, p));     Some(Warp::custom(mesh, b)) } @@ -128,41 +134,56 @@ 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"; const CYLINDER_SHAPE: &str = "a cylinder is horizontal, with no hDistort or vDistort"; +/// The cylinder `w` carried through `h` over the box `b` (its warp box's image), when `h` only+/// scales and moves: the silhouettes and edges map, and the ellipses' depth, in units of the half+/// axis, follows the change of aspect. `None` for any other map (rotated, skewed, flipped, in+/// perspective), or when `w` is not a cylinder: the mesh is the only way to carry it then.+fn cylinder_through(w: &Warp, h: &Homography, b: [f64; 4]) -> Option<Warp> {+    let m = h.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 w.style != WarpStyle::Cylinder || !axis_aligned || !(sx > 0.0 && sy > 0.0) {+        return None;+    }+    let v: [f64; 7] = w.values.as_slice().try_into().ok()?;+    let (x, y) = (|v: f64| sx * v + m[2] / m[8], |v: f64| sy * v + m[5] / m[8]);+    let k = sy / sx;+    Warp::cylinder(&[x(v[0]), y(v[1]), x(v[2]), y(v[3]), v[4] * k, v[5] * k, v[6]], b)+}+ /// 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,+/// moves keeps it a cylinder (written to PSD as one, [`cylinder_through`]). 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);-        }+    if let Some(c) = cylinder_through(w, inv, 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).+/// Whether `w` is exactly what Warp mode shows for `layer`'s stored cylinder+/// ([`smart_warp_doc_space`]): the same cylinder over the same box, or its mesh (what a rotated+/// placement shows, and what Warp mode showed before it had a cylinder editor), 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) {+    if 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(shown) = smart_warp_doc_space(layer) 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())+    match (w.style, w.mesh.as_ref()) {+        (WarpStyle::Cylinder, _) => shown.style == WarpStyle::Cylinder && near(&shown.bounds, &w.bounds) && near(&shown.values, &w.values),+        (WarpStyle::Custom, Some(m)) => {+            o.us == m.us && o.vs == m.vs && near(&shown.bounds, &w.bounds) && near(o.points.as_flattened(), m.points.as_flattened())+        }+        _ => false,+    } }  /// A Background is turned into a normal layer by the caller firstdiff --git a/crates/engine/src/cmd/warp_cmds/tests.rs b/crates/engine/src/cmd/warp_cmds/tests.rs--- a/crates/engine/src/cmd/warp_cmds/tests.rs+++ b/crates/engine/src/cmd/warp_cmds/tests.rs@@ -166,9 +166,11 @@ fn smart_object_cylinder_is_stored_in_source_space() {     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.+/// Warp mode shows a stored cylinder as a cylinder in document space (its handles edit the+/// seven values), and Enter commits it untouched: the smart object keeps its cylinder (else the+/// next PSD save writes no warp). The same holds for its mesh, which is what a rotated placement+/// shows and what Warp mode showed before it had a cylinder editor. A changed value stays a+/// cylinder; a moved mesh point makes it a custom warp. #[test] fn untouched_cylinder_committed_from_warp_mode_stays_a_cylinder() {     for scale in [100, 150] {@@ -188,30 +190,71 @@ fn untouched_cylinder_committed_from_warp_mode_stays_a_cylinder() {         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");+        assert_eq!(shown.style, WarpStyle::Cylinder, "{scale}: shown as a cylinder");+        for (v, sv) in values.iter().zip(&shown.values) {+            assert!((v - sv).abs() < 1e-6, "{scale}: the values come back in document space: {values:?} vs {:?}", shown.values);+        }+        let as_mesh = Warp::custom(shown.to_mesh(1, 1), shown.bounds);+        for (what, untouched) in [("cylinder", shown.clone()), ("mesh", as_mesh.clone())] {+            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": untouched.bounds, "warp": untouched, "interpolation": "bicubic"})).unwrap();+            assert_eq!(r["changed"], false, "{scale} {what}");+            assert_eq!(stored(&s), cylinder, "{scale} {what}: untouched");+            let st = s.active().unwrap();+            assert!(st.doc.selection == sel && st.history.past_len() == steps, "{scale} {what}: no step, the selection stays");+        }         // The same mesh over another box is another warp.-        let mut wider = shown.clone();+        let mut wider = as_mesh.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();+        let mut moved = as_mesh.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");+        assert_eq!(stored(&s).style, WarpStyle::Custom, "{scale}: edited mesh");+        s.execute("edit.undo", json!({})).unwrap();+        // A value changed by a handle commits as a cylinder with that value, one step.+        let steps = s.active().unwrap().history.past_len();+        let mut deeper = shown.values.clone();+        deeper[4] = 0.45;+        let r = s.execute("edit.transform.warp", json!({"layer": id.0, "rect": shown.bounds, "warp": Warp::cylinder(&deeper, shown.bounds).unwrap()})).unwrap();+        assert_eq!(r["changed"], true, "{scale}: edited cylinder");+        let after = stored(&s);+        assert_eq!(after.style, WarpStyle::Cylinder, "{scale}: still a cylinder");+        assert!((after.values[4] - 0.45 * cylinder.values[4] / 0.3).abs() < 1e-9, "{scale}: the depth follows, in source units: {:?}", after.values);+        assert_eq!(s.active().unwrap().history.past_len(), steps + 1, "{scale}: one step");     } } +/// Under a rotated placement a stored cylinder has no document-space values: Warp mode shows its+/// mesh, and committing that mesh untouched still changes nothing.+#[test]+fn a_rotated_cylinder_is_shown_as_its_mesh() {+    let mut s = session(8);+    s.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap();+    let id = s.active().unwrap().active_layer.unwrap();+    s.execute("layer.smartObjects.warp", json!({"style": "cylinder", "values": CYL, "rect": [10, 10, 60, 40]})).unwrap();+    s.execute("edit.transform", json!({"rotate": 30})).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()+    };+    assert_eq!(stored(&s).style, WarpStyle::Cylinder, "the placement turned, the source warp stays");+    let shown = smart_warp_doc_space(s.active().unwrap().doc.layer(id).unwrap()).unwrap();+    assert_eq!(shown.style, WarpStyle::Custom);+    assert!(shown.values.is_empty());+    let steps = s.active().unwrap().history.past_len();+    let r = s.execute("edit.transform.warp", json!({"layer": id.0, "rect": shown.bounds, "warp": shown})).unwrap();+    assert_eq!(r["changed"], false);+    assert_eq!(s.active().unwrap().history.past_len(), steps);+}+ #[test] fn identity_and_bend_zero_are_no_ops_at_every_depth() {     for depth in [8u64, 16, 32] {diff --git a/crates/geom/src/warp.rs b/crates/geom/src/warp.rs--- a/crates/geom/src/warp.rs+++ b/crates/geom/src/warp.rs@@ -67,8 +67,9 @@ impl WarpStyle {         WarpStyle::Twist,     ]; -    /// 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.+    /// The styles every Warp menu lists: none, custom and the presets. [`WarpStyle::Cylinder`] is+    /// not one of them: Transform Warp's menu adds it after these (it has its own editor there),+    /// while Warp Text has no cylinder.     pub fn all() -> impl Iterator<Item = WarpStyle> {         [WarpStyle::None, WarpStyle::Custom].into_iter().chain(Self::PRESETS)     }diff --git a/crates/ui-egui/src/tools/transform_tool.rs b/crates/ui-egui/src/tools/transform_tool.rs--- a/crates/ui-egui/src/tools/transform_tool.rs+++ b/crates/ui-egui/src/tools/transform_tool.rs@@ -24,6 +24,7 @@ use crate::state::{MadeLayer, Tool, TransformMode, TransformSession};  mod begin; mod commit;+mod cylinder; mod drag_modes; mod draw; mod options_bar;@@ -35,6 +36,9 @@ pub(crate) use begin::begin_content_aware; pub use begin::{begin, begin_copy, begin_placed, begin_selection, begin_warp, enter_warp, leave_warp, warps_type}; pub(crate) use commit::{SAVE_FOR_WEB, committing, nudge}; pub use commit::{cancel, commit, end_if_left, follow_edits, intercept, is_enabled, on_job_event, track_steps};+use cylinder::CylinderHandle;+#[cfg(test)]+use cylinder::{cylinder_cursor, cylinder_default, cylinder_dragged, cylinder_handles, cylinder_hit, cylinder_values}; pub(crate) use drag_modes::{apply_drag, corner_distorts, legacy_mods}; #[cfg(test)] pub(crate) use drag_modes::{apply_mode_drag, unlinked_mods};@@ -50,7 +54,7 @@ pub(crate) use options_bar::position_size_fields; #[cfg(test)] use options_bar::{     POSITION_LIMIT_PX, Parts, SCALE_LIMIT_PCT, SIZE_UNIT_KEY, SKEW_LIMIT_DEG, matching_anchor, readout, rotate_about_pivot, scale_about_pivot, scale_limit_pct,-    set_size_unit, size_pct, size_shown, size_unit, warp_grid_id,+    set_size_unit, size_pct, size_shown, size_unit, warp_grid_id, warp_style_options, }; pub use options_bar::{options_bar, reset_warp}; pub(crate) use pointer::{Gesture, Hit, hit, rotate_cursor_dir};@@ -97,6 +101,9 @@ enum WarpDrag {     Point { index: usize, start: [f64; 2], pts0: Vec<[f64; 2]> },     /// Anywhere else on the grid: the surface under the pointer follows it.     Surface { s: f64, t: f64, start: [f64; 2], pts0: Vec<[f64; 2]> },+    /// A cylinder's handle, or its surface (which turns the wrap); `values0` are its seven values+    /// at the press (cylinder.rs).+    Cylinder { handle: CylinderHandle, start: [f64; 2], values0: [f64; 7] }, }  /// Preview state that isn't serialisable: the document without the transformed pixels, anddiff --git a/crates/ui-egui/src/tools/transform_tool/cylinder.rs b/crates/ui-egui/src/tools/transform_tool/cylinder.rsnew file mode 100644--- /dev/null+++ b/crates/ui-egui/src/tools/transform_tool/cylinder.rs@@ -0,0 +1,190 @@+//! Edit › Transform › Warp's Cylinder in Warp mode (#4112): the preset that wraps the contents+//! round a horizontal cylinder, for the label on a can, a jar or a bottle. Photoshop keeps it as+//! seven values rather than a mesh ([`photocraft_geom::warp::cylinder_mesh`]): the left and right+//! silhouettes, the top and bottom edges, the depth of the upper and lower ellipse (in units of+//! the half axis) and how far the contents wrap round (0..1). This is its editor: the handles set+//! those values, the options bar's fields read and type them, and the warp stays a cylinder+//! through the commit, so a smart object keeps it as `warpCylinder` in a PSD.+//!+//! The gestures follow Photoshop's by observation (never its code): the four corners move a+//! silhouette and an edge together, the middle of a silhouette moves it alone, the middle of the+//! top and bottom edge sets that ellipse's depth, and a drag across the surface turns the+//! contents round the cylinder (the wrap). A cylinder has no splits and no grid.++use super::warp::surface_at;+use super::*;++/// What a press on the cylinder grabbed.+#[derive(Clone, Copy, Debug, PartialEq, Eq)]+pub(super) enum CylinderHandle {+    /// A corner, clockwise from the top-left: its silhouette and its edge follow the pointer.+    Corner(usize),+    /// The middle of the left (0) or right (1) silhouette: that silhouette follows sideways.+    Side(usize),+    /// The middle of the top (0) or bottom (1) edge: that ellipse's depth follows up and down.+    Depth(usize),+    /// Anywhere else on the cylinder: a drag across it turns the contents round it.+    Surface,+}++/// The depth of both ellipses the Warp menu's Cylinder starts with, in units of the half axis.+const DEFAULT_DEPTH: f64 = 0.15;++/// How far round the Warp menu's Cylinder starts wrapped: the middle of Photoshop's range.+const DEFAULT_WRAP: f64 = 0.5;++/// The silhouettes never cross: the right one stays this far (document px) right of the left.+const MIN_WIDTH: f64 = 1.0;++/// An ellipse is at most as deep as the half axis, either way up.+const DEPTH_LIMIT: f64 = 1.0;++/// The cylinder the Warp menu's Cylinder starts with over the box `b`: the silhouettes on the+/// box's sides, the top edge on its top and the front of the lower ellipse reaching its bottom,+/// [`DEFAULT_DEPTH`] deep (less on a box so wide that the bulge would take more than a quarter+/// of its height) and [`DEFAULT_WRAP`] wrapped. `None` for an empty box.+pub(super) fn cylinder_default(b: [f64; 4]) -> Option<Warp> {+    let (a, h) = ((b[2] - b[0]) / 2.0, b[3] - b[1]);+    if !(a > 0.0 && h > 0.0) {+        return None;+    }+    let depth = DEFAULT_DEPTH.min(0.25 * h / a);+    Warp::cylinder(&[b[0], b[3] - depth * a, b[2], b[1], depth, depth, DEFAULT_WRAP], b)+}++/// The seven values of `w` when it is a cylinder: left, bottom, right, top (document px), the+/// top and bottom ellipses' depth (half-axis units, down is positive) and the wrap (0..1).+pub(super) fn cylinder_values(w: &Warp) -> Option<[f64; 7]> {+    if w.style != WarpStyle::Cylinder {+        return None;+    }+    w.values.as_slice().try_into().ok()+}++/// Where the handles are, in document space: the corners, the middle of each silhouette and the+/// front of each ellipse (the middle of the top and bottom edge, where the mesh puts it).+pub(super) fn cylinder_handles(v: &[f64; 7]) -> [([f64; 2], CylinderHandle); 8] {+    let [left, bottom, right, top, top_depth, bottom_depth, _] = *v;+    let a = (right - left) / 2.0;+    let cx = (left + right) / 2.0;+    let cy = (top + bottom) / 2.0;+    [+        ([left, top], CylinderHandle::Corner(0)),+        ([right, top], CylinderHandle::Corner(1)),+        ([right, bottom], CylinderHandle::Corner(2)),+        ([left, bottom], CylinderHandle::Corner(3)),+        ([left, cy], CylinderHandle::Side(0)),+        ([right, cy], CylinderHandle::Side(1)),+        ([cx, top + top_depth * a], CylinderHandle::Depth(0)),+        ([cx, bottom + bottom_depth * a], CylinderHandle::Depth(1)),+    ]+}++/// The nearest handle within `tol` of `p`, else the surface when `p` is on the cylinder, else+/// `None` (off it: a press there grabs nothing).+pub(super) fn cylinder_hit(w: &Warp, p: [f64; 2], tol: f64) -> Option<CylinderHandle> {+    let v = cylinder_values(w)?;+    let d = |q: [f64; 2]| (q[0] - p[0]).hypot(q[1] - p[1]);+    let nearest = cylinder_handles(&v).into_iter().map(|(q, h)| (d(q), h)).filter(|(dist, _)| *dist < tol).min_by(|a, b| a.0.total_cmp(&b.0));+    match nearest {+        Some((_, h)) => Some(h),+        None => surface_at(w.mesh.as_ref()?, p).map(|_| CylinderHandle::Surface),+    }+}++/// The values after `handle` is dragged by `d` from the press, where they were `v0`. A corner or+/// a side takes its silhouette (and the corner its edge) along; a depth handle follows the+/// pointer up and down; dragging the surface by the cylinder's width turns the contents the whole+/// way round. The silhouettes keep their order and the depths stay within the half axis.+pub(super) fn cylinder_dragged(v0: [f64; 7], handle: CylinderHandle, d: [f64; 2]) -> [f64; 7] {+    let mut v = v0;+    let a = ((v0[2] - v0[0]) / 2.0).max(MIN_WIDTH / 2.0);+    match handle {+        CylinderHandle::Corner(i) => {+            if i == 0 || i == 3 {+                v[0] += d[0];+            } else {+                v[2] += d[0];+            }+            if i < 2 {+                v[3] += d[1];+            } else {+                v[1] += d[1];+            }+        }+        CylinderHandle::Side(0) => v[0] += d[0],+        CylinderHandle::Side(_) => v[2] += d[0],+        CylinderHandle::Depth(0) => v[4] = (v0[4] + d[1] / a).clamp(-DEPTH_LIMIT, DEPTH_LIMIT),+        CylinderHandle::Depth(_) => v[5] = (v0[5] + d[1] / a).clamp(-DEPTH_LIMIT, DEPTH_LIMIT),+        CylinderHandle::Surface => v[6] = (v0[6] + d[0] / (2.0 * a)).clamp(0.0, 1.0),+    }+    // The silhouette being moved stops just short of the other one.+    if v[2] - v[0] < MIN_WIDTH {+        if matches!(handle, CylinderHandle::Corner(1 | 2) | CylinderHandle::Side(1)) {+            v[2] = v[0] + MIN_WIDTH;+        } else {+            v[0] = v[2] - MIN_WIDTH;+        }+    }+    v+}++/// Pointer input on a cylinder in Warp mode: a press grabs a handle or the surface+/// ([`cylinder_hit`]), and the drag sets the values ([`cylinder_dragged`]) and rebuilds the mesh+/// from them on every move, so the preview follows. A press off the cylinder grabs nothing.+pub(super) fn cylinder_pointer(app: &mut PhotocraftApp, ev: ToolEvent, tol: f64) {+    let (Some(t), Some(pv)) = (app.ui.transform.as_mut(), app.transform_preview.as_mut()) else { return };+    let Some(w) = t.warp.as_mut() else { return };+    match ev {+        ToolEvent::Down { x, y, .. } => {+            pv.warp_drag =+                cylinder_values(w).and_then(|values0| cylinder_hit(w, [x, y], tol).map(|handle| WarpDrag::Cylinder { handle, start: [x, y], values0 }));+        }+        ToolEvent::Move { x, y, .. } | ToolEvent::Up { x, y } => {+            if let Some(WarpDrag::Cylinder { handle, start, values0 }) = &pv.warp_drag+                && let Some(c) = Warp::cylinder(&cylinder_dragged(*values0, *handle, [x - start[0], y - start[1]]), w.bounds)+            {+                *w = c;+            }+            if matches!(ev, ToolEvent::Up { .. }) {+                pv.warp_drag = None;+            }+        }+    }+}++/// The cursor over document point `p` of the cylinder `w`: a resize arrow on a handle (as the+/// view turns it), a hand on the surface (a drag turns the contents), the plain arrow off it.+pub(super) fn cylinder_cursor(w: &Warp, p: [f64; 2], tol: f64, xf: &ViewXform) -> CursorIcon {+    let Some(v) = cylinder_values(w) else { return CursorIcon::Default };+    let quad = [[v[0], v[3]], [v[2], v[3]], [v[2], v[1]], [v[0], v[1]]];+    match cylinder_hit(w, p, tol) {+        Some(CylinderHandle::Corner(i)) => crate::tool_cursor::corner_resize(&quad, i, xf),+        Some(CylinderHandle::Side(_)) => crate::tool_cursor::resize([1.0, 0.0], xf),+        Some(CylinderHandle::Depth(_)) => crate::tool_cursor::resize([0.0, 1.0], xf),+        Some(CylinderHandle::Surface) => CursorIcon::Grab,+        None => CursorIcon::Default,+    }+}++/// The cylinder's handles over its grid: squares at the corners and the sides, circles at the+/// front of each ellipse; the one being dragged is filled with the grid's colour.+pub(super) fn draw_cylinder(painter: &egui::Painter, w: &Warp, scr: impl Fn([f64; 2]) -> Pos2, pv: &TransformPreview, line: Stroke) {+    let Some(v) = cylinder_values(w) else { return };+    let dragged = match &pv.warp_drag {+        Some(WarpDrag::Cylinder { handle, .. }) => Some(*handle),+        _ => None,+    };+    for (q, h) in cylinder_handles(&v) {+        let p = scr(q);+        let fill = if dragged == Some(h) { line.color } else { Color32::WHITE };+        if matches!(h, CylinderHandle::Depth(_)) {+            painter.circle_filled(p, 3.5, fill);+            painter.circle_stroke(p, 3.5, line);+        } else {+            let r = egui::Rect::from_center_size(p, vec2(7.0, 7.0));+            painter.rect_filled(r, 0.0, fill);+            painter.rect_stroke(r, 0.0, line, egui::StrokeKind::Inside);+        }+    }+}diff --git a/crates/ui-egui/src/tools/transform_tool/draw.rs b/crates/ui-egui/src/tools/transform_tool/draw.rs--- a/crates/ui-egui/src/tools/transform_tool/draw.rs+++ b/crates/ui-egui/src/tools/transform_tool/draw.rs@@ -1,4 +1,5 @@ use super::begin::warps_type;+use super::cylinder::draw_cylinder; use super::options_bar::{quad_scale, readout}; use super::pointer::{Hit, rotate_dir, rotation_degrees}; use super::warp::{on_section_edge, quick_split_kind, single_patch};@@ -84,7 +85,8 @@ pub fn draw_overlay(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform         let over_canvas = painter.ctx().input(|i| i.pointer.hover_pos()).filter(|p| xf.rect.contains(*p)).map(|p| xf.to_doc(p));         let at = pv.split_pointer.or(over_canvas);         let alt = painter.ctx().input(|i| i.modifiers.alt || i.modifiers.command);-        let guide = at.filter(|_| !warps_type(app)).and_then(|p| {+        // No splits on type (#3004) or a cylinder (#4112).+        let guide = at.filter(|_| !warps_type(app) && w.style != WarpStyle::Cylinder).and_then(|p| {             let tool = pv.split_tool.or_else(|| {                 (alt || pv.split_quick).then(|| {                     let m = w.to_mesh(1, 1);@@ -479,6 +481,21 @@ fn draw_warp_overlay(painter: &egui::Painter, xf: &ViewXform, w: &Warp, pv: &Tra     if w.style == WarpStyle::Custom {         draw_warp_points(painter, &m, scr, pv, line);     }+    if w.style == WarpStyle::Cylinder {+        // The grid's columns collapse onto the silhouettes (the back of the cylinder): a few+        // lines of longitude across the front, and of latitude, show its roundness.+        if let Some(&ul) = m.us.get(1) {+            for k in [1.0, 2.0] {+                let s = ul + (0.5 - ul) * k / 3.0;+                painter.add(egui::Shape::line(curve(Some(s), None), thin));+                painter.add(egui::Shape::line(curve(Some(1.0 - s), None), thin));+            }+        }+        for t in [1.0 / 3.0, 2.0 / 3.0] {+            painter.add(egui::Shape::line(curve(None, Some(t)), thin));+        }+        draw_cylinder(painter, w, scr, pv, line);+    }     if let Some((tool, p)) = guide {         let (s, t) = m.param_at(p);         let under = Stroke::new(3.0, Color32::BLACK);diff --git a/crates/ui-egui/src/tools/transform_tool/options_bar.rs b/crates/ui-egui/src/tools/transform_tool/options_bar.rs--- a/crates/ui-egui/src/tools/transform_tool/options_bar.rs+++ b/crates/ui-egui/src/tools/transform_tool/options_bar.rs@@ -1,5 +1,6 @@ use super::begin::{enter_warp, leave_warp, warps_type}; use super::commit::{cancel, commit};+use super::cylinder::{cylinder_default, cylinder_values}; use super::pointer::point_on_quad; use super::warp::edit_session_warp; use super::*;@@ -362,12 +363,14 @@ fn warp_fields(app: &mut PhotocraftApp, ui: &mut egui::Ui, w: &Warp) {     };     // Type takes only the styles (#3004): Split and Grid are greyed, and there is no Custom.     let on_type = warps_type(app);+    // A cylinder has no splits or grid either (#4112): its handles are its own.+    let cylinder = w.style == WarpStyle::Cylinder;     // Split, Grid, then the warp style with its bend fields.-    ui.add_enabled_ui(!on_type, |ui| warp_mesh_fields(app, ui, w));+    ui.add_enabled_ui(!on_type && !cylinder, |ui| warp_mesh_fields(app, ui, w));     crate::widgets::vline(ui, 22.0);     lbl(ui, tl!("Warp:"));     let mut style = w.style;-    let opts: Vec<(WarpStyle, &str)> = WarpStyle::all().filter(|s| !(on_type && *s == WarpStyle::Custom)).map(|s| (s, s.label())).collect();+    let opts: Vec<(WarpStyle, &str)> = warp_style_options(on_type).map(|s| (s, s.label())).collect();     if crate::widgets::dropdown(ui, "warp-style", tl!("Warp"), &mut style, &opts, 120.0)         && let Some(t) = app.ui.transform.as_mut()     {@@ -376,10 +379,12 @@ fn warp_fields(app: &mut PhotocraftApp, ui: &mut egui::Ui, w: &Warp) {             WarpStyle::Custom => Warp::custom(w.to_mesh(1, 1), b),             WarpStyle::None if on_type => Warp::none(b),             WarpStyle::None => Warp::custom(BezierMesh::identity(b, 1, 1), b),+            // The cylinder the menu starts with over the box (a box too thin for one keeps its shape).+            WarpStyle::Cylinder => cylinder_default(b).unwrap_or_else(|| Warp::custom(w.to_mesh(1, 1), b)),             s => Warp { style: s, bend: if w.style.is_preset() { w.bend } else { 50.0 }, ..Warp::none(b) },         });     }-    // Orientation and the bend fields are always there, and only act on a preset style.+    // Orientation is always there and only acts on a preset style (a cylinder is horizontal).     let preset = w.style.is_preset();     ui.add_enabled_ui(preset, |ui| {         let orient = crate::icons::button(ui, "arrow-left-right", 26.0, w.vertical, tl!("Vertical"));@@ -389,6 +394,12 @@ fn warp_fields(app: &mut PhotocraftApp, ui: &mut egui::Ui, w: &Warp) {         {             cur.vertical = !cur.vertical;         }+    });+    // A cylinder's values take the place of Bend, H and V, which are there for a preset style.+    if cylinder {+        return cylinder_fields(app, ui, w);+    }+    ui.add_enabled_ui(preset, |ui| {         let fields: [(&str, f64); 3] = [(tl!("Bend:"), w.bend), ("H:", w.h_distort), ("V:", w.v_distort)];         for (k, (name, v)) in fields.iter().enumerate() {             lbl(ui, name);@@ -406,6 +417,41 @@ fn warp_fields(app: &mut PhotocraftApp, ui: &mut egui::Ui, w: &Warp) {     }); } +/// The Warp menu's styles in Photoshop's order: None, Custom and the presets, then Cylinder+/// (#4112). Type takes only the styles its Warp Text has: no Custom, no Cylinder (#3004).+pub(super) fn warp_style_options(on_type: bool) -> impl Iterator<Item = WarpStyle> {+    WarpStyle::all().filter(move |s| !(on_type && *s == WarpStyle::Custom)).chain((!on_type).then_some(WarpStyle::Cylinder))+}++/// The Cylinder's fields of the Warp options bar, in place of Bend, H and V (#4112): the depth of+/// the upper and lower ellipse in percent of the half axis (down is positive) and how far the+/// contents wrap round. Typing one rebuilds the cylinder, as its handles do.+fn cylinder_fields(app: &mut PhotocraftApp, ui: &mut egui::Ui, w: &Warp) {+    let tk = crate::theme::Tokens::get(ui.ctx());+    let lbl = |ui: &mut egui::Ui, s: &str| {+        ui.label(egui::RichText::new(s).color(tk.text_dim).size(12.0));+    };+    let Some(v) = cylinder_values(w) else { return };+    let fields: [(&str, &str, usize, std::ops::RangeInclusive<f32>); 3] = [+        (tl!("Top:"), tl!("Top depth"), 4, -100.0..=100.0),+        (tl!("Bottom:"), tl!("Bottom depth"), 5, -100.0..=100.0),+        (tl!("Wrap:"), tl!("Wrap"), 6, 0.0..=100.0),+    ];+    for (name, access, k, range) in fields {+        lbl(ui, name);+        let mut f = (v[k] * 100.0) as f32;+        if crate::widgets::value_field(ui, &mut f, range, "%", 58.0).accessible_name(access).on_hover_text(access).changed()+            && let Some(Some(cur)) = app.ui.transform.as_mut().map(|t| t.warp.as_mut())+        {+            let mut nv = v;+            nv[k] = f64::from(f) / 100.0;+            if let Some(c) = Warp::cylinder(&nv, cur.bounds) {+                *cur = c;+            }+        }+    }+}+ /// The Split icons and the Grid menu of the Warp options bar. fn warp_mesh_fields(app: &mut PhotocraftApp, ui: &mut egui::Ui, w: &Warp) {     let tk = crate::theme::Tokens::get(ui.ctx());diff --git a/crates/ui-egui/src/tools/transform_tool/pointer.rs b/crates/ui-egui/src/tools/transform_tool/pointer.rs--- a/crates/ui-egui/src/tools/transform_tool/pointer.rs+++ b/crates/ui-egui/src/tools/transform_tool/pointer.rs@@ -1,5 +1,6 @@ use super::begin::warps_type; use super::commit::{commit, committing, moves_type};+use super::cylinder::cylinder_cursor; use super::drag_modes::{apply_drag, apply_mode_drag, drag_mods, mode_allows, mode_mods}; use super::warp::{quick_split_kind, split_cursor, surface_at, warp_hit, warp_pointer}; use super::*;@@ -265,6 +266,9 @@ pub fn cursor(app: &PhotocraftApp, xf: &ViewXform, p: [f64; 2], quick_split: boo         if warps_type(app) {             return Some(CursorIcon::Default);         }+        if w.style == WarpStyle::Cylinder {+            return Some(cylinder_cursor(w, p, tol, xf));+        }         let armed = app.transform_preview.as_ref().and_then(|pv| pv.split_tool);         if armed.is_some() || quick_split {             // One search for the grid position under the pointer, for both the kind and the arrow.diff --git a/crates/ui-egui/src/tools/transform_tool/tests.rs b/crates/ui-egui/src/tools/transform_tool/tests.rs--- a/crates/ui-egui/src/tools/transform_tool/tests.rs+++ b/crates/ui-egui/src/tools/transform_tool/tests.rs@@ -278,11 +278,16 @@ fn type_into_bar_field(n: usize, text: &str) -> TransformSession { /// The transform bar over the test box, after `setup` (e.g. picking the W/H unit). Tall enough /// for the fields' unit menu to open below the bar unscrolled. fn bar_harness(setup: impl FnOnce(&mut PhotocraftApp)) -> egui_kittest::Harness<'static, PhotocraftApp> {+    bar_harness_of(setup, 900.0)+}++/// [`bar_harness`] on a bar `width` points wide.+fn bar_harness_of(setup: impl FnOnce(&mut PhotocraftApp), width: f32) -> egui_kittest::Harness<'static, PhotocraftApp> {     use egui_kittest::Harness;     let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());     app.ui.transform = Some(session());     setup(&mut app);-    let mut h = Harness::builder().with_size(vec2(900.0, 360.0)).build_ui_state(+    let mut h = Harness::builder().with_size(vec2(width, 360.0)).build_ui_state(         |ui, app: &mut PhotocraftApp| {             if !ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) {                 return;@@ -298,8 +303,13 @@ fn bar_harness(setup: impl FnOnce(&mut PhotocraftApp)) -> egui_kittest::Harness<  /// [`type_into_bar_field`] after `setup`. fn type_into_bar_field_with(setup: impl FnOnce(&mut PhotocraftApp), n: usize, text: &str) -> TransformSession {+    type_into_bar_field_in(setup, 900.0, n, text)+}++/// [`type_into_bar_field_with`] on a bar `width` points wide.+fn type_into_bar_field_in(setup: impl FnOnce(&mut PhotocraftApp), width: f32, n: usize, text: &str) -> TransformSession {     use egui_kittest::kittest::Queryable;-    let mut h = bar_harness(setup);+    let mut h = bar_harness_of(setup, width);     h.get_all_by_role(egui::accesskit::Role::SpinButton).nth(n).unwrap().click();     h.run();     for c in text.chars() {@@ -2988,3 +2998,241 @@ fn anchor_grid_point_on_quad_and_carry_pivot() {     assert_eq!(matching_anchor(quad, [12.3, 45.6]), None);     assert_eq!(matching_anchor(quad, [50.0, 30.0]), None); }++// ---- Cylinder (#4112) ----++fn cylinder_of(app: &PhotocraftApp) -> [f64; 7] {+    cylinder_values(app.ui.transform.as_ref().unwrap().warp.as_ref().unwrap()).unwrap()+}++/// The Warp menu lists Cylinder after the presets, for everything but type (Warp Text has none).+#[test]+fn the_warp_menu_lists_cylinder_after_the_presets_except_on_type() {+    let all: Vec<WarpStyle> = warp_style_options(false).collect();+    assert_eq!(all.last(), Some(&WarpStyle::Cylinder));+    assert_eq!(all.len(), WarpStyle::all().count() + 1);+    assert!(all.contains(&WarpStyle::Custom) && all.contains(&WarpStyle::Twist));+    let on_type: Vec<WarpStyle> = warp_style_options(true).collect();+    assert!(!on_type.contains(&WarpStyle::Cylinder) && !on_type.contains(&WarpStyle::Custom));+    assert!(on_type.contains(&WarpStyle::None) && on_type.contains(&WarpStyle::Arc));+}++/// The cylinder the menu starts with fits the box: silhouettes on its sides, the top edge on its+/// top, the front of the lower ellipse at its bottom, half wrapped round.+#[test]+fn the_warp_menus_cylinder_fits_the_box() {+    let b = [8.0, 8.0, 32.0, 32.0];+    let w = cylinder_default(b).unwrap();+    assert_eq!(w.style, WarpStyle::Cylinder);+    let v = cylinder_values(&w).unwrap();+    assert_eq!([v[0], v[2], v[3]], [8.0, 32.0, 8.0], "silhouettes on the sides, top edge on the top: {v:?}");+    assert!((v[1] + v[5] * 12.0 - 32.0).abs() < 1e-9, "the lower ellipse's front reaches the bottom: {v:?}");+    assert_eq!(v[6], 0.5);+    let cb = w.mesh.as_ref().unwrap().control_bounds();+    assert!(cb[0] >= 8.0 - 1e-9 && cb[2] <= 32.0 + 1e-9 && cb[1] >= 8.0 - 1e-9, "{cb:?}");+    // The mesh's bottom is the lower ellipse's front, a hair past the box (Photoshop's shape).+    assert!((cb[3] - 32.0).abs() < 0.5, "{cb:?}");+    // A wide, thin box takes a shallower cylinder: the bulge stays within a quarter of its height.+    let v = cylinder_values(&cylinder_default([0.0, 0.0, 400.0, 20.0]).unwrap()).unwrap();+    assert!((v[4] * 200.0 - 5.0).abs() < 1e-9, "{v:?}");+    assert!(cylinder_default([0.0, 0.0, 0.0, 20.0]).is_none() && cylinder_default([0.0, 0.0, 20.0, 0.0]).is_none());+    assert!(cylinder_values(&Warp::preset(WarpStyle::Arc, 50.0, b)).is_none(), "only a cylinder has values");+}++#[test]+fn cylinder_handles_sit_on_its_corners_sides_and_ellipses() {+    let v = [10.0, 40.0, 60.0, 10.0, 0.3, 0.2, 0.5];+    let h = cylinder_handles(&v);+    assert_eq!(h[0], ([10.0, 10.0], CylinderHandle::Corner(0)));+    assert_eq!(h[2], ([60.0, 40.0], CylinderHandle::Corner(2)));+    assert_eq!(h[4], ([10.0, 25.0], CylinderHandle::Side(0)));+    assert_eq!(h[6], ([35.0, 10.0 + 0.3 * 25.0], CylinderHandle::Depth(0)));+    assert_eq!(h[7], ([35.0, 40.0 + 0.2 * 25.0], CylinderHandle::Depth(1)));+    // The depth handles sit where the mesh puts the front of each ellipse.+    let w = Warp::cylinder(&v, [10.0, 10.0, 60.0, 40.0]).unwrap();+    let m = w.mesh.clone().unwrap();+    let front_top = m.eval(0.5, 0.0);+    assert!((front_top[0] - h[6].0[0]).abs() < 1e-9 && (front_top[1] - h[6].0[1]).abs() < 1e-9, "{front_top:?} vs {:?}", h[6].0);+    let front_bottom = m.eval(0.5, 1.0);+    assert!((front_bottom[1] - h[7].0[1]).abs() < 1e-9, "{front_bottom:?} vs {:?}", h[7].0);+    // A press: the nearest handle within reach, else the surface, else nothing.+    assert_eq!(cylinder_hit(&w, [11.0, 11.0], 3.0), Some(CylinderHandle::Corner(0)));+    assert_eq!(cylinder_hit(&w, [60.5, 25.0], 3.0), Some(CylinderHandle::Side(1)));+    assert_eq!(cylinder_hit(&w, [35.0, 18.0], 3.0), Some(CylinderHandle::Depth(0)));+    assert_eq!(cylinder_hit(&w, [35.0, 28.0], 3.0), Some(CylinderHandle::Surface));+    assert_eq!(cylinder_hit(&w, [90.0, 90.0], 3.0), None);+    assert_eq!(cylinder_hit(&Warp::custom(m, w.bounds), [11.0, 11.0], 3.0), None, "only a cylinder has these handles");+}++#[test]+fn cylinder_drags_set_its_values_and_keep_them_sound() {+    let v = [10.0, 40.0, 60.0, 10.0, 0.3, 0.3, 0.5];+    // A corner takes its silhouette and its edge along; a side only its silhouette.+    assert_eq!(cylinder_dragged(v, CylinderHandle::Corner(0), [2.0, 3.0]), [12.0, 40.0, 60.0, 13.0, 0.3, 0.3, 0.5]);+    assert_eq!(cylinder_dragged(v, CylinderHandle::Corner(2), [-2.0, 3.0]), [10.0, 43.0, 58.0, 10.0, 0.3, 0.3, 0.5]);+    assert_eq!(cylinder_dragged(v, CylinderHandle::Side(1), [5.0, 9.0]), [10.0, 40.0, 65.0, 10.0, 0.3, 0.3, 0.5]);+    // A depth handle follows the pointer: 5 px down on a 25 px half axis is 0.2 deeper.+    let d = cylinder_dragged(v, CylinderHandle::Depth(0), [0.0, 5.0]);+    assert!((d[4] - 0.5).abs() < 1e-9 && d[5] == 0.3, "{d:?}");+    assert_eq!(cylinder_dragged(v, CylinderHandle::Depth(1), [0.0, -100.0])[5], -1.0, "clamped to the half axis");+    // The surface turns the wrap: the cylinder's width is the whole range, clamped to it.+    let d = cylinder_dragged(v, CylinderHandle::Surface, [12.5, 0.0]);+    assert!((d[6] - 0.75).abs() < 1e-9, "{d:?}");+    assert_eq!(cylinder_dragged(v, CylinderHandle::Surface, [-80.0, 0.0])[6], 0.0);+    // The silhouettes never cross: the one dragged stops just short of the other.+    let d = cylinder_dragged(v, CylinderHandle::Side(0), [70.0, 0.0]);+    assert_eq!([d[0], d[2]], [59.0, 60.0]);+    let d = cylinder_dragged(v, CylinderHandle::Corner(1), [-80.0, 0.0]);+    assert_eq!([d[0], d[2]], [10.0, 11.0]);+    // Whatever the drag, the result is a cylinder the geometry accepts.+    for h in [CylinderHandle::Corner(3), CylinderHandle::Side(0), CylinderHandle::Depth(1), CylinderHandle::Surface] {+        assert!(Warp::cylinder(&cylinder_dragged(v, h, [-70.0, 30.0]), [10.0, 10.0, 60.0, 40.0]).is_some(), "{h:?}");+    }+}++/// In Warp mode a cylinder's handles drag its values and the preview follows; Undo steps back+/// through the drags; Enter commits the cylinder, which a smart object keeps as one with the+/// dragged values, and Warp mode shows it again as a cylinder, whose untouched commit is no step.+#[test]+fn warp_mode_drags_a_cylinders_handles_and_commits_it_as_a_cylinder() {+    let ctx = egui::Context::default();+    let mut app = app_with_square(64, photocraft_geom::Rect::new(8, 8, 32, 32));+    app.run("layer.smartObjects.convertToSmartObject", json!({})).unwrap();+    begin_warp(&mut app, &ctx).unwrap();+    let b = app.ui.transform.as_ref().unwrap().rect;+    app.ui.transform.as_mut().unwrap().warp = Some(cylinder_default(b).unwrap());+    track_steps(&mut app, &ctx);+    let v0 = cylinder_of(&app);+    let mods = egui::Modifiers::NONE;+    // The upper ellipse's depth handle, dragged 3 px down (a quarter of the 12 px half axis).+    let top = cylinder_handles(&v0)[6].0;+    warp_pointer(&mut app, ToolEvent::Down { x: top[0], y: top[1], pressure: 1.0 }, 2.0, mods);+    assert!(matches!(app.transform_preview.as_ref().unwrap().warp_drag, Some(WarpDrag::Cylinder { handle: CylinderHandle::Depth(0), .. })));+    warp_pointer(&mut app, ToolEvent::Move { x: top[0], y: top[1] + 3.0, pressure: 1.0 }, 2.0, mods);+    let v = cylinder_of(&app);+    assert!((v[4] - (v0[4] + 0.25)).abs() < 1e-9, "{v:?}");+    // The preview is the mesh of the new values, and the warp is still a cylinder.+    let w = app.ui.transform.as_ref().unwrap().warp.clone().unwrap();+    assert_eq!(w.style, WarpStyle::Cylinder);+    assert_eq!(w.mesh, Warp::cylinder(&v, b).unwrap().mesh);+    assert!(w.mesh != Warp::cylinder(&v0, b).unwrap().mesh);+    warp_pointer(&mut app, ToolEvent::Up { x: top[0], y: top[1] + 3.0 }, 2.0, mods);+    assert!(app.transform_preview.as_ref().unwrap().warp_drag.is_none());+    track_steps(&mut app, &ctx);+    // A drag across the surface turns the contents: 6 px of a 24 px wide cylinder is a quarter.+    warp_pointer(&mut app, ToolEvent::Down { x: 20.0, y: 26.0, pressure: 1.0 }, 2.0, mods);+    assert!(matches!(app.transform_preview.as_ref().unwrap().warp_drag, Some(WarpDrag::Cylinder { handle: CylinderHandle::Surface, .. })));+    warp_pointer(&mut app, ToolEvent::Up { x: 26.0, y: 26.0 }, 2.0, mods);+    assert!((cylinder_of(&app)[6] - 0.75).abs() < 1e-9, "{:?}", cylinder_of(&app));+    track_steps(&mut app, &ctx);+    // A press off the cylinder grabs nothing.+    warp_pointer(&mut app, ToolEvent::Down { x: 60.0, y: 60.0, pressure: 1.0 }, 2.0, mods);+    assert!(app.transform_preview.as_ref().unwrap().warp_drag.is_none());+    warp_pointer(&mut app, ToolEvent::Up { x: 50.0, y: 50.0 }, 2.0, mods);+    // Undo and Redo step through the drags inside the session.+    let _ = intercept(&mut app, "edit.undo");+    assert!((cylinder_of(&app)[6] - 0.5).abs() < 1e-9, "undo: {:?}", cylinder_of(&app));+    let _ = intercept(&mut app, "edit.redo");+    assert!((cylinder_of(&app)[6] - 0.75).abs() < 1e-9, "redo: {:?}", cylinder_of(&app));+    let v = cylinder_of(&app);+    let steps = app.session.active().unwrap().history.past_len();+    commit(&mut app);+    assert!(app.ui.transform.is_none(), "{}", app.ui.status);+    let st = app.session.active().unwrap();+    assert_eq!(st.history.past_len(), steps + 1, "one step");+    let photocraft_doc::LayerContent::Smart(sm) = &st.doc.layer(st.active_layer.unwrap()).unwrap().content else { panic!("smart") };+    assert_eq!(sm.warp.as_ref().map(|w| w.style), Some(WarpStyle::Cylinder), "the smart object keeps a cylinder");+    // Warp mode shows the same cylinder again, with its values, and Enter changes nothing.+    begin_warp(&mut app, &ctx).unwrap();+    let shown = app.ui.transform.as_ref().unwrap().warp.clone().unwrap();+    assert_eq!(shown.style, WarpStyle::Cylinder);+    for (a, b) in cylinder_values(&shown).unwrap().iter().zip(&v) {+        assert!((a - b).abs() < 1e-6, "{shown:?} vs {v:?}");+    }+    let steps = app.session.active().unwrap().history.past_len();+    commit(&mut app);+    assert!(app.ui.transform.is_none(), "{}", app.ui.status);+    assert_eq!(app.session.active().unwrap().history.past_len(), steps, "untouched: no step");+}++/// A cylinder on a pixel layer commits as previewed: the pixels wrap round between the+/// silhouettes and the ones on the back are hidden.+#[test]+fn a_cylinder_commits_the_wrapped_pixels() {+    let mut app = app_with_square(64, photocraft_geom::Rect::new(8, 8, 32, 32));+    begin_warp(&mut app, &egui::Context::default()).unwrap();+    let b = app.ui.transform.as_ref().unwrap().rect;+    // Silhouettes pulled in to x 12..28: the square's outer columns go round the back.+    let w = Warp::cylinder(&[12.0, 29.0, 28.0, 8.0, 0.25, 0.25, 0.5], b).unwrap();+    app.ui.transform.as_mut().unwrap().warp = Some(w.clone());+    commit(&mut app);+    assert!(app.ui.transform.is_none(), "{}", app.ui.status);+    let st = app.session.active().unwrap();+    let s = st.doc.layer(st.active_layer.unwrap()).unwrap().surface().unwrap();+    let alpha = |x: i32, y: i32| {+        let mut p = [0.0f32; 4];+        s.read_pixel(x, y, &mut p);+        p[3]+    };+    assert!(alpha(20, 20) > 0.99, "the front is covered: {}", alpha(20, 20));+    assert!(alpha(9, 20) < 0.01 && alpha(31, 20) < 0.01, "nothing outside the silhouettes: {} {}", alpha(9, 20), alpha(31, 20));+    // The upper ellipse's front dips below the top edge at the centre, so the top row there is clear.+    assert!(alpha(20, 9) < 0.2, "the top bows down in the middle: {}", alpha(20, 9));+    assert!(alpha(13, 9) > 0.5, "and stays up at the silhouettes: {}", alpha(13, 9));+}++#[test]+fn cylinder_cursors_point_along_their_handles() {+    let w = Warp::cylinder(&[10.0, 40.0, 60.0, 10.0, 0.3, 0.3, 0.5], [10.0, 10.0, 60.0, 40.0]).unwrap();+    let screen = egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0));+    let level = ViewXform { rect: screen, zoom: 1.0, center: [35.0, 25.0], flip: false, rotation: 0.0, aspect: 1.0 };+    assert_eq!(cylinder_cursor(&w, [10.0, 10.0], 3.0, &level), CursorIcon::ResizeNwSe);+    assert_eq!(cylinder_cursor(&w, [60.0, 10.0], 3.0, &level), CursorIcon::ResizeNeSw);+    assert_eq!(cylinder_cursor(&w, [10.0, 25.0], 3.0, &level), CursorIcon::ResizeHorizontal);+    assert_eq!(cylinder_cursor(&w, [35.0, 17.5], 3.0, &level), CursorIcon::ResizeVertical);+    assert_eq!(cylinder_cursor(&w, [35.0, 30.0], 3.0, &level), CursorIcon::Grab);+    assert_eq!(cylinder_cursor(&w, [90.0, 90.0], 3.0, &level), CursorIcon::Default);+    // The arrows turn with the view.+    let turned = ViewXform { rotation: 90.0, ..level };+    assert_eq!(cylinder_cursor(&w, [10.0, 25.0], 3.0, &turned), CursorIcon::ResizeVertical);+    assert_eq!(cylinder_cursor(&w, [35.0, 17.5], 3.0, &turned), CursorIcon::ResizeHorizontal);+    // Through the transform tool's cursor, a cylinder is never a split: its surface is a hand.+    let mut app = app_with_square(128, photocraft_geom::Rect::new(8, 8, 104, 104));+    begin_warp(&mut app, &egui::Context::default()).unwrap();+    let b = app.ui.transform.as_ref().unwrap().rect;+    app.ui.transform.as_mut().unwrap().warp = Some(cylinder_default(b).unwrap());+    app.transform_preview.as_mut().unwrap().split_tool = Some(SplitTool::Cross);+    let xf = ViewXform { center: [56.0, 56.0], ..level };+    assert_eq!(cursor(&app, &xf, [56.0, 56.0], true), Some(CursorIcon::Grab));+}++/// The Warp bar shows a cylinder's fields in place of Bend, H and V, greys Split and Grid, and+/// typing a field rebuilds the cylinder.+#[test]+fn the_warp_bar_shows_a_cylinders_fields() {+    use egui_kittest::kittest::{NodeT, Queryable};+    let as_cylinder = |app: &mut PhotocraftApp| {+        let t = app.ui.transform.as_mut().unwrap();+        t.warp = Some(cylinder_default(t.rect).unwrap());+    };+    // The cylinder's fields overrun the 900 px bar of the other tests, whose clipped fields sit+    // under the action buttons (a click on the third one would press Reset).+    const WIDE: f32 = 1400.0;+    let h = bar_harness_of(as_cylinder, WIDE);+    for name in ["Top depth", "Bottom depth", "Wrap"] {+        assert!(!h.get_by_label(name).accesskit_node().is_disabled(), "{name} is live");+    }+    assert!(h.query_by_label("Bend:").is_none(), "no bend field");+    for name in ["Split Warp Crosswise", "Change warp orientation"] {+        assert!(h.get_by_label(name).accesskit_node().is_disabled(), "{name} is greyed: a cylinder has no splits and is horizontal");+    }+    // Wrap is the third numeric field: 80 % wraps the contents further round.+    let t = type_into_bar_field_in(as_cylinder, WIDE, 2, "80");+    let v = cylinder_values(t.warp.as_ref().unwrap()).unwrap();+    assert!((v[6] - 0.8).abs() < 1e-6, "{v:?}");+    // Top depth is the first: 40 % of the half axis.+    let t = type_into_bar_field_in(as_cylinder, WIDE, 0, "40");+    let v = cylinder_values(t.warp.as_ref().unwrap()).unwrap();+    assert!((v[4] - 0.4).abs() < 1e-6, "{v:?}");+    assert_eq!(t.warp.as_ref().unwrap().mesh, Warp::cylinder(&v, t.rect).unwrap().mesh, "the mesh follows the typed value");+}diff --git a/crates/ui-egui/src/tools/transform_tool/warp.rs b/crates/ui-egui/src/tools/transform_tool/warp.rs--- a/crates/ui-egui/src/tools/transform_tool/warp.rs+++ b/crates/ui-egui/src/tools/transform_tool/warp.rs@@ -1,4 +1,5 @@ use super::begin::warps_type;+use super::cylinder::cylinder_pointer; use super::*;  /// An anchor or a handle on a section line. The inner control points of a patch are not shown.@@ -121,6 +122,10 @@ pub(super) fn warp_pointer(app: &mut PhotocraftApp, ev: ToolEvent, tol: f64, mod     if warps_type(app) {         return;     }+    // A cylinder has its own handles, and no splits (#4112).+    if app.ui.transform.as_ref().and_then(|t| t.warp.as_ref()).is_some_and(|w| w.style == WarpStyle::Cylinder) {+        return cylinder_pointer(app, ev, tol);+    }     if split_gesture(app, ev, mods) {         return;     }diff --git a/docs/gaps.md b/docs/gaps.md--- 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-26 · **Change:** minor (cropped selection morphology and command mask conversion performance evidence added; G9: 12 of 178 preferences do nothing, as the scorecard prefs audit counts them; G6: Art History Brush landed as partial, TOOL-213-20, #1610, so no toolbar tool is missing; G4: five hard-round built-in presets use 5% spacing to remove stacked-tip outlines and reduce pressure-opacity bands, #8842; G9: Crop tool canvas menu, #6561; G2: Content-Aware Scale seam carving 1.65–3.6× faster with unchanged output, P28 row 3.3 → 1.6 s on a 4-core VM, #5236; G9: UI scale presets done, 105–120% steps, #3222; G14: lossy DNG, raw sidecars, Camera Raw Defaults live, Calibration and Auto, CR3 assessed as not clean-room feasible; G8: PSD type on a path opens on its path and saves back into the file's `Txt2`, #7962; G11: a recorded Save As imports with its format and file (`file.saveAs`, `file.saveACopy` for As a Copy) instead of as a plain Save that wrote over the played file, #7102; folder destinations stop playback until #5430; adjustment clipping stays visible in compact Properties, #5231; G9: Color panel modes, ramp and copy commands, the same in every theme, UI-217-17, #6000; G17: Help › Check for Updates and an opt-out check once per launch, DIST-220-1 partial, #5485; G10: `.asl` style libraries landed, #1051; G8: area type (TYPE-218-10); G1: smart objects keep Photoshop's Cylinder warp, #4112; G8: paragraph direction buttons in the Paragraph panel and options bar, TYPE-218-2 partial; G1: 32-bit Hue/Saturation and Colorize as Photoshop does them, Photoshop-oracle floor 190/258, #5781; G11: compound layer properties export as separate Photoshop Set events, rename last, #8304; G11: Batch and droplets take the result at the action's Save step, so actions ending in Save As and Close work; G11: `.atn` steps that switch to another open document import, play and save back, #7103; G14: the Nikon D4 white level follows the sample depth, so 12-bit D4 NEFs no longer open 2.6 stops dark, #5978; G14: a Nikon NEF's Adobe RGB preview is read as Adobe RGB (maker-note ColorSpace 0x001E), #5987; Apply Layer Comp action mapping, #7101; G10: JPEG Options dialog landed, FILE-215-19 partial (no Matte, progressive Scans or size preview); G10: Cineon write; G10: DPX read and write, #1049, Cineon and Amiga IFF read; Classical Object Finder improvement; learned segmentation remains open; G4: named, stateful section locks and visible keyboard focus in Brush Settings; G4: independent Brush Pose tilt-axis overrides, #3825 partial; G4: Transfer Mixer-only controls, #3825 partial; 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 in local runs, baseline not re-run, #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; G10: Amiga IFF write; G10: PSD spot channels in Photoshop's polarity, earlier PhotoCraft PSDs keep their ink, #7415 split; G10: TIFF alpha and spot channels written and read as extra samples, FILE-215-7 done, FILE-215-17's TIFF part, #7415) · **Target:** Adobe Photoshop 2026+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-27 · **Change:** minor (G5: Warp mode edits Photoshop's Cylinder warp, #4112: the Warp menu lists it, handles and the Top/Bottom/Wrap fields set its seven values, and a smart object's stored cylinder opens in Warp mode as one; cropped selection morphology and command mask conversion performance evidence added; G9: 12 of 178 preferences do nothing, as the scorecard prefs audit counts them; G6: Art History Brush landed as partial, TOOL-213-20, #1610, so no toolbar tool is missing; G4: five hard-round built-in presets use 5% spacing to remove stacked-tip outlines and reduce pressure-opacity bands, #8842; G9: Crop tool canvas menu, #6561; G2: Content-Aware Scale seam carving 1.65–3.6× faster with unchanged output, P28 row 3.3 → 1.6 s on a 4-core VM, #5236; G9: UI scale presets done, 105–120% steps, #3222; G14: lossy DNG, raw sidecars, Camera Raw Defaults live, Calibration and Auto, CR3 assessed as not clean-room feasible; G8: PSD type on a path opens on its path and saves back into the file's `Txt2`, #7962; G11: a recorded Save As imports with its format and file (`file.saveAs`, `file.saveACopy` for As a Copy) instead of as a plain Save that wrote over the played file, #7102; folder destinations stop playback until #5430; adjustment clipping stays visible in compact Properties, #5231; G9: Color panel modes, ramp and copy commands, the same in every theme, UI-217-17, #6000; G17: Help › Check for Updates and an opt-out check once per launch, DIST-220-1 partial, #5485; G10: `.asl` style libraries landed, #1051; G8: area type (TYPE-218-10); G1: smart objects keep Photoshop's Cylinder warp, #4112; G8: paragraph direction buttons in the Paragraph panel and options bar, TYPE-218-2 partial; G1: 32-bit Hue/Saturation and Colorize as Photoshop does them, Photoshop-oracle floor 190/258, #5781; G11: compound layer properties export as separate Photoshop Set events, rename last, #8304; G11: Batch and droplets take the result at the action's Save step, so actions ending in Save As and Close work; G11: `.atn` steps that switch to another open document import, play and save back, #7103; G14: the Nikon D4 white level follows the sample depth, so 12-bit D4 NEFs no longer open 2.6 stops dark, #5978; G14: a Nikon NEF's Adobe RGB preview is read as Adobe RGB (maker-note ColorSpace 0x001E), #5987; Apply Layer Comp action mapping, #7101; G10: JPEG Options dialog landed, FILE-215-19 partial (no Matte, progressive Scans or size preview); G10: Cineon write; G10: DPX read and write, #1049, Cineon and Amiga IFF read; Classical Object Finder improvement; learned segmentation remains open; G4: named, stateful section locks and visible keyboard focus in Brush Settings; G4: independent Brush Pose tilt-axis overrides, #3825 partial; G4: Transfer Mixer-only controls, #3825 partial; 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 in local runs, baseline not re-run, #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; G10: Amiga IFF write; G10: PSD spot channels in Photoshop's polarity, earlier PhotoCraft PSDs keep their ink, #7415 split; G10: TIFF alpha and spot channels written and read as extra samples, FILE-215-7 done, FILE-215-17's TIFF part, #7415) · **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@@ -55,7 +55,8 @@ what an agent can't finish alone.   through io on Windows, io tests/it/large_files.rs, 2026-10-13).   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).+  vertical cylinders and ones with perspective are still unread; Warp mode edits the cylinder+  since 2026-10-27, G5). - **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.@@ -267,7 +268,11 @@ what an agent can't finish alone.   snapping (#2681, #2684); Auto-Select box follows the picked layer (#2666, #2675); ⌘-arrow   layer nudge (#2474, #2556); polygonal lasso Shift angles (#2352, #2396); double-click Zoom tool   to 100% (#2508, #2527); tool-key memory in a group (#2608, #2625); hold-a-key spring-loaded-  tools (#2672); H/V skew fields in the Free Transform options bar.+  tools (#2672); H/V skew fields in the Free Transform options bar; Warp mode's Cylinder+  (#4112, 2026-10-27): listed in the Warp menu after the presets, with corner, side and depth+  handles and a surface drag for its silhouettes, edges, ellipse depths and wrap, Top/Bottom/Wrap+  fields in the bar, and a smart object's stored cylinder opening as one (vertical cylinders+  and ones in perspective stay out, as G1's PSD reader has no measurements for them). - **Detail:** [`ui-parity.md`](ui-parity.md). - **Estimate:** 60–110 h. @@ -395,6 +400,7 @@ what an agent can't finish alone.  | Date | Change | Summary | |---|---|---|+| 2026-10-27 | minor | G5: Warp mode edits Photoshop's Cylinder warp (#4112): the Warp menu lists it after the presets, handles set its silhouettes, edges, ellipse depths and wrap, the options bar shows Top, Bottom and Wrap, and a smart object's stored cylinder opens in Warp mode as a cylinder and commits back as one; no estimate changed | | 2026-10-26 | minor | Expand/Contract/Border distance work, mask reads and replacement cropped to nonzero bounds; bounded distances for radii ≤5; 36 MP exact-output comparisons improve 1.54–37.82× across eight cases; percentages, estimates and scorecard budgets unchanged | | 2026-10-25 | minor | G9: the count of preferences that do nothing is the scorecard's again, 12 of 178 (it still said 24 of 171, from 2026-10-15); no estimate changed | | 2026-10-25 | minor | G6: Art History Brush landed as partial (TOOL-213-20, #1610), so no toolbar tool is missing; no percentage or estimate changed |diff --git a/docs/ui-parity.md b/docs/ui-parity.md--- a/docs/ui-parity.md+++ b/docs/ui-parity.md@@ -1,6 +1,6 @@ # UI parity: tools, handles, modifiers and feel -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** minor (12 of 178 preferences do nothing, from the scorecard; Art History Brush in the History group of the Y flyout, partial, TOOL-213-20, #1610; Histogram panel: Compact / Expanded / All Channels views, Luminosity and Colors channels, Source, range statistics, Uncached Refresh, live adjustment overlay, Cache Levels preference; the font menu opens on the current family, marked in the recent group when it is a recent one, #9160; Type options bar alignment and direction follow the target paragraph; a swatch click recolours selected type and shape layers with any tool, #9238; UI scale presets done: 105–120% steps, #3222; ⇧⌥ blend mode letters; painting tool Mode menus; ⌥ + an adjustment's shortcut reopens it at its last settings, ⌥⌘L / ⌥⌘M, #8900; Crop default mode: moving the image under the box snaps the frame's edges and centre, #8580; View › Extras hides the Move transform controls, #6699; adjustment clipping stays visible in compact Properties, #5231; Color panel modes from its panel menu, the same in every theme, UI-217-17, #6000; a picked colour recolours the selected type and shapes; Hand tool ⌘ / ⌥ are Zoom In / Zoom Out while held, #6434; the wheel steps value fields, #5473; a dragged guide shows its X or Y beside the pointer, #7680; Type options bar "T" icon scrubs the font size, #6437; Move tool: dragged selected pixels take their own outline as the selection, #7538; artboard backgrounds remain below child Free Transform previews; ⌫/Delete removes the selected smart filter, #4751; Object Finder foreground/background discrimination (#6548); rounded triangle, polygon and star corners; shape Fill/Stroke popovers accept hex RGB values, #6176; a guide let go over either ruler is removed, #6286; Crop rows corrected: saved crop presets and Delete Cropped Pixels off are done, #6562; marching ants in device pixels, #7489; Animated Zoom: holding the Zoom tool zooms continuously, #6210; shape Density and Feather, TOOL-214-12, #5846; Pen Auto Add/Delete on the path being drawn, #6140; Show Transform Controls frames every selected layer; guide keys and clicks; menu bar from the keyboard, #4988; Crop tool Content-Aware Fill; on-canvas corner radius widgets, TOOL-214-12; Curvature Pen edits the active path; Clip Thumbnails to Layer Bounds; Pen ⇧ 45°; last selected tool restored at launch; Layers panel filter-type dropdown with Kind, Name and Mode, #5048; Free Transform box and marching ants follow the selection, #5297; History: the trash deletes the selected snapshot, rows drag onto the trash, #5324; smart filter reordering by drag; smart object perspective row corrected to done, #426; Free Transform H/V skew fields; toolbar tool shortcuts can be reassigned; ⇧-dragged guides land on ruler ticks; layer and selection keys; tool keys; spring-loaded tool letters; type formatting keys; Pen Auto Add/Delete, #4144; smart filter rows; stale missing-tool rows corrected: Color Replacement, Red Eye and the Add/Delete Anchor Point pair are live toolbar tools, Frame landed; no percentage changed; selection-modifier and ⌥-wheel rows match their tests; Properties › Transform shows a shape's path geometry, without its stroke) · **Target:** Adobe Photoshop 2026+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-27 · **Change:** minor (Warp mode edits Photoshop's Cylinder warp, #4112; 12 of 178 preferences do nothing, from the scorecard; Art History Brush in the History group of the Y flyout, partial, TOOL-213-20, #1610; Histogram panel: Compact / Expanded / All Channels views, Luminosity and Colors channels, Source, range statistics, Uncached Refresh, live adjustment overlay, Cache Levels preference; the font menu opens on the current family, marked in the recent group when it is a recent one, #9160; Type options bar alignment and direction follow the target paragraph; a swatch click recolours selected type and shape layers with any tool, #9238; UI scale presets done: 105–120% steps, #3222; ⇧⌥ blend mode letters; painting tool Mode menus; ⌥ + an adjustment's shortcut reopens it at its last settings, ⌥⌘L / ⌥⌘M, #8900; Crop default mode: moving the image under the box snaps the frame's edges and centre, #8580; View › Extras hides the Move transform controls, #6699; adjustment clipping stays visible in compact Properties, #5231; Color panel modes from its panel menu, the same in every theme, UI-217-17, #6000; a picked colour recolours the selected type and shapes; Hand tool ⌘ / ⌥ are Zoom In / Zoom Out while held, #6434; the wheel steps value fields, #5473; a dragged guide shows its X or Y beside the pointer, #7680; Type options bar "T" icon scrubs the font size, #6437; Move tool: dragged selected pixels take their own outline as the selection, #7538; artboard backgrounds remain below child Free Transform previews; ⌫/Delete removes the selected smart filter, #4751; Object Finder foreground/background discrimination (#6548); rounded triangle, polygon and star corners; shape Fill/Stroke popovers accept hex RGB values, #6176; a guide let go over either ruler is removed, #6286; Crop rows corrected: saved crop presets and Delete Cropped Pixels off are done, #6562; marching ants in device pixels, #7489; Animated Zoom: holding the Zoom tool zooms continuously, #6210; shape Density and Feather, TOOL-214-12, #5846; Pen Auto Add/Delete on the path being drawn, #6140; Show Transform Controls frames every selected layer; guide keys and clicks; menu bar from the keyboard, #4988; Crop tool Content-Aware Fill; on-canvas corner radius widgets, TOOL-214-12; Curvature Pen edits the active path; Clip Thumbnails to Layer Bounds; Pen ⇧ 45°; last selected tool restored at launch; Layers panel filter-type dropdown with Kind, Name and Mode, #5048; Free Transform box and marching ants follow the selection, #5297; History: the trash deletes the selected snapshot, rows drag onto the trash, #5324; smart filter reordering by drag; smart object perspective row corrected to done, #426; Free Transform H/V skew fields; toolbar tool shortcuts can be reassigned; ⇧-dragged guides land on ruler ticks; layer and selection keys; tool keys; spring-loaded tool letters; type formatting keys; Pen Auto Add/Delete, #4144; smart filter rows; stale missing-tool rows corrected: Color Replacement, Red Eye and the Add/Delete Anchor Point pair are live toolbar tools, Frame landed; no percentage changed; selection-modifier and ⌥-wheel rows match their tests; Properties › Transform shows a shape's path geometry, without its stroke) · **Target:** Adobe Photoshop 2026  How PhotoCraft's direct manipulation compares with Photoshop 2026 (27.11.0): the toolbox, the handles and modifier keys of each gesture, nudging, snapping, numeric precision, navigation,@@ -66,6 +66,7 @@ Triangle and Curvature Pen (minus legacy Rounded Rectangle and Targeted Adjustme | ⌥ scales about the reference point; ⌥-click places it; draggable reference point | done | | | ⌘-corner distort, ⌘-edge skew, ⌘⌥⇧ perspective, ⇧ rotate in 15° steps | done | | | Warp: presets, bend, split crosswise/vertical/horizontal, 3×3–5×5 and custom grids | done | |+| Warp › Cylinder: listed after the presets; corner, side and depth handles and a drag across the surface set its silhouettes, edges, ellipse depths and wrap, with Top/Bottom/Wrap fields in the bar; a smart object's stored cylinder opens in Warp mode as one and commits back as `warpCylinder` | done (2026-10-27) | `transform_tool/cylinder.rs`, `warp_cmds.rs` (`smart_warp_doc_space`), `warp_mode_drags_a_cylinders_handles_and_commits_it_as_a_cylinder` (#4112). Differs: the gestures follow Photoshop's by observation; vertical cylinders and ones in perspective stay out, as the PSD reader has no measurements for them | | Options bar X/Y, W/H % with link, angle | done | | | With a selection, the box is the selection's rectangle and the marching ants follow the box live | done | `transform_frame`, `transform_tool.rs` (`draw_selection`): move, scale, rotate, skew, distort, perspective and warp, before the transform is applied; also the Move tool's Show Transform Controls (#5297) | | Options bar H/V skew fields | done | `transform_tool.rs` (`Parts`, `skew_about_pivot`): W/H, angle and skew read as Photoshop decomposes the box; `skew_fields_lean_the_box_about_the_reference_point` |@@ -246,6 +247,7 @@ Paragraph; the rest open as in-app floating windows.  | Date | Change | Summary | |---|---|---|+| 2026-10-27 | minor | Warp mode edits Photoshop's Cylinder warp (#4112): the Warp menu lists it, handles and the Top/Bottom/Wrap fields set its seven values, and a smart object's stored cylinder opens as one. Scores unchanged. | | 2026-10-25 | minor | Every preference takes effect: 12 of 178 do nothing, the scorecard's count (it still said 46 of 149) | | 2026-10-25 | minor | Art History Brush is in the History group of the Y flyout (partial, TOOL-213-20, #1610) | | 2026-10-24 | minor | Swatches: a click recolours the selected type layers and shape fills whatever the tool, as Photoshop 2025 does (measured with the Move and Brush tools); the Color panel still recolours them only with a Type, shape or Pen tool (#9238). Scores unchanged. |