Add style warp splits and grid presets (#388)

* Add warp splits and grid presets

* Translate the warp grid labels

* Satisfy clippy on the warp section-edge check

* Address the warp review notes

* Format the warp handle arm

* i18n: French and Indonesian warp grid labels

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Sebastian Solano 2026-10-07 05:40:00 -07:00 committed by GitHub
parent 90df9cfc15
commit fa0defc56b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 766 additions and 115 deletions

View File

@ -5,8 +5,8 @@
//! (`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 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).
//! 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};
@ -233,9 +233,21 @@ pub enum Split {
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),
@ -271,15 +283,25 @@ pub fn split_warp(w: &Warp, at: Option<[f64; 2]>, how: Split) -> Option<Warp> {
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 p.get("warp").is_some() || p.get("mesh").is_some() || p.get("style").is_some() {
let rect = p.get("rect").and_then(Value::as_array).map(|r| {
let v: Vec<f64> = r.iter().map(|x| x.as_f64().unwrap_or(0.0)).collect();
[v[0], v.get(1).copied().unwrap_or(0.0), v.get(2).copied().unwrap_or(1.0), v.get(3).copied().unwrap_or(1.0)]
});
let rect = rect.unwrap_or([0.0, 0.0, 1.0, 1.0]);
let w = warp_from_params(cmd, p, rect)?;
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}));
}
@ -309,9 +331,76 @@ fn split(s: &mut Session, p: &Value, cmd: &str, how: Split) -> Result<Value> {
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"}"##;
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"##;
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",
@ -373,6 +462,16 @@ pub fn specs() -> Vec<CommandSpec> {
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),
},
]
}
@ -450,6 +549,32 @@ mod tests {
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);
@ -497,4 +622,44 @@ mod tests {
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 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);
}
}

View File

