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
@@ -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@@ -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] {@@ -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) }@@ -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, andnew file mode 100644@@ -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);+ }+ }+}@@ -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);@@ -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());@@ -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.@@ -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");+}@@ -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; }@@ -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 |@@ -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. |