Files
photocraft/crates/engine/src/warp_cmds.rs
T
David MannandClaude Opus 5.5 f5533e5d82 Integration: merge main into batch 6
Main merged #5940, #6472, #7838 and #7922 directly. Kept from the batch:
the snapshot/History-follow identity at the history limit (#5940,
c7ef3676a; main still had the 3-tuple HistorySpot) and the strokeless
shape at a 0 px width step (#6472, 962539607; main still added the
stroke). Main already carries the other two fix-ups (34c9d0ea4, dff8bfd0e).

Overlaps with main resolved:
- #5842 vs main's #5568: one path, apply_color, tool-gated as #5842
  has it, with #5568's float fill, kept fill opacity and lock skip;
  shape_fill_ui's duplicate foreground_changed/gesture dropped; #5568's
  tests run with a shape tool.
- #6230 vs main's #5180: one Type tool path click (path_target), no
  second type.attachToPath; vertical type does not start type on a path,
  as main has it; main's path_at/path_cursor and canvas arm dropped.
- #5452 moved the shape swatch to shape_paint_ui.rs: main's hex field
  (#6201) ported there.
- type_extra_cmds::with_text takes main's dpi form, pub(crate) for
  type_area_cmds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 19:20:26 +02:00

1193 lines
60 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Edit › Transform › Warp, Layer › Smart Objects › Warp and split warps.
//!
//! A warp is a [`Warp`] (preset style + bend/distortions, or a custom bicubic Bezier mesh)
//! defined over a source box in document space. Pixel layers are resampled once
//! (`photocraft_algo::warp`); smart objects store the warp in their source space and re-render
//! from the source, so editing a smart object's warp never degrades it.
//!
//! The split and grid commands edit a mesh: either one passed in (`"warp"`, returned edited —
//! what the Free Transform warp UI uses) or the active smart object's stored warp (a history step).
use photocraft_algo::transform::{Homography, Interp};
use photocraft_algo::warp::{warp_mesh_gray, warp_mesh_surface_with};
use photocraft_doc::{Document, Layer, LayerContent, LayerId, Locks, Rect};
use photocraft_geom::warp::{BezierMesh, Warp, WarpStyle};
use photocraft_raster::{Interrupt, Surface};
use serde_json::{Value, json};
use crate::commands::CommandSpec;
use crate::{EngineError, Result, Session};
fn bad(cmd: &str, msg: impl Into<String>) -> EngineError {
EngineError::BadParams { cmd: cmd.into(), msg: msg.into() }
}
fn has_layer(s: &Session) -> std::result::Result<(), String> {
s.active().and_then(|d| d.active_layer).map(|_| ()).ok_or_else(|| "no active layer".into())
}
fn active_smart(s: &Session) -> std::result::Result<(), String> {
let d = s.active().ok_or("no document")?;
match d.active_layer.and_then(|id| d.doc.layer(id)).map(|l| &l.content) {
Some(LayerContent::Smart(_)) => Ok(()),
_ => Err("the active layer is not a smart object".into()),
}
}
fn rect_f(r: Rect) -> [f64; 4] {
[f64::from(r.x0), f64::from(r.y0), f64::from(r.x1), f64::from(r.y1)]
}
/// `p` through a projective map: a smart object's placement (affine, or Distort / Perspective).
fn hom_apply(h: &Homography, p: [f64; 2]) -> [f64; 2] {
let (x, y) = h.apply(p[0], p[1]);
[x, y]
}
/// The box a warp of `layer` starts from (Free Transform's frame).
/// For a warped smart object that is its (unwarped) source box, so re-warping replaces the warp
/// over the same frame.
pub fn warp_bounds(doc: &Document, layer: &Layer) -> Rect {
if let LayerContent::Smart(sm) = &layer.content
&& let Some(w) = &sm.warp
{
let t = crate::smart_cmds::placement(sm);
let c = [[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 = c.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])]);
return Rect::new(b[0].round() as i32, b[1].round() as i32, b[2].round() as i32, b[3].round() as i32);
}
crate::transform_cmds::transform_bounds(doc, layer)
}
/// A smart object's stored warp expressed in document space (for editing), if it has one.
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])]);
Some(Warp::custom(mesh, b))
}
/// Parses the warp params: a full `"warp"` object, or `"style"` (+ `"bend"`, `"hDistort"`,
/// `"vDistort"`, `"vertical"`; `"values"` for a cylinder), `"mesh"` and `"grid"` over `rect`.
pub fn warp_from_params(cmd: &str, p: &Value, rect: [f64; 4]) -> Result<Warp> {
if let Some(w) = p.get("warp") {
let mut w: Warp = serde_json::from_value(w.clone()).map_err(|e| bad(cmd, format!("bad `warp`: {e}")))?;
// A cylinder is its values: the mesh is rebuilt from them, whatever was passed.
if w.style == WarpStyle::Cylinder {
if w.vertical || w.h_distort != 0.0 || w.v_distort != 0.0 {
return Err(bad(cmd, CYLINDER_SHAPE));
}
w = Warp::cylinder(&w.values, w.bounds).ok_or_else(|| bad(cmd, CYLINDER_VALUES))?;
}
if w.mesh.as_ref().is_some_and(|m| !m.is_valid()) {
return Err(bad(cmd, "malformed warp mesh"));
}
return Ok(w);
}
let num = |k: &str, d: f64| p.get(k).and_then(Value::as_f64).unwrap_or(d);
let style = match p.get("style").and_then(Value::as_str) {
Some(st) => WarpStyle::parse(st).ok_or_else(|| bad(cmd, format!("unknown warp style \"{st}\"")))?,
None if p.get("mesh").is_some() || p.get("grid").is_some() => WarpStyle::Custom,
None => return Err(bad(cmd, "pass `style` (arc, flag, …, custom), `mesh` or `warp`")),
};
let mut w = Warp::preset(style, num("bend", 50.0), rect);
w.h_distort = num("hDistort", 0.0);
w.v_distort = num("vDistort", 0.0);
w.vertical = p.get("vertical").and_then(Value::as_bool).unwrap_or(false);
if style == WarpStyle::Cylinder {
if w.vertical || w.h_distort != 0.0 || w.v_distort != 0.0 {
return Err(bad(cmd, CYLINDER_SHAPE));
}
let values: Option<Vec<f64>> = p.get("values").and_then(Value::as_array).and_then(|a| a.iter().map(Value::as_f64).collect());
return values.and_then(|v| Warp::cylinder(&v, rect)).ok_or_else(|| bad(cmd, CYLINDER_VALUES));
}
if style == WarpStyle::Custom {
let mesh = match p.get("mesh") {
Some(m) => {
let m: BezierMesh = serde_json::from_value(m.clone()).map_err(|e| bad(cmd, format!("bad `mesh`: {e}")))?;
if !m.is_valid() {
return Err(bad(cmd, "malformed mesh (need (3·cols+1)×(3·rows+1) points and knots 0..1)"));
}
m
}
None => {
let g = p.get("grid").and_then(Value::as_array);
let at = |i: usize| g.and_then(|a| a.get(i)).and_then(Value::as_u64).unwrap_or(1) as usize;
BezierMesh::identity(rect, at(0), at(1))
}
};
w.mesh = Some(mesh);
}
Ok(w)
}
const CYLINDER_VALUES: &str =
"a cylinder needs `values`: seven numbers [left, bottom, right, top, top depth, bottom depth, wrap 0..1], the right silhouette right of the left one";
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;
let axis_aligned = m[8] != 0.0 && m[1] == 0.0 && m[3] == 0.0 && m[6] == 0.0 && m[7] == 0.0;
let (sx, sy) = (m[0] / m[8], m[4] / m[8]);
if axis_aligned && sx > 0.0 && sy > 0.0 && w.values.len() == 7 {
let v = &w.values;
let (x, y) = (|v: f64| sx * v + m[2] / m[8], |v: f64| sy * v + m[5] / m[8]);
let k = sy / sx;
if let Some(c) = Warp::cylinder(&[x(v[0]), y(v[1]), x(v[2]), y(v[3]), v[4] * k, v[5] * k, v[6]], sb) {
return Some(c);
}
}
let mesh = w.to_mesh(1, 1).map_points(|p| hom_apply(inv, p));
Some(Warp::custom(mesh, sb))
}
/// Whether the custom warp `w` is exactly what Warp mode shows for `layer`'s stored cylinder
/// ([`smart_warp_doc_space`]: its mesh over the same box), so committing it untouched changes
/// nothing. Stored as a custom warp instead, the cylinder would be lost, as a PSD keeps it only
/// as `warpCylinder` (#4112).
fn is_stored_cylinder(layer: &Layer, w: &Warp) -> bool {
let LayerContent::Smart(sm) = &layer.content else { return false };
if w.style != WarpStyle::Custom || sm.warp.as_ref().is_none_or(|s| s.style != WarpStyle::Cylinder) {
return false;
}
let (Some(shown), Some(m)) = (smart_warp_doc_space(layer), w.mesh.as_ref()) else { return false };
let Some(o) = shown.mesh.as_ref() else { return false };
let tol = 1e-6 * (1.0 + o.points.iter().flatten().chain(&shown.bounds).fold(0.0f64, |a, v| a.max(v.abs())));
let near = |a: &[f64], b: &[f64]| a.len() == b.len() && a.iter().zip(b).all(|(x, y)| (x - y).abs() <= tol);
o.us == m.us && o.vs == m.vs && near(&shown.bounds, &w.bounds) && near(o.points.as_flattened(), m.points.as_flattened())
}
/// A Background is turned into a normal layer by the caller first
/// ([`crate::extra_cmds::background_to_layer`]).
/// `Err(Cancelled)` once `ctl` is cancelled: between the steps of a warp that don't check it.
fn cancelled(ctl: &Interrupt) -> Result<()> {
if ctl.cancelled() { Err(EngineError::Cancelled) } else { Ok(()) }
}
/// `ctl` reports progress and cancels (`Err(Cancelled)`, the layer left half-done for the caller
/// to discard).
#[allow(clippy::too_many_arguments)]
fn warp_layer(
doc_sel: Option<&Surface>,
group: Locks,
l: &mut Layer,
w: &Warp,
rect: Rect,
interp: Interp,
frames: Option<&Frames>,
ctl: &Interrupt,
) -> Result<()> {
let locks = l.locks.union(group);
if locks.position || locks.all {
return Err(EngineError::Other(format!("layer \"{}\" is locked", l.name)));
}
let map = |x: f64, y: f64| w.map(x, y);
// A selection limits the linked mask exactly as it limits the pixels (#4175, as Free Transform
// and Perspective Warp do, #3083); a layer that moves whole takes its whole mask along.
let mask_sel = doc_sel.filter(|_| !crate::transform_cmds::moves_whole(l));
// The masks (and so a fill's frame) move, but a fill layer's cached render isn't warped:
// drop it, the fill re-renders from its model.
l.fill_cache = None;
match &mut l.content {
LayerContent::Group(g) => {
for c in g.children.iter_mut() {
warp_layer(None, locks, c, w, rect, interp, frames, ctl)?;
}
}
LayerContent::Text(_) => return Err(EngineError::Other("Warp needs rasterized type (or use Type › Warp Text)".into())),
// The path is warped and the shape stays a shape (#3009).
LayerContent::Shape(sh) => crate::vector_cmds::map_shape(sh, None, Some(w))?,
LayerContent::Smart(sm) => {
// Store in source space: undo the placement (affine, or Distort / Perspective) around the warp.
let inv = crate::smart_cmds::placement(sm).inverse().ok_or_else(|| EngineError::Other("the smart object's transform is degenerate".into()))?;
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(&inv, p));
let sb = 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])]);
let src_warp = match w.style {
WarpStyle::None => None,
WarpStyle::Custom => {
let mesh = w.to_mesh(1, 1).map_points(|p| hom_apply(&inv, p));
// A `mesh` parameter's knots span the contents, not the warp box's image (#4113).
match frames.and_then(|f| f.get(&l.id)).and_then(|&c| content_frame_warp(&mesh, c, sb)) {
Some(cw) => Some(cw),
None => Some(Warp::custom(mesh, sb)),
}
}
WarpStyle::Cylinder => cylinder_in_source(w, &inv, sb),
_ => Some(Warp { bounds: sb, mesh: None, ..w.clone() }),
};
sm.warp = src_warp.filter(|w| !w.is_identity());
// Fallback appearance for sources that can't be re-rendered.
if let Some(c) = &mut sm.cache {
*c = warp_mesh_surface_with(c, rect, &map, interp, ctl).ok_or(EngineError::Cancelled)?;
}
if let Some(m) = &mut sm.filter_mask {
m.surface = warp_mesh_gray(&m.surface, &map, interp);
}
}
_ => {
if let Some(surf) = l.surface_mut() {
*surf = match doc_sel {
Some(sel) => {
let (lifted, mut rest) = crate::transform_cmds::split_selected(surf, sel)?;
let moved = warp_mesh_surface_with(&lifted, rect, &map, interp, ctl).ok_or(EngineError::Cancelled)?;
cancelled(ctl)?;
crate::transform_cmds::composite_over(&mut rest, &moved);
rest.prune();
rest
}
None => {
// Content outside the warp box stays where it is.
let fmt = surf.format();
let warped = warp_mesh_surface_with(surf, rect, &map, interp, ctl).ok_or(EngineError::Cancelled)?;
cancelled(ctl)?;
// Tiles are shared, so a surface that has alpha already costs nothing to copy.
let mut rest = if fmt.alpha { surf.clone() } else { surf.convert(photocraft_color::PixelFormat::new(fmt.mode, fmt.sample, true)) };
crate::pixels::try_clear_surface(&mut rest, rect, None)?;
if rest.content_bounds().is_empty() {
// The box held all of the layer: the warp is the whole result.
warped
} else {
cancelled(ctl)?;
crate::transform_cmds::composite_over(&mut rest, &warped);
rest.prune();
rest
}
}
};
}
}
}
if let Some(m) = l.mask.as_mut()
&& m.linked
{
let whole = |m: &Surface| Ok(warp_mesh_gray(m, &map, interp));
m.surface = match mask_sel {
Some(sel) => crate::transform_cmds::move_gray_selected(
&m.surface,
sel,
&|lifted, src| warp_mesh_surface_with(lifted, src, &map, interp, ctl).ok_or(EngineError::Cancelled),
&whole,
)?,
None => whole(&m.surface)?,
};
}
if let Some(vm) = l.vector_mask.as_mut()
&& vm.linked
{
vm.path = crate::vector_cmds::map_path(&vm.path, None, Some(w))?;
}
Ok(())
}
/// Source boxes of the smart objects under a warped layer, by layer id.
type Frames = std::collections::HashMap<LayerId, [f64; 4]>;
fn source_frames(doc: &Document, l: &Layer, out: &mut Frames) {
match &l.content {
LayerContent::Group(g) => g.children.iter().for_each(|c| source_frames(doc, c, out)),
LayerContent::Smart(sm) => {
if let Some(b) = crate::smart_cmds::source_box(doc, sm) {
out.insert(l.id, b);
}
}
_ => {}
}
}
/// Rescales `knots` (spanning the box starting at `c0`, `cs` long) to span the frame `t0`, `ts`
/// (which contains it). Returns the knots and whether the frame extends past the box at the start / end.
fn reframe_knots(knots: &[f64], c0: f64, cs: f64, t0: f64, ts: f64) -> (Vec<f64>, bool, bool) {
let pre = (c0 - t0) / ts > 1e-9;
let post = (t0 + ts - (c0 + cs)) / ts > 1e-9;
let mut out: Vec<f64> = Vec::with_capacity(knots.len() + 2);
if pre {
out.push(0.0);
}
out.extend(knots.iter().map(|k| (c0 + k * cs - t0) / ts));
if post {
out.push(1.0);
}
// Keep the ends exactly 0 and 1.
if let Some(f) = out.first_mut() {
*f = 0.0;
}
if let Some(l) = out.last_mut() {
*l = 1.0;
}
(out, pre, post)
}
/// `items` with three more copies of the first / last one at the front (`pre`) / back (`post`).
fn pad<T: Clone>(items: &[T], pre: bool, post: bool) -> Option<Vec<T>> {
let mut out = Vec::with_capacity(items.len() + 6);
if pre {
out.extend(std::iter::repeat_n(items.first()?.clone(), 3));
}
out.extend_from_slice(items);
if post {
out.extend(std::iter::repeat_n(items.last()?.clone(), 3));
}
Some(out)
}
/// A stored smart-object warp from `mesh` (points in source space) whose knots span the source
/// box `c`: the knots are re-expressed over the frame `hull(c, b)` (`b` = the warp box's image in
/// source space), with a degenerate patch padding every side where the frame extends past `c`.
/// The surface over `c` is exactly the mesh's (#4113).
fn content_frame_warp(mesh: &BezierMesh, c: [f64; 4], b: [f64; 4]) -> Option<Warp> {
let t = [c[0].min(b[0]), c[1].min(b[1]), c[2].max(b[2]), c[3].max(b[3])];
let (cw, ch) = (c[2] - c[0], c[3] - c[1]);
if !(cw > 0.0 && ch > 0.0 && t.iter().all(|v| v.is_finite())) {
return None;
}
let (us, left, right) = reframe_knots(&mesh.us, c[0], cw, t[0], t[2] - t[0]);
let (vs, top, bottom) = reframe_knots(&mesh.vs, c[1], ch, t[1], t[3] - t[1]);
let rows: Vec<Vec<[f64; 2]>> = mesh.points.chunks(mesh.nx()).map(|r| pad(r, left, right)).collect::<Option<_>>()?;
let rows = pad(&rows, top, bottom)?;
let m = BezierMesh { us, vs, points: rows.into_iter().flatten().collect() };
m.is_valid().then(|| Warp::custom(m, t))
}
/// Largest warp frame side: far above any real layer, far below what would exhaust memory.
const MAX_WARP_SIDE: u32 = 100_000;
fn target(s: &Session, cmd: &str, p: &Value) -> Result<(LayerId, Rect)> {
let st = s.active().ok_or(EngineError::NoDocument)?;
let id = match p.get("layer").and_then(Value::as_u64) {
Some(v) => LayerId(v),
None => st.active_layer.ok_or(EngineError::Other("no active layer".into()))?,
};
let layer = st.doc.layer(id).ok_or(EngineError::NoLayer(id))?;
let rect = match p.get("rect").and_then(Value::as_array) {
Some(r) if r.len() == 4 => {
let v: Vec<i32> = r.iter().map(|x| x.as_f64().unwrap_or(0.0).round() as i32).collect();
Rect::new(v[0], v[1], v[2], v[3])
}
_ => warp_bounds(&st.doc, layer),
};
if rect.is_empty() {
return Err(bad(cmd, "nothing to warp"));
}
let (w, h) = (rect.width(), rect.height());
if w > MAX_WARP_SIDE || h > MAX_WARP_SIDE {
return Err(bad(cmd, format!("rect too large for warp: {w} x {h} (max {MAX_WARP_SIDE} per side)")));
}
Ok((id, rect))
}
/// The Warp Text settings a transform warp gives a type layer: a preset style (or none) keeps the
/// type editable, as Photoshop's Edit › Transform › Warp on type does (#3004).
fn type_warp(cmd: &str, w: &Warp) -> Result<Option<photocraft_doc::text::TextWarp>> {
let clamp = |v: f64| if v.is_finite() { v.clamp(-100.0, 100.0) as f32 } else { 0.0 };
match w.style {
WarpStyle::None => Ok(None),
WarpStyle::Custom if w.is_identity() => Ok(None),
WarpStyle::Custom => Err(bad(cmd, "type can only take a warp style (arc, flag, …); a custom mesh needs rasterized type")),
WarpStyle::Cylinder => Err(bad(cmd, "type can only take a warp style (arc, flag, …); a cylinder needs rasterized type")),
s => Ok(Some(photocraft_doc::text::TextWarp {
style: s.psd_name().into(),
value: clamp(w.bend),
horizontal_distortion: clamp(w.h_distort),
vertical_distortion: clamp(w.v_distort),
horizontal: !w.vertical,
})),
}
}
/// Warp on a type layer sets its Warp Text style: one history step, and the type stays editable.
fn warp_type_layer(s: &mut Session, cmd: &str, id: LayerId, w: &Warp) -> Result<Value> {
let tw = type_warp(cmd, w)?;
let unchanged = s.active().and_then(|d| d.doc.layer(id)).is_some_and(|l| matches!(&l.content, LayerContent::Text(t) if t.warp == tw));
if unchanged {
return Ok(json!({"layer": id.0, "changed": false}));
}
s.edit("Warp", |doc, _| {
let group = crate::transform_cmds::group_locks(doc, id);
let (dpi, format) = (doc.resolution_dpi, doc.pixel_format());
let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?;
let locks = l.locks.union(group);
if locks.position || locks.all {
return Err(EngineError::Other(format!("layer \"{}\" is locked", l.name)));
}
let LayerContent::Text(t) = &mut l.content else { return Err(EngineError::Other("not a type layer".into())) };
t.warp = tw.clone();
crate::type_cmds::refresh_at(dpi, format, t);
Ok(())
})?;
Ok(json!({"layer": id.0, "changed": true, "warp": tw}))
}
fn apply(s: &mut Session, p: &Value, cmd: &str) -> Result<Value> {
let (id, rect) = target(s, cmd, p)?;
let w = warp_from_params(cmd, p, rect_f(rect))?;
if matches!(s.active().and_then(|d| d.doc.layer(id)).map(|l| &l.content), Some(LayerContent::Text(_))) {
return warp_type_layer(s, cmd, id, &w);
}
let is_smart = matches!(s.active().and_then(|d| d.doc.layer(id)).map(|l| &l.content), Some(LayerContent::Smart(_)));
if w.is_identity() && !is_smart {
return Ok(json!({"layer": id.0, "changed": false}));
}
// An untouched cylinder (Warp mode, then Enter) stays as it is, with no history step.
if p.get("mesh").is_none() && s.active().and_then(|d| d.doc.layer(id)).is_some_and(|l| is_stored_cylinder(l, &w)) {
return Ok(json!({"layer": id.0, "changed": false}));
}
let interp = Interp::parse(p.get("interpolation").and_then(Value::as_str).unwrap_or("bicubic"));
let content_knots = p.get("mesh").is_some() && p.get("warp").is_none();
// A background job when started with `Session::start` (as Free Transform, #4218): the warp of
// a large layer runs on a worker with progress, and can be cancelled.
crate::jobs::edit_job(
s,
"Warp",
move |doc, _, ctx| ctx.stage(0.0, 1.0, "Warping", |ctl| warp_doc(doc, id, &w, rect, interp, content_knots, ctl)),
move |()| json!({"layer": id.0, "changed": true, "rect": [rect.x0, rect.y0, rect.x1, rect.y1]}),
)
}
/// The edit of [`apply`]: the layer and the selection outline.
fn warp_doc(doc: &mut Document, id: LayerId, w: &Warp, rect: Rect, interp: Interp, content_knots: bool, ctl: &Interrupt) -> Result<()> {
let sel = doc.selection.clone();
let frames = content_knots.then(|| {
let mut f = Frames::new();
if let Some(l) = doc.layer(id) {
source_frames(doc, l, &mut f);
}
f
});
let is_group = doc.layer(id).is_some_and(Layer::is_group);
let group = crate::transform_cmds::group_locks(doc, id);
crate::extra_cmds::background_to_layer(doc, id);
let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?;
warp_layer(if is_group { None } else { sel.as_ref() }, group, l, w, rect, interp, frames.as_ref(), ctl)?;
let snapshot = doc.clone();
if let Some(l) = doc.layer_mut(id) {
crate::transform_cmds::refresh_text(&snapshot, l);
}
if let Some(sel) = &doc.selection {
doc.selection = Some(warp_mesh_gray(sel, &|x, y| w.map(x, y), Interp::Bilinear)).filter(|s| !s.content_bounds().is_empty());
}
Ok(())
}
#[derive(Clone, Copy)]
pub enum Split {
Crosswise,
Horizontal,
Vertical,
Remove,
}
/// The default warp draws rule-of-thirds guides on its single patch. Make those real sections
/// before a split, so the cut divides the grid on screen instead of replacing it.
fn commit_default_sections(mesh: &mut BezierMesh) {
let _ = mesh.split_u(1.0 / 3.0);
let _ = mesh.split_u(2.0 / 3.0);
let _ = mesh.split_v(1.0 / 3.0);
let _ = mesh.split_v(2.0 / 3.0);
}
/// Applies a split (or removes the split nearest `at`) on `w`'s mesh (presets become custom).
pub fn split_warp(w: &Warp, at: Option<[f64; 2]>, how: Split) -> Option<Warp> {
let mut mesh = w.to_mesh(1, 1);
if !matches!(how, Split::Remove) && w.style == WarpStyle::Custom && mesh.us.len() == 2 && mesh.vs.len() == 2 {
commit_default_sections(&mut mesh);
}
let (s, t) = match at {
Some(p) => mesh.param_at(p),
None => (0.5, 0.5),
};
// Split inside the patch: the middle of the patch containing (s, t) when no point is given.
let mid = |k: &[f64], v: f64| {
let i = k.windows(2).position(|w| v <= w[1]).unwrap_or(k.len() - 2);
(k[i] + k[i + 1]) / 2.0
};
let (s, t) = if at.is_some() { (s, t) } else { (mid(&mesh.us, s), mid(&mesh.vs, t)) };
let ok = match how {
Split::Crosswise => {
let a = mesh.split_u(s);
let b = mesh.split_v(t);
a || b
}
// A horizontal split line divides rows; a vertical one divides columns.
Split::Horizontal => mesh.split_v(t),
Split::Vertical => mesh.split_u(s),
Split::Remove => {
let near = |k: &[f64], v: f64| (1..k.len() - 1).min_by(|a, b| (k[*a] - v).abs().total_cmp(&(k[*b] - v).abs()));
let (iu, iv) = (near(&mesh.us, s), near(&mesh.vs, t));
let du = iu.map_or(f64::MAX, |i| (mesh.us[i] - s).abs());
let dv = iv.map_or(f64::MAX, |i| (mesh.vs[i] - t).abs());
match (iu, iv) {
(Some(i), _) if du <= dv => mesh.remove_split_u(i),
(_, Some(i)) => mesh.remove_split_v(i),
(Some(i), None) => mesh.remove_split_u(i),
_ => false,
}
}
};
ok.then(|| Warp::custom(mesh, w.bounds))
}
fn param_rect(p: &Value) -> [f64; 4] {
let Some(r) = p.get("rect").and_then(Value::as_array) else {
return [0.0, 0.0, 1.0, 1.0];
};
let at = |i: usize, d: f64| r.get(i).and_then(Value::as_f64).unwrap_or(d);
[at(0, 0.0), at(1, 0.0), at(2, 1.0), at(3, 1.0)]
}
/// A warp passed inline (`warp`, `mesh` or `style`). `None` means "use the active smart object".
fn inline_warp(cmd: &str, p: &Value) -> Result<Option<Warp>> {
if p.get("warp").is_none() && p.get("mesh").is_none() && p.get("style").is_none() {
return Ok(None);
}
Ok(Some(warp_from_params(cmd, p, param_rect(p))?))
}
fn split(s: &mut Session, p: &Value, cmd: &str, how: Split) -> Result<Value> {
let at = p.get("at").and_then(Value::as_array).and_then(|a| Some([a.first()?.as_f64()?, a.get(1)?.as_f64()?]));
if let Some(w) = inline_warp(cmd, p)? {
let out = split_warp(&w, at, how).ok_or_else(|| bad(cmd, "nothing to split or remove there"))?;
return Ok(json!({"warp": out}));
}
// The active smart object's stored warp.
let st = s.active().ok_or(EngineError::NoDocument)?;
let id = st.active_layer.ok_or(EngineError::Other("no active layer".into()))?;
let layer = st.doc.layer(id).ok_or(EngineError::NoLayer(id))?;
let LayerContent::Smart(sm) = &layer.content else {
return Err(bad(cmd, "pass the warp being edited (`warp`), or select a warped smart object"));
};
let w = sm.warp.clone().unwrap_or_else(|| {
let b = layer.surface().map_or(Rect::EMPTY, Surface::content_bounds);
let inv = crate::smart_cmds::placement(sm).inverse().unwrap_or(Homography::IDENTITY);
let c = [hom_apply(&inv, [f64::from(b.x0), f64::from(b.y0)]), hom_apply(&inv, [f64::from(b.x1), f64::from(b.y1)])];
Warp::none([c[0][0].min(c[1][0]), c[0][1].min(c[1][1]), c[0][0].max(c[1][0]), c[0][1].max(c[1][1])])
});
let t = crate::smart_cmds::placement(sm);
let at_src = at.and_then(|a| t.inverse().map(|inv| hom_apply(&inv, a)));
let out = split_warp(&w, at_src, how).ok_or_else(|| bad(cmd, "nothing to split or remove there"))?;
let label = if matches!(how, Split::Remove) { "Remove Warp Split" } else { "Split Warp" };
s.edit(label, |doc, _| {
if let Some(Layer { content: LayerContent::Smart(sm), .. }) = doc.layer_mut(id) {
sm.warp = Some(out.clone());
}
crate::smart_cmds::refresh(doc, id).map(|_| ())
})?;
Ok(json!({"layer": id.0, "warp": out}))
}
/// Square preset size. `"default"` is 1. Numbers outside 1..=64 are rejected, not clamped.
fn grid_size(cmd: &str, p: &Value) -> Result<usize> {
let Some(v) = p.get("size").or_else(|| p.get("grid")) else {
return Err(bad(cmd, "pass `size` (1, 3, 4, 5 or \"default\")"));
};
if v.as_array().is_some() {
return Err(bad(cmd, "pass `size` as a number or \"default\", \"3\", \"4\" or \"5\""));
}
let n = match v {
Value::String(s) => match s.as_str() {
"default" => 1,
"3" | "3x3" => 3,
"4" | "4x4" => 4,
"5" | "5x5" => 5,
_ => return Err(bad(cmd, "grid size must be default, 3, 4 or 5")),
},
Value::Number(num) => num.as_u64().ok_or_else(|| bad(cmd, "grid size must be a whole number from 1 to 64"))?,
_ => return Err(bad(cmd, "grid size must be a number or default, 3, 4 or 5")),
};
if !(1..=64).contains(&n) {
return Err(bad(cmd, "grid size must be from 1 to 64"));
}
Ok(n as usize)
}
/// Even `n`×`n` patches fitted to `w`. The surface stays; knots sit at `i/n`.
fn resize_warp(w: &Warp, n: usize) -> Warp {
let mesh = w.to_mesh(1, 1);
let knots: Vec<f64> = (0..=n).map(|i| i as f64 / n as f64).collect();
Warp::custom(BezierMesh::fit(&|s, t| mesh.eval(s, t), knots.clone(), knots), w.bounds)
}
fn grid(s: &mut Session, p: &Value) -> Result<Value> {
const CMD: &str = "edit.transform.warpGrid";
let n = grid_size(CMD, p)?;
if let Some(w) = inline_warp(CMD, p)? {
return Ok(json!({"warp": resize_warp(&w, n)}));
}
let st = s.active().ok_or(EngineError::NoDocument)?;
let id = st.active_layer.ok_or(EngineError::Other("no active layer".into()))?;
let layer = st.doc.layer(id).ok_or(EngineError::NoLayer(id))?;
let LayerContent::Smart(sm) = &layer.content else {
return Err(bad(CMD, "pass the warp being edited (`warp`), or select a smart object"));
};
let w = sm.warp.clone().unwrap_or_else(|| {
let b = layer.surface().map_or(Rect::EMPTY, Surface::content_bounds);
// The layer's pixels back in source space, through the full placement (affine, or
// Distort / Perspective).
let r = rect_f(b);
let c = [[r[0], r[1]], [r[2], r[1]], [r[2], r[3]], [r[0], r[3]]];
let c = match crate::smart_cmds::placement(sm).inverse() {
Some(inv) => c.map(|p| hom_apply(&inv, p)),
None => c,
};
Warp::none(c.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])]))
});
let out = resize_warp(&w, n);
s.edit("Warp Grid", |doc, _| {
if let Some(Layer { content: LayerContent::Smart(sm), .. }) = doc.layer_mut(id) {
sm.warp = Some(out.clone());
}
crate::smart_cmds::refresh(doc, id).map(|_| ())
})?;
Ok(json!({"layer": id.0, "warp": out}))
}
pub fn specs() -> Vec<CommandSpec> {
const P: &str = r##"{"layer":id?,"rect":[x0,y0,x1,y1]? (warp box; default = layer content ∩ selection),"style":"custom|none|arc|arcLower|arcUpper|arch|bulge|shellLower|shellUpper|flag|wave|fish|rise|fisheye|inflate|squeeze|twist|cylinder","bend":%=50,"hDistort":%,"vDistort":%,"vertical":bool,"values":[left,bottom,right,top,topDepth,bottomDepth,wrap]? (cylinder: silhouettes and edges in document px, the ellipses' depths in half-axis units, wrap 0..1; horizontal only),"mesh":{"us":[0,…,1],"vs":[0,…,1],"points":[[x,y]…]} ((3c+1)×(3r+1) control points, row-major, document px; on a smart object `us`/`vs` span the source contents, so points equal to the placement are an identity warp even when it is skewed),"grid":[cols,rows]?,"warp":{full warp object}?,"interpolation":"bicubic|bilinear|nearest"} — on a type layer a style (or none) sets its Warp Text and the type stays editable"##;
const S: &str = r##"{"warp":{…}? (the warp being edited; returned split),"rect":[x0,y0,x1,y1]?,"at":[x,y]? (document point; default = middle of the patch)} — without `warp`, edits the active smart object's warp. A 1×1 custom mesh is divided at the thirds first (the guides drawn on the default grid), so splitWarpCrosswise on an identity custom warp returns a 4×4 mesh (knots at 0, 1/3, 1/2, 2/3 and 1), not a 2×2. A preset is fitted as one patch and split without that step."##;
const G: &str = r##"{"warp":{…}? (the warp being edited; returned with the new mesh),"rect":[x0,y0,x1,y1]?,"size":n|"default"|"3"|"4"|"5" (square patches, 1..=64; 1 and "default" are the single-patch grid)} — without `warp`/`style`/`mesh`, edits the active smart object's warp. The surface is kept and the new lines are real splits at i/n."##;
vec![
CommandSpec {
id: "edit.transform.warp",
label: "Warp",
menu: &["Edit", "Transform"],
shortcut: None,
params: P,
enabled: has_layer,
journal: true,
run: |s, p| apply(s, p, "edit.transform.warp"),
},
CommandSpec {
id: "layer.smartObjects.warp",
label: "Warp",
menu: &["Layer", "Smart Objects"],
shortcut: None,
params: P,
enabled: active_smart,
journal: true,
run: |s, p| apply(s, p, "layer.smartObjects.warp"),
},
CommandSpec {
id: "edit.transform.splitWarpCrosswise",
label: "Split Warp Crosswise",
menu: &["Edit", "Transform"],
shortcut: None,
params: S,
enabled: has_layer,
journal: true,
run: |s, p| split(s, p, "edit.transform.splitWarpCrosswise", Split::Crosswise),
},
CommandSpec {
id: "edit.transform.splitWarpHorizontally",
label: "Split Warp Horizontally",
menu: &["Edit", "Transform"],
shortcut: None,
params: S,
enabled: has_layer,
journal: true,
run: |s, p| split(s, p, "edit.transform.splitWarpHorizontally", Split::Horizontal),
},
CommandSpec {
id: "edit.transform.splitWarpVertically",
label: "Split Warp Vertically",
menu: &["Edit", "Transform"],
shortcut: None,
params: S,
enabled: has_layer,
journal: true,
run: |s, p| split(s, p, "edit.transform.splitWarpVertically", Split::Vertical),
},
CommandSpec {
id: "edit.transform.removeWarpSplit",
label: "Remove Warp Split",
menu: &["Edit", "Transform"],
shortcut: None,
params: S,
enabled: has_layer,
journal: true,
run: |s, p| split(s, p, "edit.transform.removeWarpSplit", Split::Remove),
},
CommandSpec {
id: "edit.transform.warpGrid",
label: "Warp Grid",
menu: &[],
shortcut: None,
params: G,
enabled: has_layer,
journal: true,
run: |s, p| grid(s, p),
},
]
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_warp_box_holding_the_whole_layer_gives_what_compositing_over_the_cleared_layer_did() {
for depth in [8, 16, 32] {
let s = session(depth);
let st = s.active().unwrap();
let l = st.doc.layer(st.active_layer.unwrap()).unwrap().clone();
let surf = l.surface().unwrap().clone();
let rect = surf.content_bounds();
let w = Warp::preset(WarpStyle::Flag, 35.0, rect_f(rect));
// What the warp built before the shortcut: the warp composited over the cleared layer.
let map = |x: f64, y: f64| w.map(x, y);
let warped = warp_mesh_surface_with(&surf, rect, &map, Interp::Bicubic, &Interrupt::NONE).unwrap();
let mut want = surf.clone();
crate::pixels::try_clear_surface(&mut want, rect, None).unwrap();
crate::transform_cmds::composite_over(&mut want, &warped);
want.prune();
let mut got = l.clone();
warp_layer(None, Locks::default(), &mut got, &w, rect, Interp::Bicubic, None, &Interrupt::NONE).unwrap();
let got = got.surface().unwrap();
let b = want.content_bounds();
assert_eq!(got.content_bounds(), b, "depth {depth}");
let (g, wv) = (got.read_region(b), want.read_region(b));
// Exact at 8 and 16 bits. At 32 the composite's `t·a / a` rounds a few values by an
// ulp; the shortcut keeps the warp's own.
let tol = if depth == 32 { 1e-6 } else { 0.0 };
let worst = g.iter().zip(&wv).map(|(a, b)| (a - b).abs()).fold(0.0f32, f32::max);
assert!(worst <= tol, "depth {depth}: off by {worst}");
}
}
fn session(depth: u64) -> Session {
let mut s = Session::new();
s.execute("file.new", json!({"width": 80, "height": 60, "depth": depth})).unwrap();
s.execute("layer.new.layer", json!({})).unwrap();
s.edit("paint", |doc, active| {
let l = doc.layer_mut(active.unwrap()).unwrap();
let surf = l.surface_mut().unwrap();
for y in 10..40 {
for x in 10..60 {
let v = ((x * 3 + y * 5) % 11) as f32 / 10.0;
surf.write_pixel(x, y, &[v, 1.0 - v, 0.5, 1.0]);
}
}
Ok(())
})
.unwrap();
s
}
fn active_surface(s: &Session) -> Surface {
let st = s.active().unwrap();
st.doc.layer(st.active_layer.unwrap()).unwrap().surface().unwrap().clone()
}
/// Warp on type sets its Warp Text (#3004): the layer stays type, one step; a custom mesh is
/// refused rather than rasterizing.
#[test]
fn warp_style_on_type_is_warp_text() {
let mut s = Session::new();
s.execute("file.new", json!({"width": 200, "height": 100})).unwrap();
s.execute("type.create", json!({"x": 10, "y": 60, "text": "Hi"})).unwrap();
let n = s.active().unwrap().history.past_len();
s.execute("edit.transform.warp", json!({"style": "arc", "bend": 30})).unwrap();
let st = s.active().unwrap();
let LayerContent::Text(t) = &st.doc.layer(st.active_layer.unwrap()).unwrap().content else { panic!("type") };
assert_eq!(t.warp.as_ref().map(|w| (w.style.as_str(), w.value)), Some(("warpArc", 30.0)));
assert_eq!(st.history.past_len(), n + 1);
let mesh = BezierMesh::identity([0.0, 0.0, 10.0, 10.0], 1, 1).map_points(|p| [p[0] * 2.0, p[1]]);
assert!(s.execute("edit.transform.warp", json!({"warp": Warp::custom(mesh, [0.0, 0.0, 10.0, 10.0])})).is_err());
}
/// The seven values of a cylinder over the session's painted box (x 10..60, y 10..40).
const CYL: [f64; 7] = [10.0, 40.0, 60.0, 10.0, 0.3, 0.3, 0.5];
/// `style: "cylinder"` takes its seven values (#4112); anything that isn't a horizontal
/// cylinder is refused, never a silent no-op, and the layer is left alone.
#[test]
fn cylinder_style_needs_its_values() {
let mut s = session(8);
let before = active_surface(&s);
for p in [
json!({"style": "cylinder"}),
json!({"style": "cylinder", "values": [10, 40, 60, 10, 0.3, 0.3]}),
json!({"style": "cylinder", "values": [60, 40, 10, 10, 0.3, 0.3, 0.5]}),
json!({"style": "cylinder", "values": [10, 40, 60, 10, 0.3, 0.3, "x"]}),
json!({"style": "cylinder", "values": "seven"}),
json!({"style": "cylinder", "values": CYL, "vertical": true}),
json!({"style": "cylinder", "values": CYL, "hDistort": 10}),
json!({"warp": {"style": "cylinder", "bounds": [10.0, 10.0, 60.0, 40.0]}}),
json!({"warp": {"style": "cylinder", "values": CYL, "bounds": [10.0, 10.0, 60.0, 40.0], "vertical": true}}),
json!({"warp": {"style": "cylinder", "values": CYL, "bounds": [10.0, 10.0, 60.0, 40.0], "hDistort": 10.0}}),
json!({"warp": {"style": "cylinder", "values": CYL, "bounds": [10.0, 10.0, 60.0, 40.0], "vDistort": -5.0}}),
] {
let e = s.execute("edit.transform.warp", p.clone()).unwrap_err().to_string();
assert!(e.contains("cylinder"), "{p}: {e}");
}
assert_eq!(active_surface(&s), before);
// On type it is refused like a custom mesh: Warp Text has no cylinder.
s.execute("type.create", json!({"x": 10, "y": 50, "text": "Hi"})).unwrap();
assert!(s.execute("edit.transform.warp", json!({"style": "cylinder", "values": CYL})).is_err());
}
#[test]
fn cylinder_warps_pixels_at_every_depth() {
for depth in [8u64, 16, 32] {
let mut s = session(depth);
let r = s.execute("edit.transform.warp", json!({"style": "cylinder", "values": CYL})).unwrap();
assert_eq!(r["changed"], true, "{depth}");
let after = active_surface(&s);
let b = after.content_bounds();
// Between the silhouettes, the edges bowed down by the ellipses.
assert!(b.x0 >= 9 && b.x1 <= 61 && b.y0 >= 9 && b.y1 > 40, "{depth}: {b:?}");
// The same cylinder as a full warp object (its mesh rebuilt from the values) agrees.
let mut t = session(depth);
let w = Warp::cylinder(&CYL, [10.0, 10.0, 60.0, 40.0]).unwrap();
let mut garbled = w.clone();
garbled.mesh = None;
t.execute("edit.transform.warp", json!({"warp": garbled})).unwrap();
assert_eq!(active_surface(&t), after, "{depth}");
}
}
/// On a smart object the cylinder is stored in source space: still a cylinder (so it goes to
/// PSD as one) when the placement only scales and moves, its mesh when it rotates.
#[test]
fn smart_object_cylinder_is_stored_in_source_space() {
let stored = |s: &Session| {
let st = s.active().unwrap();
let LayerContent::Smart(sm) = &st.doc.layer(st.active_layer.unwrap()).unwrap().content else { panic!("smart") };
sm.warp.clone().unwrap()
};
let mut s = session(8);
s.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap();
s.execute("layer.smartObjects.warp", json!({"style": "cylinder", "values": CYL, "rect": [10, 10, 60, 40]})).unwrap();
let w = stored(&s);
assert_eq!(w.style, WarpStyle::Cylinder);
let LayerContent::Smart(sm) = &s.active().unwrap().doc.layers[1].content else { panic!("smart") };
let inv = crate::smart_cmds::placement(sm).inverse().unwrap();
let src = |x: f64, y: f64| inv.apply(x, y);
let ((l, t), (r, bt)) = (src(10.0, 10.0), src(60.0, 40.0));
for (a, b) in w.values.iter().zip([l, bt, r, t, 0.3, 0.3, 0.5]) {
assert!((a - b).abs() < 1e-9, "{:?}", w.values);
}
// Twice as wide: the silhouettes double, the ellipses' depth (in half axes) halves.
let mut s = session(8);
s.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap();
s.execute("edit.transform", json!({"scaleX": 200, "scaleY": 100})).unwrap();
let wide = [-15.0, 40.0, 85.0, 10.0, 0.3, 0.3, 0.5];
s.execute("layer.smartObjects.warp", json!({"style": "cylinder", "values": wide, "rect": [-15, 10, 85, 40]})).unwrap();
let w = stored(&s);
assert_eq!(w.style, WarpStyle::Cylinder);
assert!((w.values[4] - 0.6).abs() < 1e-9 && (w.values[5] - 0.6).abs() < 1e-9, "{:?}", w.values);
// The silhouettes land on the source's own edges (its contents span the scaled box).
let LayerContent::Smart(sm) = &s.active().unwrap().doc.layers[1].content else { panic!("smart") };
let inv = crate::smart_cmds::placement(sm).inverse().unwrap();
assert!((w.values[0] - inv.apply(-15.0, 10.0).0).abs() < 1e-9 && (w.values[2] - inv.apply(85.0, 10.0).0).abs() < 1e-9, "{:?}", w.values);
// Rotated: a custom mesh with the same shape.
let mut s = session(8);
s.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap();
s.execute("edit.transform", json!({"rotate": 30})).unwrap();
s.execute("layer.smartObjects.warp", json!({"style": "cylinder", "values": CYL, "rect": [10, 10, 60, 40]})).unwrap();
let w = stored(&s);
assert_eq!(w.style, WarpStyle::Custom);
assert_eq!(w.mesh.as_ref().map(|m| (m.nx(), m.ny())), Some((13, 4)));
}
/// Warp mode shows a stored cylinder as its mesh, and Enter commits that mesh untouched: the
/// smart object keeps its cylinder (else the next PSD save writes no warp), while a moved
/// point still makes it a custom warp.
#[test]
fn untouched_cylinder_committed_from_warp_mode_stays_a_cylinder() {
for scale in [100, 150] {
let mut s = session(8);
s.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap();
s.execute("edit.transform", json!({"scaleX": scale, "scaleY": 100})).unwrap();
let id = s.active().unwrap().active_layer.unwrap();
let rect = warp_bounds(&s.active().unwrap().doc, s.active().unwrap().doc.layer(id).unwrap());
let [x0, x1] = [f64::from(rect.x0), f64::from(rect.x1)];
let values = [x0, 40.0, x1, 10.0, 0.3, 0.3, 0.5];
s.execute("layer.smartObjects.warp", json!({"style": "cylinder", "values": values, "rect": [rect.x0, 10, rect.x1, 40]})).unwrap();
let stored = |s: &Session| {
let st = s.active().unwrap();
let LayerContent::Smart(sm) = &st.doc.layer(id).unwrap().content else { panic!("smart") };
sm.warp.clone().unwrap()
};
let cylinder = stored(&s);
assert_eq!(cylinder.style, WarpStyle::Cylinder, "{scale}");
let shown = smart_warp_doc_space(s.active().unwrap().doc.layer(id).unwrap()).unwrap();
assert_eq!(shown.style, WarpStyle::Custom);
s.execute("select.all", json!({})).unwrap();
let (sel, steps) = (s.active().unwrap().doc.selection.clone(), s.active().unwrap().history.past_len());
let r = s.execute("edit.transform.warp", json!({"layer": id.0, "rect": shown.bounds, "warp": shown, "interpolation": "bicubic"})).unwrap();
assert_eq!(r["changed"], false, "{scale}");
assert_eq!(stored(&s), cylinder, "{scale}: untouched");
let st = s.active().unwrap();
assert!(st.doc.selection == sel && st.history.past_len() == steps, "{scale}: no step, the selection stays");
// The same mesh over another box is another warp.
let mut wider = shown.clone();
wider.bounds[2] += 10.0;
s.execute("edit.transform.warp", json!({"layer": id.0, "rect": wider.bounds, "warp": wider})).unwrap();
assert_eq!(stored(&s).style, WarpStyle::Custom, "{scale}: other box");
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(stored(&s), cylinder);
let mut moved = shown.clone();
if let Some(m) = moved.mesh.as_mut() {
m.points[20][1] += 5.0;
}
s.execute("edit.transform.warp", json!({"layer": id.0, "rect": moved.bounds, "warp": moved})).unwrap();
assert_eq!(stored(&s).style, WarpStyle::Custom, "{scale}: edited");
}
}
#[test]
fn identity_and_bend_zero_are_no_ops_at_every_depth() {
for depth in [8u64, 16, 32] {
let mut s = session(depth);
let before = active_surface(&s);
let r = s.execute("edit.transform.warp", json!({"style": "custom"})).unwrap();
assert_eq!(r["changed"], false);
s.execute("edit.transform.warp", json!({"style": "flag", "bend": 0})).unwrap();
// A custom identity mesh passed explicitly (forces a resample) stays within 1/255.
let m = BezierMesh::identity([10.0, 10.0, 60.0, 40.0], 2, 2);
let mut w = Warp::custom(m, [10.0, 10.0, 60.0, 40.0]);
w.mesh.as_mut().unwrap().points[5][0] += 1e-12; // not exactly identity
s.execute("edit.transform.warp", json!({"warp": w})).unwrap();
let after = active_surface(&s);
let r = Rect::new(10, 10, 60, 40);
assert_eq!(after.content_bounds(), r, "{depth}");
let worst = before.read_region(r).iter().zip(after.read_region(r)).map(|(a, b)| (a - b).abs()).fold(0.0f32, f32::max);
assert!(worst <= 1.0 / 255.0, "{depth}: {worst}");
}
}
#[test]
fn arc_warps_pixels_and_undoes() {
let mut s = session(8);
let before = active_surface(&s);
s.execute("edit.transform.warp", json!({"style": "arc", "bend": 50})).unwrap();
let after = active_surface(&s);
// As in Photoshop: the bottom edge keeps its corners, the top rises and spreads.
let c = after.content_bounds();
assert!(c.y1 <= 41 && c.y0 < 8 && c.x0 < 8 && c.x1 > 62, "{c:?}");
s.undo();
assert_eq!(active_surface(&s), before);
assert!(s.execute("edit.transform.warp", json!({"style": "spiral"})).is_err());
assert!(s.execute("edit.transform.warp", json!({})).is_err());
assert!(s.execute("edit.transform.warp", json!({"mesh": {"us": [0, 1], "vs": [0, 1], "points": [[0, 0]]}})).is_err());
}
#[test]
fn custom_mesh_moves_a_control_point() {
let mut s = session(16);
let mut m = BezierMesh::identity([10.0, 10.0, 60.0, 40.0], 1, 1);
m.points[15] = [70.0, 50.0]; // bottom-right corner
s.execute("edit.transform.warp", json!({"mesh": m})).unwrap();
let b = active_surface(&s).content_bounds();
assert!(b.x1 >= 69 && b.y1 >= 49, "{b:?}");
}
#[test]
fn split_cuts_existing_sections_instead_of_replacing_the_grid() {
let b = [0.0, 0.0, 90.0, 60.0];
let plain = Warp::custom(BezierMesh::identity(b, 1, 1), b);
let cut = split_warp(&plain, Some([b[0] + 0.25 * 90.0, 30.0]), Split::Vertical).unwrap();
let m = cut.mesh.unwrap();
for u in [0.0, 0.25, 1.0 / 3.0, 2.0 / 3.0, 1.0] {
assert!(m.us.iter().any(|k| (k - u).abs() < 1e-6), "default guides stay, cut at 0.25: {:?}", m.us);
}
assert_eq!(m.vs, vec![0.0, 1.0 / 3.0, 2.0 / 3.0, 1.0]);
let grid = BezierMesh::identity(b, 3, 3);
let cut = split_warp(&Warp::custom(grid.clone(), b), Some([45.0, 10.0]), Split::Vertical).unwrap();
let m = cut.mesh.unwrap();
assert_eq!(m.vs, grid.vs);
assert_eq!(m.us.len(), grid.us.len() + 1);
for u in [0.0, 1.0 / 3.0, 0.5, 2.0 / 3.0, 1.0] {
assert!(m.us.iter().any(|k| (k - u).abs() < 1e-6), "{:?}", m.us);
}
let (nx0, nx1) = (grid.nx(), m.nx());
for j in 0..grid.ny() {
for i in 0..3 {
assert_eq!(m.points[j * nx1 + i], grid.points[j * nx0 + i]);
}
}
}
#[test]
fn split_commands_edit_meshes_exactly() {
let mut s = session(8);
let w = Warp::preset(WarpStyle::Bulge, 30.0, [10.0, 10.0, 60.0, 40.0]);
let r = s.execute("edit.transform.splitWarpCrosswise", json!({"warp": w, "at": [30.0, 20.0]})).unwrap();
let split: Warp = serde_json::from_value(r["warp"].clone()).unwrap();
let m = split.mesh.as_ref().unwrap();
assert_eq!((m.nx(), m.ny()), (7, 7));
let r = s.execute("edit.transform.splitWarpVertically", json!({"warp": split})).unwrap();
let v: Warp = serde_json::from_value(r["warp"].clone()).unwrap();
assert_eq!(v.mesh.as_ref().unwrap().us.len(), 4);
let r = s.execute("edit.transform.removeWarpSplit", json!({"warp": v, "at": [30.0, 20.0]})).unwrap();
let back: Warp = serde_json::from_value(r["warp"].clone()).unwrap();
assert!(back.mesh.as_ref().unwrap().us.len() + back.mesh.as_ref().unwrap().vs.len() < 7);
// Splitting never changes the shape.
for (x, y) in [(15.0, 15.0), (40.0, 33.0)] {
let (a, b) = (w.map(x, y), split.map(x, y));
assert!((a.0 - b.0).abs() < 0.6 && (a.1 - b.1).abs() < 0.6, "{a:?} {b:?}");
}
}
#[test]
fn smart_object_warp_is_lossless() {
for depth in [8u64, 16, 32] {
let mut s = session(depth);
s.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap();
let original = active_surface(&s);
s.execute("layer.smartObjects.warp", json!({"style": "wave", "bend": 60})).unwrap();
let warped = active_surface(&s);
assert_ne!(warped, original);
let st = s.active().unwrap();
let LayerContent::Smart(sm) = &st.doc.layer(st.active_layer.unwrap()).unwrap().content else { panic!() };
assert!(sm.warp.is_some());
// Removing the warp re-renders the source exactly (no accumulated resampling).
s.execute("layer.smartObjects.warp", json!({"style": "none"})).unwrap();
assert_eq!(active_surface(&s), original, "{depth}");
// Warping twice equals warping once with the second warp.
s.execute("layer.smartObjects.warp", json!({"style": "arc", "bend": 20})).unwrap();
s.execute("layer.smartObjects.warp", json!({"style": "wave", "bend": 60})).unwrap();
assert_eq!(active_surface(&s), warped, "{depth}");
// Split on the stored warp keeps the appearance (within resampling of the mesh fit).
s.execute("edit.transform.splitWarpCrosswise", json!({})).unwrap();
let st = s.active().unwrap();
let LayerContent::Smart(sm) = &st.doc.layer(st.active_layer.unwrap()).unwrap().content else { panic!() };
assert_eq!(sm.warp.as_ref().unwrap().style, WarpStyle::Custom);
}
}
#[test]
fn crosswise_split_of_an_identity_custom_warp_is_four_by_four() {
let mut s = session(8);
let r = s.execute("edit.transform.splitWarpCrosswise", json!({"style": "custom", "rect": [0.0, 0.0, 90.0, 60.0]})).unwrap();
let out: Warp = serde_json::from_value(r["warp"].clone()).unwrap();
let m = out.mesh.unwrap();
assert_eq!((m.us.len(), m.vs.len()), (5, 5), "thirds, then the centre cut: {:?}", (m.us.clone(), m.vs.clone()));
for u in [0.0, 1.0 / 3.0, 0.5, 2.0 / 3.0, 1.0] {
assert!(m.us.iter().any(|k| (k - u).abs() < 1e-6), "{:?}", m.us);
assert!(m.vs.iter().any(|k| (k - u).abs() < 1e-6), "{:?}", m.vs);
}
}
#[test]
fn extreme_rect_is_bad_params_not_a_giant_warp() {
let mut s = session(8);
for cmd in ["edit.transform.warp", "layer.smartObjects.warp"] {
if cmd == "layer.smartObjects.warp" {
s.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap();
}
let e = s.execute(cmd, json!({"style": "arc", "bend": 50, "rect": [-1e30, -1e30, 1e30, 1e30]})).unwrap_err();
assert!(matches!(&e, EngineError::BadParams { msg, .. } if msg.contains("rect too large")), "{cmd}: {e}");
}
s.execute("layer.smartObjects.warp", json!({"style": "arc", "bend": 50, "rect": [10, 10, 60, 40]})).unwrap();
}
#[test]
fn warp_grid_fits_the_surface_and_rejects_bad_sizes() {
let mut s = session(8);
assert!(s.execute("edit.transform.warpGrid", json!({})).is_err());
assert!(s.execute("edit.transform.warpGrid", json!({"size": 0})).is_err());
assert!(s.execute("edit.transform.warpGrid", json!({"size": -3})).is_err());
assert!(s.execute("edit.transform.warpGrid", json!({"size": 65})).is_err());
assert!(s.execute("edit.transform.warpGrid", json!({"size": 1.5})).is_err());
assert!(s.execute("edit.transform.warpGrid", json!({"size": "custom"})).is_err());
assert!(s.execute("edit.transform.warpGrid", json!({"size": "nope"})).is_err());
assert!(s.execute("edit.transform.warpGrid", json!({"grid": [3, 3]})).is_err());
let b = [10.0, 10.0, 60.0, 40.0];
let mut bent = BezierMesh::identity(b, 1, 1);
bent.points[0][0] += 12.0;
let r = s.execute("edit.transform.warpGrid", json!({"warp": Warp::custom(bent.clone(), b), "size": 4})).unwrap();
let out: Warp = serde_json::from_value(r["warp"].clone()).unwrap();
let m = out.mesh.unwrap();
assert_eq!(out.style, WarpStyle::Custom);
assert_eq!(m.us, vec![0.0, 0.25, 0.5, 0.75, 1.0]);
assert_eq!(m.vs, m.us);
assert!((m.eval(0.0, 0.0)[0] - bent.eval(0.0, 0.0)[0]).abs() < 1e-3);
assert!((m.eval(1.0, 1.0)[0] - b[2]).abs() < 1e-3);
let r = s.execute("edit.transform.warpGrid", json!({"warp": Warp::custom(m, b), "size": "default"})).unwrap();
let back: Warp = serde_json::from_value(r["warp"].clone()).unwrap();
assert_eq!(back.mesh.unwrap().us.len(), 2);
}
/// A selection limits the warp of a linked mask too, as it does the pixels (#4175; Free
/// Transform and Perspective Warp did this already, #3083): the moved part of the mask lines
/// up with the moved pixels and the unselected part stays put.
#[test]
fn linked_mask_with_a_selection_warps_only_the_selected_region() {
let mut s = Session::new();
s.execute("file.new", json!({"width": 100, "height": 100})).unwrap();
s.execute("layer.new.layer", json!({})).unwrap();
s.execute("layer.layerMask.revealAll", json!({})).unwrap();
s.edit("paint", |doc, active| {
let l = doc.layer_mut(active.unwrap()).unwrap();
l.surface_mut().unwrap().fill_rect(Rect::new(10, 10, 30, 20), &[1.0, 0.0, 0.0, 1.0]);
// Hide the left quarter (selected) and the right quarter (not selected).
let m = l.mask.as_mut().unwrap();
m.surface.fill_rect(Rect::new(10, 10, 15, 20), &[0.0]);
m.surface.fill_rect(Rect::new(25, 10, 30, 20), &[0.0]);
m.linked = true;
Ok(())
})
.unwrap();
let mask_at = |s: &Session, x, y| {
let st = s.active().unwrap();
st.doc.layer(st.active_layer.unwrap()).unwrap().mask.as_ref().unwrap().surface.sample_channel(x, y, 0)
};
let alpha_at = |s: &Session, x, y| {
let st = s.active().unwrap();
st.doc.layer(st.active_layer.unwrap()).unwrap().surface().unwrap().pixel(x, y)[3]
};
s.execute("select.rect", json!({"x": 10, "y": 10, "width": 10, "height": 10})).unwrap();
// A mesh that translates the selected box 50 px down.
let b = [10.0, 10.0, 20.0, 20.0];
let mesh = BezierMesh::identity(b, 1, 1).map_points(|p| [p[0], p[1] + 50.0]);
s.execute("edit.transform.warp", json!({"rect": [10, 10, 20, 20], "mesh": mesh})).unwrap();
// Moved pixels and their mask line up.
assert!(alpha_at(&s, 12, 65) > 0.99);
assert!(mask_at(&s, 12, 65) < 0.01, "selected hidden quarter moved");
assert!(mask_at(&s, 17, 65) > 0.99, "selected visible quarter moved");
// The vacated area reads as revealed, with transparent pixels.
assert!(alpha_at(&s, 12, 15) < 0.01);
assert!(mask_at(&s, 12, 15) > 0.99);
// The unselected part of the mask stays, aligned with the unselected pixels.
assert!(alpha_at(&s, 27, 15) > 0.99);
assert!(mask_at(&s, 27, 15) < 0.01, "unselected mask stays put");
assert!(mask_at(&s, 27, 65) > 0.99, "and isn't copied along");
for (x, y) in [(11, 61), (13, 68), (16, 61), (18, 68)] {
let want = if x < 15 { 0.0 } else { 1.0 };
assert!((mask_at(&s, x, y) - want).abs() < 0.01, "({x},{y}): {}", mask_at(&s, x, y));
}
}
/// Esc stops the warp of a selected linked mask too, not only of the pixels. A fill layer has
/// no pixels to warp, so the mask is the only part that can see the cancel.
#[test]
fn a_cancelled_warp_stops_in_a_selected_linked_mask() {
let mut s = Session::new();
s.execute("file.new", json!({"width": 100, "height": 100})).unwrap();
s.execute("layer.newFillLayer.solidColor", json!({})).unwrap();
s.execute("layer.layerMask.revealAll", json!({})).unwrap();
s.edit("link", |doc, active| {
doc.layer_mut(active.unwrap()).unwrap().mask.as_mut().unwrap().linked = true;
Ok(())
})
.unwrap();
s.execute("select.rect", json!({"x": 10, "y": 10, "width": 10, "height": 10})).unwrap();
let st = s.active().unwrap();
let (sel, mut l) = (st.doc.selection.clone().unwrap(), st.doc.layer(st.active_layer.unwrap()).unwrap().clone());
let rect = Rect::new(10, 10, 20, 20);
let w = Warp::preset(WarpStyle::Flag, 35.0, rect_f(rect));
let ctl = Interrupt::cancel_only(&|| true);
let r = warp_layer(Some(&sel), Locks::default(), &mut l, &w, rect, Interp::Bicubic, None, &ctl);
assert!(matches!(r, Err(EngineError::Cancelled)), "{r:?}");
}
}