@ -1774,7 +1774,7 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect:
});
if let Some((vertical, _)) = guide_hover {
ui.ctx().set_cursor_icon(if vertical { egui::CursorIcon::ResizeHorizontal } else { egui::CursorIcon::ResizeVertical });
} else if let Some(c) = response.hover_pos().and_then(|p| crate::transform_tool::cursor(app, xf.to_doc(p))) {
} else if let Some(c) = response.hover_pos().and_then(|p| crate::transform_tool::cursor(app, xf.to_doc(p), ui.input(|i| i.modifiers.alt))) {
ui.ctx().set_cursor_icon(c);
} else if let Some(c) = response.hover_pos().filter(|_| tool == Tool::Crop).and_then(|p| crate::crop_ui::cursor(app, xf.to_doc(p))) {
ui.ctx().set_cursor_icon(c);

View File

@ -2009,6 +2009,10 @@
Opening… Otevírání…
Reading… Načítání…
Working… Zpracování…
3 x 3 3 × 3
4 x 4 4 × 4
5 x 5 5 × 5
Grid: Mřížka:
# Rendering mode and recovery
Rendering Mode Režim vykreslování

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

@ -1995,6 +1995,10 @@
Opening… Abriendo…
Reading… Leyendo…
Working… Procesando…
3 x 3 3 × 3
4 x 4 4 × 4
5 x 5 5 × 5
Grid: Rejilla:
# Tool, filter, warp-style, path, layer-comp and metadata names that fell outside the `tl!` test.
# Spanish follows the tool wording used above (Herramienta X).

Can't render this file because it contains an unexpected character in line 4 and column 47.

View File

@ -2484,3 +2484,9 @@ cameraRaw Blending Fusion
Patch Tool Outil Pièce
Patch: Pièce :
Lasso around an area, then drag the selection Entourez une zone au lasso, puis faites glisser la sélection
# Warp grid presets (#388)
3 x 3 3 × 3
4 x 4 4 × 4
5 x 5 5 × 5
Grid: Grille :

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

@ -2471,3 +2471,9 @@ cameraRaw Blending Pembauran
GPU Backend Backend GPU
Details Detail
Rendering mode Mode rendering
# Warp grid presets (#388)
3 x 3 3 × 3
4 x 4 4 × 4
5 x 5 5 × 5
Grid: Kisi-kisi:

Can't render this file because it contains an unexpected character in line 4 and column 47.

View File

@ -1999,6 +1999,10 @@
Opening… 開いています…
Reading… 読み込み中…
Working… 処理中…
3 x 3 3 × 3
4 x 4 4 × 4
5 x 5 5 × 5
Grid: グリッド:
# Rendering mode and recovery
Rendering Mode レンダリングモード

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

@ -2277,6 +2277,10 @@
Opening… Открытие…
Reading… Чтение…
Working… Обработка…
3 x 3 3 × 3
4 x 4 4 × 4
5 x 5 5 × 5
Grid: Сетка:
# Rendering mode and recovery
Rendering Mode Режим отрисовки

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

@ -1911,6 +1911,10 @@
Brush picker 画笔选择器
Erase 擦除
Right click with painting tools 使用绘画工具时右键单击
3 x 3 3 × 3
4 x 4 4 × 4
5 x 5 5 × 5
Grid: 网格:
Freeze Lasso: drag to freeze an area, Alt-drag to thaw it (L) 冻结套索:拖动以冻结区域,按住 Alt 拖动以解冻 (L)
Shadow Clipping Warning (U) 阴影剪切警告 (U)

Can't render this file because it has a wrong number of fields in line 14.

View File

@ -1991,6 +1991,10 @@
Opening… 正在開啟…
Reading… 正在讀取…
Working… 處理中…
3 x 3 3 × 3
4 x 4 4 × 4
5 x 5 5 × 5
Grid: 格點:
# Rendering mode and recovery
Rendering Mode 渲染模式

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

@ -106,24 +106,28 @@ pub fn tool_icon(t: Tool) -> &'static str {
}
}
/// Square icon button: transparent until hovered; `selected` gets the accent treatment.
pub fn button(ui: &mut egui::Ui, name: &str, box_size: f32, selected: bool, tooltip: &str) -> Response {
/// Selected and hover fill shared by square icon buttons. Returns the icon tint.
pub fn button_chrome(ui: &egui::Ui, rect: Rect, selected: bool, hovered: bool) -> Color32 {
let t = Tokens::get(ui.ctx());
let (rect, resp) = ui.allocate_exact_size(Vec2::splat(box_size), Sense::click());
let hovered = resp.hovered();
if selected {
ui.painter().rect_filled(rect, t.radius_sm, t.accent_soft);
ui.painter().rect_stroke(rect, t.radius_sm, egui::Stroke::new(1.0, t.accent_border), egui::StrokeKind::Inside);
} else if hovered {
ui.painter().rect_filled(rect, t.radius_sm, t.hover);
}
let tint = if selected {
if selected {
t.accent_text
} else if hovered {
t.text
} else {
t.icon
};
}
}
/// Square icon button: transparent until hovered; `selected` gets the accent treatment.
pub fn button(ui: &mut egui::Ui, name: &str, box_size: f32, selected: bool, tooltip: &str) -> Response {
let (rect, resp) = ui.allocate_exact_size(Vec2::splat(box_size), Sense::click());
let tint = button_chrome(ui, rect, selected, resp.hovered());
paint(ui, rect, name, (box_size * 0.52).round(), tint);
if tooltip.is_empty() { resp } else { resp.on_hover_text(tl!(tooltip)) }
}

View File

@ -385,6 +385,9 @@ pub(crate) fn invoke_unguarded(app: &mut PhotocraftApp, ctx: &egui::Context, id:
let at = params.get("at").and_then(Value::as_array).and_then(|a| Some([a.first()?.as_f64()?, a.get(1)?.as_f64()?]));
crate::transform_tool::split(app, id, at).map(|_| json!({"transform": app.ui.transform}))
}
"edit.transform.warpGrid" if app.ui.transform.as_ref().is_some_and(|t| t.warp.is_some()) && params.get("warp").is_none() => {
crate::transform_tool::edit_session_warp(app, id, &params).map(|_| json!({"transform": app.ui.transform}))
}
sz if crate::sizing::is_sizing(sz) && params.as_object().is_none_or(|o| o.is_empty()) => {
Ok(json!({"dialog": crate::sizing::open(app, sz).ok_or("no document")?}))
}

View File

@ -20,6 +20,32 @@ use crate::PhotocraftApp;
use crate::canvas::{ToolEvent, ViewXform};
use crate::state::TransformSession;
/// Which Split button is armed. The guide follows the pointer and the split is added on release.
#[derive(Clone, Copy, PartialEq, Eq)]
enum SplitTool {
Cross,
Vertical,
Horizontal,
}
impl SplitTool {
fn command(self) -> &'static str {
match self {
SplitTool::Cross => "edit.transform.splitWarpCrosswise",
SplitTool::Vertical => "edit.transform.splitWarpVertically",
SplitTool::Horizontal => "edit.transform.splitWarpHorizontally",
}
}
fn icon(self) -> &'static str {
match self {
SplitTool::Cross => "cross",
SplitTool::Vertical => "vertical",
SplitTool::Horizontal => "horizontal",
}
}
}
/// Preview state that isn't serialisable: the document without the transformed pixels, and
/// full-resolution textures of those pixels (transform_tex.rs).
pub struct TransformPreview {
@ -30,6 +56,13 @@ pub struct TransformPreview {
gesture: Option<Gesture>,
/// Warp-mode drag: (control point, pointer start, mesh points at the start).
warp_drag: Option<(usize, [f64; 2], Vec<[f64; 2]>)>,
/// Armed split tool. None while the buttons are off and control points drag as usual.
split_tool: Option<SplitTool>,
/// Document point the split guide is following (a drag, or the hover while placing).
split_pointer: Option<[f64; 2]>,
split_placing: bool,
/// Option-click places a split without a button armed.
split_quick: bool,
steps: Steps,
}
@ -138,6 +171,10 @@ pub fn begin(app: &mut PhotocraftApp, ctx: &egui::Context) -> Result<(), String>
opacity: layer.opacity * layer.fill_opacity,
gesture: None,
warp_drag: None,
split_tool: None,
split_pointer: None,
split_placing: false,
split_quick: false,
steps: Steps::default(),
});
app.ui.transform = Some(TransformSession {
@ -225,8 +262,19 @@ fn begin_lone(
let max_side = ctx.input(|i| i.max_texture_side).max(app.gpu.as_ref().map_or(1, |g| g.max_texture_side()));
let (image, uv) = crate::transform_tex::read_surface(&lifted, None, b, max_side);
let texture = crate::transform_tex::PreviewTextures::new(ctx, format!("transform-{session}"), image, uv);
app.transform_preview =
Some(TransformPreview { session, doc: Arc::new(pd), texture, opacity: 0.6, gesture: None, warp_drag: None, steps: Steps::default() });
app.transform_preview = Some(TransformPreview {
session,
doc: Arc::new(pd),
texture,
opacity: 0.6,
gesture: None,
warp_drag: None,
split_tool: None,
split_pointer: None,
split_placing: false,
split_quick: false,
steps: Steps::default(),
});
app.ui.transform = Some(TransformSession {
session,
layer,
@ -271,8 +319,19 @@ pub fn begin_selection(app: &mut PhotocraftApp, ctx: &egui::Context) -> Result<(
let texture = crate::transform_tex::PreviewTextures::new(ctx, format!("transform-{session}"), egui::ColorImage::new([tw, th], px), uv);
let mut pd = (*doc).clone();
pd.selection = None;
app.transform_preview =
Some(TransformPreview { session, doc: Arc::new(pd), texture, opacity: 1.0, gesture: None, warp_drag: None, steps: Steps::default() });
app.transform_preview = Some(TransformPreview {
session,
doc: Arc::new(pd),
texture,
opacity: 1.0,
gesture: None,
warp_drag: None,
split_tool: None,
split_pointer: None,
split_placing: false,
split_quick: false,
steps: Steps::default(),
});
app.ui.transform = Some(TransformSession {
session,
layer: layer.0,
@ -336,6 +395,12 @@ pub fn leave_warp(app: &mut PhotocraftApp) {
if let Some(t) = app.ui.transform.as_mut() {
t.warp = None;
}
if let Some(pv) = app.transform_preview.as_mut() {
pv.split_tool = None;
pv.split_pointer = None;
pv.split_placing = false;
pv.split_quick = false;
}
}
/// Texels of the moving pixels over `b`: full resolution unless the long side exceeds the GPU's
@ -561,7 +626,7 @@ pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) ->
let Some(t) = app.ui.transform.clone() else { return false };
let tol = handle_tolerance(app);
if t.warp.is_some() {
warp_pointer(app, ev, tol);
warp_pointer(app, ev, tol, mods);
return true;
}
let Some(pv) = app.transform_preview.as_mut() else { return false };
@ -752,21 +817,109 @@ fn apply_drag(s: &mut TransformSession, g: Gesture, p: [f64; 2], mods: egui::Mod
}
}
/// Index of the warp control point within `tol` of `p` (nearest).
/// An anchor or a handle on a section line. The inner control points of a patch are not shown.
fn on_section_edge(i: usize, j: usize) -> bool {
i.is_multiple_of(3) || j.is_multiple_of(3)
}
/// Index of the warp control point within `tol` of `p` (nearest anchor or handle).
fn warp_hit(w: &Warp, p: [f64; 2], tol: f64) -> Option<usize> {
let m = w.mesh.as_ref()?;
let nx = m.nx();
m.points
.iter()
.enumerate()
.filter(|(i, _)| on_section_edge(i % nx, i / nx))
.map(|(i, q)| (i, ((q[0] - p[0]).powi(2) + (q[1] - p[1]).powi(2)).sqrt()))
.filter(|(_, d)| *d < tol)
.min_by(|a, b| a.1.total_cmp(&b.1))
.map(|(i, _)| i)
}
/// Drags a warp control point. Anchors (patch corners) carry their handles along, as in
/// Photoshop. Preset warps turn into a custom mesh on the first drag.
fn warp_pointer(app: &mut PhotocraftApp, ev: ToolEvent, tol: f64) {
/// A 1×1 mesh (Grid › Default) draws rule-of-thirds guides. Denser presets are real patch
/// boundaries only — a 3×3 is three cells, not a third-line lattice inside every cell.
fn single_patch(mesh: &BezierMesh) -> bool {
mesh.us.len() == 2 && mesh.vs.len() == 2
}
/// Option-click picks the split: crosswise in the open, and the perpendicular split when the
/// pointer is close to an existing grid line.
fn quick_split_kind(mesh: &BezierMesh, bounds: [f64; 4], p: [f64; 2]) -> SplitTool {
let (s, t) = mesh.param_at(p);
let w = (bounds[2] - bounds[0]).abs().max(1.0);
let h = (bounds[3] - bounds[1]).abs().max(1.0);
let su = (8.0 / w).clamp(0.02, 0.12);
let sv = (8.0 / h).clamp(0.02, 0.12);
let thirds = single_patch(mesh);
let near = |knots: &[f64], v: f64, tol: f64| {
knots.iter().any(|k| *k > 0.0 && *k < 1.0 && (k - v).abs() < tol) || (thirds && [1.0 / 3.0, 2.0 / 3.0].iter().any(|k| (k - v).abs() < tol))
};
match (near(&mesh.us, s, su), near(&mesh.vs, t, sv)) {
(true, false) => SplitTool::Horizontal,
(false, true) => SplitTool::Vertical,
_ => SplitTool::Cross,
}
}
fn split_cursor(tool: SplitTool) -> CursorIcon {
match tool {
SplitTool::Vertical => CursorIcon::ResizeHorizontal,
SplitTool::Horizontal => CursorIcon::ResizeVertical,
SplitTool::Cross => CursorIcon::Crosshair,
}
}
/// Armed split tool, or Option held: the guide tracks the pointer and the split lands on release.
/// Returns false when this event is an ordinary control-point drag.
fn split_gesture(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) -> bool {
let armed = app.transform_preview.as_ref().and_then(|p| p.split_tool);
let placing = app.transform_preview.as_ref().is_some_and(|p| p.split_placing);
match ev {
ToolEvent::Down { x, y, .. } if armed.is_some() || mods.alt => {
if let Some(pv) = app.transform_preview.as_mut() {
pv.split_placing = true;
pv.split_pointer = Some([x, y]);
pv.split_quick = armed.is_none();
}
true
}
ToolEvent::Move { x, y, .. } if placing => {
if let Some(pv) = app.transform_preview.as_mut() {
pv.split_pointer = Some([x, y]);
}
true
}
ToolEvent::Up { x, y } if placing => {
let quick = app.transform_preview.as_ref().is_some_and(|p| p.split_quick);
if let Some(pv) = app.transform_preview.as_mut() {
pv.split_placing = false;
pv.split_pointer = None;
pv.split_quick = false;
}
let kind = if let Some(tool) = armed {
Some(tool)
} else if quick {
app.ui.transform.as_ref().and_then(|t| t.warp.as_ref()).map(|w| quick_split_kind(&w.to_mesh(1, 1), w.bounds, [x, y]))
} else {
None
};
if let Some(kind) = kind {
// On an existing line, or a click the drag path also delivers, this is a no-op.
let _ = split(app, kind.command(), Some([x, y]));
}
true
}
_ => false,
}
}
/// Drags a warp control point. Anchors (patch corners) carry their handles along. Preset warps
/// turn into a custom mesh on the first drag. An armed split tool (or Option) places a split
/// where the pointer is released instead of moving a point.
fn warp_pointer(app: &mut PhotocraftApp, ev: ToolEvent, tol: f64, mods: egui::Modifiers) {
if split_gesture(app, ev, mods) {
return;
}
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 {
@ -804,26 +957,43 @@ fn warp_pointer(app: &mut PhotocraftApp, ev: ToolEvent, tol: f64) {
}
}
/// Applies a split-warp command to the mesh being edited (at the box centre unless `at`).
pub fn split(app: &mut PhotocraftApp, id: &str, at: Option<[f64; 2]>) -> Result<(), String> {
/// Runs a warp-edit command against the mesh in the open Warp session and writes the mesh back.
pub fn edit_session_warp(app: &mut PhotocraftApp, id: &str, params: &serde_json::Value) -> Result<(), String> {
let Some(w) = app.ui.transform.as_ref().and_then(|t| t.warp.clone()) else { return Err("not warping".into()) };
let mut p = json!({"warp": w});
if let Some(a) = at {
p["at"] = json!(a);
let mut p = if params.is_object() { params.clone() } else { json!({}) };
if p.get("warp").is_none() {
p["warp"] = serde_json::to_value(&w).map_err(|e| e.to_string())?;
}
let r = app.session.execute(id, p).map_err(|e| e.to_string())?;
let nw: Warp = serde_json::from_value(r["warp"].clone()).map_err(|e| e.to_string())?;
let raw = r.get("warp").cloned().ok_or_else(|| "warp command returned no mesh".to_string())?;
let nw: Warp = serde_json::from_value(raw).map_err(|e| e.to_string())?;
if let Some(t) = app.ui.transform.as_mut() {
t.warp = Some(nw);
}
Ok(())
}
/// Cursor for hovering a document point while transforming.
pub fn cursor(app: &PhotocraftApp, p: [f64; 2]) -> Option<CursorIcon> {
/// Applies a split-warp command to the mesh being edited (at the box centre unless `at`).
pub fn split(app: &mut PhotocraftApp, id: &str, at: Option<[f64; 2]>) -> Result<(), String> {
let mut p = json!({});
if let Some(a) = at {
p["at"] = json!(a);
}
edit_session_warp(app, id, &p)
}
/// Cursor for hovering a document point while transforming. `alt` is Option, which arms a quick
/// split while a warp is active.
pub fn cursor(app: &PhotocraftApp, p: [f64; 2], alt: bool) -> Option<CursorIcon> {
let t = app.ui.transform.as_ref()?;
let tol = handle_tolerance(app);
if let Some(w) = &t.warp {
if let Some(tool) = app.transform_preview.as_ref().and_then(|pv| pv.split_tool) {
return Some(split_cursor(tool));
}
if alt {
return Some(split_cursor(quick_split_kind(&w.to_mesh(1, 1), w.bounds, p)));
}
return Some(if w.style != WarpStyle::Custom || warp_hit(w, p, tol).is_some() { CursorIcon::Crosshair } else { CursorIcon::Default });
}
Some(match hit(t, p, tol) {
@ -842,7 +1012,14 @@ pub fn draw_overlay(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform
let (Some(t), Some(pv)) = (&app.ui.transform, &app.transform_preview) else { return };
let scr = |q: [f64; 2]| xf.to_screen(q[0] as f32, q[1] as f32);
if let Some(w) = &t.warp {
draw_warp(painter, xf, t, w, pv);
let over_canvas = painter.ctx().input(|i| i.pointer.hover_pos()).filter(|p| xf.rect.contains(*p)).map(|p| xf.to_doc(p));
let at = pv.split_pointer.or(over_canvas);
let alt = painter.ctx().input(|i| i.modifiers.alt);
let guide = at.and_then(|p| {
let tool = pv.split_tool.or_else(|| (alt || pv.split_quick).then(|| quick_split_kind(&w.to_mesh(1, 1), w.bounds, p)));
tool.map(|tool| (tool, p))
});
draw_warp(painter, xf, t, w, pv, guide);
return;
}
if let Some(h) = Homography::rect_to_quad([0.0, 0.0, 1.0, 1.0], t.quad) {
@ -884,9 +1061,11 @@ pub fn draw_overlay(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform
painter.line_segment([c - vec2(0.0, 8.0), c + vec2(0.0, 8.0)], Stroke::new(1.0, Color32::WHITE));
}
/// Warp preview (the moving pixels on a fine textured mesh) plus the control mesh: grid curves at
/// patch boundaries and thirds, anchors (squares), handles and their arms.
fn draw_warp(painter: &egui::Painter, xf: &ViewXform, t: &TransformSession, w: &Warp, pv: &TransformPreview) {
/// Warp preview (the moving pixels on a fine textured mesh) plus the control mesh. Patch
/// boundaries are solid. A single patch (Grid › Default) also draws its rule-of-thirds guides;
/// 3×3, 4×4 and 5×5 are just those even cells, with an anchor at every intersection. `guide` is
/// the split line following the pointer.
fn draw_warp(painter: &egui::Painter, xf: &ViewXform, t: &TransformSession, w: &Warp, pv: &TransformPreview, guide: Option<(SplitTool, [f64; 2])>) {
let scr = |q: [f64; 2]| xf.to_screen(q[0] as f32, q[1] as f32);
let r = t.rect;
let n = 32;
@ -921,10 +1100,14 @@ fn draw_warp(painter: &egui::Painter, xf: &ViewXform, t: &TransformSession, w: &
})
.collect()
};
// Patch boundaries (solid) and thirds (thin), like Photoshop's 3×3 grid per patch.
// 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);
for (k, win) in m.us.windows(2).enumerate() {
for third in [1.0, 2.0] {
painter.add(egui::Shape::line(curve(Some(win[0] + (win[1] - win[0]) * third / 3.0), None), thin));
if thirds {
for third in [1.0, 2.0] {
painter.add(egui::Shape::line(curve(Some(win[0] + (win[1] - win[0]) * third / 3.0), None), thin));
}
}
if k == 0 {
painter.add(egui::Shape::line(curve(Some(win[0]), None), line));
@ -932,38 +1115,55 @@ fn draw_warp(painter: &egui::Painter, xf: &ViewXform, t: &TransformSession, w: &
painter.add(egui::Shape::line(curve(Some(win[1]), None), line));
}
for (k, win) in m.vs.windows(2).enumerate() {
for third in [1.0, 2.0] {
painter.add(egui::Shape::line(curve(None, Some(win[0] + (win[1] - win[0]) * third / 3.0)), thin));
if thirds {
for third in [1.0, 2.0] {
painter.add(egui::Shape::line(curve(None, Some(win[0] + (win[1] - win[0]) * third / 3.0)), thin));
}
}
if k == 0 {
painter.add(egui::Shape::line(curve(None, Some(win[0])), line));
}
painter.add(egui::Shape::line(curve(None, Some(win[1])), line));
}
if w.style != WarpStyle::Custom {
return; // presets are edited from the options bar
}
let (nx, ny) = (m.nx(), m.ny());
for j in 0..ny {
for i in 0..nx {
let p = scr(m.point(i, j));
let anchor = i % 3 == 0 && j % 3 == 0;
let handle = (i % 3 == 0) != (j % 3 == 0);
if anchor {
let r = egui::Rect::from_center_size(p, vec2(7.0, 7.0));
painter.rect_filled(r, 0.0, Color32::WHITE);
painter.rect_stroke(r, 0.0, line, egui::StrokeKind::Inside);
} else if handle {
// Arm to the nearest anchor along the row/column.
let (ai, aj) = if i % 3 == 0 { (i, if j % 3 == 1 { j - 1 } else { j + 1 }) } else { (if i % 3 == 1 { i - 1 } else { i + 1 }, j) };
painter.line_segment([scr(m.point(ai, aj)), p], line);
painter.circle_filled(p, 3.5, Color32::WHITE);
painter.circle_stroke(p, 3.5, line);
} else {
painter.circle_filled(p, 2.5, accent);
if w.style == WarpStyle::Custom {
let (nx, ny) = (m.nx(), m.ny());
for j in 0..ny {
for i in 0..nx {
if !on_section_edge(i, j) {
continue;
}
let p = scr(m.point(i, j));
let anchor = i.is_multiple_of(3) && j.is_multiple_of(3);
if anchor {
let r = egui::Rect::from_center_size(p, vec2(7.0, 7.0));
painter.rect_filled(r, 0.0, Color32::WHITE);
painter.rect_stroke(r, 0.0, line, egui::StrokeKind::Inside);
} else {
// Arm to the nearest anchor along the row/column.
let (ai, aj) = if i.is_multiple_of(3) { (i, if j % 3 == 1 { j - 1 } else { j + 1 }) } else { (if i % 3 == 1 { i - 1 } else { i + 1 }, j) };
painter.line_segment([scr(m.point(ai, aj)), p], line);
painter.circle_filled(p, 3.5, Color32::WHITE);
painter.circle_stroke(p, 3.5, line);
}
}
}
}
if let Some((tool, p)) = guide {
let (s, t) = m.param_at(p);
let under = Stroke::new(3.0, Color32::BLACK);
let over = Stroke::new(1.25, Color32::WHITE);
let mut strokes = Vec::new();
if matches!(tool, SplitTool::Vertical | SplitTool::Cross) {
strokes.push(curve(Some(s), None));
}
if matches!(tool, SplitTool::Horizontal | SplitTool::Cross) {
strokes.push(curve(None, Some(t)));
}
for pts in strokes {
painter.add(egui::Shape::line(pts.clone(), under));
painter.add(egui::Shape::line(pts, over));
}
}
}
/// How much the box enlarges its pixels: the longest edge relative to the original (picks the
@ -986,15 +1186,34 @@ fn readout(t: &TransformSession) -> (f64, f64, f64, f64) {
(sx, sy, e[1].atan2(e[0]).to_degrees(), 0.0)
}
/// Options bar while transforming: reference point X/Y, W/H %, angle, interpolation, ✓ / ⊘.
/// Width of the mode, cancel and commit cluster kept on the right of the options bar.
const ACTIONS_W: f32 = 140.0;
/// Options bar while transforming: reference point X/Y, W/H %, angle, interpolation, and, pinned
/// to the right, the warp switch, cancel and commit.
pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
let Some(t) = app.ui.transform.clone() else { return };
let tk = crate::theme::Tokens::get(ui.ctx());
if let Some(w) = t.warp.clone() {
warp_options_bar(app, ui, &w);
return;
let bar = ui.available_rect_before_wrap();
let split = (bar.right() - ACTIONS_W).max(bar.left());
let fields = egui::Rect::from_min_max(bar.min, egui::pos2(split, bar.bottom()));
let actions = egui::Rect::from_min_max(egui::pos2(split, bar.top()), bar.max);
{
let mut row =
ui.new_child(egui::UiBuilder::new().id_salt("transform-fields").max_rect(fields).layout(egui::Layout::left_to_right(egui::Align::Center)));
row.set_clip_rect(fields);
if let Some(w) = t.warp.clone() {
warp_fields(app, &mut row, &w);
} else {
transform_fields(app, &mut row, &t);
}
}
let (sx, sy, angle, _) = readout(&t);
let mut row = ui.new_child(egui::UiBuilder::new().id_salt("transform-actions").max_rect(actions).layout(egui::Layout::right_to_left(egui::Align::Center)));
transform_actions(app, &mut row, t.warp.is_some());
}
fn transform_fields(app: &mut PhotocraftApp, ui: &mut egui::Ui, t: &TransformSession) {
let tk = crate::theme::Tokens::get(ui.ctx());
let (sx, sy, angle, _) = readout(t);
let lbl = |ui: &mut egui::Ui, s: &str| {
ui.label(egui::RichText::new(s).color(tk.text_dim).size(12.0));
};
@ -1049,25 +1268,10 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
{
s.interpolation = interp;
}
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
ui.add_space(8.0);
if crate::icons::button(ui, "check", 24.0, false, &crate::i18n::fmt(tl!("Commit transform ({key})"), &[("key", &crate::shortcuts::pretty("Enter"))]))
.clicked()
{
commit(app);
}
if crate::icons::button(ui, "ban", 24.0, false, tl!("Cancel transform (Esc)")).clicked() {
cancel(app);
}
crate::widgets::vline(ui, 22.0);
if crate::icons::button(ui, "grid-3x3", 24.0, false, tl!("Switch between free transform and warp modes")).clicked() {
enter_warp(app);
}
});
}
/// Options bar in Warp mode: style, bend and distortions, split buttons, mode toggle, ✓ / ⊘.
fn warp_options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui, w: &Warp) {
/// Options bar fields in Warp mode: style, bend, the split icons and the grid menu.
fn warp_fields(app: &mut PhotocraftApp, ui: &mut egui::Ui, w: &Warp) {
let tk = crate::theme::Tokens::get(ui.ctx());
let lbl = |ui: &mut egui::Ui, s: &str| {
ui.label(egui::RichText::new(s).color(tk.text_dim).size(12.0));
@ -1109,34 +1313,128 @@ fn warp_options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui, w: &Warp) {
} else {
crate::widgets::vline(ui, 22.0);
lbl(ui, tl!("Split:"));
for (id, label) in [
("edit.transform.splitWarpCrosswise", "Crosswise"),
("edit.transform.splitWarpVertically", "Vertical"),
("edit.transform.splitWarpHorizontally", "Horizontal"),
("edit.transform.removeWarpSplit", tl!("Remove")),
let armed = app.transform_preview.as_ref().and_then(|p| p.split_tool);
for (tool, tip) in [
(SplitTool::Cross, tl!("Split Warp Crosswise")),
(SplitTool::Vertical, tl!("Split Warp Vertically")),
(SplitTool::Horizontal, tl!("Split Warp Horizontally")),
] {
if crate::widgets::secondary_button(ui, label, 0.0).clicked()
&& let Err(e) = split(app, id, None)
let on = armed == Some(tool);
if split_icon(ui, tool.icon(), tip, on).clicked()
&& let Some(pv) = app.transform_preview.as_mut()
{
app.ui.status = e;
pv.split_tool = if on { None } else { Some(tool) };
pv.split_placing = false;
pv.split_pointer = None;
pv.split_quick = false;
}
}
}
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
ui.add_space(8.0);
if crate::icons::button(ui, "check", 24.0, false, &crate::i18n::fmt(tl!("Commit warp ({key})"), &[("key", &crate::shortcuts::pretty("Enter"))]))
.clicked()
{
commit(app);
}
if crate::icons::button(ui, "ban", 24.0, false, tl!("Cancel warp (Esc)")).clicked() {
cancel(app);
}
crate::widgets::vline(ui, 22.0);
if crate::icons::button(ui, "grid-3x3", 24.0, true, tl!("Switch between free transform and warp modes")).clicked() {
leave_warp(app);
lbl(ui, tl!("Grid:"));
let mesh = w.mesh.clone().unwrap_or_else(|| BezierMesh::identity(w.bounds, 1, 1));
let mut grid = warp_grid_id(&mesh).to_string();
let opts = [
("custom".to_string(), tl!("Custom")),
("default".to_string(), tl!("Default")),
("3".to_string(), tl!("3 x 3")),
("4".to_string(), tl!("4 x 4")),
("5".to_string(), tl!("5 x 5")),
];
if crate::widgets::dropdown(ui, "warp-grid", &mut grid, &opts, 92.0)
&& let Some(n) = match grid.as_str() {
"default" => Some(1),
"3" => Some(3),
"4" => Some(4),
"5" => Some(5),
_ => None,
}
{
let _ = edit_session_warp(app, "edit.transform.warpGrid", &json!({ "size": n }));
}
});
}
}
/// Grid menu readout: Default is 1×1, the numbered entries are uniform n×n patches, and anything
/// split by hand reads as Custom.
fn warp_grid_id(mesh: &BezierMesh) -> &'static str {
let cols = mesh.us.len().saturating_sub(1);
let rows = mesh.vs.len().saturating_sub(1);
let even = |k: &[f64]| {
let n = k.len().saturating_sub(1);
n > 0 && k.iter().enumerate().all(|(i, v)| (v - i as f64 / n as f64).abs() < 1e-4)
};
if cols == rows && even(&mesh.us) && even(&mesh.vs) {
match cols {
1 => "default",
3 => "3",
4 => "4",
5 => "5",
_ => "custom",
}
} else {
"custom"
}
}
/// One of the three Split icons. `on` is the armed tool: the line then follows the pointer.
fn split_icon(ui: &mut egui::Ui, kind: &str, tip: &str, on: bool) -> egui::Response {
let (rect, resp) = ui.allocate_exact_size(vec2(22.0, 22.0), egui::Sense::click());
let ink = crate::icons::button_chrome(ui, rect, on, resp.hovered());
let c = rect.center();
let stroke = Stroke::new(1.15, ink);
let box_r = egui::Rect::from_center_size(c, vec2(13.0, 13.0));
ui.painter().rect_stroke(box_r, 2.0, stroke, egui::StrokeKind::Inside);
let h = 5.2;
match kind {
"vertical" => {
ui.painter().line_segment([c - vec2(0.0, h), c + vec2(0.0, h)], stroke);
}
"horizontal" => {
ui.painter().line_segment([c - vec2(h, 0.0), c + vec2(h, 0.0)], stroke);
}
_ => {
ui.painter().line_segment([c - vec2(h, 0.0), c + vec2(h, 0.0)], stroke);
ui.painter().line_segment([c - vec2(0.0, h), c + vec2(0.0, h)], stroke);
}
}
if kind == "cross" {
ui.painter().circle_filled(c, 1.5, ink);
} else {
ui.painter().circle_stroke(c, 2.1, stroke);
}
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tip));
resp.on_hover_text(tip)
}
/// Warp switch, cancel and commit, kept on the right of the options bar.
fn transform_actions(app: &mut PhotocraftApp, ui: &mut egui::Ui, warping: bool) {
ui.add_space(8.0);
let commit_tip = if warping {
crate::i18n::fmt(tl!("Commit warp ({key})"), &[("key", &crate::shortcuts::pretty("Enter"))])
} else {
crate::i18n::fmt(tl!("Commit transform ({key})"), &[("key", &crate::shortcuts::pretty("Enter"))])
};
let commit_btn = crate::icons::button(ui, "check", 26.0, false, &commit_tip);
commit_btn.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, "Commit transform"));
if commit_btn.clicked() {
commit(app);
}
let cancel_tip = if warping { tl!("Cancel warp (Esc)") } else { tl!("Cancel transform (Esc)") };
let cancel_btn = crate::icons::button(ui, "ban", 26.0, false, cancel_tip);
cancel_btn.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, "Cancel transform"));
if cancel_btn.clicked() {
cancel(app);
}
crate::widgets::vline(ui, 22.0);
let mode = crate::icons::button(ui, "grid-3x3", 26.0, warping, tl!("Switch between free transform and warp modes"));
mode.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, "Switch between free transform and warp modes"));
if mode.clicked() {
if warping {
leave_warp(app);
} else {
enter_warp(app);
}
}
}
/// Scale the box along its own axes about the reference point.
@ -1181,6 +1479,41 @@ mod tests {
}
}
#[test]
fn transform_bar_pins_warp_cancel_and_commit() {
use egui_kittest::Harness;
use egui_kittest::kittest::Queryable;
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
app.ui.transform = Some(session());
let width = 720.0;
let mut h = Harness::builder().with_size(vec2(width, 48.0)).build_ui_state(
|ui, app: &mut PhotocraftApp| {
if !ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) {
return;
}
ui.horizontal_centered(|ui| options_bar(app, ui));
},
app,
);
PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::Studio);
h.run_steps(6);
let commit = h.get_by_label("Commit transform").rect();
let cancel = h.get_by_label("Cancel transform").rect();
let mode = h.get_by_label("Switch between free transform and warp modes").rect();
assert!(commit.right() > width - 36.0, "commit sits on the right: {commit:?}");
assert!(cancel.right() < commit.left() + 1.0, "cancel is left of commit");
assert!(mode.right() < cancel.left() + 1.0, "warp switch is left of cancel");
h.get_by_label("Switch between free transform and warp modes").click();
h.run_steps(2);
assert!(h.state().ui.transform.as_ref().unwrap().warp.is_some());
h.get_by_label("Switch between free transform and warp modes").click();
h.run_steps(2);
assert!(h.state().ui.transform.as_ref().unwrap().warp.is_none());
h.get_by_label("Cancel transform").click();
h.run_steps(2);
assert!(h.state().ui.transform.is_none());
}
fn drag(s: &mut TransformSession, from: [f64; 2], to: [f64; 2], mods: egui::Modifiers) {
let g = Gesture { hit: hit(s, from, 8.0), start: from, quad0: s.quad, pivot0: s.pivot };
apply_drag(s, g, to, mods);
@ -1204,6 +1537,114 @@ mod tests {
assert_eq!(hit(&s, [40.0, 40.0], 12.0), Hit::Outside);
}
#[test]
fn interior_patch_points_are_hidden_and_grid_anchors_stay() {
assert!(on_section_edge(0, 0) && on_section_edge(1, 0) && on_section_edge(0, 2));
assert!(!on_section_edge(1, 1) && !on_section_edge(2, 2));
let b = [0.0, 0.0, 30.0, 30.0];
let w = Warp::custom(BezierMesh::identity(b, 1, 1), b);
assert!(warp_hit(&w, [10.0, 10.0], 4.0).is_none(), "the point inside the patch is not shown");
assert_eq!(warp_hit(&w, [0.0, 0.0], 4.0), Some(0));
let grid = Warp::custom(BezierMesh::identity(b, 3, 3), b);
assert_eq!(warp_hit(&grid, [10.0, 10.0], 4.0), Some(33), "a grid-corner anchor stays draggable");
assert!(warp_hit(&grid, [30.0 / 9.0, 30.0 / 9.0], 2.0).is_none());
}
#[test]
fn grid_menu_reads_default_and_even_presets() {
let b = [0.0, 0.0, 100.0, 80.0];
let plain = BezierMesh::identity(b, 1, 1);
assert_eq!(warp_grid_id(&plain), "default");
assert_eq!(warp_grid_id(&BezierMesh::identity(b, 3, 3)), "3");
assert_eq!(warp_grid_id(&BezierMesh::identity(b, 4, 4)), "4");
assert_eq!(warp_grid_id(&BezierMesh::identity(b, 5, 5)), "5");
assert_eq!(warp_grid_id(&BezierMesh::identity(b, 2, 2)), "custom");
let mut split = plain.clone();
assert!(split.split_u(0.5));
assert_eq!(warp_grid_id(&split), "custom");
assert!(single_patch(&plain));
assert!(!single_patch(&BezierMesh::identity(b, 3, 3)));
}
#[test]
fn split_tool_places_the_line_where_the_pointer_releases() {
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
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();
begin_warp(&mut app, &egui::Context::default()).unwrap();
app.transform_preview.as_mut().unwrap().split_tool = Some(SplitTool::Vertical);
let knots = |app: &PhotocraftApp| app.ui.transform.as_ref().unwrap().warp.as_ref().unwrap().mesh.as_ref().unwrap().us.clone();
assert_eq!(knots(&app).len(), 2);
let mods = egui::Modifiers::NONE;
warp_pointer(&mut app, ToolEvent::Down { x: 20.0, y: 20.0, pressure: 1.0 }, 2.0, mods);
warp_pointer(&mut app, ToolEvent::Move { x: 14.0, y: 20.0, pressure: 1.0 }, 2.0, mods);
assert_eq!(knots(&app).len(), 2, "the line is only a guide until release");
warp_pointer(&mut app, ToolEvent::Up { x: 14.0, y: 20.0 }, 2.0, mods);
let us = knots(&app);
let has = |ks: &[f64], u: f64| ks.iter().any(|k| (k - u).abs() < 0.02);
// The default grid's thirds stay; the release cuts the section that contains it.
assert!(has(&us, 0.25) && has(&us, 1.0 / 3.0) && has(&us, 2.0 / 3.0), "{us:?}");
let vs = app.ui.transform.as_ref().unwrap().warp.as_ref().unwrap().mesh.as_ref().unwrap().vs.clone();
assert!(has(&vs, 1.0 / 3.0) && has(&vs, 2.0 / 3.0), "horizontal sections stay: {vs:?}");
assert!(app.transform_preview.as_ref().unwrap().split_tool.is_some(), "the tool stays armed");
// Option-click in the open places both lines, without a button armed.
app.transform_preview.as_mut().unwrap().split_tool = None;
let alt = egui::Modifiers::ALT;
warp_pointer(&mut app, ToolEvent::Down { x: 20.0, y: 20.0, pressure: 1.0 }, 2.0, alt);
warp_pointer(&mut app, ToolEvent::Up { x: 20.0, y: 20.0 }, 2.0, alt);
let m = app.ui.transform.as_ref().unwrap().warp.as_ref().unwrap().mesh.as_ref().unwrap();
assert!(m.us.len() == us.len() + 1 && has(&m.vs, 0.5), "crosswise cuts the open section: us {:?} vs {:?}", m.us, m.vs);
}
#[test]
fn split_of_a_grid_cuts_one_section_and_keeps_the_rest() {
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
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();
begin_warp(&mut app, &egui::Context::default()).unwrap();
let rect = app.ui.transform.as_ref().unwrap().rect;
let before = BezierMesh::identity(rect, 3, 3);
app.ui.transform.as_mut().unwrap().warp = Some(Warp::custom(before.clone(), rect));
// Middle of the centre column (s = 0.5), inside the top row of cells.
let x = rect[0] + 0.5 * (rect[2] - rect[0]);
let y = rect[1] + (1.0 / 6.0) * (rect[3] - rect[1]);
app.transform_preview.as_mut().unwrap().split_tool = Some(SplitTool::Vertical);
let mods = egui::Modifiers::NONE;
warp_pointer(&mut app, ToolEvent::Down { x, y, pressure: 1.0 }, 2.0, mods);
warp_pointer(&mut app, ToolEvent::Up { x, y }, 2.0, mods);
let m = app.ui.transform.as_ref().unwrap().warp.as_ref().unwrap().mesh.as_ref().unwrap();
assert_eq!(m.vs.len(), before.vs.len(), "horizontal sections are not rebuilt: {:?}", m.vs);
for (a, b) in m.vs.iter().zip(&before.vs) {
assert!((a - b).abs() < 1e-6, "{:?} vs {:?}", m.vs, before.vs);
}
assert_eq!(m.us.len(), before.us.len() + 1, "one cut: {:?}", m.us);
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-3), "missing {u} in {:?}", m.us);
}
let (nx0, nx1) = (before.nx(), m.nx());
for j in 0..before.ny() {
for i in 0..3 {
let a = before.points[j * nx0 + i];
let b = m.points[j * nx1 + i];
assert!((a[0] - b[0]).abs() < 1e-6 && (a[1] - b[1]).abs() < 1e-6, "left section moved");
}
}
}
#[test]
fn corner_drag_scales_proportionally_about_opposite_corner() {
let mut s = session();
@ -1457,15 +1898,17 @@ mod tests {
let w = app.ui.transform.as_ref().unwrap().warp.clone().unwrap();
assert!(w.is_identity());
// Drag the bottom-right anchor (point 15) by (+10, +6): its two handles follow.
warp_pointer(&mut app, ToolEvent::Down { x: 32.0, y: 32.0, pressure: 1.0 }, 2.0);
warp_pointer(&mut app, ToolEvent::Up { x: 42.0, y: 38.0 }, 2.0);
warp_pointer(&mut app, ToolEvent::Down { x: 32.0, y: 32.0, pressure: 1.0 }, 2.0, egui::Modifiers::NONE);
warp_pointer(&mut app, ToolEvent::Up { x: 42.0, y: 38.0 }, 2.0, egui::Modifiers::NONE);
let m = app.ui.transform.as_ref().unwrap().warp.as_ref().unwrap().mesh.clone().unwrap();
assert_eq!(m.points[15], [42.0, 38.0]);
let near = |a: [f64; 2], b: [f64; 2]| (a[0] - b[0]).abs() < 1e-9 && (a[1] - b[1]).abs() < 1e-9;
assert!(near(m.points[14], [24.0 + 10.0, 38.0]), "{:?}", m.points[14]);
assert!(near(m.points[11], [42.0, 24.0 + 6.0]), "{:?}", m.points[11]);
split(&mut app, "edit.transform.splitWarpCrosswise", None).unwrap();
assert_eq!(app.ui.transform.as_ref().unwrap().warp.as_ref().unwrap().mesh.as_ref().unwrap().nx(), 7);
let split_m = app.ui.transform.as_ref().unwrap().warp.as_ref().unwrap().mesh.as_ref().unwrap();
assert_eq!(split_m.points.last().copied(), Some([42.0, 38.0]), "the corner survives the cut");
assert!(split_m.us.iter().any(|u| (u - 1.0 / 3.0).abs() < 1e-6) && split_m.us.iter().any(|u| (u - 0.5).abs() < 1e-6));
commit(&mut app);
let st = app.session.active().unwrap();
let b = st.doc.layer(st.active_layer.unwrap()).unwrap().surface().unwrap().content_bounds();
@ -1495,11 +1938,11 @@ mod tests {
let t = app.ui.transform.as_mut().unwrap();
let preset = Warp::preset(WarpStyle::Arc, 50.0, t.rect);
t.warp = Some(preset.clone());
warp_pointer(&mut app, ToolEvent::Down { x: 20.0, y: 20.0, pressure: 1.0 }, 2.0);
warp_pointer(&mut app, ToolEvent::Up { x: 20.0, y: 20.0 }, 2.0);
warp_pointer(&mut app, ToolEvent::Down { x: 20.0, y: 20.0, pressure: 1.0 }, 2.0, egui::Modifiers::NONE);
warp_pointer(&mut app, ToolEvent::Up { x: 20.0, y: 20.0 }, 2.0, egui::Modifiers::NONE);
assert_eq!(app.ui.transform.as_ref().unwrap().warp.as_ref(), Some(&preset), "a miss changes nothing");
let corner = preset.to_mesh(1, 1).points[15];
warp_pointer(&mut app, ToolEvent::Down { x: corner[0], y: corner[1], pressure: 1.0 }, 2.0);
warp_pointer(&mut app, ToolEvent::Down { x: corner[0], y: corner[1], pressure: 1.0 }, 2.0, egui::Modifiers::NONE);
assert_eq!(app.ui.transform.as_ref().unwrap().warp.as_ref().unwrap().style, WarpStyle::Custom);
}