mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-29 04:17:11 -06:00
The 15 warp presets (Edit > Transform > Warp, smart object warps, Warp Text) were our own closed-form approximations, up to 70 px off Photoshop's: Arc kept its centre fixed and pulled the ends in where Photoshop keeps the inner edge's corners and raises the middle, Flag waved the wrong way at a third of the height, Squeeze shrank instead of pinching. Measured on Photoshop 27.11 (scripted renders of a grid image whose colours encode each pixel's position, 102 cases: every preset at bend ±50 and ±100, both distortions, vertical orientation, square, wide and tall boxes), every preset is a single bicubic Bezier patch (fits within 0.5 px). Its control grid: - Arc: each row a circular arc of b·π around one centre, the inner edge keeping its chord; Arch, Bulge, Arc Lower/Upper and Shell built from the same arcs. Circular in pixels on any box. - Flag, Wave, Fish, Rise, Fisheye, Inflate, Squeeze, Twist: control points moved linearly with the bend, in box units. - Vertical distortion scales the top and bottom rows about their midpoints and blends the rows between; horizontal distortion scales each column. Vertical orientation swaps the axes and the two distortions. All 102 cases land within 1 px (median) of Photoshop. On the corpus's Photoshop-made warped text, warp-arc-50 now spans 340x85 at (-3, 3) against Photoshop's 350x88 at (-7, 3), where it was 278x74 at (28, 62). Presets also convert to custom meshes exactly now (to_mesh splits the patch instead of fitting). Tests that pinned the old shapes now assert Photoshop's; presets_match_photoshop checks measured points. Still open: Warp Text warps over our line box, while Photoshop uses the type layer's stored `bounds`; with those the five corpus warp files land within a few px (arc, flag, bulge with distortion, twist, vertical rise). Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
756 lines
36 KiB
Rust
756 lines
36 KiB
Rust
//! 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};
|
||
use photocraft_doc::{Document, Layer, LayerContent, LayerId, Locks, Rect};
|
||
use photocraft_geom::warp::{BezierMesh, Warp, WarpStyle};
|
||
use photocraft_raster::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"`), `"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 w: Warp = serde_json::from_value(w.clone()).map_err(|e| bad(cmd, format!("bad `warp`: {e}")))?;
|
||
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::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)
|
||
}
|
||
|
||
fn warp_layer(doc_sel: Option<&Surface>, group: Locks, l: &mut Layer, w: &Warp, rect: Rect, interp: Interp) -> Result<()> {
|
||
if l.locks.position && l.name == "Background" {
|
||
l.locks.position = false;
|
||
l.locks.transparency = false;
|
||
l.name = "Layer 0".into();
|
||
}
|
||
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);
|
||
match &mut l.content {
|
||
LayerContent::Group(g) => {
|
||
for c in g.children.iter_mut() {
|
||
warp_layer(None, locks, c, w, rect, interp)?;
|
||
}
|
||
}
|
||
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 => Some(Warp::custom(w.to_mesh(1, 1).map_points(|p| hom_apply(&inv, p)), 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(c, rect, &map, interp);
|
||
}
|
||
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(&lifted, rect, &map, interp);
|
||
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(surf, rect, &map, interp);
|
||
let mut rest = surf.convert(photocraft_color::PixelFormat::new(fmt.mode, fmt.sample, true));
|
||
crate::pixels::try_clear_surface(&mut rest, rect, None)?;
|
||
crate::transform_cmds::composite_over(&mut rest, &warped);
|
||
rest.prune();
|
||
rest
|
||
}
|
||
};
|
||
}
|
||
}
|
||
}
|
||
if let Some(m) = l.mask.as_mut()
|
||
&& m.linked
|
||
{
|
||
m.surface = warp_mesh_gray(&m.surface, &map, interp);
|
||
}
|
||
if let Some(vm) = l.vector_mask.as_mut()
|
||
&& vm.linked
|
||
{
|
||
vm.path = crate::vector_cmds::map_path(&vm.path, None, Some(w))?;
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// 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")),
|
||
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 snapshot = doc.clone();
|
||
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(&snapshot, 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}));
|
||
}
|
||
let interp = Interp::parse(p.get("interpolation").and_then(Value::as_str).unwrap_or("bicubic"));
|
||
s.edit("Warp", |doc, _| {
|
||
let sel = doc.selection.clone();
|
||
let is_group = doc.layer(id).is_some_and(Layer::is_group);
|
||
let group = crate::transform_cmds::group_locks(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)?;
|
||
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(())
|
||
})?;
|
||
Ok(json!({"layer": id.0, "changed": true, "rect": [rect.x0, rect.y0, rect.x1, rect.y1]}))
|
||
}
|
||
|
||
#[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","bend":%=50,"hDistort":%,"vDistort":%,"vertical":bool,"mesh":{"us":[0,…,1],"vs":[0,…,1],"points":[[x,y]…]} ((3c+1)×(3r+1) control points, row-major, document px),"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::*;
|
||
|
||
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());
|
||
}
|
||
|
||
#[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);
|
||
}
|
||
}
|