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 first
diff --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, and
diff --git a/crates/ui-egui/src/tools/transform_tool/cylinder.rs b/crates/ui-egui/src/tools/transform_tool/cylinder.rs
new 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. |
