mirror of
https://github.com/storytold/photocraft.git
synced 2026-11-11 00:11:46 +00:00
Merge pull request #9798 from TimothyBritt/feat/cylinder-warp-editor
Warp mode: edit Photoshop's Cylinder warp with handles and fields (#4112)
This commit is contained in:
16 files changed
+883
-54
No files matched your search
@@ -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,60 @@ 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";
|
||||
|
||||
/// A document-space cylinder `w` stored on a smart object, whose placement's inverse is `inv` and
|
||||
/// whose warp box `w.bounds` lands on `sb` in source space. A placement that only scales and
|
||||
/// moves keeps it a cylinder (written to PSD as one): the silhouettes and edges map, and the
|
||||
/// ellipses' depth, in units of the half axis, follows the change of aspect. Otherwise (rotated,
|
||||
/// skewed, flipped, in perspective) it becomes its mesh, a custom warp.
|
||||
fn cylinder_in_source(w: &Warp, inv: &Homography, sb: [f64; 4]) -> Option<Warp> {
|
||||
let m = inv.0;
|
||||
/// 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 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 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, [`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> {
|
||||
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())
|
||||
// A cylinder's depths and wrap are dimensionless: at a large document offset the coordinate
|
||||
// tolerance would pass off a change of wrap as none.
|
||||
let near_values =
|
||||
|a: &[f64], b: &[f64]| a.len() == b.len() && a.iter().zip(b).enumerate().all(|(i, (x, y))| (x - y).abs() <= if i < 4 { tol } else { 1e-9 });
|
||||
match (w.style, w.mesh.as_ref()) {
|
||||
(WarpStyle::Cylinder, _) => shown.style == WarpStyle::Cylinder && near(&shown.bounds, &w.bounds) && near_values(&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)
|
||||
}
|
||||
|
||||
@@ -151,6 +151,12 @@ pub(super) fn checked(app: &PhotocraftApp, id: &str) -> Option<bool> {
|
||||
if id == "select.isolateLayers" {
|
||||
return Some(app.session.active().is_some_and(|d| !d.isolated_layers.is_empty()));
|
||||
}
|
||||
// Edit › Transform › Warp carries a check while the active layer is warped (a smart object
|
||||
// with a stored warp, or a Warp box open), as the reference's does: picking it then edits
|
||||
// that warp (#9798).
|
||||
if id == "edit.transform.warp" {
|
||||
return Some(crate::transform_tool::warps_active_layer(app));
|
||||
}
|
||||
if id == "view.proofColors" || id == "view.gamutWarning" {
|
||||
let Some(d) = app.session.active() else { return Some(false) };
|
||||
let pv = app.session.color.proof(d.doc.id);
|
||||
|
||||
@@ -575,3 +575,30 @@ fn copy_css_puts_the_layers_css_on_the_clipboard() {
|
||||
assert!(css.starts_with(".Box {\n background-image: url(\"Box.png\");\n"), "{css}");
|
||||
assert_eq!(out.platform_output.commands, [egui::OutputCommand::CopyText(css)]);
|
||||
}
|
||||
|
||||
/// Edit › Transform › Warp is checked while the active layer is warped, as the reference's is:
|
||||
/// a smart object with a stored warp, or a Warp box open (#9798).
|
||||
#[test]
|
||||
fn transform_warp_is_checked_for_a_warped_layer() {
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
|
||||
assert_eq!(checked(&app, "edit.transform.warp"), Some(false), "no document");
|
||||
app.session.execute("file.new", json!({"width": 64, "height": 64})).unwrap();
|
||||
app.sync_views();
|
||||
app.session.execute("layer.new.layer", json!({})).unwrap();
|
||||
app.session
|
||||
.edit("paint", |doc, a| {
|
||||
doc.layer_mut(a.unwrap()).unwrap().surface_mut().unwrap().fill_rect(photocraft_geom::Rect::new(8, 8, 32, 32), &[1.0, 0.0, 0.0, 1.0]);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(checked(&app, "edit.transform.warp"), Some(false), "a plain layer");
|
||||
let ctx = egui::Context::default();
|
||||
crate::transform_tool::begin_warp(&mut app, &ctx).unwrap();
|
||||
assert_eq!(checked(&app, "edit.transform.warp"), Some(true), "a Warp box is open");
|
||||
crate::transform_tool::cancel(&mut app);
|
||||
assert_eq!(checked(&app, "edit.transform.warp"), Some(false));
|
||||
app.run("layer.smartObjects.convertToSmartObject", json!({})).unwrap();
|
||||
assert_eq!(checked(&app, "edit.transform.warp"), Some(false), "a smart object without a warp");
|
||||
app.run("layer.smartObjects.warp", json!({"style": "cylinder", "values": [8, 32, 32, 8, 0.5, 0.5, 0.85], "rect": [8, 8, 32, 32]})).unwrap();
|
||||
assert_eq!(checked(&app, "edit.transform.warp"), Some(true), "a stored warp");
|
||||
}
|
||||
@@ -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,9 +36,12 @@ pub(crate) use begin::begin_content_aware;
|
||||
pub(crate) use begin::moving_layers;
|
||||
#[cfg(test)]
|
||||
pub(crate) use begin::moving_texel;
|
||||
pub use begin::{WARP_ONE_LAYER, begin, begin_copy, begin_placed, begin_selection, begin_warp, enter_warp, leave_warp, warps_type};
|
||||
pub use begin::{WARP_ONE_LAYER, begin, begin_copy, begin_placed, begin_selection, begin_warp, enter_warp, leave_warp, warps_active_layer, 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};
|
||||
#[cfg(test)]
|
||||
use drag_modes::{HandleIndicator, handle_indicator, keep_convex, turns};
|
||||
pub(crate) use drag_modes::{apply_drag, corner_distorts, legacy_mods};
|
||||
@@ -56,7 +60,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, box_hit, hit, rotate_cursor_dir};
|
||||
@@ -103,6 +107,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] },
|
||||
}
|
||||
|
||||
/// Opens a prepared box once its job has landed (see [`Preparing`]).
|
||||
@@ -197,6 +204,11 @@ pub struct TransformPreview {
|
||||
committing: Option<JobId>,
|
||||
/// Content-Aware Scale's live preview (cas_transform.rs).
|
||||
pub(crate) content_aware: Option<crate::cas_transform::Live>,
|
||||
/// While a smart object's stored warp is edited (begin.rs `preview_without_smart_warp`):
|
||||
/// the texels and box of the mode the session is not in. Warp mode shows the source without
|
||||
/// the warp over the warp box, Free Transform the layer's render with it over its own box;
|
||||
/// leaving or re-entering Warp mode, and Undo across it, swap them.
|
||||
other_texels: Option<OtherTexels>,
|
||||
/// A box over several layers (#6501): their composite, which `texture` holds.
|
||||
several: Option<Several>,
|
||||
}
|
||||
@@ -278,6 +290,25 @@ fn style_reach(doc: &Document, ids: &[LayerId]) -> i32 {
|
||||
reach
|
||||
}
|
||||
|
||||
/// The preview texels of the transform mode the session is not in, and the box they cover
|
||||
/// ([`TransformPreview::other_texels`]).
|
||||
struct OtherTexels {
|
||||
texture: crate::transform_tex::PreviewTextures,
|
||||
rect: [f64; 4],
|
||||
quad: [[f64; 2]; 4],
|
||||
}
|
||||
|
||||
/// Swaps the session's texels and box with the other mode's (`t` holds the box the texels
|
||||
/// cover), when there are any: the stored warp's edit shows the source without the warp, the
|
||||
/// box the render with it.
|
||||
fn swap_mode_texels(t: &mut TransformSession, pv: &mut TransformPreview) {
|
||||
if let Some(o) = pv.other_texels.as_mut() {
|
||||
std::mem::swap(&mut pv.texture, &mut o.texture);
|
||||
std::mem::swap(&mut t.rect, &mut o.rect);
|
||||
std::mem::swap(&mut t.quad, &mut o.quad);
|
||||
}
|
||||
}
|
||||
|
||||
/// What [`follow_edits`] needs to rebuild the preview document.
|
||||
#[derive(Clone, Debug)]
|
||||
struct Follow {
|
||||
|
||||
@@ -3,8 +3,8 @@ use super::commit::{cancel, commit, start_steps};
|
||||
use super::draw::texture_quad;
|
||||
use super::preview::{Above, AbovePlan, preview_image};
|
||||
use super::{
|
||||
Arc, BezierMesh, CAS_ONLY_SCALES, Color32, DocId, Document, Follow, Homography, LOCKED, LayerContent, LayerId, MadeLayer, PhotocraftApp, Several, Steps,
|
||||
TransformPreview, TransformSession, Warp, WarpStyle, corners, default_interpolation, json,
|
||||
Arc, BezierMesh, CAS_ONLY_SCALES, Color32, DocId, Document, Follow, Homography, LOCKED, LayerContent, LayerId, MadeLayer, OtherTexels, PhotocraftApp,
|
||||
Several, Steps, TransformPreview, TransformSession, Warp, WarpStyle, corners, default_interpolation, json, swap_mode_texels,
|
||||
};
|
||||
|
||||
/// Start Free Transform on the active layer (or its selected pixels), or on the targeted unlinked
|
||||
@@ -130,6 +130,7 @@ pub fn begin(app: &mut PhotocraftApp, ctx: &egui::Context) -> Result<(), String>
|
||||
styled: (styled && !frame_only).then(|| crate::transform_fx::StyledPreview::new(format!("transform-{session}-styled"))),
|
||||
follow: Some(Follow { revision, hidden: if whole && !frame_only { vec![id] } else { Vec::new() }, selected: selected_now(app) }),
|
||||
committing: None,
|
||||
other_texels: None,
|
||||
gesture: None,
|
||||
warp_drag: None,
|
||||
split_tool: None,
|
||||
@@ -336,6 +337,7 @@ fn begin_several(app: &mut PhotocraftApp, ctx: &egui::Context, doc: Arc<Document
|
||||
styled: None,
|
||||
follow: Some(Follow { revision, hidden, selected: selected_now(app) }),
|
||||
committing: None,
|
||||
other_texels: None,
|
||||
gesture: None,
|
||||
warp_drag: None,
|
||||
split_tool: None,
|
||||
@@ -494,6 +496,7 @@ pub(crate) fn begin_content_aware(
|
||||
above: Some(above),
|
||||
follow: Some(Follow { revision, hidden: Vec::new(), selected: None }),
|
||||
committing: None,
|
||||
other_texels: None,
|
||||
gesture: None,
|
||||
warp_drag: None,
|
||||
split_tool: None,
|
||||
@@ -623,6 +626,7 @@ fn begin_lone(
|
||||
styled: None,
|
||||
follow: None,
|
||||
committing: None,
|
||||
other_texels: None,
|
||||
gesture: None,
|
||||
warp_drag: None,
|
||||
split_tool: None,
|
||||
@@ -688,6 +692,7 @@ fn begin_path(app: &mut PhotocraftApp, ctx: &egui::Context, params: serde_json::
|
||||
above: None,
|
||||
follow: None,
|
||||
committing: None,
|
||||
other_texels: None,
|
||||
several: None,
|
||||
});
|
||||
app.ui.transform = Some(TransformSession {
|
||||
@@ -765,6 +770,7 @@ pub fn begin_selection(app: &mut PhotocraftApp, ctx: &egui::Context) -> Result<(
|
||||
styled: None,
|
||||
follow: None,
|
||||
committing: None,
|
||||
other_texels: None,
|
||||
gesture: None,
|
||||
warp_drag: None,
|
||||
split_tool: None,
|
||||
@@ -841,16 +847,30 @@ pub fn enter_warp(app: &mut PhotocraftApp, ctx: &egui::Context) {
|
||||
if t.warp.is_some() || t.target.is_some() || !t.layers.is_empty() {
|
||||
return;
|
||||
}
|
||||
if t.quad == corners(t.rect)
|
||||
&& !t.selection
|
||||
&& t.path.is_none()
|
||||
let untouched = t.quad == corners(t.rect) && !t.selection && t.path.is_none();
|
||||
// Back into Warp mode after leaving it: the texels and box it showed are kept.
|
||||
if untouched
|
||||
&& let Some(w) = &existing
|
||||
&& let Some(pv) = app.transform_preview.as_mut()
|
||||
&& pv.other_texels.is_some()
|
||||
{
|
||||
swap_mode_texels(t, pv);
|
||||
t.warp = Some(w.clone());
|
||||
return;
|
||||
}
|
||||
// The stored warp is edited only when the source can be previewed without it: the texels
|
||||
// the box opened with have the warp in them, and the mesh would warp them again.
|
||||
if untouched
|
||||
&& let Some(w) = existing
|
||||
&& preview_without_smart_warp(app, ctx, w.bounds)
|
||||
&& let Some(t) = app.ui.transform.as_mut()
|
||||
{
|
||||
t.rect = w.bounds;
|
||||
t.quad = corners(w.bounds);
|
||||
t.warp = Some(w);
|
||||
return;
|
||||
}
|
||||
let Some(t) = app.ui.transform.as_mut() else { return };
|
||||
let h = Homography::rect_to_quad(t.rect, t.quad).unwrap_or(Homography::IDENTITY);
|
||||
let mesh = BezierMesh::identity(t.rect, 1, 1).map_points(|p| {
|
||||
let (x, y) = h.apply(p[0], p[1]);
|
||||
@@ -859,6 +879,49 @@ pub fn enter_warp(app: &mut PhotocraftApp, ctx: &egui::Context) {
|
||||
t.warp = Some(Warp::custom(mesh, t.rect));
|
||||
}
|
||||
|
||||
/// Whether the active layer is warped: a Warp box is open on it, or it is a smart object with a
|
||||
/// stored warp, which Edit › Transform › Warp then opens for editing (its menu item is checked).
|
||||
pub fn warps_active_layer(app: &PhotocraftApp) -> bool {
|
||||
if app.ui.transform.as_ref().is_some_and(|t| t.warp.is_some()) {
|
||||
return true;
|
||||
}
|
||||
let Some(st) = app.session.active() else { return false };
|
||||
st.active_layer
|
||||
.and_then(|id| st.doc.layer(id))
|
||||
.is_some_and(|l| matches!(&l.content, LayerContent::Smart(sm) if sm.warp.as_ref().is_some_and(|w| !w.is_identity())))
|
||||
}
|
||||
|
||||
/// The preview texels for editing a smart object's stored warp: its source placed without the
|
||||
/// warp, over the warp box `r`, so the box's mesh warps it once. The texels read when the box
|
||||
/// opened are the layer's render, which already has the warp: warping that again drew the
|
||||
/// picture well inside the grid (#9798). Those texels and the box are kept as the other mode's
|
||||
/// ([`OtherTexels`]), for leaving Warp mode. False when the source can't be re-rendered (a
|
||||
/// missing linked file): the texels keep the warp, so the caller must not adopt it.
|
||||
fn preview_without_smart_warp(app: &mut PhotocraftApp, ctx: &egui::Context, r: [f64; 4]) -> bool {
|
||||
let (Some(t), Some(st)) = (app.ui.transform.as_ref(), app.session.active()) else { return false };
|
||||
let (id, session, box_rect, box_quad) = (LayerId(t.layer), t.session, t.rect, t.quad);
|
||||
let doc = st.doc.clone();
|
||||
let Some(LayerContent::Smart(sm)) = doc.layer(id).map(|l| &l.content) else { return false };
|
||||
let mut flat = sm.clone();
|
||||
flat.warp = None;
|
||||
let Ok(Some(surface)) = photocraft_engine::smart_cmds::render(&doc, &flat) else { return false };
|
||||
flat.cache = Some(surface);
|
||||
let mut plain = (*doc).clone();
|
||||
if let Some(l) = plain.layer_mut(id) {
|
||||
l.content = LayerContent::Smart(flat);
|
||||
}
|
||||
let b = photocraft_geom::Rect::new(r[0].round() as i32, r[1].round() as i32, r[2].round() as i32, r[3].round() as i32);
|
||||
if b.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let max_side = ctx.input(|i| i.max_texture_side).max(app.gpu.as_ref().map_or(1, |g| g.max_texture_side()));
|
||||
let Some(pv) = app.transform_preview.as_mut() else { return false };
|
||||
let (image, uv) = preview_image(&plain, id, None, b, max_side, pv.styled.is_some());
|
||||
let unwarped = crate::transform_tex::PreviewTextures::new(ctx, format!("transform-{session}-unwarped"), image, uv);
|
||||
pv.other_texels = Some(OtherTexels { texture: std::mem::replace(&mut pv.texture, unwarped), rect: box_rect, quad: box_quad });
|
||||
true
|
||||
}
|
||||
|
||||
/// The open transform moves an editable type layer as a whole (not a lone mask, channel, path or
|
||||
/// the selection). Its Warp is Warp Text: only the styles, and the type stays editable (#3004).
|
||||
pub fn warps_type(app: &PhotocraftApp) -> bool {
|
||||
@@ -927,6 +990,10 @@ fn enter_type_warp(app: &mut PhotocraftApp, ctx: &egui::Context) {
|
||||
pub fn leave_warp(app: &mut PhotocraftApp) {
|
||||
if let Some(t) = app.ui.transform.as_mut() {
|
||||
t.warp = None;
|
||||
// A stored warp's edit showed the source without it: the box shows the render with it.
|
||||
if let Some(pv) = app.transform_preview.as_mut() {
|
||||
swap_mode_texels(t, pv);
|
||||
}
|
||||
}
|
||||
if let Some(pv) = app.transform_preview.as_mut() {
|
||||
pv.split_tool = None;
|
||||
|
||||
@@ -443,6 +443,15 @@ fn step(app: &mut PhotocraftApp, redo: bool) -> serde_json::Value {
|
||||
let Some(step) = from.pop() else { return serde_json::Value::Null };
|
||||
to.push(Step::of(t));
|
||||
s.settled = Some(step.clone());
|
||||
let (had_warp, rect, quad) = (t.warp.is_some(), t.rect, t.quad);
|
||||
step.restore(t);
|
||||
// Across the Warp boundary, a stored warp's edit shows other texels (begin.rs): the step
|
||||
// brings its own box, so only the texels swap, and the other mode keeps the box it left.
|
||||
if had_warp != t.warp.is_some()
|
||||
&& let Some(o) = pv.other_texels.as_mut()
|
||||
{
|
||||
std::mem::swap(&mut pv.texture, &mut o.texture);
|
||||
(o.rect, o.quad) = (rect, quad);
|
||||
}
|
||||
json!({"transform": t})
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
//! 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: five handles
|
||||
//! set those values, and the warp stays a cylinder through the commit, so a smart object keeps
|
||||
//! it as `warpCylinder` in a PSD.
|
||||
//!
|
||||
//! The grid and the handles follow the reference application by observation (never its code),
|
||||
//! as compared on #9798. The wrapped picture is outlined and cut into a 3 × 2 grid: two
|
||||
//! horizontal curves and the vertical centre line, which the bend and wrap handles work from.
|
||||
//! Squares at the top-right and the bottom-left corner each move their silhouette and edge
|
||||
//! (the opposite corner stays put). The handle in the middle of the upper arc sets the bend of
|
||||
//! both arcs (and so its own height); the one in the middle of the lower arc sets the lower
|
||||
//! edge's height. The handle at the centre of the grid slides to the right, stretching the
|
||||
//! middle of the picture and squeezing its sides further round the back (the wrap). A drag
|
||||
//! anywhere else on the cylinder moves the whole of it. The options bar keeps Bend, H and V
|
||||
//! greyed, as for a custom warp, and a cylinder has no splits and no grid menu.
|
||||
|
||||
use super::warp::surface_at;
|
||||
use super::{Color32, CursorIcon, PhotocraftApp, Pos2, Stroke, ToolEvent, TransformPreview, ViewXform, Warp, WarpDrag, WarpStyle, vec2};
|
||||
|
||||
/// What a press on the cylinder grabbed.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(super) enum CylinderHandle {
|
||||
/// A corner, clockwise from the top-left; the reference shows the top-right (1) and the
|
||||
/// bottom-left (3). Its silhouette and its edge follow the pointer, the opposite corner stays.
|
||||
Corner(usize),
|
||||
/// The middle of the upper arc: the bend of both arcs follows it up and down.
|
||||
Bend,
|
||||
/// The middle of the lower arc: the lower edge follows it up and down (the height).
|
||||
Bottom,
|
||||
/// The centre of the grid: dragged to the right, it stretches the middle of the picture and
|
||||
/// squeezes its sides round the back.
|
||||
Wrap,
|
||||
/// Anywhere else on the cylinder: the whole cylinder moves.
|
||||
Surface,
|
||||
}
|
||||
|
||||
/// The bend of both arcs the Warp menu's Cylinder starts with, in units of the half axis: about
|
||||
/// a quarter of the width, as the reference starts.
|
||||
const DEFAULT_DEPTH: f64 = 0.5;
|
||||
|
||||
/// How far the wrap handle starts along its travel from the centre: about 15 %, as the reference.
|
||||
const DEFAULT_WRAP_POS: f64 = 0.15;
|
||||
|
||||
/// The silhouettes never cross, nor do the edges: the moved one stops this far (document px)
|
||||
/// short of the other.
|
||||
const MIN_SIDE: f64 = 1.0;
|
||||
|
||||
/// An arc 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`: its four corners on the
|
||||
/// box (so the lower arc hangs below it), both arcs [`DEFAULT_DEPTH`] deep and the wrap handle
|
||||
/// [`DEFAULT_WRAP_POS`] along its travel. `None` for an empty box.
|
||||
pub(super) fn cylinder_default(b: [f64; 4]) -> Option<Warp> {
|
||||
if !(b[2] > b[0] && b[3] > b[1]) {
|
||||
return None;
|
||||
}
|
||||
Warp::cylinder(&[b[0], b[3], b[2], b[1], DEFAULT_DEPTH, DEFAULT_DEPTH, wrap_of_pos(DEFAULT_WRAP_POS)], b)
|
||||
}
|
||||
|
||||
/// The seven values of `w` when it is a cylinder: left, bottom, right, top (document px), the
|
||||
/// top and bottom arcs' 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()
|
||||
}
|
||||
|
||||
/// The wrap handle's place along its travel (0 at the centre, 1 at the right silhouette) for the
|
||||
/// stored wrap `g`. At `g` = 0 the model puts a sixth of the picture round the back on each side
|
||||
/// with the middle stretched most, at 1 almost none: the handle slides right as the picture's
|
||||
/// middle is stretched, so its travel is the wrap reversed.
|
||||
fn pos_of_wrap(g: f64) -> f64 {
|
||||
1.0 - g.clamp(0.0, 1.0)
|
||||
}
|
||||
|
||||
/// The stored wrap for the handle's place along its travel ([`pos_of_wrap`] inverted).
|
||||
fn wrap_of_pos(p: f64) -> f64 {
|
||||
1.0 - p.clamp(0.0, 1.0)
|
||||
}
|
||||
|
||||
/// Where the five handles are, in document space: the top-right and bottom-left corners, the
|
||||
/// middle of each arc (where the mesh puts its front) and the wrap handle, on the centre line
|
||||
/// midway between the arcs' fronts, [`pos_of_wrap`] of the half axis to the right of the centre.
|
||||
pub(super) fn cylinder_handles(v: &[f64; 7]) -> [([f64; 2], CylinderHandle); 5] {
|
||||
let [left, bottom, right, top, top_depth, bottom_depth, wrap] = *v;
|
||||
let a = (right - left) / 2.0;
|
||||
let cx = (left + right) / 2.0;
|
||||
let (top_front, bottom_front) = (top + top_depth * a, bottom + bottom_depth * a);
|
||||
[
|
||||
([right, top], CylinderHandle::Corner(1)),
|
||||
([left, bottom], CylinderHandle::Corner(3)),
|
||||
([cx, top_front], CylinderHandle::Bend),
|
||||
([cx, bottom_front], CylinderHandle::Bottom),
|
||||
([cx + pos_of_wrap(wrap) * a, (top_front + bottom_front) / 2.0], CylinderHandle::Wrap),
|
||||
]
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// takes its silhouette and its edge along (the opposite corner stays put); the bend handle sets
|
||||
/// both arcs' depth from where it is dragged to; the bottom handle moves the lower edge; the wrap
|
||||
/// handle follows the pointer along its travel; the surface moves the whole cylinder. The
|
||||
/// silhouettes and the edges keep their order, and the depth stays 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_SIDE / 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::Bend => {
|
||||
// Both arcs bend by the same amount, so a file's unequal depths keep their difference
|
||||
// and a press that doesn't move changes nothing; a stored depth past the limit is
|
||||
// kept as the limit rather than clamped by the press.
|
||||
let step = d[1] / a;
|
||||
v[4] = depth_dragged(v0[4], step);
|
||||
v[5] = depth_dragged(v0[5], step);
|
||||
}
|
||||
CylinderHandle::Bottom => v[1] += d[1],
|
||||
CylinderHandle::Wrap => v[6] = wrap_of_pos(pos_of_wrap(v0[6]) + d[0] / a),
|
||||
CylinderHandle::Surface => {
|
||||
v[0] += d[0];
|
||||
v[2] += d[0];
|
||||
v[1] += d[1];
|
||||
v[3] += d[1];
|
||||
}
|
||||
}
|
||||
// A move keeps the shape as it is, however narrow.
|
||||
if handle == CylinderHandle::Surface {
|
||||
return v;
|
||||
}
|
||||
// The silhouette or edge being moved stops just short of the other one.
|
||||
if v[2] - v[0] < MIN_SIDE {
|
||||
if matches!(handle, CylinderHandle::Corner(1 | 2)) {
|
||||
v[2] = v[0] + MIN_SIDE;
|
||||
} else {
|
||||
v[0] = v[2] - MIN_SIDE;
|
||||
}
|
||||
}
|
||||
if v[1] - v[3] < MIN_SIDE {
|
||||
if matches!(handle, CylinderHandle::Corner(0 | 1)) {
|
||||
v[3] = v[1] - MIN_SIDE;
|
||||
} else {
|
||||
v[1] = v[3] + MIN_SIDE;
|
||||
}
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
/// `depth0` moved by `step`, kept within the half axis, or within its own reach when it already
|
||||
/// lies past that (a cylinder read from a file).
|
||||
fn depth_dragged(depth0: f64, step: f64) -> f64 {
|
||||
let limit = DEPTH_LIMIT.max(depth0.abs());
|
||||
(depth0 + step).clamp(-limit, limit)
|
||||
}
|
||||
|
||||
/// 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), the move cursor on the surface, 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::Bend | CylinderHandle::Bottom) => crate::tool_cursor::resize([0.0, 1.0], xf),
|
||||
Some(CylinderHandle::Wrap) => crate::tool_cursor::resize([1.0, 0.0], xf),
|
||||
Some(CylinderHandle::Surface) => CursorIcon::Move,
|
||||
None => CursorIcon::Default,
|
||||
}
|
||||
}
|
||||
|
||||
/// The cylinder's outline, its 3 × 2 grid (two horizontal curves and the centre line) and its
|
||||
/// handles: squares at the two corners, circles in the middle of each arc, a diamond for the
|
||||
/// wrap; the one being dragged is filled with the lines' colour. `curve(u, v)` traces the mesh
|
||||
/// along a fixed `u` or `v`.
|
||||
pub(super) fn draw_cylinder(
|
||||
painter: &egui::Painter,
|
||||
w: &Warp,
|
||||
curve: impl Fn(Option<f64>, Option<f64>) -> Vec<Pos2>,
|
||||
scr: impl Fn([f64; 2]) -> Pos2,
|
||||
pv: &TransformPreview,
|
||||
line: Stroke,
|
||||
) {
|
||||
let Some(v) = cylinder_values(w) else { return };
|
||||
for u in [0.0, 0.5, 1.0] {
|
||||
painter.add(egui::Shape::line(curve(Some(u), None), line));
|
||||
}
|
||||
for t in [0.0, 1.0 / 3.0, 2.0 / 3.0, 1.0] {
|
||||
painter.add(egui::Shape::line(curve(None, Some(t)), line));
|
||||
}
|
||||
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 };
|
||||
match h {
|
||||
CylinderHandle::Bend | CylinderHandle::Bottom => {
|
||||
painter.circle_filled(p, 3.5, fill);
|
||||
painter.circle_stroke(p, 3.5, line);
|
||||
}
|
||||
CylinderHandle::Wrap => {
|
||||
let pts = vec![p - vec2(0.0, 5.0), p + vec2(5.0, 0.0), p + vec2(0.0, 5.0), p - vec2(5.0, 0.0)];
|
||||
painter.add(egui::Shape::convex_polygon(pts, fill, line));
|
||||
}
|
||||
_ => {
|
||||
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::drag_modes::{HandleIndicator, handle_indicator};
|
||||
use super::options_bar::{quad_scale, readout};
|
||||
use super::pointer::{Hit, handle_at, rotation_degrees};
|
||||
@@ -179,7 +180,8 @@ pub fn draw_overlay(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform
|
||||
// The same keys as `cursor`, sticky ones included, so the guide matches the pointer.
|
||||
let mods = crate::workspace_ui::sticky_mods(app, painter.ctx().input(|i| i.modifiers));
|
||||
let alt = mods.alt || mods.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);
|
||||
@@ -565,6 +567,12 @@ fn draw_warp_overlay(painter: &egui::Painter, xf: &ViewXform, w: &Warp, pv: &Tra
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
// A cylinder is outlined and cut into a 3 × 2 grid, as the reference draws it, with its own
|
||||
// handles on the lines (#4112, cylinder.rs). It has no split guide.
|
||||
if w.style == WarpStyle::Cylinder {
|
||||
draw_cylinder(painter, w, curve, scr, pv, line);
|
||||
return;
|
||||
}
|
||||
// Patch boundaries are the grid. Only Default (one patch) draws the rule-of-thirds guides;
|
||||
// a 3×3 preset is three even cells, not those guides repeated inside every cell.
|
||||
let thirds = single_patch(&m);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use super::begin::{enter_warp, interpolation_preference, leave_warp, warps_type};
|
||||
use super::commit::{cancel, commit};
|
||||
use super::cylinder::cylinder_default;
|
||||
use super::pointer::point_on_quad;
|
||||
use super::warp::edit_session_warp;
|
||||
use super::{BezierMesh, Homography, PhotocraftApp, SplitTool, Stroke, TransformSession, Warp, WarpStyle, json, vec2};
|
||||
@@ -464,12 +465,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()
|
||||
{
|
||||
@@ -478,10 +481,13 @@ 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 and the bend fields are always there, and only act on a preset style (a
|
||||
// cylinder keeps them greyed, as the reference does: its handles are its only controls).
|
||||
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"));
|
||||
@@ -508,6 +514,12 @@ 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 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::{moving_layers, 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, handle_indicator, mode_allows, mode_mods};
|
||||
use super::warp::{quick_split_kind, split_cursor, surface_at, warp_hit, warp_pointer};
|
||||
use super::{
|
||||
@@ -413,6 +414,9 @@ pub fn cursor(app: &PhotocraftApp, xf: &ViewXform, p: [f64; 2], mods: egui::Modi
|
||||
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);
|
||||
let quick_split = mods.alt || mods.command;
|
||||
if armed.is_some() || quick_split {
|
||||
|
||||
@@ -3707,6 +3707,325 @@ fn applying_the_box_hides_no_pointer() {
|
||||
assert_eq!(cursor(&app, &xf, [96.0, 32.0], cmd()), Some(CursorIcon::ResizeVertical));
|
||||
}
|
||||
|
||||
// ---- 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, as the reference starts it: its four corners on the box,
|
||||
/// both arcs half the half axis deep (a quarter of the width), so the lower one hangs below the
|
||||
/// box, and the wrap handle 15 % along its travel.
|
||||
#[test]
|
||||
fn the_warp_menus_cylinder_starts_on_the_box_corners() {
|
||||
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[..6], &[8.0, 32.0, 32.0, 8.0, 0.5, 0.5]);
|
||||
assert!((v[6] - 0.85).abs() < 1e-9, "{v:?}");
|
||||
let wrap = cylinder_handles(&v)[4].0;
|
||||
assert!((wrap[0] - (20.0 + 0.15 * 12.0)).abs() < 1e-9, "the wrap handle starts 15 % of the half axis right of the centre: {wrap:?}");
|
||||
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:?}");
|
||||
assert!(cb[3] > 32.0 + 5.0, "the lower arc's front hangs about 6 px below the box: {cb:?}");
|
||||
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");
|
||||
}
|
||||
|
||||
/// Five handles, as the reference shows: the top-right and bottom-left corners, the middle of
|
||||
/// each arc, and the wrap handle at the grid's centre, which slides right as the wrap goes down.
|
||||
#[test]
|
||||
fn cylinder_handles_are_two_corners_the_arcs_and_the_wrap() {
|
||||
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], ([60.0, 10.0], CylinderHandle::Corner(1)));
|
||||
assert_eq!(h[1], ([10.0, 40.0], CylinderHandle::Corner(3)));
|
||||
assert_eq!(h[2], ([35.0, 17.5], CylinderHandle::Bend));
|
||||
assert_eq!(h[3], ([35.0, 45.0], CylinderHandle::Bottom));
|
||||
assert_eq!(h[4], ([35.0 + 0.5 * 25.0, 31.25], CylinderHandle::Wrap), "midway between the arcs' fronts, half way along at wrap 0.5");
|
||||
assert_eq!(cylinder_handles(&[10.0, 40.0, 60.0, 10.0, 0.3, 0.2, 1.0])[4].0, [35.0, 31.25], "at the centre at wrap 1");
|
||||
assert_eq!(cylinder_handles(&[10.0, 40.0, 60.0, 10.0, 0.3, 0.2, 0.0])[4].0, [60.0, 31.25], "at the silhouette at wrap 0");
|
||||
assert!(!h.iter().any(|(_, k)| matches!(k, CylinderHandle::Corner(0 | 2))), "no top-left or bottom-right corner");
|
||||
// The arc handles sit where the mesh puts the front of each arc.
|
||||
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] - 35.0).abs() < 1e-9 && (front_top[1] - 17.5).abs() < 1e-9, "{front_top:?}");
|
||||
assert!((m.eval(0.5, 1.0)[1] - 45.0).abs() < 1e-9);
|
||||
// A press: the nearest handle within reach, else the surface, else nothing.
|
||||
assert_eq!(cylinder_hit(&w, [60.5, 10.5], 3.0), Some(CylinderHandle::Corner(1)));
|
||||
assert_eq!(cylinder_hit(&w, [10.5, 39.0], 3.0), Some(CylinderHandle::Corner(3)));
|
||||
assert_eq!(cylinder_hit(&w, [35.0, 18.0], 3.0), Some(CylinderHandle::Bend));
|
||||
assert_eq!(cylinder_hit(&w, [35.0, 44.0], 3.0), Some(CylinderHandle::Bottom));
|
||||
assert_eq!(cylinder_hit(&w, [47.0, 31.0], 3.0), Some(CylinderHandle::Wrap));
|
||||
assert_eq!(cylinder_hit(&w, [11.0, 11.0], 3.0), Some(CylinderHandle::Surface), "the top-left corner is just surface");
|
||||
assert_eq!(cylinder_hit(&w, [20.0, 30.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), [60.5, 10.5], 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; the opposite corner stays put.
|
||||
assert_eq!(cylinder_dragged(v, CylinderHandle::Corner(1), [2.0, 3.0]), [10.0, 40.0, 62.0, 13.0, 0.3, 0.3, 0.5]);
|
||||
assert_eq!(cylinder_dragged(v, CylinderHandle::Corner(3), [-2.0, 3.0]), [8.0, 43.0, 60.0, 10.0, 0.3, 0.3, 0.5]);
|
||||
// The bend handle follows the pointer and bends both arcs: 5 px down on a 25 px half axis
|
||||
// is 0.2 deeper.
|
||||
let d = cylinder_dragged(v, CylinderHandle::Bend, [0.0, 5.0]);
|
||||
assert!((d[4] - 0.5).abs() < 1e-9 && (d[5] - 0.5).abs() < 1e-9, "{d:?}");
|
||||
assert_eq!(&cylinder_dragged(v, CylinderHandle::Bend, [0.0, -100.0])[4..6], &[-1.0, -1.0], "clamped to the half axis");
|
||||
// Unequal depths from a file keep their difference, and a press that doesn't move keeps a
|
||||
// depth past the limit as it is.
|
||||
let file = [10.0, 40.0, 60.0, 10.0, 1.4, 0.2, 0.5];
|
||||
assert_eq!(cylinder_dragged(file, CylinderHandle::Bend, [0.0, 0.0]), file);
|
||||
let d = cylinder_dragged(file, CylinderHandle::Bend, [0.0, -5.0]);
|
||||
assert!((d[4] - 1.2).abs() < 1e-9 && (d[5] - 0.0).abs() < 1e-9, "{d:?}");
|
||||
assert_eq!(cylinder_dragged(file, CylinderHandle::Bend, [0.0, 50.0])[4], 1.4, "no further than it already reaches");
|
||||
// A move keeps the shape, however narrow the cylinder.
|
||||
let narrow = [10.0, 40.0, 10.5, 10.0, 0.3, 0.3, 0.5];
|
||||
assert_eq!(cylinder_dragged(narrow, CylinderHandle::Surface, [5.0, 0.0]), [15.0, 40.0, 15.5, 10.0, 0.3, 0.3, 0.5]);
|
||||
// The bottom handle moves the lower edge; the arc keeps its depth.
|
||||
assert_eq!(cylinder_dragged(v, CylinderHandle::Bottom, [7.0, 6.0]), [10.0, 46.0, 60.0, 10.0, 0.3, 0.3, 0.5]);
|
||||
// The wrap handle follows the pointer along the half axis: right stretches the middle (wrap down).
|
||||
let d = cylinder_dragged(v, CylinderHandle::Wrap, [6.25, 0.0]);
|
||||
assert!((d[6] - 0.25).abs() < 1e-9, "{d:?}");
|
||||
assert_eq!(cylinder_dragged(v, CylinderHandle::Wrap, [80.0, 0.0])[6], 0.0);
|
||||
assert_eq!(cylinder_dragged(v, CylinderHandle::Wrap, [-80.0, 0.0])[6], 1.0);
|
||||
// The surface moves the whole cylinder.
|
||||
assert_eq!(cylinder_dragged(v, CylinderHandle::Surface, [5.0, -4.0]), [15.0, 36.0, 65.0, 6.0, 0.3, 0.3, 0.5]);
|
||||
// The silhouettes never cross, nor do the edges: the one dragged stops just short of the other.
|
||||
let d = cylinder_dragged(v, CylinderHandle::Corner(3), [70.0, -50.0]);
|
||||
assert_eq!([d[0], d[2], d[1], d[3]], [59.0, 60.0, 11.0, 10.0]);
|
||||
let d = cylinder_dragged(v, CylinderHandle::Corner(1), [-80.0, 60.0]);
|
||||
assert_eq!([d[0], d[2], d[1], d[3]], [10.0, 11.0, 40.0, 39.0]);
|
||||
assert_eq!(cylinder_dragged(v, CylinderHandle::Bottom, [0.0, -80.0])[1], 11.0);
|
||||
// Whatever the drag, the result is a cylinder the geometry accepts.
|
||||
for h in [CylinderHandle::Corner(1), CylinderHandle::Corner(3), CylinderHandle::Bend, CylinderHandle::Bottom, CylinderHandle::Wrap, 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 bend handle, dragged 3 px down (a quarter of the 12 px half axis): both arcs bend more.
|
||||
let bend = cylinder_handles(&v0)[2].0;
|
||||
warp_pointer(&mut app, ToolEvent::Down { x: bend[0], y: bend[1], pressure: 1.0 }, 2.0, mods);
|
||||
assert!(matches!(app.transform_preview.as_ref().unwrap().warp_drag, Some(WarpDrag::Cylinder { handle: CylinderHandle::Bend, .. })));
|
||||
warp_pointer(&mut app, ToolEvent::Move { x: bend[0], y: bend[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[5] - (v0[5] + 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: bend[0], y: bend[1] + 3.0 }, 2.0, mods);
|
||||
assert!(app.transform_preview.as_ref().unwrap().warp_drag.is_none());
|
||||
track_steps(&mut app, &ctx);
|
||||
// The wrap handle, dragged 3 px right: a quarter of the 12 px half axis further along.
|
||||
let wrap = cylinder_handles(&cylinder_of(&app))[4].0;
|
||||
warp_pointer(&mut app, ToolEvent::Down { x: wrap[0], y: wrap[1], pressure: 1.0 }, 2.0, mods);
|
||||
assert!(matches!(app.transform_preview.as_ref().unwrap().warp_drag, Some(WarpDrag::Cylinder { handle: CylinderHandle::Wrap, .. })));
|
||||
warp_pointer(&mut app, ToolEvent::Up { x: wrap[0] + 3.0, y: wrap[1] }, 2.0, mods);
|
||||
assert!((cylinder_of(&app)[6] - 0.6).abs() < 1e-9, "{:?}", cylinder_of(&app));
|
||||
assert!((cylinder_handles(&cylinder_of(&app))[4].0[0] - (wrap[0] + 3.0)).abs() < 1e-9, "the wrap handle followed the pointer");
|
||||
track_steps(&mut app, &ctx);
|
||||
// A drag on the surface moves the whole cylinder.
|
||||
let before = cylinder_of(&app);
|
||||
warp_pointer(&mut app, ToolEvent::Down { x: 14.0, y: 20.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: 18.0, y: 22.0 }, 2.0, mods);
|
||||
let moved = cylinder_of(&app);
|
||||
assert_eq!([moved[0] - before[0], moved[2] - before[2], moved[1] - before[1], moved[3] - before[3]], [4.0, 4.0, 2.0, 2.0], "{moved:?}");
|
||||
assert_eq!(&moved[4..], &before[4..], "the shape is unchanged");
|
||||
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_eq!(cylinder_of(&app), before, "undo takes back the move");
|
||||
let _ = intercept(&mut app, "edit.undo");
|
||||
assert!((cylinder_of(&app)[6] - 0.85).abs() < 1e-9, "undo takes back the wrap: {:?}", cylinder_of(&app));
|
||||
let _ = intercept(&mut app, "edit.redo");
|
||||
assert!((cylinder_of(&app)[6] - 0.6).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");
|
||||
}
|
||||
|
||||
/// Warp mode on a smart object that already has a warp previews the source placed without it,
|
||||
/// over the warp box, so the mesh warps the picture once and it sits on the grid (#9798). The
|
||||
/// texels read when the box opened were the warped render, taller than the box by the lower
|
||||
/// arc's bulge; warping them again put the picture well inside the lines.
|
||||
#[test]
|
||||
fn warp_mode_previews_a_stored_smart_warp_from_the_unwarped_source() {
|
||||
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();
|
||||
app.run("layer.smartObjects.warp", json!({"style": "cylinder", "values": [8, 32, 32, 8, 0.5, 0.5, 0.85], "rect": [8, 8, 32, 32]})).unwrap();
|
||||
// The warped render reaches below the box.
|
||||
let st = app.session.active().unwrap();
|
||||
let warped = st.doc.layer(st.active_layer.unwrap()).unwrap().surface().unwrap().content_bounds();
|
||||
assert!(warped.y1 > 36, "{warped:?}");
|
||||
begin_warp(&mut app, &ctx).unwrap();
|
||||
let t = app.ui.transform.as_ref().unwrap();
|
||||
assert_eq!(t.rect, [8.0, 8.0, 32.0, 32.0]);
|
||||
assert_eq!(t.warp.as_ref().map(|w| w.style), Some(WarpStyle::Cylinder));
|
||||
let (img, _) = app.transform_preview.as_ref().unwrap().texture.level_image(0).unwrap();
|
||||
assert_eq!(img.size, [24, 24], "the texels cover the box, not the warped render");
|
||||
// Opaque to its corners: the flat square, not the cylinder's shape (whose corners are clear).
|
||||
for (x, y) in [(0, 0), (23, 0), (0, 23), (23, 23), (12, 12)] {
|
||||
assert_eq!(img.pixels[y * 24 + x].a(), 255, "texel {x},{y}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Leaving Warp mode, and Undo back into Free Transform, show the render with the stored warp
|
||||
/// again (the texels the box opened with), and re-entering shows the source without it (#9798).
|
||||
#[test]
|
||||
fn leaving_warp_mode_restores_the_stored_warps_render() {
|
||||
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();
|
||||
app.run("layer.smartObjects.warp", json!({"style": "cylinder", "values": [8, 32, 32, 8, 0.5, 0.5, 0.85], "rect": [8, 8, 32, 32]})).unwrap();
|
||||
begin(&mut app, &ctx).unwrap();
|
||||
let texels = |app: &PhotocraftApp| app.transform_preview.as_ref().unwrap().texture.level_image(0).unwrap().0.size;
|
||||
let rect = |app: &PhotocraftApp| app.ui.transform.as_ref().unwrap().rect;
|
||||
let (warped, box0) = (texels(&app), rect(&app));
|
||||
assert!(warped[1] > 24 && box0[3] > 32.0, "the render with the warp hangs below the box: {warped:?} {box0:?}");
|
||||
enter_warp(&mut app, &ctx);
|
||||
track_steps(&mut app, &ctx);
|
||||
assert_eq!((texels(&app), rect(&app)), ([24, 24], [8.0, 8.0, 32.0, 32.0]), "the source over the warp box");
|
||||
leave_warp(&mut app);
|
||||
track_steps(&mut app, &ctx);
|
||||
assert!(app.ui.transform.as_ref().unwrap().warp.is_none());
|
||||
assert_eq!((texels(&app), rect(&app)), (warped, box0), "back to the render with the warp");
|
||||
enter_warp(&mut app, &ctx);
|
||||
track_steps(&mut app, &ctx);
|
||||
assert_eq!((texels(&app), rect(&app)), ([24, 24], [8.0, 8.0, 32.0, 32.0]), "and back again, without a re-render");
|
||||
let _ = intercept(&mut app, "edit.undo");
|
||||
assert!(app.ui.transform.as_ref().unwrap().warp.is_none());
|
||||
assert_eq!((texels(&app), rect(&app)), (warped, box0), "undo into Free Transform");
|
||||
let _ = intercept(&mut app, "edit.redo");
|
||||
assert_eq!((texels(&app), rect(&app)), ([24, 24], [8.0, 8.0, 32.0, 32.0]), "redo into Warp");
|
||||
leave_warp(&mut app);
|
||||
assert_eq!((texels(&app), rect(&app)), (warped, box0), "leaving after a redo");
|
||||
}
|
||||
|
||||
/// 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 arc'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, [60.0, 10.0], 3.0, &level), CursorIcon::ResizeNeSw);
|
||||
assert_eq!(cylinder_cursor(&w, [10.0, 40.0], 3.0, &level), CursorIcon::ResizeNeSw);
|
||||
assert_eq!(cylinder_cursor(&w, [35.0, 17.5], 3.0, &level), CursorIcon::ResizeVertical);
|
||||
assert_eq!(cylinder_cursor(&w, [35.0, 47.5], 3.0, &level), CursorIcon::ResizeVertical);
|
||||
let wrap = cylinder_handles(&cylinder_values(&w).unwrap())[4].0;
|
||||
assert_eq!(cylinder_cursor(&w, wrap, 3.0, &level), CursorIcon::ResizeHorizontal);
|
||||
assert_eq!(cylinder_cursor(&w, [20.0, 30.0], 3.0, &level), CursorIcon::Move);
|
||||
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, wrap, 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 moves it.
|
||||
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, [30.0, 56.0], cmd()), Some(CursorIcon::Move));
|
||||
}
|
||||
|
||||
/// The Warp bar keeps Bend, H and V for a cylinder, greyed like Split, Grid and the orientation
|
||||
/// switch: its handles are its only controls, as in the reference.
|
||||
#[test]
|
||||
fn the_warp_bar_greys_its_fields_for_a_cylinder() {
|
||||
use egui_kittest::kittest::{NodeT, Queryable};
|
||||
let h = bar_harness(|app| {
|
||||
let t = app.ui.transform.as_mut().unwrap();
|
||||
t.warp = Some(cylinder_default(t.rect).unwrap());
|
||||
});
|
||||
// A field's caption and the field itself share the label: every match is greyed.
|
||||
for name in ["Bend:", "H:", "V:", "Split Warp Crosswise", "Change warp orientation"] {
|
||||
let nodes: Vec<_> = h.get_all_by_label(name).collect();
|
||||
assert!(!nodes.is_empty() && nodes.iter().all(|n| n.accesskit_node().is_disabled()), "{name} is greyed");
|
||||
}
|
||||
assert!(h.query_by_label("Top depth").is_none() && h.query_by_label("Wrap").is_none(), "no cylinder fields");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn several_layers_reuse_the_transform_interpolation_preference() {
|
||||
let mut app = app_with_square(64, photocraft_geom::Rect::new(8, 8, 24, 24));
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use super::begin::warps_type;
|
||||
use super::cylinder::cylinder_pointer;
|
||||
use super::{BezierMesh, CursorIcon, PhotocraftApp, SplitTool, ToolEvent, ViewXform, Warp, WarpDrag, WarpStyle, json};
|
||||
|
||||
/// 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;
|
||||
}
|
||||
|
||||
+11
-3
@@ -1,6 +1,6 @@
|
||||
# Where PhotoCraft falls short of Photoshop
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-07 · **Change:** minor (Average executes through shared filters on Smart Objects and imported PSD records; Average preserves per-pixel alpha and includes hidden RGB in its mean; RGB8 observations against Photoshop 26.6.1, see average-filter.md; G4: Both Axes on Scatter headers, #3825 partial; G4: combined Spacing checkbox and percentage header, #3825 partial; G14: Hasselblad 3FR/FFF, the X2D II 100C's uncompressed CFA, decodes; G4: Angle/Roundness fields left of the Brush Tip Shape widget, #3825 partial; G12: the eraser end of flipped pens selects the Eraser on Windows with Windows Ink on, #2285; no estimate changed; RGB and CMYK documents preview their spot inks as an overprint on the canvas and in the PSD thumbnail, FILE-215-17 narrows to PDF Separation, #10249; G2: compositor compilation moved off the first document refresh, with startup measurements pending, #9157; G11: Playback Options dialog with Step by Step and Pause For, #5212; G11: Stop step messages with Continue/Stop and nested resume, #8386; adjustment-layer Make keeps name, blend mode and clipping, #8384; tile-sparse Select and Mask outputs and preview coverage, #8822; classical Select › Sky can seed substantial high-confidence components beneath an obstructed top band; learned segmentation remains open; Art History Brush Opacity on the options bar, #10263; no estimate changed; G8: Type › OpenType › Stylistic Sets submenu, Set 1–20, #10285; G8: PhotoCraft-native PSDs keep stylistic sets and other Txt2-only OpenType flags through save/reopen, #10308; G10: TIFF Options dialog on Save As and Save a Copy, compression, Save Image Pyramid, Save Transparency, #8827; G11: Batch Esc Continue/Stop and read-only input previews; full live tabs remain open, #4579; G11: action recording folds Move-tool nudges, records relative keyboard document switches and scales point text placement; paragraph boxes remain open, #4514; G11: Batch measured output naming and UTF-16 log corrections; remaining progress and cancel prompt parity tracked, #4579; G11: Render ATN events mapped; Stylize/Pixelate ATN events mapped, current counts 128 supported/89 unmapped; G9: 11 of 183 preferences do nothing: startup reads Graphics Adapter through the typed Performance section, so the scorecard audit sees it; selected colour sets edit drawing, tone and supported filters; geometry remains open; G11: `.atn` document offsets preserve opening order after tab reordering, #7103; embedded AI/PDF/EPS save-back retains native format with a source-bound SVG display preview; Smooth and Feather command masks cropped to selection bounds plus blur reach; G1: Halftone Pattern Filter Gallery settings survive PSD/PSB saves (#8940); cropped selection morphology and command mask conversion performance evidence added; 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; ClassicLine truncated
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-07 · **Change:** minor (G5: Warp mode edits Photoshop's Cylinder warp, #4112: the Warp menu lists it, five handles set its seven values as in the reference, and a smart object's stored cylinder opens in Warp mode as one; Average executes through shared filters on Smart Objects and imported PSD records; Average preserves per-pixel alpha and includes hidden RGB in its mean; RGB8 observations against Photoshop 26.6.1, see average-filter.md; G4: Both Axes on Scatter headers, #3825 partial; G4: combined Spacing checkbox and percentage header, #3825 partial; G14: Hasselblad 3FR/FFF, the X2D II 100C's uncompressed CFA, decodes; G4: Angle/Roundness fields left of the Brush Tip Shape widget, #3825 partial; G12: the eraser end of flipped pens selects the Eraser on Windows with Windows Ink on, #2285; no estimate changed; RGB and CMYK documents preview their spot inks as an overprint on the canvas and in the PSD thumbnail, FILE-215-17 narrows to PDF Separation, #10249; G2: compositor compilation moved off the first document refresh, with startup measurements pending, #9157; G11: Playback Options dialog with Step by Step and Pause For, #5212; G11: Stop step messages with Continue/Stop and nested resume, #8386; adjustment-layer Make keeps name, blend mode and clipping, #8384; tile-sparse Select and Mask outputs and preview coverage, #8822; classical Select › Sky can seed substantial high-confidence components beneath an obstructed top band; learned segmentation remains open; Art History Brush Opacity on the options bar, #10263; no estimate changed; G8: Type › OpenType › Stylistic Sets submenu, Set 1–20, #10285; G8: PhotoCraft-native PSDs keep stylistic sets and other Txt2-only OpenType flags through save/reopen, #10308; G10: TIFF Options dialog on Save As and Save a Copy, compression, Save Image Pyramid, Save Transparency, #8827; G11: Batch Esc Continue/Stop and read-only input previews; full live tabs remain open, #4579; G11: action recording folds Move-tool nudges, records relative keyboard document switches and scales point text placement; paragraph boxes remain open, #4514; G11: Batch measured output naming and UTF-16 log corrections; remaining progress and cancel prompt parity tracked, #4579; G11: Render ATN events mapped; Stylize/Pixelate ATN events mapped, current counts 128 supported/89 unmapped; G9: 11 of 183 preferences do nothing: startup reads Graphics Adapter through the typed Performance section, so the scorecard audit sees it; selected colour sets edit drawing, tone and supported filters; geometry remains open; G11: `.atn` document offsets preserve opening order after tab reordering, #7103; embedded AI/PDF/EPS save-back retains native format with a source-bound SVG display preview; Smooth and Feather command masks cropped to selection bounds plus blur reach; G1: Halftone Pattern Filter Gallery settings survive PSD/PSB saves (#8940); cropped selection morphology and command mask conversion performance evidence added; 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 mappLine truncated
|
||||
|
||||
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
|
||||
@@ -60,7 +60,8 @@ what an agent can't finish alone.
|
||||
decoded whole beside the layer; layered TIFFs over the limit and huge PSBs still decode whole).
|
||||
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.
|
||||
@@ -329,7 +330,13 @@ what an agent can't finish alone.
|
||||
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; Free Transform of several
|
||||
selected layers, ⌘T and the Move tool's shared frame (#6501, #3384, #6096; Warp stays one
|
||||
layer).
|
||||
layer); Warp mode's Cylinder
|
||||
(#4112, 2026-10-27): listed in the Warp menu after the presets, outlined and cut into the
|
||||
reference's 3 × 2 grid, with its five handles (two corners, the bend in the middle of the
|
||||
upper arc, the lower edge in the middle of the lower arc, the wrap at the grid's centre), a
|
||||
surface drag moving it, Bend, H and V greyed in the bar, and a smart object's stored cylinder
|
||||
opening as one, previewed from its unwarped source (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.
|
||||
|
||||
@@ -479,6 +486,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, five handles set its silhouettes, edges, ellipse depths and wrap as in the reference (Bend, H and V stay greyed), and a smart object's stored cylinder opens in Warp mode as a cylinder and commits back as one; no estimate changed |
|
||||
| 2026-11-07 | minor | G14: Hasselblad 3FR/FFF, the X2D II 100C's uncompressed CFA, decodes; compressed 3FR still opens as the embedded preview |
|
||||
| 2026-11-06 | minor | G12: eraser end of flipped pens on Windows (Windows Ink on) selects the Eraser (#2285); no estimate changed |
|
||||
| 2026-10-29 | minor | G17: web build memory handling (#10024): up-front memory checks for Place, Image Size, smart-object re-render and Save; a low-memory warning; a stopped message when the module traps; PSD save reserves its whole output; no estimate changed |
|
||||
|
||||
+3
-1
@@ -1,6 +1,6 @@
|
||||
# UI parity: tools, handles, modifiers and feel
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-08 · **Change:** minor (Hue/Saturation range zones drag, #9252; a click on the image centres a filter dialog's preview window, #9648; a gradient stop dragged off the bar is removed in every stop editor, Gradient Map and Layer Style included, #10742; Image › Adjustments › Photo Filter… keeps Preserve Luminosity from its last OK and opens at Warming Filter (85), 25 %, as the reference application does, #9879; foreground/background swatches and exact CMYK picks restored across launches, #8485; Layer ▸ Smart Objects ▸ Reset Transform and the Layers panel row-menu item for a smart object layer: 100 % again in place with the Warp cleared and centred on the corners' average, #11662; each toolbar slot and tool key keeps its last tool across launches, #3569; Image › Adjustments › Selective Color keeps each colour's own values and applies all nine, #11463; History Options snapshot options, #3634; Channels panel menu: New Channel…, Duplicate Channel, Delete Channel, New Spot Channel…, Merge Spot Channel, Channel Options…, Split Channels, Merge Channels…, #11451; a Color Picker for another dialog's colour or the Crop shield shows the picked colour there before OK, #9886; Edit › Transform › Distort side handles move their edge, Perspective side handles skew, #10085; Ctrl + Alt + wheel zooms the canvas like ⌥ + wheel on Windows and Linux, where winit reports a held Ctrl as both ctrl and command, #10488; Layers panel filter by Color, #5898; 11 of 183 preferences do nothing: startup reads Graphics Adapter through the typed Performance section, so the scorecard audit sees it; Shape Fill/Stroke popover as Photoshop's, every colour swatch opens the full Color Picker with a live preview; the text and gradient stop Color Pickers show the picked colour on the canvas before OK, #5530; selected colour sets edit drawing, tone and supported filters; geometry remains open; a paragraph box that can't hold its text enlarges its lower-right handle and shows a black + in it, as the reference application; Free Transform of several selected layers, #6501; Path Operations fixes: the first component keeps combining, a hidden component selection is left alone, a dragged live shape stays live, a refused Pen draft ends once; Pen, Freeform Pen, Curvature Pen and Path Selection Path Operations menu, which changes selected components, `+` / `-` keys, #1397; the Pen carries on an open shape or vector mask path; selection-aware font controls, pending caret fonts and focused commit, plus direct font-name editing with a compact no-match message, #9338; New Group dialog and button modifiers; new layers go into an open active group and join a clipping mask, except that type, shapes, pastes and placed files go above a group whose lock would refuse them; Color panel Hue and Brightness Cube show the out-of-gamut ⚠ and nearest printable colour when the field is at least 53 pt tall, #8672; 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 everyLine truncated
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-08 · **Change:** minor (Warp mode edits Photoshop's Cylinder warp with its five handles, #4112; Hue/Saturation range zones drag, #9252; a click on the image centres a filter dialog's preview window, #9648; a gradient stop dragged off the bar is removed in every stop editor, Gradient Map and Layer Style included, #10742; Image › Adjustments › Photo Filter… keeps Preserve Luminosity from its last OK and opens at Warming Filter (85), 25 %, as the reference application does, #9879; foreground/background swatches and exact CMYK picks restored across launches, #8485; Layer ▸ Smart Objects ▸ Reset Transform and the Layers panel row-menu item for a smart object layer: 100 % again in place with the Warp cleared and centred on the corners' average, #11662; each toolbar slot and tool key keeps its last tool across launches, #3569; Image › Adjustments › Selective Color keeps each colour's own values and applies all nine, #11463; History Options snapshot options, #3634; Channels panel menu: New Channel…, Duplicate Channel, Delete Channel, New Spot Channel…, Merge Spot Channel, Channel Options…, Split Channels, Merge Channels…, #11451; a Color Picker for another dialog's colour or the Crop shield shows the picked colour there before OK, #9886; Edit › Transform › Distort side handles move their edge, Perspective side handles skew, #10085; Ctrl + Alt + wheel zooms the canvas like ⌥ + wheel on Windows and Linux, where winit reports a held Ctrl as both ctrl and command, #10488; Layers panel filter by Color, #5898; 11 of 183 preferences do nothing: startup reads Graphics Adapter through the typed Performance section, so the scorecard audit sees it; Shape Fill/Stroke popover as Photoshop's, every colour swatch opens the full Color Picker with a live preview; the text and gradient stop Color Pickers show the picked colour on the canvas before OK, #5530; selected colour sets edit drawing, tone and supported filters; geometry remains open; a paragraph box that can't hold its text enlarges its lower-right handle and shows a black + in it, as the reference application; Free Transform of several selected layers, #6501; Path Operations fixes: the first component keeps combining, a hidden component selection is left alone, a dragged live shape stays live, a refused Pen draft ends once; Pen, Freeform Pen, Curvature Pen and Path Selection Path Operations menu, which changes selected components, `+` / `-` keys, #1397; the Pen carries on an open shape or vector mask path; selection-aware font controls, pending caret fonts and focused commit, plus direct font-name editing with a compact no-match message, #9338; New Group dialog and button modifiers; new layers go into an open active group and join a clipping mask, except that type, shapes, pastes and placed files go above a group whose lock would refuse them; Color panel Hue and Brightness Cube show the out-of-gamut ⚠ and nearest printable colour when the field is at least 53 pt tall, #8672; 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/DelLine truncated
|
||||
|
||||
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,
|
||||
@@ -68,6 +68,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; Edit › Transform › Distort moves a side handle's edge freely (both its corners by the drag, the opposite edge fixed, stopping before the box folds), Perspective's side handles skew along their edge | done | `drag_modes.rs` (`apply_mode_drag`); Distort and Perspective side handles (#10085): `distort_mode_moves_an_edge_by_the_drag`, `distort_mode_edge_stops_before_the_box_collapses`, `distort_mode_edge_drag_commits_what_it_shows_in_one_step`, `perspective_mode_edge_slides_along_itself` |
|
||||
| Warp: presets, bend, split crosswise/vertical/horizontal, 3×3–5×5 and custom grids | done | |
|
||||
| Warp › Cylinder: listed after the presets; outlined and cut into the reference's 3 × 2 grid; five handles (top-right and bottom-left corners, the bend in the middle of the upper arc for both arcs, the lower edge in the middle of the lower arc, the wrap handle at the grid's centre sliding right to stretch the middle) set its values, a drag on the surface moves it, and Bend, H and V stay greyed in the bar; under a placement that only scales and moves, a smart object's stored cylinder opens in Warp mode as one, previewed from its unwarped source so the picture sits on the grid, and commits back as `warpCylinder` (under a rotated, skewed, flipped or perspective placement Warp mode shows its mesh, which an untouched commit keeps and an edited one stores as a custom warp); Edit › Transform › Warp is checked while the active layer is warped | 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 handles were matched against the reference by a maintainer (#9798), the default wrap 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) |
|
||||
| Several layers selected: one box frames them all and moves every one (pixels, type, shapes, smart objects, groups), one history step; with a selection each pixel layer's selected pixels move; Transform Again repeats on them | partial | #6501, `transform_tool.rs` (`begin_several`), `transform_several_tests.rs`, engine `edit.transform` `layers` (`transform_multi_tests.rs`); the Background is left out, as Show Transform Controls leaves it; Warp and splits are greyed for several layers; the preview moves their styles past the frame and follows opacity, fill, visibility and style edits made while the box is open (`styles_past_the_frame_move_with_the_box`, `hiding_or_fading_a_moving_layer_shows_in_the_preview_at_once`), but draws the layers stacked between the moving ones under them (the commit keeps every layer's place) |
|
||||
@@ -275,6 +276,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, its five handles set its seven values as in the reference, and a smart object's stored cylinder opens as one. Scores unchanged. |
|
||||
| 2026-11-08 | minor | Image › Adjustments › Photo Filter… keeps Preserve Luminosity from its last OK; filter, colour and density reopen at Warming Filter (85) and 25 %, measured in the reference application (#9879); no percentage changed |
|
||||
| 2026-11-07 | minor | Gradient stops dragged off the bar are removed, with a live preview and drag-back cancel, in every stop editor: Gradient Map and Layer Style gained it, the Gradient Editor, Properties and shape popover share the same threshold (#10742); row added (done); no percentage changed |
|
||||
| 2026-11-07 | minor | Foreground/background colours restored before the first frame (`Preferences.toolColors`, #8485), with separate pixel/mask pairs, HDR/alpha, exact CMYK ink picks and a final normal-exit flush; malformed values are ignored one by one, and first saves from older preferences merge against the default swatches so separate edits in two instances survive. No percentage changed |
|
||||
|
||||
Reference in new issue
Block a user