mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-29 20:37:24 -06:00
5365 lines
263 KiB
Rust
5365 lines
263 KiB
Rust
//! Free Transform (⌘T): bounding box with handles over the canvas, live preview, commit via the
|
||
//! engine's `edit.transform` (one quad for scale/rotate/skew/distort/perspective).
|
||
//!
|
||
//! Gestures follow Photoshop CC: corner and edge drags scale proportionally (⇧ for free: an edge
|
||
//! then stretches its one axis), ⌥ scales about the reference point, ⌘-drag a corner distorts
|
||
//! (⌘⌥⇧: perspective), ⌘-drag an edge skews (⇧ along the edge), drag outside rotates (⇧ snaps to
|
||
//! 15°), drag inside moves (⇧ locks to 8 directions), the reference point can be dragged and
|
||
//! ⌥-click puts it under the pointer. Arrow keys nudge the box (move_mods.rs). ↩ commits, Esc
|
||
//! cancels. Undo and Redo step through the session's own changes (`Steps`), not the document's
|
||
//! history.
|
||
|
||
use std::sync::Arc;
|
||
|
||
use egui::{Color32, CursorIcon, Pos2, Stroke, pos2, vec2};
|
||
use photocraft_algo::transform::Homography;
|
||
use photocraft_doc::{DocId, Document, LayerContent, LayerId};
|
||
use photocraft_engine::jobs::{JobEvent, JobId, JobOutcome};
|
||
use photocraft_geom::warp::{BezierMesh, Warp, WarpStyle};
|
||
use serde_json::json;
|
||
|
||
use crate::PhotocraftApp;
|
||
use crate::canvas::{ToolEvent, ViewXform};
|
||
use crate::state::{MadeLayer, Tool, TransformMode, 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",
|
||
}
|
||
}
|
||
}
|
||
|
||
/// What a Warp-mode press grabbed. `pts0` is the mesh's control grid at the press.
|
||
enum WarpDrag {
|
||
/// An anchor or handle: it follows the pointer (an anchor takes its handles along).
|
||
Point { index: usize, start: [f64; 2], pts0: Vec<[f64; 2]> },
|
||
/// Anywhere else on the grid: the surface under the pointer follows it.
|
||
Surface { s: f64, t: f64, start: [f64; 2], pts0: Vec<[f64; 2]> },
|
||
}
|
||
|
||
/// 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 {
|
||
pub session: u64,
|
||
pub doc: Arc<Document>,
|
||
pub texture: crate::transform_tex::PreviewTextures,
|
||
pub opacity: f32,
|
||
/// The layers stacked over the moving one, drawn over its pixels ([`Above`]).
|
||
above: Option<Above>,
|
||
/// A layer with a style: its pixels composited with the style as the box has them now,
|
||
/// drawn instead of the bare pixels (transform_fx.rs, #3653).
|
||
styled: Option<crate::transform_fx::StyledPreview>,
|
||
gesture: Option<Gesture>,
|
||
/// Warp-mode drag in progress.
|
||
warp_drag: Option<WarpDrag>,
|
||
/// 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,
|
||
/// The tool the session began with: picking another one applies the transform.
|
||
tool: Tool,
|
||
/// Free Transform Path: the path as it was, drawn through the box instead of pixels.
|
||
path: Option<photocraft_doc::vector::Path>,
|
||
/// A layer's session keeps `doc` and `opacity` up to date with edits made while the box is
|
||
/// open ([`follow_edits`]); `None` for a lone mask or channel and Transform Selection.
|
||
follow: Option<Follow>,
|
||
/// The commit running as a background job (#1015): the moving pixels stay on screen, without
|
||
/// the box, until it lands; Esc cancels it. The session ends with the job.
|
||
committing: Option<JobId>,
|
||
/// Content-Aware Scale's live preview (cas_transform.rs).
|
||
pub(crate) content_aware: Option<crate::cas_transform::Live>,
|
||
}
|
||
|
||
/// What [`follow_edits`] needs to rebuild the preview document.
|
||
#[derive(Clone, Copy, Debug)]
|
||
struct Follow {
|
||
/// The document revision `doc` was built from.
|
||
revision: u64,
|
||
/// The moving layer is hidden in `doc` (nothing was lifted out of it).
|
||
hidden: bool,
|
||
}
|
||
|
||
/// Photoshop's alert when Free Transform (⌘T, Edit › Transform, the Move tool's transform controls)
|
||
/// starts on a layer locked in place, by its own lock or a group's.
|
||
pub const LOCKED: &str = "Could not complete the Free Transform command because the layer is locked.";
|
||
|
||
/// Why Edit › Transform's modes and Warp refuse while Content-Aware Scale's box is open.
|
||
pub const CAS_ONLY_SCALES: &str = "Content-Aware Scale only scales: commit or cancel it first";
|
||
|
||
/// Grab radius of the box's handles, in screen points. Generous, so a corner is easy to catch;
|
||
/// just beyond it, outside the box, a drag rotates.
|
||
pub const HANDLE_PX: f64 = 12.0;
|
||
|
||
/// [`HANDLE_PX`] in document pixels at the current zoom.
|
||
pub fn handle_tolerance(app: &PhotocraftApp) -> f64 {
|
||
HANDLE_PX / app.point_zoom().max(0.01) as f64
|
||
}
|
||
|
||
/// The box as one undo step restores it.
|
||
#[derive(Clone, Debug, PartialEq)]
|
||
struct Step {
|
||
rect: [f64; 4],
|
||
quad: [[f64; 2]; 4],
|
||
pivot: [f64; 2],
|
||
warp: Option<Warp>,
|
||
}
|
||
|
||
impl Step {
|
||
fn of(t: &TransformSession) -> Self {
|
||
Step { rect: t.rect, quad: t.quad, pivot: t.pivot, warp: t.warp.clone() }
|
||
}
|
||
|
||
fn restore(self, t: &mut TransformSession) {
|
||
(t.rect, t.quad, t.pivot, t.warp) = (self.rect, self.quad, self.pivot, self.warp);
|
||
}
|
||
}
|
||
|
||
/// The session's own history: inside Free Transform, Undo steps back through the
|
||
/// handle drags, nudges and option edits rather than the document's history.
|
||
#[derive(Default)]
|
||
struct Steps {
|
||
/// The box when it last came to rest (set when the session starts).
|
||
settled: Option<Step>,
|
||
undo: Vec<Step>,
|
||
redo: Vec<Step>,
|
||
/// When the latest arrow-key nudge came (egui time) and the box it left.
|
||
nudged: Option<(f64, Step)>,
|
||
/// The nudge that moved the box since it last came to rest follows another one closely: it
|
||
/// joins that one's step instead of adding its own (#2259).
|
||
fold: bool,
|
||
}
|
||
|
||
impl Steps {
|
||
/// An arrow-key nudge at `now` moved the box from `before` to `after`. With `pause` (seconds,
|
||
/// `None`: nudges aren't bundled) it folds into the previous nudge's step when it came within
|
||
/// that time of it and found the box as that nudge left it, so a drag, an option edit, Undo
|
||
/// or Redo in between start a step of their own.
|
||
fn nudged(&mut self, before: &Step, after: Step, now: f64, pause: Option<f64>) {
|
||
self.fold = pause.is_some_and(|w| self.nudged.as_ref().is_some_and(|(at, left)| left == before && now >= *at && now - at <= w));
|
||
self.nudged = pause.map(|_| (now, after));
|
||
}
|
||
|
||
/// Record a step if the box changed since it last came to rest.
|
||
fn settle(&mut self, now: Step) {
|
||
if self.settled.as_ref() == Some(&now) {
|
||
return;
|
||
}
|
||
if std::mem::take(&mut self.fold) && self.settled.is_some() {
|
||
self.settled = Some(now);
|
||
return;
|
||
}
|
||
if let Some(prev) = self.settled.replace(now) {
|
||
self.undo.push(prev);
|
||
self.redo.clear();
|
||
}
|
||
}
|
||
|
||
/// Something to undo, counting a change not yet recorded (a nudge this frame).
|
||
fn can_undo(&self, now: &Step) -> bool {
|
||
!self.undo.is_empty() || self.settled.as_ref().is_some_and(|p| p != now)
|
||
}
|
||
}
|
||
|
||
fn corners(r: [f64; 4]) -> [[f64; 2]; 4] {
|
||
[[r[0], r[1]], [r[2], r[1]], [r[2], r[3]], [r[0], r[3]]]
|
||
}
|
||
|
||
/// Start Free Transform on the active layer (or its selected pixels), or on the targeted unlinked
|
||
/// layer mask, alpha channel or Quick Mask.
|
||
pub fn begin(app: &mut PhotocraftApp, ctx: &egui::Context) -> Result<(), String> {
|
||
if let Some((params, path)) = crate::vector_ui::free_transform_path(app) {
|
||
return begin_path(app, ctx, params, path);
|
||
}
|
||
if app.session.active().is_some_and(|st| photocraft_engine::float_cmds::floating(st).is_some()) {
|
||
app.run("select.drop", json!({}))?;
|
||
}
|
||
let target = crate::canvas::paint_target(app);
|
||
let st = app.session.active().ok_or("no document")?;
|
||
let (doc, revision) = (st.doc.clone(), st.revision);
|
||
let lone = photocraft_engine::transform_cmds::lone_target(&doc, st.active_layer, &json!({ "target": target })).map_err(|e| e.to_string())?.cloned();
|
||
if let Some(surf) = lone {
|
||
return begin_lone(app, ctx, doc, surf, target);
|
||
}
|
||
let id = st.active_layer.ok_or("no active layer")?;
|
||
let layer = doc.layer(id).ok_or("no layer")?;
|
||
if photocraft_engine::transform_cmds::locked_for_transform(&doc, id) {
|
||
// Photoshop refuses before the box opens rather than when it is committed (#2585).
|
||
app.ui.open_dialog(crate::state::DialogKind::Error, json!({"message": tl!(LOCKED)}).as_object().cloned().unwrap_or_default());
|
||
return Err(LOCKED.into());
|
||
}
|
||
if matches!(layer.content, LayerContent::Adjustment(_)) && layer.mask.is_none() {
|
||
return Err("Adjustment layers have nothing to transform".into());
|
||
}
|
||
// With a selection the box is the selection's (#5297).
|
||
let b = photocraft_engine::transform_cmds::transform_frame(&doc, layer);
|
||
if b.is_empty() {
|
||
return Err("Could not transform: the layer is empty".into());
|
||
}
|
||
crate::type_tool::commit(app);
|
||
let rect = [b.x0 as f64, b.y0 as f64, b.x1 as f64, b.y1 as f64];
|
||
let session = app.ui.alloc_id();
|
||
// Preview document: the moving pixels removed (layer hidden, or the selection lifted out).
|
||
let mut pd = (*doc).clone();
|
||
let mut lifted = None;
|
||
// Type, shapes and smart objects move whole, selection or not (#2630): only pixels are lifted.
|
||
if let Some(sel) = doc.selection.clone().filter(|_| !photocraft_engine::transform_cmds::moves_whole(layer) && layer.surface().is_some()) {
|
||
if let Some(surf) = pd.layer_mut(id).and_then(|l| l.surface_mut()) {
|
||
let (l, rest) = photocraft_engine::transform_cmds::split_selected(surf, &sel).map_err(|e| e.to_string())?;
|
||
*surf = rest;
|
||
lifted = Some(l);
|
||
}
|
||
} else if let Some(l) = pd.layer_mut(id) {
|
||
l.visible = false;
|
||
}
|
||
// egui reports the renderer's limit in the app; offscreen harnesses only through the device.
|
||
let max_side = ctx.input(|i| i.max_texture_side).max(app.gpu.as_ref().map_or(1, |g| g.max_texture_side()));
|
||
// The layers covering the moving pixels draw over them instead of under them.
|
||
let above = Above::new(ctx, format!("transform-{session}-above"), &doc, id, max_side);
|
||
above.hide_in(&mut pd.layers);
|
||
// A style is drawn again from the pixels as the box moves them, so they are read without it.
|
||
// Not for the selected part of a layer: its style belongs to that part and the rest together.
|
||
let styled = lifted.is_none() && photocraft_compose::effects::has_effects(layer);
|
||
let (image, uv) = preview_image(&doc, id, lifted.as_ref(), b, max_side, styled);
|
||
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: layer.opacity * layer.fill_opacity,
|
||
above: Some(above),
|
||
styled: styled.then(|| crate::transform_fx::StyledPreview::new(format!("transform-{session}-styled"))),
|
||
follow: Some(Follow { revision, hidden: lifted.is_none() }),
|
||
committing: None,
|
||
gesture: None,
|
||
warp_drag: None,
|
||
split_tool: None,
|
||
split_pointer: None,
|
||
split_placing: false,
|
||
split_quick: false,
|
||
steps: Steps::default(),
|
||
tool: app.ui.tool,
|
||
path: None,
|
||
content_aware: None,
|
||
});
|
||
app.ui.transform = Some(TransformSession {
|
||
session,
|
||
layer: id.0,
|
||
rect,
|
||
quad: corners(rect),
|
||
pivot: [(rect[0] + rect[2]) / 2.0, (rect[1] + rect[3]) / 2.0],
|
||
interpolation: "bicubic".into(),
|
||
warp: None,
|
||
selection: false,
|
||
target: None,
|
||
path: None,
|
||
made: None,
|
||
mode: Default::default(),
|
||
content_aware: None,
|
||
skew_v: 0.0,
|
||
});
|
||
start_steps(app);
|
||
Ok(())
|
||
}
|
||
|
||
/// Free Transform on a copy (⌥⌘T, no menu item): duplicates the active layer (with a selection,
|
||
/// its selected pixels, as Layer via Copy) and transforms the copy (#352).
|
||
pub fn begin_copy(app: &mut PhotocraftApp, ctx: &egui::Context) -> Result<(), String> {
|
||
let selection = app.session.active().is_some_and(|d| d.doc.selection.is_some());
|
||
let params = if selection { json!({}) } else { json!({"inPlace": true}) };
|
||
app.run(if selection { "layer.new.layerViaCopy" } else { "layer.duplicate" }, params)?;
|
||
let made = app.session.active().and_then(|st| st.active_layer.map(|layer| (st.doc.id, layer)));
|
||
if let Err(e) = begin(app, ctx) {
|
||
if let Some((document, layer)) = made {
|
||
take_back_made(app, document, layer);
|
||
}
|
||
return Err(e);
|
||
}
|
||
if let Some(t) = app.ui.transform.as_mut() {
|
||
t.made = Some(MadeLayer::Copy);
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// Free Transform on the layer a file dropped on the canvas just placed, like the reference app's
|
||
/// Place: Esc takes the place back, ↩ makes the place and the transform one Place Embedded step.
|
||
pub fn begin_placed(app: &mut PhotocraftApp, ctx: &egui::Context) -> Result<(), String> {
|
||
begin(app, ctx)?;
|
||
if let Some(t) = app.ui.transform.as_mut() {
|
||
t.made = Some(MadeLayer::Place);
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// Edit › Content-Aware Scale (#3096): a scale-only box over `region` of the active pixel layer
|
||
/// (the selection's bounds, else the layer's content), cleared under the box as the command
|
||
/// clears it. OK runs `edit.contentAwareScale` (cas_transform.rs) instead of `edit.transform`.
|
||
pub(crate) fn begin_content_aware(
|
||
app: &mut PhotocraftApp,
|
||
ctx: &egui::Context,
|
||
region: photocraft_geom::Rect,
|
||
live: crate::cas_transform::Live,
|
||
) -> Result<(), String> {
|
||
let st = app.session.active().ok_or("no document")?;
|
||
let (doc, revision) = (st.doc.clone(), st.revision);
|
||
let id = st.active_layer.ok_or("no active layer")?;
|
||
let layer = doc.layer(id).ok_or("no layer")?;
|
||
let surf = layer.surface().ok_or("Content-Aware Scale needs a pixel layer")?;
|
||
if region.is_empty() {
|
||
return Err("Could not scale: the layer is empty".into());
|
||
}
|
||
crate::type_tool::commit(app);
|
||
let rect = [region.x0 as f64, region.y0 as f64, region.x1 as f64, region.y1 as f64];
|
||
let session = app.ui.alloc_id();
|
||
// The command clears the area before writing the result (a layer without transparency keeps it).
|
||
let mut pd = (*doc).clone();
|
||
if let Some(s) = pd.layer_mut(id).and_then(|l| l.surface_mut()).filter(|s| s.format().alpha) {
|
||
let clear = vec![0.0; s.format().channels()];
|
||
s.fill_rect(region, &clear);
|
||
s.prune();
|
||
}
|
||
let max_side = ctx.input(|i| i.max_texture_side).max(app.gpu.as_ref().map_or(1, |g| g.max_texture_side()));
|
||
let above = Above::new(ctx, format!("transform-{session}-above"), &doc, id, max_side);
|
||
above.hide_in(&mut pd.layers);
|
||
let (image, uv) = crate::transform_tex::read_surface(surf, None, region, 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: layer.opacity * layer.fill_opacity,
|
||
above: Some(above),
|
||
follow: Some(Follow { revision, hidden: false }),
|
||
committing: None,
|
||
gesture: None,
|
||
warp_drag: None,
|
||
split_tool: None,
|
||
split_pointer: None,
|
||
split_placing: false,
|
||
split_quick: false,
|
||
steps: Steps::default(),
|
||
tool: app.ui.tool,
|
||
path: None,
|
||
content_aware: Some(live),
|
||
styled: None,
|
||
});
|
||
app.ui.transform = Some(TransformSession {
|
||
session,
|
||
layer: id.0,
|
||
rect,
|
||
quad: corners(rect),
|
||
pivot: [(rect[0] + rect[2]) / 2.0, (rect[1] + rect[3]) / 2.0],
|
||
interpolation: "bicubic".into(),
|
||
warp: None,
|
||
selection: false,
|
||
target: None,
|
||
path: None,
|
||
made: None,
|
||
mode: Default::default(),
|
||
content_aware: Some(Default::default()),
|
||
skew_v: 0.0,
|
||
});
|
||
start_steps(app);
|
||
Ok(())
|
||
}
|
||
|
||
/// Undoes the layer a cancelled or failed session made (⌥⌘T's copy, a placed file), leaving
|
||
/// nothing to redo.
|
||
fn take_back_made(app: &mut PhotocraftApp, document: DocId, layer: LayerId) {
|
||
// Tabs can move or close while transforming. Only undo the creation in its own document,
|
||
// and only while it is still the latest step (it may already have been undone).
|
||
let Some(index) = app.session.documents().iter().position(|st| {
|
||
st.doc.id == document
|
||
&& st.doc.layer(layer).is_some()
|
||
&& st.history.past_len().checked_sub(1).and_then(|i| st.history.state(i)).is_some_and(|before| before.layer(layer).is_none())
|
||
}) else {
|
||
return;
|
||
};
|
||
let active = app.session.active_index();
|
||
app.session.set_active(index);
|
||
if app.session.undo()
|
||
&& let Some(st) = app.session.active_mut()
|
||
{
|
||
st.history.clear_redo();
|
||
}
|
||
if let Some(active) = active {
|
||
app.session.set_active(active);
|
||
}
|
||
app.sync_views();
|
||
}
|
||
|
||
/// After the transform: the step that made the layer and the transform become one history step,
|
||
/// the transform's for a copy and Place Embedded for a placed file.
|
||
fn fold_made(app: &mut PhotocraftApp, document: DocId, made: MadeLayer) {
|
||
// A background commit lands in its own document, which need not be the active one by then.
|
||
let Some(index) = app.session.documents().iter().position(|st| st.doc.id == document) else { return };
|
||
let active = app.session.active_index();
|
||
app.session.set_active(index);
|
||
if let Some(st) = app.session.active_mut() {
|
||
st.history.purge_last();
|
||
if made == MadeLayer::Place {
|
||
st.history.set_current_label(photocraft_engine::file_cmds::PLACE_EMBEDDED);
|
||
}
|
||
}
|
||
if let Some(active) = active {
|
||
app.session.set_active(active);
|
||
}
|
||
}
|
||
|
||
/// Free Transform of a targeted unlinked layer mask, alpha channel or Quick Mask by itself: the
|
||
/// box frames its content (or the selection) and previews the moving values in grey over the
|
||
/// document with them vacated.
|
||
fn begin_lone(
|
||
app: &mut PhotocraftApp,
|
||
ctx: &egui::Context,
|
||
doc: Arc<Document>,
|
||
surf: photocraft_raster::Surface,
|
||
target: serde_json::Value,
|
||
) -> Result<(), String> {
|
||
use photocraft_engine::transform_cmds as tc;
|
||
let b = tc::target_frame(&doc, &surf);
|
||
if b.is_empty() {
|
||
return Err("Could not transform: nothing is selected".into());
|
||
}
|
||
let whole;
|
||
let sel = match &doc.selection {
|
||
Some(s) => s,
|
||
None => {
|
||
let mut s = photocraft_raster::Surface::new(photocraft_color::PixelFormat::GRAY8);
|
||
s.fill_rect(b, &[1.0]);
|
||
whole = s;
|
||
&whole
|
||
}
|
||
};
|
||
let (lifted, rest) = tc::split_gray_selected(&surf, sel).map_err(|e| e.to_string())?.ok_or("this channel can't be transformed")?;
|
||
crate::type_tool::commit(app);
|
||
let layer = app.session.active().and_then(|st| st.active_layer).map_or(0, |l| l.0);
|
||
let mut pd = (*doc).clone();
|
||
let tp = json!({ "target": target });
|
||
if let Ok(Some(s)) = tc::lone_target_mut(&mut pd, Some(LayerId(layer)), &tp) {
|
||
*s = rest;
|
||
}
|
||
let rect = [b.x0 as f64, b.y0 as f64, b.x1 as f64, b.y1 as f64];
|
||
let session = app.ui.alloc_id();
|
||
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,
|
||
above: None,
|
||
styled: None,
|
||
follow: None,
|
||
committing: None,
|
||
gesture: None,
|
||
warp_drag: None,
|
||
split_tool: None,
|
||
split_pointer: None,
|
||
split_placing: false,
|
||
split_quick: false,
|
||
steps: Steps::default(),
|
||
tool: app.ui.tool,
|
||
path: None,
|
||
content_aware: None,
|
||
});
|
||
app.ui.transform = Some(TransformSession {
|
||
session,
|
||
layer,
|
||
rect,
|
||
quad: corners(rect),
|
||
pivot: [(rect[0] + rect[2]) / 2.0, (rect[1] + rect[3]) / 2.0],
|
||
interpolation: "bicubic".into(),
|
||
warp: None,
|
||
selection: false,
|
||
target: Some(target),
|
||
path: None,
|
||
made: None,
|
||
mode: Default::default(),
|
||
content_aware: None,
|
||
skew_v: 0.0,
|
||
});
|
||
start_steps(app);
|
||
Ok(())
|
||
}
|
||
|
||
/// Edit › Free Transform Path: the box frames the path's anchors and handles and draws the
|
||
/// path through it; OK commits one `path.transform` step.
|
||
fn begin_path(app: &mut PhotocraftApp, ctx: &egui::Context, params: serde_json::Value, path: photocraft_doc::vector::Path) -> Result<(), String> {
|
||
let (x0, y0, x1, y1) = path.control_bounds().ok_or("the path has no points to transform")?;
|
||
let st = app.session.active().ok_or("no document")?;
|
||
let doc = st.doc.clone();
|
||
let layer = st.active_layer.or_else(|| doc.layers.first().map(|l| l.id)).ok_or("no layer")?;
|
||
crate::type_tool::commit(app);
|
||
// A straight horizontal or vertical path still gets a box to turn.
|
||
let rect = [x0, y0, x1.max(x0 + 1.0), y1.max(y0 + 1.0)];
|
||
let session = app.ui.alloc_id();
|
||
let texture =
|
||
crate::transform_tex::PreviewTextures::new(ctx, format!("transform-{session}"), egui::ColorImage::new([1, 1], vec![Color32::TRANSPARENT]), [1.0, 1.0]);
|
||
app.transform_preview = Some(TransformPreview {
|
||
session,
|
||
doc,
|
||
texture,
|
||
opacity: 1.0,
|
||
styled: None,
|
||
gesture: None,
|
||
warp_drag: None,
|
||
split_tool: None,
|
||
split_pointer: None,
|
||
split_placing: false,
|
||
split_quick: false,
|
||
steps: Steps::default(),
|
||
tool: app.ui.tool,
|
||
path: Some(path),
|
||
content_aware: None,
|
||
above: None,
|
||
follow: None,
|
||
committing: None,
|
||
});
|
||
app.ui.transform = Some(TransformSession {
|
||
session,
|
||
layer: layer.0,
|
||
rect,
|
||
quad: corners(rect),
|
||
pivot: [(rect[0] + rect[2]) / 2.0, (rect[1] + rect[3]) / 2.0],
|
||
interpolation: "bicubic".into(),
|
||
warp: None,
|
||
selection: false,
|
||
target: None,
|
||
path: Some(params),
|
||
made: None,
|
||
mode: Default::default(),
|
||
content_aware: None,
|
||
skew_v: 0.0,
|
||
});
|
||
start_steps(app);
|
||
Ok(())
|
||
}
|
||
|
||
/// The affine map taking `rect`'s corners to `quad`'s; `None` when the box was distorted out of a
|
||
/// parallelogram (Distort, Perspective): the path then goes as a `quad`.
|
||
fn rect_to_quad_affine(r: [f64; 4], q: [[f64; 2]; 4]) -> Option<photocraft_geom::Affine> {
|
||
let (w, h) = (r[2] - r[0], r[3] - r[1]);
|
||
if w <= 0.0 || h <= 0.0 {
|
||
return None;
|
||
}
|
||
let tol = 1e-3 * (1.0 + w.max(h));
|
||
if (q[0][0] + q[2][0] - q[1][0] - q[3][0]).abs() > tol || (q[0][1] + q[2][1] - q[1][1] - q[3][1]).abs() > tol {
|
||
return None;
|
||
}
|
||
let (a, b) = ((q[1][0] - q[0][0]) / w, (q[1][1] - q[0][1]) / w);
|
||
let (c, d) = ((q[3][0] - q[0][0]) / h, (q[3][1] - q[0][1]) / h);
|
||
let m = [a, b, c, d, q[0][0] - a * r[0] - c * r[1], q[0][1] - b * r[0] - d * r[1]];
|
||
m.iter().all(|v| v.is_finite()).then_some(photocraft_geom::Affine { m })
|
||
}
|
||
|
||
/// Start Select › Transform Selection: the same box over the selection's bounds, previewing the
|
||
/// selection mask (the marching ants are hidden meanwhile); commits `select.transformSelection`.
|
||
pub fn begin_selection(app: &mut PhotocraftApp, ctx: &egui::Context) -> Result<(), String> {
|
||
let st = app.session.active().ok_or("no document")?;
|
||
let doc = st.doc.clone();
|
||
let sel = doc.selection.clone().ok_or("no selection")?;
|
||
let b = sel.content_bounds();
|
||
if b.is_empty() {
|
||
return Err("no selection".into());
|
||
}
|
||
let layer = st.active_layer.or_else(|| doc.layers.first().map(|l| l.id)).ok_or("no layer")?;
|
||
crate::type_tool::commit(app);
|
||
let rect = [b.x0 as f64, b.y0 as f64, b.x1 as f64, b.y1 as f64];
|
||
let session = app.ui.alloc_id();
|
||
let (w, h) = (b.width() as usize, b.height() as usize);
|
||
let k = w.max(h).div_ceil(2048).max(1);
|
||
let (tw, th) = (w.div_ceil(k), h.div_ceil(k));
|
||
let mut px = vec![Color32::TRANSPARENT; tw * th];
|
||
for ty in 0..th {
|
||
for tx in 0..tw {
|
||
let m = sel.sample_channel(b.x0 + (tx * k) as i32, b.y0 + (ty * k) as i32, 0).clamp(0.0, 1.0);
|
||
px[ty * tw + tx] = Color32::from_rgba_unmultiplied(120, 170, 255, (m * 110.0) as u8);
|
||
}
|
||
}
|
||
let uv = [w as f32 / (tw * k) as f32, h as f32 / (th * k) as f32];
|
||
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,
|
||
above: None,
|
||
styled: None,
|
||
follow: None,
|
||
committing: None,
|
||
gesture: None,
|
||
warp_drag: None,
|
||
split_tool: None,
|
||
split_pointer: None,
|
||
split_placing: false,
|
||
split_quick: false,
|
||
steps: Steps::default(),
|
||
tool: app.ui.tool,
|
||
path: None,
|
||
content_aware: None,
|
||
});
|
||
app.ui.transform = Some(TransformSession {
|
||
session,
|
||
layer: layer.0,
|
||
rect,
|
||
quad: corners(rect),
|
||
pivot: [(rect[0] + rect[2]) / 2.0, (rect[1] + rect[3]) / 2.0],
|
||
interpolation: "bilinear".into(),
|
||
warp: None,
|
||
selection: true,
|
||
target: None,
|
||
path: None,
|
||
made: None,
|
||
mode: Default::default(),
|
||
content_aware: None,
|
||
skew_v: 0.0,
|
||
});
|
||
start_steps(app);
|
||
Ok(())
|
||
}
|
||
|
||
/// Start (or switch an active Free Transform into) Warp mode.
|
||
pub fn begin_warp(app: &mut PhotocraftApp, ctx: &egui::Context) -> Result<(), String> {
|
||
if app.ui.transform.as_ref().is_some_and(|t| t.content_aware.is_some()) {
|
||
return Err(CAS_ONLY_SCALES.into());
|
||
}
|
||
let fresh = app.ui.transform.is_none();
|
||
if fresh {
|
||
begin(app, ctx)?;
|
||
}
|
||
enter_warp(app, ctx);
|
||
// Started in Warp: that is where Undo stops. Switching an open transform to Warp is a step.
|
||
if fresh {
|
||
start_steps(app);
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// Switch to Warp mode: a smart object's existing warp (when the box is untouched), else a
|
||
/// custom mesh following the current box (so a transform made first carries over). Editable type
|
||
/// gets its own Warp Text style instead ([`enter_type_warp`]).
|
||
pub fn enter_warp(app: &mut PhotocraftApp, ctx: &egui::Context) {
|
||
if app.ui.transform.as_ref().is_some_and(|t| t.warp.is_none()) && warps_type(app) {
|
||
return enter_type_warp(app, ctx);
|
||
}
|
||
let existing =
|
||
app.session.active().and_then(|d| d.doc.layer(LayerId(app.ui.transform.as_ref()?.layer)).and_then(photocraft_engine::warp_cmds::smart_warp_doc_space));
|
||
let Some(t) = app.ui.transform.as_mut() else { return };
|
||
// Warp moves layers and paths (#3027): a lone mask or channel keeps the box.
|
||
if t.warp.is_some() || t.target.is_some() {
|
||
return;
|
||
}
|
||
if t.quad == corners(t.rect)
|
||
&& !t.selection
|
||
&& t.path.is_none()
|
||
&& let Some(w) = existing
|
||
{
|
||
t.rect = w.bounds;
|
||
t.quad = corners(w.bounds);
|
||
t.warp = Some(w);
|
||
return;
|
||
}
|
||
let h = Homography::rect_to_quad(t.rect, t.quad).unwrap_or(Homography::IDENTITY);
|
||
let mesh = BezierMesh::identity(t.rect, 1, 1).map_points(|p| {
|
||
let (x, y) = h.apply(p[0], p[1]);
|
||
[x, y]
|
||
});
|
||
t.warp = Some(Warp::custom(mesh, t.rect));
|
||
}
|
||
|
||
/// The open transform moves an editable type layer as a whole (not a lone mask, channel, path or
|
||
/// the selection). Its Warp is Warp Text: only the styles, and the type stays editable (#3004).
|
||
pub fn warps_type(app: &PhotocraftApp) -> bool {
|
||
let Some(t) = &app.ui.transform else { return false };
|
||
if t.selection || t.target.is_some() || t.path.is_some() {
|
||
return false;
|
||
}
|
||
app.session.active().and_then(|st| st.doc.layer(LayerId(t.layer))).is_some_and(|l| matches!(l.content, LayerContent::Text(_)))
|
||
}
|
||
|
||
/// Warp on editable type, as in Photoshop: no rasterizing, no mesh to drag, just the warp styles
|
||
/// of the options bar (Warp Text's). A box scaled or rotated first is applied first (that keeps
|
||
/// type editable). The preview shows the type without its current Warp Text, under that style.
|
||
fn enter_type_warp(app: &mut PhotocraftApp, ctx: &egui::Context) {
|
||
if app.ui.transform.as_ref().is_some_and(|t| t.quad != corners(t.rect)) {
|
||
commit(app);
|
||
if app.ui.transform.is_some() {
|
||
return; // refused: the box stays as it was
|
||
}
|
||
if let Err(e) = begin(app, ctx) {
|
||
app.ui.status = e;
|
||
return;
|
||
}
|
||
}
|
||
let (Some(t), Some(st)) = (app.ui.transform.as_ref(), app.session.active()) else { return };
|
||
let (id, session) = (LayerId(t.layer), t.session);
|
||
let doc = st.doc.clone();
|
||
let Some(LayerContent::Text(text)) = doc.layer(id).map(|l| &l.content) else { return };
|
||
let current = text.warp.clone();
|
||
let mut plain = (*doc).clone();
|
||
if let Some(LayerContent::Text(tt)) = plain.layer_mut(id).map(|l| &mut l.content)
|
||
&& current.is_some()
|
||
{
|
||
tt.warp = None;
|
||
photocraft_engine::type_cmds::refresh(&doc, tt);
|
||
}
|
||
let Some(b) = plain.layer(id).map(|l| photocraft_engine::transform_cmds::transform_bounds(&plain, l)).filter(|b| !b.is_empty()) else { return };
|
||
let rect = [b.x0 as f64, b.y0 as f64, b.x1 as f64, b.y1 as f64];
|
||
if current.is_some()
|
||
&& let Some(pv) = app.transform_preview.as_mut()
|
||
{
|
||
let max_side = ctx.input(|i| i.max_texture_side).max(app.gpu.as_ref().map_or(1, |g| g.max_texture_side()));
|
||
// Read as the session's texels were: without the style when the box draws it itself.
|
||
let (image, uv) = preview_image(&plain, id, None, b, max_side, pv.styled.is_some());
|
||
pv.texture = crate::transform_tex::PreviewTextures::new(ctx, format!("transform-{session}"), image, uv);
|
||
}
|
||
let warp =
|
||
current.as_ref().and_then(|w| WarpStyle::parse(&w.style).filter(|s| s.is_preset()).map(|s| (w, s))).map_or(Warp::none(rect), |(w, style)| Warp {
|
||
style,
|
||
bend: f64::from(w.value),
|
||
h_distort: f64::from(w.horizontal_distortion),
|
||
v_distort: f64::from(w.vertical_distortion),
|
||
vertical: !w.horizontal,
|
||
..Warp::none(rect)
|
||
});
|
||
if let Some(t) = app.ui.transform.as_mut() {
|
||
t.rect = rect;
|
||
t.quad = corners(rect);
|
||
t.pivot = [(rect[0] + rect[2]) / 2.0, (rect[1] + rect[3]) / 2.0];
|
||
t.warp = Some(warp);
|
||
}
|
||
}
|
||
|
||
/// Leave Warp mode (back to the box; warp edits are dropped, like Photoshop's mode toggle on an
|
||
/// untouched warp).
|
||
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
|
||
/// `max_side` (#91: this used to be capped at 2048 px, so big layers previewed blurry), plus the
|
||
/// uv extent covering `b`. `bare` leaves the layer's own style, opacity and fill out of a
|
||
/// flattened render: the styled preview applies them to the moved pixels itself.
|
||
fn preview_image(
|
||
doc: &Document,
|
||
id: LayerId,
|
||
lifted: Option<&photocraft_raster::Surface>,
|
||
b: photocraft_geom::Rect,
|
||
max_side: usize,
|
||
bare: bool,
|
||
) -> (egui::ColorImage, [f32; 2]) {
|
||
let layer = doc.layer(id);
|
||
// Linked masks move with the pixels, so the moving texels carry them (#205).
|
||
let mask = layer.and_then(|l| preview_mask(l, b));
|
||
// Groups and type/fill layers preview from a flattened render of just that layer.
|
||
let rendered;
|
||
let surf: Option<&photocraft_raster::Surface> = match (lifted, layer.and_then(|l| l.surface())) {
|
||
(Some(s), _) => Some(s),
|
||
(None, Some(s)) if layer.is_some_and(|l| matches!(l.content, LayerContent::Raster(_))) => Some(s),
|
||
_ => {
|
||
let mut solo = doc.clone();
|
||
show_only(&mut solo.layers, &[id]);
|
||
// Its clipping base is hidden here, which would clip it away entirely (#5239).
|
||
// Clear the target itself, not only the top-level group that contains it.
|
||
if let Some(l) = solo.layer_mut(id) {
|
||
l.clipped = false;
|
||
}
|
||
if let Some(l) = solo.layer_mut(id).filter(|_| bare) {
|
||
l.effects.enabled = false;
|
||
l.opacity = 1.0;
|
||
l.fill_opacity = 1.0;
|
||
}
|
||
let buf = photocraft_compose::render(&solo, b);
|
||
let mut s = photocraft_raster::Surface::new(photocraft_color::PixelFormat::RGBA8);
|
||
let flat: Vec<f32> = buf.px.iter().flat_map(|p| *p).collect();
|
||
s.write_region(b, &flat);
|
||
rendered = s;
|
||
Some(&rendered)
|
||
}
|
||
};
|
||
let Some(surf) = surf else { return (egui::ColorImage::new([1, 1], vec![Color32::TRANSPARENT]), [1.0, 1.0]) };
|
||
// A flattened render already has the masks applied.
|
||
let raw = lifted.is_some() || layer.is_some_and(|l| matches!(l.content, LayerContent::Raster(_)));
|
||
crate::transform_tex::read_surface(surf, mask.as_ref().filter(|_| raw), b, max_side)
|
||
}
|
||
|
||
/// The layer's masks as the moving texels carry them: the enabled masks when all of them are linked
|
||
/// (the vector mask and feathering folded in as the compositor applies them), else the linked
|
||
/// pixel mask alone. `None` when no enabled mask moves with the pixels.
|
||
fn preview_mask(l: &photocraft_doc::Layer, b: photocraft_geom::Rect) -> Option<crate::transform_tex::PreviewMask> {
|
||
use crate::transform_tex::PreviewMask;
|
||
let pixel = l.mask.as_ref().filter(|m| m.enabled);
|
||
let vector = l.vector_mask.as_ref().filter(|v| v.enabled);
|
||
let linked_pixel = pixel.filter(|m| m.linked);
|
||
if pixel.is_none_or(|m| m.linked)
|
||
&& vector.is_none_or(|v| v.linked)
|
||
&& let Some(surface) = photocraft_compose::masks::combined_mask(l, b)
|
||
{
|
||
return Some(PreviewMask { surface, density: 1.0 });
|
||
}
|
||
linked_pixel.map(|m| PreviewMask { surface: m.surface.clone(), density: m.density })
|
||
}
|
||
|
||
/// The layers covering the moving one ([`covering`]), composited on their own and drawn over its
|
||
/// preview, so the layer keeps its place in the stack while the box is open (#2439): they cover
|
||
/// it, as in Photoshop, instead of the moving pixels covering the whole document. The canvas
|
||
/// under the box leaves them out ([`Above::hide_in`]). Built when the session starts and again
|
||
/// when an edit changes the document; the box itself moves without re-compositing.
|
||
struct Above {
|
||
ctx: egui::Context,
|
||
name: String,
|
||
max_side: usize,
|
||
/// The covering layers (whole layers or groups, at any depth).
|
||
ids: Vec<LayerId>,
|
||
/// Their composite and the canvas area it covers; `None` when nothing visible covers the layer.
|
||
shown: Option<(crate::transform_tex::PreviewTextures, photocraft_geom::Rect)>,
|
||
}
|
||
|
||
impl Above {
|
||
fn new(ctx: &egui::Context, name: String, doc: &Document, id: LayerId, max_side: usize) -> Self {
|
||
let mut a = Self { ctx: ctx.clone(), name, max_side, ids: Vec::new(), shown: None };
|
||
a.rebuild(doc, id);
|
||
a
|
||
}
|
||
|
||
/// Composite what covers layer `id` of `doc` (the live document) again.
|
||
fn rebuild(&mut self, doc: &Document, id: LayerId) {
|
||
self.ids.clear();
|
||
covering(&doc.layers, id, &mut self.ids);
|
||
let mut up = doc.clone();
|
||
show_only(&mut up.layers, &self.ids);
|
||
let canvas = up.bounds();
|
||
let rect = up
|
||
.layers
|
||
.iter()
|
||
.filter(|l| l.visible)
|
||
.fold(photocraft_geom::Rect::EMPTY, |r, l| r.union(&photocraft_compose::composite_bounds(l, canvas).unwrap_or(canvas)))
|
||
.intersect(&canvas);
|
||
self.shown = (!rect.is_empty()).then(|| {
|
||
let (image, uv) = composite_image(&up, rect, self.max_side);
|
||
(crate::transform_tex::PreviewTextures::new(&self.ctx, self.name.clone(), image, uv), rect)
|
||
});
|
||
}
|
||
|
||
/// Leave the covering layers out of `layers` (the document under the box).
|
||
fn hide_in(&self, layers: &mut [photocraft_doc::Layer]) {
|
||
for l in layers.iter_mut() {
|
||
if self.ids.contains(&l.id) {
|
||
l.visible = false;
|
||
} else if let LayerContent::Group(g) = &mut l.content {
|
||
self.hide_in(&mut g.children);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Draw the composite over the moving pixels.
|
||
fn draw(&self, painter: &egui::Painter, xf: &ViewXform) {
|
||
let Some((texture, r)) = &self.shown else { return };
|
||
let (tex, uv) = texture.pick(painter.ctx(), xf.zoom * painter.ctx().pixels_per_point(), 1.0, false);
|
||
let mut mesh = egui::Mesh::with_texture(tex);
|
||
let corners = [(r.x0, r.y0, 0.0, 0.0), (r.x1, r.y0, uv[0], 0.0), (r.x1, r.y1, uv[0], uv[1]), (r.x0, r.y1, 0.0, uv[1])];
|
||
for (x, y, u, v) in corners {
|
||
mesh.vertices.push(egui::epaint::Vertex { pos: xf.to_screen(x as f32, y as f32), uv: pos2(u, v), color: Color32::WHITE });
|
||
}
|
||
mesh.add_triangle(0, 1, 2);
|
||
mesh.add_triangle(0, 2, 3);
|
||
painter.add(mesh);
|
||
}
|
||
}
|
||
|
||
/// `rect` of `doc`'s composite as premultiplied texels: full resolution when it fits in
|
||
/// `max_side`, composited in bands (no full-size float copy), else area-averaged down to fit.
|
||
fn composite_image(doc: &Document, rect: photocraft_geom::Rect, max_side: usize) -> (egui::ColorImage, [f32; 2]) {
|
||
let (w, h) = (rect.width() as usize, rect.height() as usize);
|
||
let to_color = |p: &[u8; 4]| Color32::from_rgba_unmultiplied(p[0], p[1], p[2], p[3]);
|
||
if w.max(h) <= max_side.max(1) {
|
||
let mut px = Vec::with_capacity(w * h);
|
||
let _ = photocraft_compose::render_bands(doc, rect, 0, |band| {
|
||
px.extend(band.to_rgba8().pixels.as_chunks::<4>().0.iter().map(to_color));
|
||
Ok::<(), ()>(())
|
||
});
|
||
px.resize(w * h, Color32::TRANSPARENT);
|
||
return (egui::ColorImage::new([w, h], px), [1.0, 1.0]);
|
||
}
|
||
let k = w.max(h).div_ceil(max_side.max(1));
|
||
let (tw, th) = (w.div_ceil(k).max(1), h.div_ceil(k).max(1));
|
||
let buf = photocraft_compose::render_reduced_rect(doc, rect, tw as u32, th as u32);
|
||
let px: Vec<Color32> = buf.to_rgba8().pixels.as_chunks::<4>().0.iter().map(to_color).collect();
|
||
let [bw, bh] = [buf.rect.width() as usize, buf.rect.height() as usize];
|
||
if px.len() != bw * bh || bw == 0 || bh == 0 {
|
||
return (egui::ColorImage::new([1, 1], vec![Color32::TRANSPARENT]), [1.0, 1.0]);
|
||
}
|
||
(egui::ColorImage::new([bw, bh], px), [1.0, 1.0])
|
||
}
|
||
|
||
/// A layer that composites over anything the same as over transparency, so it can be drawn over
|
||
/// the moving pixels on its own: a Normal layer that isn't an adjustment, or a Normal or Pass
|
||
/// Through group of such layers (hidden layers count, they draw nothing). Adjustment layers and
|
||
/// the other blend modes depend on what is under them.
|
||
fn plain(l: &photocraft_doc::Layer) -> bool {
|
||
use photocraft_color::BlendMode;
|
||
!l.visible
|
||
|| match &l.content {
|
||
LayerContent::Adjustment(_) => false,
|
||
LayerContent::Group(g) => matches!(l.blend, BlendMode::Normal | BlendMode::PassThrough) && g.children.iter().all(plain),
|
||
_ => l.blend == BlendMode::Normal,
|
||
}
|
||
}
|
||
|
||
/// The layers over `id` in `layers` (bottom to top, groups holding `id` searched too) that cover
|
||
/// it in the preview: from the top down, while they are [`plain`]. The first layer that isn't
|
||
/// stops the search, so it and everything under it stay in the document under the box as before
|
||
/// (an adjustment there keeps applying to the layers under it). A clipping group goes as a whole,
|
||
/// and the layers clipped to `id` (or to the group holding it) stay with it.
|
||
fn covering(layers: &[photocraft_doc::Layer], id: LayerId, out: &mut Vec<LayerId>) {
|
||
let mut end = layers.len();
|
||
while end > 0 {
|
||
// The clipping group ending at `end`: its base and the layers clipped to it.
|
||
let mut start = end - 1;
|
||
while start > 0 && layers.get(start).is_some_and(|l| l.clipped) {
|
||
start -= 1;
|
||
}
|
||
let unit = layers.get(start..end).unwrap_or_default();
|
||
if unit.iter().any(|l| contains(l, id)) {
|
||
// A group holding `id` with nothing clipped to it: go on inside it.
|
||
if let [g] = unit
|
||
&& g.id != id
|
||
&& let LayerContent::Group(group) = &g.content
|
||
&& matches!(g.blend, photocraft_color::BlendMode::Normal | photocraft_color::BlendMode::PassThrough)
|
||
{
|
||
covering(&group.children, id, out);
|
||
}
|
||
return;
|
||
}
|
||
if !unit.iter().all(plain) {
|
||
return;
|
||
}
|
||
out.extend(unit.iter().map(|l| l.id));
|
||
end = start;
|
||
}
|
||
}
|
||
|
||
/// Hide everything in `layers` but the `keep` layers and the groups holding them.
|
||
fn show_only(layers: &mut [photocraft_doc::Layer], keep: &[LayerId]) {
|
||
for l in layers.iter_mut() {
|
||
if keep.contains(&l.id) {
|
||
continue;
|
||
}
|
||
match &mut l.content {
|
||
LayerContent::Group(g) if g.children.iter().any(|c| keep.iter().any(|k| contains(c, *k))) => show_only(&mut g.children, keep),
|
||
_ => l.visible = false,
|
||
}
|
||
}
|
||
}
|
||
|
||
fn contains(l: &photocraft_doc::Layer, id: LayerId) -> bool {
|
||
l.id == id || matches!(&l.content, LayerContent::Group(g) if g.children.iter().any(|c| contains(c, id)))
|
||
}
|
||
|
||
/// Undo and Redo while the commit runs in the background.
|
||
const STILL_APPLYING: &str = "Free Transform is still being applied";
|
||
|
||
pub fn commit(app: &mut PhotocraftApp) {
|
||
if committing(app).is_some() {
|
||
return;
|
||
}
|
||
let document = app.transform_preview.as_ref().map(|pv| pv.doc.id);
|
||
if document.is_some_and(|id| app.session.active().is_none_or(|st| st.doc.id != id)) {
|
||
cancel(app);
|
||
return;
|
||
}
|
||
let type_layer = warps_type(app);
|
||
let Some(t) = app.ui.transform.take() else { return };
|
||
let mut preview = app.transform_preview.take();
|
||
if let Some(options) = &t.content_aware {
|
||
crate::cas_transform::commit(app, &t, options);
|
||
return;
|
||
}
|
||
if t.selection {
|
||
let mut p = json!({"rect": t.rect, "interpolation": t.interpolation});
|
||
match &t.warp {
|
||
Some(w) if !w.is_identity() => p["warp"] = json!(w),
|
||
Some(_) => return,
|
||
None if t.quad == corners(t.rect) => return,
|
||
None => p["quad"] = json!(t.quad),
|
||
}
|
||
if let Err(e) = app.run("select.transformSelection", p) {
|
||
keep_open(app, t, preview, e);
|
||
}
|
||
return;
|
||
}
|
||
if let Some(target) = &t.path {
|
||
let mut p = target.clone();
|
||
match &t.warp {
|
||
Some(w) if w.is_identity() => return,
|
||
Some(w) => p["warp"] = json!(w),
|
||
None if t.quad == corners(t.rect) => return,
|
||
// Distort and Perspective map the path through the box (#3027).
|
||
None => match rect_to_quad_affine(t.rect, t.quad) {
|
||
Some(a) => p["matrix"] = json!(a.m),
|
||
None => {
|
||
p["rect"] = json!(t.rect);
|
||
p["quad"] = json!(t.quad);
|
||
}
|
||
},
|
||
}
|
||
if let Err(e) = app.run("path.transform", p) {
|
||
keep_open(app, t, preview, e);
|
||
}
|
||
return;
|
||
}
|
||
let made = t.made;
|
||
let r = if let Some(w) = &t.warp {
|
||
// Type with a Warp Text can be warped back to None; the engine skips an unchanged style.
|
||
if w.is_identity() && !type_layer {
|
||
return;
|
||
}
|
||
app.run("edit.transform.warp", json!({"layer": t.layer, "rect": t.rect, "warp": w, "interpolation": t.interpolation}))
|
||
} else {
|
||
if t.quad == corners(t.rect) {
|
||
return; // untouched: nothing to do and no history step; a ⌥⌘T copy or a place stays
|
||
}
|
||
let mut p = json!({"layer": t.layer, "rect": t.rect, "quad": t.quad, "interpolation": t.interpolation});
|
||
if let Some(target) = &t.target {
|
||
p["target"] = target.clone();
|
||
}
|
||
app.run("edit.transform", p)
|
||
};
|
||
// A large layer's warp runs in the background: the session stays until the job ends.
|
||
if let Ok(v) = &r
|
||
&& let Some(job) = v.get("pending").and_then(serde_json::Value::as_bool).filter(|p| *p).and(v.get("job")).and_then(serde_json::Value::as_u64)
|
||
&& let Some(pv) = preview.as_mut()
|
||
{
|
||
pv.committing = Some(JobId(job));
|
||
app.ui.transform = Some(t);
|
||
app.transform_preview = preview;
|
||
return;
|
||
}
|
||
match r {
|
||
Ok(_) => finish(app, document, LayerId(t.layer), made, true),
|
||
Err(e) => keep_open(app, t, preview, e),
|
||
}
|
||
}
|
||
|
||
/// After the transform landed or not: fold the layer the session made into its step, or take it
|
||
/// back.
|
||
fn finish(app: &mut PhotocraftApp, document: Option<DocId>, layer: LayerId, made: Option<MadeLayer>, landed: bool) {
|
||
let (Some(made), Some(document)) = (made, document) else { return };
|
||
if landed {
|
||
fold_made(app, document, made);
|
||
} else {
|
||
take_back_made(app, document, layer);
|
||
}
|
||
}
|
||
|
||
/// The job applying the transform, while it runs.
|
||
pub(crate) fn committing(app: &PhotocraftApp) -> Option<JobId> {
|
||
app.transform_preview.as_ref().and_then(|pv| pv.committing)
|
||
}
|
||
|
||
/// A job ended (see [`crate::jobs_ui`]): when it was the commit, the session ends with it. Its
|
||
/// status and errors are reported like any job's.
|
||
pub fn on_job_event(app: &mut PhotocraftApp, e: &JobEvent) {
|
||
if committing(app) != Some(e.id) {
|
||
return;
|
||
}
|
||
let (Some(t), Some(mut pv)) = (app.ui.transform.take(), app.transform_preview.take()) else { return };
|
||
// A refused commit (a result over the pixel budget, #3773) keeps the box open, as inline.
|
||
if let JobOutcome::Failed(msg) = &e.outcome {
|
||
pv.committing = None;
|
||
keep_open(app, t, Some(pv), msg.clone());
|
||
return;
|
||
}
|
||
finish(app, Some(pv.doc.id), LayerId(t.layer), t.made, matches!(e.outcome, JobOutcome::Done(_)));
|
||
}
|
||
|
||
/// A commit the engine refused (a result over the pixel budget, #3773): the box stays open as it
|
||
/// was, with the reason in the status bar, so the transform can be changed or cancelled. A copy or
|
||
/// a placed layer it made goes only when it is cancelled.
|
||
fn keep_open(app: &mut PhotocraftApp, t: TransformSession, preview: Option<TransformPreview>, e: String) {
|
||
app.ui.transform = Some(t);
|
||
app.transform_preview = preview;
|
||
app.ui.status = e;
|
||
}
|
||
|
||
/// A transform whose layer or document went away (undo, close) ends silently; picking another tool
|
||
/// or selecting another layer (Layers panel, ⌘-click, the control channel) applies it, so the box
|
||
/// never stays on a layer that is no longer the active one (#1400). Checked every frame and before
|
||
/// each pointer event, so a press with a tool chosen just before it (`ui.pointer`'s `tool`) goes to
|
||
/// that tool.
|
||
pub fn end_if_left(app: &mut PhotocraftApp) {
|
||
let Some(t) = &app.ui.transform else { return };
|
||
if committing(app).is_some() {
|
||
return;
|
||
}
|
||
let Some(st) = app.session.active() else { return cancel(app) };
|
||
|
||
let layer = LayerId(t.layer);
|
||
let made = t.made;
|
||
let selection = t.selection;
|
||
let preview_doc_id = app.transform_preview.as_ref().map(|pv| pv.doc.id);
|
||
let switched_document = preview_doc_id.is_some_and(|id| id != st.doc.id);
|
||
|
||
let editing_placed_smart_object = switched_document
|
||
&& made == Some(MadeLayer::Place)
|
||
&& preview_doc_id
|
||
.is_some_and(|parent_id| app.session.smart_links.iter().any(|link| link.parent == parent_id && link.child == st.doc.id && link.layer == layer));
|
||
|
||
if editing_placed_smart_object {
|
||
app.ui.transform = None;
|
||
app.transform_preview = None;
|
||
} else if switched_document || st.doc.layer(layer).is_none() {
|
||
cancel(app);
|
||
} else if app.transform_preview.as_ref().is_some_and(|pv| pv.tool != app.ui.tool)
|
||
|| (!selection && st.active_layer.is_some_and(|active| active.0 != layer.0))
|
||
{
|
||
commit(app);
|
||
// Picking another tool or layer can't leave a refused transform open: it ends, as Esc would.
|
||
if app.ui.transform.is_some() {
|
||
cancel(app);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Edits made while the box is open show in the preview at once, not only after the commit
|
||
/// (#1384): the moving layer's opacity, fill and visibility tint the moving pixels, and the
|
||
/// document under them (other layers, and the part of the layer a selection leaves behind) is
|
||
/// rebuilt from the current one. The moving pixels keep the session's. Called before the canvas
|
||
/// reads the preview, so it never caches a revision with the old preview.
|
||
pub fn follow_edits(app: &mut PhotocraftApp) {
|
||
let (Some(t), Some(pv)) = (&app.ui.transform, app.transform_preview.as_mut()) else { return };
|
||
let Some(follow) = pv.follow.as_mut() else { return };
|
||
let Some(st) = app.session.active().filter(|st| st.doc.id == pv.doc.id && st.revision != follow.revision) else { return };
|
||
let id = LayerId(t.layer);
|
||
let (Some(live), Some(moving)) = (st.doc.layer(id), pv.doc.layer(id)) else { return };
|
||
let mut pd = (*st.doc).clone();
|
||
if let Some(l) = pd.layer_mut(id) {
|
||
l.content = moving.content.clone();
|
||
l.visible = l.visible && !follow.hidden;
|
||
}
|
||
if let Some(a) = pv.above.as_mut() {
|
||
a.rebuild(&st.doc, id);
|
||
a.hide_in(&mut pd.layers);
|
||
}
|
||
pv.opacity = if live.visible { live.opacity * live.fill_opacity } else { 0.0 };
|
||
follow.revision = st.revision;
|
||
pv.doc = Arc::new(pd);
|
||
}
|
||
|
||
pub fn cancel(app: &mut PhotocraftApp) {
|
||
// Esc while the commit runs cancels the job; the session ends when it does.
|
||
if let Some(job) = committing(app) {
|
||
crate::jobs_ui::cancel(app, job);
|
||
return;
|
||
}
|
||
let transform = app.ui.transform.take();
|
||
let preview = app.transform_preview.take();
|
||
if let (Some(t), Some(pv)) = (transform, preview)
|
||
&& t.made.is_some()
|
||
{
|
||
take_back_made(app, pv.doc.id, LayerId(t.layer));
|
||
}
|
||
}
|
||
|
||
/// The session's starting box: where Undo stops.
|
||
fn start_steps(app: &mut PhotocraftApp) {
|
||
if let (Some(t), Some(pv)) = (&app.ui.transform, app.transform_preview.as_mut()) {
|
||
pv.steps = Steps { settled: Some(Step::of(t)), ..Steps::default() };
|
||
}
|
||
}
|
||
|
||
/// Record the box as an undo step whenever it comes to rest in a new state: once per drag (on
|
||
/// release), nudge or option edit (once typing in its field ends). Call once per frame.
|
||
pub fn track_steps(app: &mut PhotocraftApp, ctx: &egui::Context) {
|
||
let (Some(t), Some(pv)) = (&app.ui.transform, app.transform_preview.as_mut()) else { return };
|
||
if pv.gesture.is_some() || pv.warp_drag.is_some() || ctx.input(|i| i.pointer.any_down()) || ctx.text_edit_focused() {
|
||
// Whatever comes to rest after this is more than the nudge.
|
||
pv.steps.fold = false;
|
||
return;
|
||
}
|
||
pv.steps.settle(Step::of(t));
|
||
}
|
||
|
||
/// Arrow keys while Free Transform is active: move the box (and its pivot) by `(dx, dy)`.
|
||
/// Nudges that follow each other closely are one Undo step of the session (`nudge_bundle`).
|
||
pub(crate) fn nudge(app: &mut PhotocraftApp, dx: f64, dy: f64) {
|
||
let (pause, now) = (crate::nudge_bundle::pause(app), app.nudge_bundle.now);
|
||
let Some(t) = app.ui.transform.as_mut().filter(|t| t.warp.is_none()) else { return };
|
||
let before = Step::of(t);
|
||
t.quad = t.quad.map(|q| [q[0] + dx, q[1] + dy]);
|
||
t.pivot = [t.pivot[0] + dx, t.pivot[1] + dy];
|
||
if let Some(pv) = app.transform_preview.as_mut() {
|
||
pv.steps.nudged(&before, Step::of(t), now, pause);
|
||
}
|
||
}
|
||
|
||
/// While transforming, Undo and Redo apply to the box: `Some(enabled)` for those commands (and
|
||
/// Toggle Last State, which would change the document under the box), `None` for the rest.
|
||
pub fn is_enabled(app: &PhotocraftApp, id: &str) -> Option<bool> {
|
||
let t = app.ui.transform.as_ref()?;
|
||
if committing(app).is_some() {
|
||
return box_command(id).then_some(false);
|
||
}
|
||
let steps = app.transform_preview.as_ref().map(|pv| &pv.steps);
|
||
match id {
|
||
// Content-Aware Scale's box only scales: no switching it to another transform or Warp.
|
||
"edit.freeTransform" | "edit.freeTransformCopy" if t.content_aware.is_some() => Some(false),
|
||
i if i.starts_with("edit.transform.") && t.content_aware.is_some() => Some(false),
|
||
"edit.undo" => Some(steps.is_some_and(|s| s.can_undo(&Step::of(t)))),
|
||
"edit.redo" => Some(steps.is_some_and(|s| !s.redo.is_empty())),
|
||
// No Toggle Last State or history jump under the box, and one box at a time.
|
||
"edit.toggleLastState" | "history.selectState" | "edit.contentAwareScale" | "edit.puppetWarp" => Some(false),
|
||
// Rotate and Flip turn the box (not while warping, where the mesh is the box).
|
||
_ if box_preset(id).is_some() => Some(t.warp.is_none()),
|
||
// Editable type can't be distorted (#2630, as #2701 rules for the menus outside a box).
|
||
"edit.transform.distort" | "edit.transform.perspective" if moves_type(app, t) => Some(false),
|
||
_ => None,
|
||
}
|
||
}
|
||
|
||
/// Does the box move an editable type layer (not its selection outline, a lone mask or a path)?
|
||
fn moves_type(app: &PhotocraftApp, t: &TransformSession) -> bool {
|
||
!t.selection
|
||
&& t.target.is_none()
|
||
&& t.path.is_none()
|
||
&& app.session.active().and_then(|st| st.doc.layer(LayerId(t.layer))).is_some_and(|l| matches!(l.content, LayerContent::Text(_)))
|
||
}
|
||
|
||
/// Edit › Transform's Rotate and Flip presets while a box is open: what they do to the box, as
|
||
/// `(kx, ky, angle)`: a scale along the box's own axes, then a rotation (radians, clockwise on
|
||
/// screen), both about the reference point.
|
||
fn box_preset(id: &str) -> Option<(f64, f64, f64)> {
|
||
use std::f64::consts::{FRAC_PI_2, PI};
|
||
Some(match id {
|
||
"edit.transform.rotate180" => (1.0, 1.0, PI),
|
||
"edit.transform.rotate90Cw" => (1.0, 1.0, FRAC_PI_2),
|
||
"edit.transform.rotate90Ccw" => (1.0, 1.0, -FRAC_PI_2),
|
||
"edit.transform.flipHorizontal" => (-1.0, 1.0, 0.0),
|
||
"edit.transform.flipVertical" => (1.0, -1.0, 0.0),
|
||
_ => return None,
|
||
})
|
||
}
|
||
|
||
/// While transforming, the box owns the history for every caller (menus, shortcuts, the control
|
||
/// channel, MCP): Undo and Redo step through it, and Toggle Last State and History state jumps,
|
||
/// which would change the document under the box, are refused. Edit › Transform's Rotate and
|
||
/// Flip turn the box itself, as in Photoshop, instead of the layer under it; committing stays
|
||
/// with ↩ and the options bar.
|
||
/// `None` for other commands or when not transforming.
|
||
/// The commands the open box answers itself ([`is_enabled`], [`intercept`]).
|
||
fn box_command(id: &str) -> bool {
|
||
matches!(id, "edit.undo" | "edit.redo" | "edit.toggleLastState" | "edit.contentAwareScale" | "edit.puppetWarp") || box_preset(id).is_some()
|
||
}
|
||
|
||
pub fn intercept(app: &mut PhotocraftApp, id: &str) -> Option<Result<serde_json::Value, String>> {
|
||
let warping = app.ui.transform.as_ref()?.warp.is_some();
|
||
if committing(app).is_some() {
|
||
return box_command(id).then(|| Err(STILL_APPLYING.into()));
|
||
}
|
||
match id {
|
||
"edit.undo" => Some(Ok(step(app, false))),
|
||
"edit.redo" => Some(Ok(step(app, true))),
|
||
"edit.toggleLastState" | "history.selectState" | "edit.contentAwareScale" | "edit.puppetWarp" => {
|
||
Some(Err("Commit or cancel the transform first".into()))
|
||
}
|
||
_ => {
|
||
let (kx, ky, angle) = box_preset(id)?;
|
||
if warping {
|
||
return Some(Err("Commit or cancel the transform first".into()));
|
||
}
|
||
scale_about_pivot(app, kx, ky);
|
||
rotate_about_pivot(app, angle);
|
||
Some(Ok(json!({"transform": app.ui.transform})))
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Undo (or redo) one step of the open session; nothing mid-drag or with no step to take.
|
||
fn step(app: &mut PhotocraftApp, redo: bool) -> serde_json::Value {
|
||
let Some(t) = app.ui.transform.as_mut() else { return serde_json::Value::Null };
|
||
let Some(pv) = app.transform_preview.as_mut().filter(|pv| pv.gesture.is_none() && pv.warp_drag.is_none()) else {
|
||
return serde_json::Value::Null;
|
||
};
|
||
let s = &mut pv.steps;
|
||
s.settle(Step::of(t));
|
||
let (from, to) = if redo { (&mut s.redo, &mut s.undo) } else { (&mut s.undo, &mut s.redo) };
|
||
let Some(step) = from.pop() else { return serde_json::Value::Null };
|
||
to.push(Step::of(t));
|
||
s.settled = Some(step.clone());
|
||
step.restore(t);
|
||
json!({"transform": t})
|
||
}
|
||
|
||
/// Which part of the box a document point hits.
|
||
#[derive(Clone, Copy, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
|
||
pub(crate) enum Hit {
|
||
Corner(usize),
|
||
Edge(usize),
|
||
Pivot,
|
||
Inside,
|
||
Outside,
|
||
}
|
||
|
||
/// The nearest handle within `tol` (on a small box their grab areas overlap), else inside or
|
||
/// outside the box.
|
||
pub(crate) fn hit(t: &TransformSession, p: [f64; 2], tol: f64) -> Hit {
|
||
let d = |a: [f64; 2]| ((a[0] - p[0]).powi(2) + (a[1] - p[1]).powi(2)).sqrt();
|
||
let mid = |i: usize| {
|
||
let (a, b) = (t.quad[i], t.quad[(i + 1) % 4]);
|
||
[(a[0] + b[0]) / 2.0, (a[1] + b[1]) / 2.0]
|
||
};
|
||
let handles = (0..4).map(|i| (t.quad[i], Hit::Corner(i))).chain((0..4).map(|i| (mid(i), Hit::Edge(i)))).chain([(t.pivot, Hit::Pivot)]);
|
||
let nearest = handles.map(|(q, h)| (d(q), h)).filter(|(dist, _)| *dist < tol).min_by(|a, b| a.0.total_cmp(&b.0));
|
||
match nearest {
|
||
Some((_, h)) => h,
|
||
None if inside(&t.quad, p) => Hit::Inside,
|
||
None => Hit::Outside,
|
||
}
|
||
}
|
||
|
||
fn inside(q: &[[f64; 2]; 4], p: [f64; 2]) -> bool {
|
||
let mut c = false;
|
||
for i in 0..4 {
|
||
let (a, b) = (q[i], q[(i + 3) % 4]);
|
||
if (a[1] > p[1]) != (b[1] > p[1]) && p[0] < (b[0] - a[0]) * (p[1] - a[1]) / (b[1] - a[1]) + a[0] {
|
||
c = !c;
|
||
}
|
||
}
|
||
c
|
||
}
|
||
|
||
/// Transient drag state (kept in egui memory; not part of the serialised session).
|
||
#[derive(Clone, Copy, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
|
||
pub(crate) struct Gesture {
|
||
pub(crate) hit: Hit,
|
||
pub(crate) start: [f64; 2],
|
||
pub(crate) quad0: [[f64; 2]; 4],
|
||
pub(crate) pivot0: [f64; 2],
|
||
}
|
||
|
||
/// A click outside the box accepts the transform; a drag outside still rotates it.
|
||
/// Decide on release, so the rotation zone keeps working for drags. The screen-sized
|
||
/// click tolerance absorbs pointer jitter without accepting a real rotation gesture.
|
||
fn accepts_outside_click(g: Gesture, end: [f64; 2], tol: f64) -> bool {
|
||
g.hit == Hit::Outside && (end[0] - g.start[0]).hypot(end[1] - g.start[1]) <= 2.0 * tol / HANDLE_PX
|
||
}
|
||
|
||
/// Pointer input while transforming. Returns false when no transform is active.
|
||
pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) -> bool {
|
||
let Some(t) = app.ui.transform.clone() else { return false };
|
||
if committing(app).is_some() {
|
||
return true;
|
||
}
|
||
let tol = handle_tolerance(app);
|
||
if t.warp.is_some() {
|
||
warp_pointer(app, ev, tol, mods);
|
||
return true;
|
||
}
|
||
let Some(pv) = app.transform_preview.as_mut() else { return false };
|
||
match ev {
|
||
ToolEvent::Down { x, y, .. } => {
|
||
let mut h = hit(&t, [x, y], tol);
|
||
// ⌥-click away from the handles moves the reference point there (and drags it).
|
||
if mods.alt
|
||
&& matches!(h, Hit::Inside | Hit::Outside)
|
||
&& let Some(s) = app.ui.transform.as_mut()
|
||
{
|
||
s.pivot = [x, y];
|
||
h = Hit::Pivot;
|
||
}
|
||
// Distort only moves corners (and the whole box from inside): no rotating, no edges.
|
||
// Even in Distort mode, remember an outside press so a plain click can
|
||
// accept the transform. A drag outside remains inert in that mode, in Skew, and in
|
||
// Content-Aware Scale, which only scales and moves.
|
||
pv.gesture = (mode_allows(t.mode, h) || h == Hit::Outside).then_some(Gesture { hit: h, start: [x, y], quad0: t.quad, pivot0: t.pivot });
|
||
}
|
||
ToolEvent::Move { x, y, .. } | ToolEvent::Up { x, y } => {
|
||
let g = pv.gesture;
|
||
if matches!(ev, ToolEvent::Up { .. }) {
|
||
pv.gesture = None;
|
||
}
|
||
// Clicking an empty spot commits instead of silently arming rotation (#2851).
|
||
// Dragging the rotation zone and Option-clicking to move the pivot
|
||
// keep their existing behavior.
|
||
if matches!(ev, ToolEvent::Up { .. }) && g.is_some_and(|g| accepts_outside_click(g, [x, y], tol)) {
|
||
commit(app);
|
||
return true;
|
||
}
|
||
let type_layer = moves_type(app, &t);
|
||
let adjusted = g.map(|g| drag_mods(app, &t, g.hit, mods));
|
||
if let (Some(g), Some((mode, mods)), Some(s)) = (g, adjusted, app.ui.transform.as_mut())
|
||
&& mode_allows(t.mode, g.hit)
|
||
&& (t.content_aware.is_none() || g.hit != Hit::Outside)
|
||
{
|
||
if t.content_aware.is_some() {
|
||
let mut mods = mode_mods(mode, g.hit, mods);
|
||
// Content-Aware Scale: ⌘ neither skews nor distorts.
|
||
mods.command = false;
|
||
apply_drag(s, g, [x, y], mods);
|
||
} else {
|
||
apply_mode_drag(s, g, [x, y], mode, type_layer, mods);
|
||
}
|
||
round_to_pixel_grid(app);
|
||
}
|
||
}
|
||
}
|
||
crate::snap_ui::retain_transform_lines(app, mods);
|
||
true
|
||
}
|
||
|
||
/// Preferences › Tools › Snap Vector Tools and Transforms to Pixel Grid (#5751): while a shape
|
||
/// layer or a path is moved or scaled (the box still an upright rectangle), its edges stay on
|
||
/// whole pixels, whatever the tool. Rounding the pointer alone left the edges off by the grab
|
||
/// offset, and a proportional scale off by its ratio: a shape dragged to 540 px came out 539.6.
|
||
/// Rotated, skewed and distorted boxes are left as dragged.
|
||
fn round_to_pixel_grid(app: &mut PhotocraftApp) {
|
||
if !app.session.prefs().tools.snap_vector_tools_and_transforms_to_pixel_grid {
|
||
return;
|
||
}
|
||
let Some(t) = app.ui.transform.as_ref() else { return };
|
||
if t.selection || t.warp.is_some() || t.content_aware.is_some() || t.target.is_some() {
|
||
return;
|
||
}
|
||
let vector =
|
||
t.path.is_some() || app.session.active().and_then(|st| st.doc.layer(LayerId(t.layer))).is_some_and(|l| matches!(l.content, LayerContent::Shape(_)));
|
||
if !vector || !upright(&t.quad) {
|
||
return;
|
||
}
|
||
let q = t.quad.map(|p| p.map(f64::round));
|
||
// A box narrower than a pixel keeps its size rather than collapsing.
|
||
if (q[1][0] - q[0][0]).abs() < 1.0 || (q[3][1] - q[0][1]).abs() < 1.0 || q.iter().flatten().any(|v| !v.is_finite()) {
|
||
return;
|
||
}
|
||
if let Some(s) = app.ui.transform.as_mut() {
|
||
s.quad = q;
|
||
}
|
||
}
|
||
|
||
/// Whether corners clockwise from top-left form an axis-aligned rectangle (flips included).
|
||
fn upright(q: &[[f64; 2]; 4]) -> bool {
|
||
let near = |a: f64, b: f64| (a - b).abs() < 1e-6;
|
||
near(q[0][1], q[1][1]) && near(q[2][1], q[3][1]) && near(q[0][0], q[3][0]) && near(q[1][0], q[2][0])
|
||
}
|
||
|
||
/// The same effective modifiers drive the geometry and decide whether Ctrl is a distortion
|
||
/// gesture rather than the ordinary snap override. Keep type and Content-Aware Scale refusals.
|
||
fn drag_mods(app: &PhotocraftApp, t: &TransformSession, hit: Hit, mods: egui::Modifiers) -> (TransformMode, egui::Modifiers) {
|
||
let (mode, mods) = if distort_refused(app, t) { scale_not_distort(t.mode, hit, mods) } else { (t.mode, mods) };
|
||
let legacy = app.session.prefs().general.use_legacy_free_transform;
|
||
(mode, legacy_mods(legacy, hit, unlinked_mods(app.ui.transform_unlinked, legacy, hit, mods)))
|
||
}
|
||
|
||
/// Only a freely distorted corner can snap its proposed position. Perspective moves paired
|
||
/// corners on one axis; Skew, Warp and Content-Aware Scale have their own constrained geometry.
|
||
pub(crate) fn corner_distorts(app: &PhotocraftApp, index: usize, mods: egui::Modifiers) -> bool {
|
||
let Some(t) = app.ui.transform.as_ref() else { return false };
|
||
if t.quad.get(index).is_none() || t.warp.is_some() || t.content_aware.is_some() || t.mode == TransformMode::Skew {
|
||
return false;
|
||
}
|
||
let (mode, mods) = drag_mods(app, t, Hit::Corner(index), mods);
|
||
let mods = mode_mods(mode, Hit::Corner(index), mods);
|
||
mods.command && !(mods.alt && mods.shift)
|
||
}
|
||
|
||
/// Editable type can be scaled, rotated and skewed but not distorted (#2630): the rule that greys
|
||
/// Distort and Perspective in `enable_rules::disabled`, for the box's own layer. A box on a path,
|
||
/// on the selection outline or on a lone mask or channel moves no type.
|
||
fn distort_refused(app: &PhotocraftApp, t: &TransformSession) -> bool {
|
||
t.path.is_none()
|
||
&& !t.selection
|
||
&& t.target.is_none()
|
||
&& app.session.active().and_then(|st| st.doc.layer(LayerId(t.layer))).is_some_and(|l| matches!(l.content, LayerContent::Text(_)))
|
||
}
|
||
|
||
/// Where Distort and Perspective are refused ([`distort_refused`]), ⌘-drag and ⌘⌥⇧-drag of a
|
||
/// corner scale like a plain corner drag (with ⌥ and ⇧ keeping their meaning), so the box never
|
||
/// takes a shape the commit would refuse (#3010).
|
||
fn scale_not_distort(mode: TransformMode, hit: Hit, mut mods: egui::Modifiers) -> (TransformMode, egui::Modifiers) {
|
||
if matches!(hit, Hit::Corner(_)) {
|
||
mods.command = false;
|
||
}
|
||
let mode = if matches!(mode, TransformMode::Distort | TransformMode::Perspective) { TransformMode::Free } else { mode };
|
||
(mode, mods)
|
||
}
|
||
|
||
/// Preferences › General › Use Legacy Free Transform, Photoshop's scaling before CC 2019: corner
|
||
/// drags stretch freely and ⇧ keeps the proportions, edge drags stretch their one axis whatever
|
||
/// the keys. The default is proportional for both, with ⇧ freeing them.
|
||
pub(crate) fn legacy_mods(legacy: bool, hit: Hit, mut mods: egui::Modifiers) -> egui::Modifiers {
|
||
if legacy && !mods.command {
|
||
match hit {
|
||
Hit::Corner(_) => mods.shift = !mods.shift,
|
||
Hit::Edge(_) => mods.shift = true,
|
||
_ => {}
|
||
}
|
||
}
|
||
mods
|
||
}
|
||
|
||
/// The options bar's Maintain Aspect Ratio link turned off: corner and edge drags stretch freely
|
||
/// and ⇧ keeps the proportions, as in Photoshop CC 2019 and later (#4174). The legacy preference
|
||
/// already works that way, so it is left alone there.
|
||
pub(crate) fn unlinked_mods(unlinked: bool, legacy: bool, hit: Hit, mut mods: egui::Modifiers) -> egui::Modifiers {
|
||
if unlinked && !legacy && !mods.command && matches!(hit, Hit::Corner(_) | Hit::Edge(_)) {
|
||
mods.shift = !mods.shift;
|
||
}
|
||
mods
|
||
}
|
||
|
||
/// Distort / Perspective modes: a corner drag acts as if that gesture's keys were held (⌘-drag a
|
||
/// corner distorts, ⌘⌥⇧-drag a corner adds perspective). Skew has its own ([`apply_mode_drag`]).
|
||
fn mode_mods(mode: TransformMode, hit: Hit, mut mods: egui::Modifiers) -> egui::Modifiers {
|
||
match (mode, hit) {
|
||
(TransformMode::Distort, Hit::Corner(_)) => mods.command = true,
|
||
(TransformMode::Perspective, Hit::Corner(_)) => {
|
||
mods.command = true;
|
||
mods.alt = true;
|
||
mods.shift = true;
|
||
}
|
||
_ => {}
|
||
}
|
||
mods
|
||
}
|
||
|
||
/// A handle drag in Edit › Transform `mode` (`Free` for Free Transform). `type_layer`: the box
|
||
/// moves editable type, which only takes affine transforms.
|
||
pub(crate) fn apply_mode_drag(s: &mut TransformSession, g: Gesture, p: [f64; 2], mode: TransformMode, type_layer: bool, mut mods: egui::Modifiers) {
|
||
if !mode_allows(mode, g.hit) {
|
||
return;
|
||
}
|
||
match (mode, g.hit) {
|
||
// Skew, as in Photoshop (#3437): a corner moves alone along one of its two edges,
|
||
// whichever the drag mostly follows; ⌥ moves the opposite corner the other way.
|
||
(TransformMode::Skew, Hit::Corner(i)) if i < 4 => {
|
||
let (d, q0) = ([p[0] - g.start[0], p[1] - g.start[1]], g.quad0);
|
||
// Corners 0..3 = TL, TR, BR, BL: the neighbour along the top/bottom edge, and along
|
||
// the left/right one.
|
||
let along = |j: usize| {
|
||
let e = [q0[i][0] - q0[j][0], q0[i][1] - q0[j][1]];
|
||
let l = e[0].hypot(e[1]);
|
||
let u = if l > 0.0 { [e[0] / l, e[1] / l] } else { [0.0, 0.0] };
|
||
let t = d[0] * u[0] + d[1] * u[1];
|
||
[t * u[0], t * u[1]]
|
||
};
|
||
let (hn, vn) = ([1, 0, 3, 2][i], [3, 2, 1, 0][i]);
|
||
let (h, v) = (along(hn), along(vn));
|
||
let (m, partner) = if h[0].hypot(h[1]) >= v[0].hypot(v[1]) { (h, hn) } else { (v, vn) };
|
||
// Editable type stays a parallelogram: the corner's whole edge slides with it.
|
||
let moved: &[usize] = if type_layer { &[i, partner] } else { &[i] };
|
||
let mut q = q0;
|
||
for k in 0..4 {
|
||
let sign = if moved.contains(&k) {
|
||
1.0
|
||
} else if mods.alt && (if type_layer { true } else { k == (i + 2) % 4 }) {
|
||
// ⌥: the opposite corner (on type, the opposite edge) the other way.
|
||
-1.0
|
||
} else {
|
||
continue;
|
||
};
|
||
if let (Some(dst), Some(src)) = (q.get_mut(k), q0.get(k)) {
|
||
*dst = [src[0] + sign * m[0], src[1] + sign * m[1]];
|
||
}
|
||
}
|
||
s.quad = keep_convex(q0, q);
|
||
s.pivot = carry_pivot(q0, s.quad, g.pivot0);
|
||
}
|
||
// Rotate (#3941): a corner or edge turns about the reference point, the same drag as the
|
||
// zone just outside the box. Free Transform still scales from those handles.
|
||
(TransformMode::Rotate, Hit::Corner(_) | Hit::Edge(_)) => {
|
||
let mut g = g;
|
||
g.hit = Hit::Outside;
|
||
apply_drag(s, g, p, mods);
|
||
}
|
||
// Skew: an edge slides only along itself (⌘⇧ in Free Transform).
|
||
(TransformMode::Skew, Hit::Edge(_)) => {
|
||
mods.command = true;
|
||
mods.shift = true;
|
||
apply_drag(s, g, p, mods);
|
||
}
|
||
_ => apply_drag(s, g, p, mode_mods(mode, g.hit, mods)),
|
||
}
|
||
}
|
||
|
||
/// What a drag can do in each Edit › Transform mode: in Distort only the corners (and moving the
|
||
/// box from inside); in Skew everything but rotating from outside the box (#3437). A click
|
||
/// outside still commits.
|
||
fn mode_allows(mode: TransformMode, h: Hit) -> bool {
|
||
match mode {
|
||
TransformMode::Distort => !matches!(h, Hit::Outside | Hit::Edge(_)),
|
||
TransformMode::Skew => h != Hit::Outside,
|
||
_ => true,
|
||
}
|
||
}
|
||
|
||
/// The turn at each corner of a quad (cross products of consecutive edges): all the same sign
|
||
/// for a convex quad, mixed for a concave or self-intersecting one.
|
||
fn turns(q: &[[f64; 2]; 4]) -> [f64; 4] {
|
||
std::array::from_fn(|k| {
|
||
let (a, b, c) = (q[k], q[(k + 1) % 4], q[(k + 2) % 4]);
|
||
(b[0] - a[0]) * (c[1] - b[1]) - (b[1] - a[1]) * (c[0] - b[0])
|
||
})
|
||
}
|
||
|
||
/// Is `q` convex with the orientation `sign`, every corner turning by more than a sliver?
|
||
fn convex(q: &[[f64; 2]; 4], sign: f64, min_turn: f64) -> bool {
|
||
turns(q).iter().all(|t| t * sign > min_turn)
|
||
}
|
||
|
||
/// Distort and Perspective stop a corner where the box would turn concave or fold over itself,
|
||
/// as Photoshop does (#1323): the result moves from `from` towards `to` as far as the quad stays
|
||
/// convex. A box that is not convex to begin with is left free.
|
||
fn keep_convex(from: [[f64; 2]; 4], to: [[f64; 2]; 4]) -> [[f64; 2]; 4] {
|
||
let t0 = turns(&from);
|
||
let sign = t0[0].signum();
|
||
// A sliver: a thousandth of the starting box's smallest turn, so the stop is short of flat.
|
||
let min_turn = t0.iter().map(|t| t.abs()).fold(f64::INFINITY, f64::min) * 1e-3;
|
||
if !(min_turn.is_finite() && min_turn > 0.0 && convex(&from, sign, 0.0)) || convex(&to, sign, min_turn) {
|
||
return to;
|
||
}
|
||
let lerp = |t: f64| -> [[f64; 2]; 4] { std::array::from_fn(|k| [from[k][0] + (to[k][0] - from[k][0]) * t, from[k][1] + (to[k][1] - from[k][1]) * t]) };
|
||
let (mut lo, mut hi) = (0.0f64, 1.0f64);
|
||
for _ in 0..40 {
|
||
let mid = (lo + hi) / 2.0;
|
||
if convex(&lerp(mid), sign, min_turn) { lo = mid } else { hi = mid }
|
||
}
|
||
lerp(lo)
|
||
}
|
||
|
||
pub(crate) fn apply_drag(s: &mut TransformSession, g: Gesture, p: [f64; 2], mods: egui::Modifiers) {
|
||
let (dx, dy) = (p[0] - g.start[0], p[1] - g.start[1]);
|
||
match g.hit {
|
||
Hit::Inside => {
|
||
// ⇧ locks the move to the axes and diagonals; the current offset keeps the lock stable.
|
||
let (dx, dy) = if mods.shift {
|
||
let prev = [s.quad[0][0] - g.quad0[0][0], s.quad[0][1] - g.quad0[0][1]];
|
||
let d = crate::move_mods::constrain([dx, dy], Some(prev), crate::move_mods::TRANSFORM_DIRECTIONS);
|
||
(d[0], d[1])
|
||
} else {
|
||
(dx, dy)
|
||
};
|
||
s.quad = g.quad0.map(|q| [q[0] + dx, q[1] + dy]);
|
||
s.pivot = [g.pivot0[0] + dx, g.pivot0[1] + dy];
|
||
}
|
||
Hit::Pivot => s.pivot = p,
|
||
Hit::Outside => {
|
||
let a0 = (g.start[1] - g.pivot0[1]).atan2(g.start[0] - g.pivot0[0]);
|
||
let a1 = (p[1] - g.pivot0[1]).atan2(p[0] - g.pivot0[0]);
|
||
let mut da = a1 - a0;
|
||
if mods.shift {
|
||
// Snap the resulting angle to 15° steps.
|
||
let base = (g.quad0[1][1] - g.quad0[0][1]).atan2(g.quad0[1][0] - g.quad0[0][0]);
|
||
let step = 15f64.to_radians();
|
||
da = ((base + da) / step).round() * step - base;
|
||
}
|
||
let (sn, cs) = da.sin_cos();
|
||
let c = g.pivot0;
|
||
s.quad = g.quad0.map(|q| {
|
||
let (x, y) = (q[0] - c[0], q[1] - c[1]);
|
||
[c[0] + x * cs - y * sn, c[1] + x * sn + y * cs]
|
||
});
|
||
}
|
||
Hit::Corner(i) if mods.command && mods.alt && mods.shift => {
|
||
// Perspective: the corner moves along the dominant axis and the corner sharing that
|
||
// side moves the opposite way.
|
||
s.quad = g.quad0;
|
||
let horizontal = dx.abs() >= dy.abs();
|
||
// Corners 0..3 = TL, TR, BR, BL: horizontal drags pair along the top/bottom edge,
|
||
// vertical ones along the left/right edge.
|
||
let j = match (i, horizontal) {
|
||
(0, true) => 1,
|
||
(1, true) => 0,
|
||
(2, true) => 3,
|
||
(3, true) => 2,
|
||
(0, false) => 3,
|
||
(3, false) => 0,
|
||
(1, false) => 2,
|
||
_ => 1,
|
||
};
|
||
let (mx, my) = if horizontal { (dx, 0.0) } else { (0.0, dy) };
|
||
let mut q = g.quad0;
|
||
q[i] = [g.quad0[i][0] + mx, g.quad0[i][1] + my];
|
||
q[j] = [g.quad0[j][0] - mx, g.quad0[j][1] - my];
|
||
s.quad = keep_convex(g.quad0, q);
|
||
s.pivot = carry_pivot(g.quad0, s.quad, g.pivot0);
|
||
}
|
||
Hit::Corner(i) if mods.command => {
|
||
// Distort: move the corner freely, up to where the box would fold over (#1323).
|
||
let mut q = g.quad0;
|
||
q[i] = [g.quad0[i][0] + dx, g.quad0[i][1] + dy];
|
||
s.quad = keep_convex(g.quad0, q);
|
||
s.pivot = carry_pivot(g.quad0, s.quad, g.pivot0);
|
||
}
|
||
Hit::Edge(i) if mods.command => {
|
||
// Skew: the edge's two corners move together (⇧: only along the edge).
|
||
let (a, b) = (i, (i + 1) % 4);
|
||
let (mut mx, mut my) = (dx, dy);
|
||
if mods.shift {
|
||
let e = [g.quad0[b][0] - g.quad0[a][0], g.quad0[b][1] - g.quad0[a][1]];
|
||
let l2 = e[0] * e[0] + e[1] * e[1];
|
||
let t = if l2 > 0.0 { (dx * e[0] + dy * e[1]) / l2 } else { 0.0 };
|
||
(mx, my) = (t * e[0], t * e[1]);
|
||
}
|
||
s.quad = g.quad0;
|
||
s.quad[a] = [g.quad0[a][0] + mx, g.quad0[a][1] + my];
|
||
s.quad[b] = [g.quad0[b][0] + mx, g.quad0[b][1] + my];
|
||
if mods.alt {
|
||
// ⌥: the opposite edge skews the other way (about the centre).
|
||
let (c, d) = ((i + 2) % 4, (i + 3) % 4);
|
||
s.quad[c] = [g.quad0[c][0] - mx, g.quad0[c][1] - my];
|
||
s.quad[d] = [g.quad0[d][0] - mx, g.quad0[d][1] - my];
|
||
}
|
||
s.pivot = carry_pivot(g.quad0, s.quad, g.pivot0);
|
||
}
|
||
Hit::Corner(_) | Hit::Edge(_) => {
|
||
// Work in the box's own (unit) frame so rotated/skewed boxes scale along their axes.
|
||
let Some(h) = Homography::rect_to_quad([0.0, 0.0, 1.0, 1.0], g.quad0) else { return };
|
||
let Some(inv) = h.inverse() else { return };
|
||
let (u, v) = inv.apply(p[0], p[1]);
|
||
let (pu, pv) = inv.apply(g.pivot0[0], g.pivot0[1]);
|
||
// Which sides of the unit box move: (u = 0, u = 1) and (v = 0, v = 1).
|
||
let (mu, mv) = match g.hit {
|
||
Hit::Corner(0) => ((true, false), (true, false)),
|
||
Hit::Corner(1) => ((false, true), (true, false)),
|
||
Hit::Corner(2) => ((false, true), (false, true)),
|
||
Hit::Corner(3) => ((true, false), (false, true)),
|
||
Hit::Edge(0) => ((false, false), (true, false)),
|
||
Hit::Edge(1) => ((false, true), (false, false)),
|
||
Hit::Edge(2) => ((false, false), (false, true)),
|
||
_ => ((true, false), (false, false)),
|
||
};
|
||
// Each axis scales by a factor `k` about an anchor: the opposite side, or with ⌥ the
|
||
// reference point, wherever it was set (#1329). The dragged handle follows the cursor.
|
||
let axis = |moving: (bool, bool), at: f64, pivot: f64| -> (f64, f64) {
|
||
if !moving.0 && !moving.1 {
|
||
return (0.5, 1.0);
|
||
}
|
||
let handle = if moving.0 { 0.0 } else { 1.0 };
|
||
let anchor = if mods.alt { pivot } else { 1.0 - handle };
|
||
let d = handle - anchor;
|
||
// A reference point on the dragged side leaves nothing to scale against.
|
||
let k = if d.abs() > 1e-9 { (at - anchor) / d } else { 1.0 };
|
||
(anchor, if k.is_finite() { k } else { 1.0 })
|
||
};
|
||
let (mut au, mut ku) = axis(mu, u, pu);
|
||
let (mut av, mut kv) = axis(mv, v, pv);
|
||
// Corners and edges scale proportionally by default; ⇧ frees them (the legacy
|
||
// preference swaps the two for corners and always stretches edges, `legacy_mods`).
|
||
let corner = matches!(g.hit, Hit::Corner(_));
|
||
if !corner && !mods.shift {
|
||
// An edge sets the scale of its own axis, and the other axis follows by the same
|
||
// factor about the middle of the box (⌥: about the reference point, as for
|
||
// corners), so the opposite edge's handle stays put. Past the opposite edge only
|
||
// the dragged axis flips.
|
||
if mu.0 || mu.1 {
|
||
(av, kv) = (if mods.alt { pv } else { 0.5 }, ku.abs());
|
||
} else {
|
||
(au, ku) = (if mods.alt { pu } else { 0.5 }, kv.abs());
|
||
}
|
||
}
|
||
if corner && !mods.shift {
|
||
let k = ku.abs().max(kv.abs());
|
||
(ku, kv) = (k * ku.signum(), k * kv.signum());
|
||
}
|
||
let r = [au - au * ku, av - av * kv, au + (1.0 - au) * ku, av + (1.0 - av) * kv];
|
||
let map = |a: f64, b: f64| {
|
||
let (x, y) = h.apply(a, b);
|
||
[x, y]
|
||
};
|
||
s.quad = [map(r[0], r[1]), map(r[2], r[1]), map(r[2], r[3]), map(r[0], r[3])];
|
||
// ⌥ scales about the reference point, so it stays put; otherwise it rides along with
|
||
// the box, keeping its place in it (#1329).
|
||
s.pivot = if mods.alt { g.pivot0 } else { carry_pivot(g.quad0, s.quad, g.pivot0) };
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Where the reference point lands when the box goes from `quad0` to `quad`: the same place
|
||
/// within the box, as in Photoshop, where it is attached to the bounding box (#1329). A box
|
||
/// that has collapsed leaves it where it was.
|
||
fn carry_pivot(quad0: [[f64; 2]; 4], quad: [[f64; 2]; 4], pivot0: [f64; 2]) -> [f64; 2] {
|
||
let unit = [0.0, 0.0, 1.0, 1.0];
|
||
let (Some(h0), Some(h1)) = (Homography::rect_to_quad(unit, quad0), Homography::rect_to_quad(unit, quad)) else { return pivot0 };
|
||
let Some(inv) = h0.inverse() else { return pivot0 };
|
||
let (u, v) = inv.apply(pivot0[0], pivot0[1]);
|
||
let (x, y) = h1.apply(u, v);
|
||
if x.is_finite() && y.is_finite() { [x, y] } else { pivot0 }
|
||
}
|
||
|
||
/// 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)
|
||
}
|
||
|
||
/// 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
|
||
}
|
||
|
||
/// The surface parameters under `p` when it is inside the warped mesh, else None.
|
||
fn surface_at(mesh: &BezierMesh, p: [f64; 2]) -> Option<(f64, f64)> {
|
||
let (s, t) = mesh.param_at(p);
|
||
let q = mesh.eval(s, t);
|
||
((q[0] - p[0]).hypot(q[1] - p[1]) < 1.0).then_some((s, t))
|
||
}
|
||
|
||
/// Ctrl-click (or Option-click) picks the split: crosswise in the open, and the perpendicular
|
||
/// split when the pointer's grid position `(s, t)` is close to an existing grid line.
|
||
fn quick_split_kind(mesh: &BezierMesh, bounds: [f64; 4], (s, t): (f64, f64)) -> SplitTool {
|
||
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,
|
||
}
|
||
}
|
||
|
||
/// The cursor for a split armed at grid position `(s, t)` of warp grid `m`: the arrow across the
|
||
/// split line as it looks on screen. A vertical split line runs along the grid's columns and is
|
||
/// placed by moving across them, in the grid's own direction there, so the arrow turns with a
|
||
/// turned or bent grid and with the view.
|
||
fn split_cursor(m: &BezierMesh, tool: SplitTool, (s, t): (f64, f64), xf: &ViewXform) -> CursorIcon {
|
||
let e = 1e-3;
|
||
let (a, b) = match tool {
|
||
SplitTool::Vertical => (m.eval((s - e).max(0.0), t), m.eval((s + e).min(1.0), t)),
|
||
SplitTool::Horizontal => (m.eval(s, (t - e).max(0.0)), m.eval(s, (t + e).min(1.0))),
|
||
SplitTool::Cross => return CursorIcon::Crosshair,
|
||
};
|
||
crate::tool_cursor::resize([b[0] - a[0], b[1] - a[1]], xf)
|
||
}
|
||
|
||
/// 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 || mods.command => {
|
||
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| {
|
||
let m = w.to_mesh(1, 1);
|
||
quick_split_kind(&m, w.bounds, m.param_at([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 the warp grid. A press on an anchor or handle moves that point (an anchor carries its
|
||
/// handles along); a press anywhere else inside the grid pulls the surface under the pointer.
|
||
/// Preset warps turn into a custom mesh when the press grabs something. An armed split tool, or
|
||
/// Ctrl / Option held, places a split where the pointer is released instead.
|
||
fn warp_pointer(app: &mut PhotocraftApp, ev: ToolEvent, tol: f64, mods: egui::Modifiers) {
|
||
// Type takes only the warp styles: nothing on the canvas to drag or split (#3004).
|
||
if warps_type(app) {
|
||
return;
|
||
}
|
||
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 {
|
||
ToolEvent::Down { x, y, .. } => {
|
||
// A preset warp becomes a custom mesh only when the press grabs one of its points.
|
||
let custom = if w.mesh.is_none() || w.style != WarpStyle::Custom { Warp::custom(w.to_mesh(1, 1), w.bounds) } else { w.clone() };
|
||
let pts0 = custom.mesh.as_ref().map(|m| m.points.clone()).unwrap_or_default();
|
||
pv.warp_drag = warp_hit(&custom, [x, y], tol).map(|index| WarpDrag::Point { index, start: [x, y], pts0: pts0.clone() }).or_else(|| {
|
||
let (s, t) = surface_at(custom.mesh.as_ref()?, [x, y])?;
|
||
Some(WarpDrag::Surface { s, t, start: [x, y], pts0 })
|
||
});
|
||
if pv.warp_drag.is_some() {
|
||
*w = custom;
|
||
}
|
||
}
|
||
ToolEvent::Move { x, y, .. } | ToolEvent::Up { x, y } => {
|
||
match (&pv.warp_drag, w.mesh.as_mut()) {
|
||
(Some(WarpDrag::Point { index, start, pts0 }), Some(m)) => m.drag_point(pts0, *index, [x - start[0], y - start[1]]),
|
||
(Some(WarpDrag::Surface { s, t, start, pts0 }), Some(m)) => m.pull(pts0, *s, *t, [x - start[0], y - start[1]]),
|
||
_ => {}
|
||
}
|
||
if matches!(ev, ToolEvent::Up { .. }) {
|
||
pv.warp_drag = None;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 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()) };
|
||
if warps_type(app) {
|
||
return Err("Warp on type takes only the warp styles".into());
|
||
}
|
||
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 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())?;
|
||
// A warp-edit command can replace the mesh while pointer input is split across control
|
||
// requests. Any drag index captured from the old mesh is invalid once that happens.
|
||
if let Some(pv) = app.transform_preview.as_mut() {
|
||
pv.warp_drag = None;
|
||
}
|
||
if let Some(t) = app.ui.transform.as_mut() {
|
||
t.warp = Some(nw);
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// 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)
|
||
}
|
||
|
||
/// True just outside a corner: rotate handle feedback lives here, not over the entire
|
||
/// empty canvas. The actual rotation gesture continues to work farther away.
|
||
fn near_rotate_corner(t: &TransformSession, p: [f64; 2], tol: f64) -> bool {
|
||
t.warp.is_none()
|
||
&& t.mode != TransformMode::Distort
|
||
&& {
|
||
let h = hit(t, p, tol);
|
||
h == Hit::Outside || (t.mode == TransformMode::Rotate && matches!(h, Hit::Corner(_) | Hit::Edge(_)))
|
||
}
|
||
&& t.quad.iter().any(|q| (p[0] - q[0]).hypot(p[1] - q[1]) <= tol * 3.0)
|
||
}
|
||
|
||
/// Clockwise degrees in canvas coordinates (positive Y points down).
|
||
fn rotation_degrees(quad: [[f64; 2]; 4]) -> f64 {
|
||
let dx = quad[1][0] - quad[0][0];
|
||
let dy = quad[1][1] - quad[0][1];
|
||
if dx.is_finite() && dy.is_finite() && dx.hypot(dy) > 1e-9 { dy.atan2(dx).to_degrees() } else { 0.0 }
|
||
}
|
||
|
||
/// Where the rotate cursor's arc bends at document point `p`, or `None` outside the rotate zone:
|
||
/// just outside a corner, or anywhere while a rotate drag is on. It points into the box along the
|
||
/// bisector of the nearest corner, so each corner gets its own arrow and they turn with the box.
|
||
pub(crate) fn rotate_cursor_dir(app: &PhotocraftApp, p: [f64; 2]) -> Option<[f64; 2]> {
|
||
rotate_dir(app.ui.transform.as_ref()?, app.transform_preview.as_ref(), p, handle_tolerance(app))
|
||
}
|
||
|
||
fn rotate_dir(t: &TransformSession, pv: Option<&TransformPreview>, p: [f64; 2], tol: f64) -> Option<[f64; 2]> {
|
||
// Content-Aware Scale's box only scales, and Skew doesn't rotate: no rotate arrow.
|
||
if !(p[0].is_finite() && p[1].is_finite())
|
||
|| t.warp.is_some()
|
||
|| t.content_aware.is_some()
|
||
|| matches!(t.mode, TransformMode::Distort | TransformMode::Skew)
|
||
{
|
||
return None;
|
||
}
|
||
let rotating = pv.is_some_and(|pv| pv.gesture.is_some_and(|g| g.hit == Hit::Outside));
|
||
if !rotating && !near_rotate_corner(t, p, tol) {
|
||
return None;
|
||
}
|
||
let dist = |q: [f64; 2]| (p[0] - q[0]).hypot(p[1] - q[1]);
|
||
let i = (0..4).min_by(|&a, &b| dist(t.quad[a]).total_cmp(&dist(t.quad[b])))?;
|
||
let unit = |to: [f64; 2]| {
|
||
let d = [to[0] - t.quad[i][0], to[1] - t.quad[i][1]];
|
||
let l = d[0].hypot(d[1]);
|
||
(l.is_finite() && l > 1e-9).then(|| [d[0] / l, d[1] / l])
|
||
};
|
||
let (a, b) = (unit(t.quad[(i + 1) % 4])?, unit(t.quad[(i + 3) % 4])?);
|
||
let s = [a[0] + b[0], a[1] + b[1]];
|
||
let l = s[0].hypot(s[1]);
|
||
(l.is_finite() && l > 1e-9).then(|| [s[0] / l, s[1] / l])
|
||
}
|
||
|
||
/// Cursor for hovering a document point while transforming. `xf` is the canvas's view;
|
||
/// `quick_split` is Ctrl or Option, which arms a quick split while a warp is active.
|
||
pub fn cursor(app: &PhotocraftApp, xf: &ViewXform, p: [f64; 2], quick_split: bool) -> Option<CursorIcon> {
|
||
let t = app.ui.transform.as_ref()?;
|
||
let tol = handle_tolerance(app);
|
||
if let Some(w) = &t.warp {
|
||
if warps_type(app) {
|
||
return Some(CursorIcon::Default);
|
||
}
|
||
let armed = app.transform_preview.as_ref().and_then(|pv| pv.split_tool);
|
||
if armed.is_some() || quick_split {
|
||
// One search for the grid position under the pointer, for both the kind and the arrow.
|
||
let m = w.to_mesh(1, 1);
|
||
let at = m.param_at(p);
|
||
let tool = armed.unwrap_or_else(|| quick_split_kind(&m, w.bounds, at));
|
||
return Some(split_cursor(&m, tool, at, xf));
|
||
}
|
||
if w.style != WarpStyle::Custom || warp_hit(w, p, tol).is_some() {
|
||
return Some(CursorIcon::Crosshair);
|
||
}
|
||
return Some(if w.mesh.as_ref().and_then(|m| surface_at(m, p)).is_some() { CursorIcon::Grab } else { CursorIcon::Default });
|
||
}
|
||
let h = hit(t, p, tol);
|
||
if !mode_allows(t.mode, h) || (t.content_aware.is_some() && h == Hit::Outside) {
|
||
return Some(CursorIcon::Default);
|
||
}
|
||
if rotate_cursor_dir(app, p).is_some() {
|
||
// The overlay draws the curved rotate arrow (`rotate_cursor_dir`), also during the drag.
|
||
return Some(CursorIcon::None);
|
||
}
|
||
// The box's corners run clockwise from the top-left; edge k joins corners k and k + 1.
|
||
let corner = |k: usize| t.quad[k % 4];
|
||
let edge_mid = |k: usize| {
|
||
let (a, b) = (corner(k), corner(k + 1));
|
||
[(a[0] + b[0]) / 2.0, (a[1] + b[1]) / 2.0]
|
||
};
|
||
// A handle resizes the box away from the opposite handle.
|
||
let resize = |from: [f64; 2], to: [f64; 2]| crate::tool_cursor::resize([to[0] - from[0], to[1] - from[1]], xf);
|
||
Some(match h {
|
||
Hit::Corner(i) => resize(corner(i + 2), corner(i)),
|
||
Hit::Edge(i) => resize(edge_mid(i + 2), edge_mid(i)),
|
||
Hit::Pivot => CursorIcon::Crosshair,
|
||
Hit::Inside => CursorIcon::Move,
|
||
Hit::Outside => CursorIcon::Alias,
|
||
})
|
||
}
|
||
|
||
/// For each vertex of the preview's `n` × `n` grid over the unit square (row by row), whether it
|
||
/// maps to the front side of `h`'s horizon. A Distort dragged past the horizon sends the rest of
|
||
/// the image to the far side, where its cells would wrap round infinity; the preview leaves them
|
||
/// out, as the committed warp does (#4863).
|
||
fn front_vertices(h: &Homography, n: usize) -> Vec<bool> {
|
||
let front = h.front_side([0.0, 0.0, 1.0, 1.0]);
|
||
(0..=n).flat_map(|j| (0..=n).map(move |i| (i as f64 / n as f64, j as f64 / n as f64))).map(|(u, v)| front * h.w(u, v) > 1e-9).collect()
|
||
}
|
||
|
||
/// The moving pixels as the box shows them (warped, skewed or styled), plus the layers above
|
||
/// them. This is image content: the canvas draws it right after the document, under the grid
|
||
/// and guides, so a guide stays visible across a layer being transformed (#5086), as in
|
||
/// Photoshop. The box, handles and reference point are [`draw_overlay`], drawn over the guides.
|
||
pub fn draw_content(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform) {
|
||
let (Some(t), Some(pv)) = (&app.ui.transform, &app.transform_preview) else { return };
|
||
if let Some(w) = &t.warp {
|
||
draw_warp_content(painter, xf, t, w, pv);
|
||
return;
|
||
}
|
||
let ctx = painter.ctx();
|
||
let styled = styled_frame(app, xf.zoom * ctx.pixels_per_point(), ctx.input(|i| i.pointer.any_down())).and_then(|(s, frame)| s.texture(ctx, &frame));
|
||
if let Some((tex, r)) = styled {
|
||
let mut mesh = egui::Mesh::with_texture(tex);
|
||
for (x, y, u, v) in [(r[0], r[1], 0.0, 0.0), (r[2], r[1], 1.0, 0.0), (r[2], r[3], 1.0, 1.0), (r[0], r[3], 0.0, 1.0)] {
|
||
mesh.vertices.push(egui::epaint::Vertex { pos: pos2(x, y), uv: pos2(u, v), color: Color32::WHITE });
|
||
}
|
||
mesh.add_triangle(0, 1, 2);
|
||
mesh.add_triangle(0, 2, 3);
|
||
painter.add(clip_to_canvas(mesh, canvas_size(pv), |p| xf.to_screen(p.x, p.y)));
|
||
} else if let Some(h) = Homography::rect_to_quad([0.0, 0.0, 1.0, 1.0], t.quad) {
|
||
// A 24×24 grid keeps perspective previews straight.
|
||
let n = 24;
|
||
let zoom = xf.zoom * painter.ctx().pixels_per_point();
|
||
// Content-Aware Scale shows its latest live result once there is one.
|
||
let (tex, uv) = match pv.content_aware.as_ref().and_then(|l| l.shown(t)) {
|
||
Some((texture, texel)) => texture.pick(painter.ctx(), zoom, texel, false),
|
||
None => pv.texture.pick(painter.ctx(), zoom, quad_scale(t), t.interpolation == "nearest"),
|
||
};
|
||
let mut mesh = egui::Mesh::with_texture(tex);
|
||
let tint = Color32::from_white_alpha((pv.opacity.clamp(0.0, 1.0) * 255.0) as u8);
|
||
let ahead = front_vertices(&h, n);
|
||
for j in 0..=n {
|
||
for i in 0..=n {
|
||
let (u, v) = (i as f64 / n as f64, j as f64 / n as f64);
|
||
let (x, y) = h.apply(u, v);
|
||
mesh.vertices.push(egui::epaint::Vertex { pos: pos2(x as f32, y as f32), uv: pos2(u as f32 * uv[0], v as f32 * uv[1]), color: tint });
|
||
}
|
||
}
|
||
for j in 0..n {
|
||
for i in 0..n {
|
||
let a = (j * (n + 1) + i) as u32;
|
||
for tri in [[a, a + 1, a + n as u32 + 2], [a, a + n as u32 + 2, a + n as u32 + 1]] {
|
||
// A cell with a corner past the horizon would stretch across the canvas (#4863).
|
||
if tri.iter().all(|&k| ahead[k as usize]) {
|
||
mesh.add_triangle(tri[0], tri[1], tri[2]);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
painter.add(clip_to_canvas(mesh, canvas_size(pv), |p| xf.to_screen(p.x, p.y)));
|
||
}
|
||
if let Some(a) = &pv.above {
|
||
a.draw(painter, xf);
|
||
}
|
||
}
|
||
|
||
/// The box, handles, reference point and path outline over the preview that [`draw_content`]
|
||
/// drew; the warp grid and its split guides for a warp.
|
||
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 {
|
||
let over_canvas = painter.ctx().input(|i| i.pointer.hover_pos()).filter(|p| xf.rect.contains(*p)).map(|p| xf.to_doc(p));
|
||
let at = pv.split_pointer.or(over_canvas);
|
||
let alt = painter.ctx().input(|i| i.modifiers.alt || i.modifiers.command);
|
||
let guide = at.filter(|_| !warps_type(app)).and_then(|p| {
|
||
let tool = pv.split_tool.or_else(|| {
|
||
(alt || pv.split_quick).then(|| {
|
||
let m = w.to_mesh(1, 1);
|
||
quick_split_kind(&m, w.bounds, m.param_at(p))
|
||
})
|
||
});
|
||
tool.map(|tool| (tool, p))
|
||
});
|
||
draw_warp_overlay(painter, xf, w, pv, guide);
|
||
return;
|
||
}
|
||
if pv.committing.is_some() {
|
||
return;
|
||
}
|
||
let accent = crate::theme::Tokens::get(painter.ctx()).accent;
|
||
if let (Some(path), Some(h)) = (&pv.path, Homography::rect_to_quad(t.rect, t.quad)) {
|
||
crate::vector_ui::draw_outline(
|
||
painter,
|
||
path,
|
||
&|q| {
|
||
let (x, y) = h.apply(q[0], q[1]);
|
||
xf.to_screen(x as f32, y as f32)
|
||
},
|
||
accent,
|
||
);
|
||
}
|
||
let pts: Vec<Pos2> = t.quad.iter().map(|q| scr(*q)).collect();
|
||
painter.add(egui::Shape::closed_line(pts.clone(), Stroke::new(1.0, accent)));
|
||
let mids: Vec<Pos2> = (0..4).map(|i| pts[i].lerp(pts[(i + 1) % 4], 0.5)).collect();
|
||
for p in pts.iter().chain(mids.iter()) {
|
||
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, Stroke::new(1.0, accent), egui::StrokeKind::Inside);
|
||
}
|
||
// Reference point (Photoshop's crosshair circle).
|
||
let c = scr(t.pivot);
|
||
painter.circle_stroke(c, 5.0, Stroke::new(1.0, Color32::WHITE));
|
||
painter.circle_stroke(c, 5.0, Stroke::new(0.5, Color32::BLACK));
|
||
painter.line_segment([c - vec2(8.0, 0.0), c + vec2(8.0, 0.0)], Stroke::new(1.0, Color32::WHITE));
|
||
painter.line_segment([c - vec2(0.0, 8.0), c + vec2(0.0, 8.0)], Stroke::new(1.0, Color32::WHITE));
|
||
if let Some(at) = painter.ctx().pointer_hover_pos().filter(|p| xf.rect.contains(*p)) {
|
||
let p = xf.to_doc(at);
|
||
let tol = HANDLE_PX / f64::from(xf.zoom.max(0.01));
|
||
if let Some(k) = rotate_dir(t, Some(pv), p, tol) {
|
||
// Through the view, so the arc bends round the corner as it shows under a turned or
|
||
// flipped view.
|
||
let toward = xf.to_screen((p[0] + k[0] * 16.0) as f32, (p[1] + k[1] * 16.0) as f32) - at;
|
||
crate::crop_ui::draw_turn_cursor(painter.ctx(), at, toward);
|
||
}
|
||
// Preferences › Tools › Show Transformation Values off hides it, as in Photoshop (#204).
|
||
if let Some(r) = live_readout(app).filter(|_| app.session.prefs().tools.show_transformation_values) {
|
||
draw_live_readout(painter.ctx(), at, r);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Applying the open box moves the selection outline with it: `edit.transform` and
|
||
/// `edit.transform.warp` map it like the pixels. Not Transform Selection (its ants are hidden), a
|
||
/// path, Content-Aware Scale or a type layer's Warp Text, which leave the selection alone.
|
||
fn carries_selection(app: &PhotocraftApp, t: &TransformSession) -> bool {
|
||
!t.selection && t.path.is_none() && t.content_aware.is_none() && !(t.warp.is_some() && warps_type(app))
|
||
}
|
||
|
||
/// A transform of `document` is open that carries its selection along (#5297): the canvas traces
|
||
/// the whole outline, not only the part in view where it started, for [`draw_selection`].
|
||
pub(crate) fn carries_selection_of(app: &PhotocraftApp, document: DocId) -> bool {
|
||
matches!((&app.ui.transform, &app.transform_preview), (Some(t), Some(pv)) if pv.doc.id == document && carries_selection(app, t))
|
||
}
|
||
|
||
/// The marching ants where the open box puts them (#5297): the selection follows a move, scale,
|
||
/// rotation, skew, distort, perspective or warp while it is made, to where applying it leaves it.
|
||
/// `segs` is the outline of `document`'s selection at rest. False when no open transform carries
|
||
/// that selection: the caller draws it where it is.
|
||
pub(crate) fn draw_selection(
|
||
app: &PhotocraftApp,
|
||
painter: &egui::Painter,
|
||
xf: &ViewXform,
|
||
document: DocId,
|
||
segs: &[crate::outline::Segment],
|
||
time: f64,
|
||
) -> bool {
|
||
let Some(t) = app.ui.transform.as_ref().filter(|_| carries_selection_of(app, document)) else { return false };
|
||
let scr = |(x, y): (f64, f64)| xf.to_screen(x as f32, y as f32);
|
||
let doc = |p: [i32; 2]| [f64::from(p[0]), f64::from(p[1])];
|
||
let along = |a: [f64; 2], b: [f64; 2], k: f64| [a[0] + (b[0] - a[0]) * k, a[1] + (b[1] - a[1]) * k];
|
||
if let Some(w) = &t.warp {
|
||
// A warp bends the outline's straight runs: each is drawn in pieces about 8 points long.
|
||
let piece = (8.0 / f64::from(xf.zoom.max(1e-3))).max(1.0);
|
||
let lines = segs.iter().flat_map(|(a, b)| {
|
||
let (a, b) = (doc(*a), doc(*b));
|
||
let n = (((b[0] - a[0]).abs() + (b[1] - a[1]).abs()) / piece).ceil().clamp(1.0, 256.0) as usize;
|
||
let at = move |i: usize| {
|
||
let p = along(a, b, i as f64 / n as f64);
|
||
scr(w.map(p[0], p[1]))
|
||
};
|
||
(0..n).map(move |i| (at(i), at(i + 1)))
|
||
});
|
||
crate::canvas::marching_ants_lines(painter, lines, time);
|
||
} else if let Some(h) = Homography::rect_to_quad(t.rect, t.quad) {
|
||
// Past a Distort's horizon the outline has no place, like the pixels (#4863): a run that
|
||
// crosses it stops short of it, where its points are still a finite way off.
|
||
let front = h.front_side(t.rect);
|
||
let depth = |p: [f64; 2]| front * h.w(p[0], p[1]);
|
||
let near = corners(t.rect).iter().map(|c| depth(*c)).fold(0.0, f64::max) * 1e-3;
|
||
let lines = segs.iter().filter_map(|(a, b)| {
|
||
let (mut a, mut b) = (doc(*a), doc(*b));
|
||
let (da, db) = (depth(a), depth(b));
|
||
if da < near && db < near {
|
||
return None;
|
||
}
|
||
if da < near {
|
||
a = along(a, b, (near - da) / (db - da));
|
||
} else if db < near {
|
||
b = along(b, a, (near - db) / (da - db));
|
||
}
|
||
Some((scr(h.apply(a[0], a[1])), scr(h.apply(b[0], b[1]))))
|
||
});
|
||
crate::canvas::marching_ants_lines(painter, lines, time);
|
||
}
|
||
true
|
||
}
|
||
|
||
/// What the styled preview of the moving layer is drawn from (transform_fx.rs), at `zoom` screen
|
||
/// pixels per document pixel. `None` for a layer without a style (also once it is switched off
|
||
/// while the box is open) or a hidden one: the bare pixels are drawn instead. The style, opacity
|
||
/// and fill are the layer's as they are now, so edits made while the box is open show.
|
||
pub(crate) fn styled_frame(app: &PhotocraftApp, zoom: f32, dragging: bool) -> Option<(&crate::transform_fx::StyledPreview, crate::transform_fx::Frame<'_>)> {
|
||
let (t, pv) = (app.ui.transform.as_ref()?, app.transform_preview.as_ref()?);
|
||
let styled = pv.styled.as_ref().filter(|_| t.warp.is_none())?;
|
||
let st = app.session.active().filter(|st| st.doc.id == pv.doc.id)?;
|
||
let layer = st.doc.layer(LayerId(t.layer)).filter(|l| l.visible && photocraft_compose::effects::has_effects(l))?;
|
||
let frame = crate::transform_fx::Frame {
|
||
doc: &st.doc,
|
||
layer,
|
||
revision: st.revision,
|
||
texels: &pv.texture,
|
||
quad: t.quad,
|
||
quad_scale: quad_scale(t),
|
||
nearest: t.interpolation == "nearest",
|
||
zoom,
|
||
dragging,
|
||
};
|
||
Some((styled, frame))
|
||
}
|
||
|
||
/// The value shown beside the pointer while the box is dragged, like Photoshop's Show
|
||
/// Transformation Values: the box's angle while rotating, its scale while resizing, the offset
|
||
/// while moving.
|
||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||
pub(crate) enum Readout {
|
||
/// Clockwise degrees in document space (`rotation_degrees`), whatever the view's turn or flip.
|
||
Angle(f64),
|
||
/// Width and height in percent of the original box.
|
||
Scale([f64; 2]),
|
||
/// Document pixels moved since the press.
|
||
Offset([f64; 2]),
|
||
}
|
||
|
||
/// The readout for the drag in progress, or `None` when nothing is dragged (or the drag is the
|
||
/// reference point, a warp, or a distortion that has no single scale).
|
||
pub(crate) fn live_readout(app: &PhotocraftApp) -> Option<Readout> {
|
||
let t = app.ui.transform.as_ref().filter(|t| t.warp.is_none())?;
|
||
let g = app.transform_preview.as_ref()?.gesture?;
|
||
match g.hit {
|
||
Hit::Outside => Some(Readout::Angle(rotation_degrees(t.quad))),
|
||
Hit::Inside => Some(Readout::Offset([t.quad[0][0] - g.quad0[0][0], t.quad[0][1] - g.quad0[0][1]])),
|
||
Hit::Corner(_) | Hit::Edge(_) if parallelogram(&t.quad) => {
|
||
let (w, h, _, _) = readout(t);
|
||
(w.is_finite() && h.is_finite()).then_some(Readout::Scale([w, h]))
|
||
}
|
||
Hit::Corner(_) | Hit::Edge(_) | Hit::Pivot => None,
|
||
}
|
||
}
|
||
|
||
/// True when `q` is still an affine image of the box (opposite sides parallel), so a W/H scale
|
||
/// describes it; a distorted or perspective box has none.
|
||
fn parallelogram(q: &[[f64; 2]; 4]) -> bool {
|
||
let scale = q.iter().flatten().fold(1.0f64, |m, v| m.max(v.abs()));
|
||
(0..2).all(|k| ((q[0][k] + q[2][k]) - (q[1][k] + q[3][k])).abs() <= scale * 1e-9)
|
||
}
|
||
|
||
/// [`live_readout`] as the rounded label beside the pointer (the marquee's and Crop's readout).
|
||
fn draw_live_readout(ctx: &egui::Context, at: Pos2, r: Readout) {
|
||
// Round first so a hair below zero never reads "-0".
|
||
let fixed = |v: f64, places: i32| {
|
||
let k = 10f64.powi(places);
|
||
let v = (v * k).round() / k;
|
||
if v == 0.0 { 0.0 } else { v }
|
||
};
|
||
let id = "transform-readout";
|
||
match r {
|
||
Readout::Angle(a) => crate::canvas::draw_readout(ctx, id, at, ["Angle:"], [format!("{:.1}°", fixed(a, 1))]),
|
||
Readout::Scale([w, h]) => crate::canvas::draw_readout(ctx, id, at, ["W:", "H:"], [w, h].map(|v| format!("{:.1}%", fixed(v, 1)))),
|
||
Readout::Offset([dx, dy]) => crate::canvas::draw_readout(ctx, id, at, ["ΔX:", "ΔY:"], [dx, dy].map(|v| format!("{:.0} px", fixed(v, 0)))),
|
||
}
|
||
}
|
||
|
||
/// Width and height of the document being transformed, in document pixels.
|
||
fn canvas_size(pv: &TransformPreview) -> [f32; 2] {
|
||
[pv.doc.size.width as f32, pv.doc.size.height as f32]
|
||
}
|
||
|
||
/// Clips `mesh` (positions in document pixels) to the canvas `[0, 0, size]` and maps it to the
|
||
/// screen. Pixels past the canvas edge are never shown, also not while transforming (Photoshop;
|
||
/// #2027): only the box and its handles reach over the pasteboard. Clipping in document space
|
||
/// stays exact under a rotated or flipped view, where a screen clip rectangle would not.
|
||
fn clip_to_canvas(mut mesh: egui::Mesh, size: [f32; 2], to_screen: impl Fn(Pos2) -> Pos2) -> egui::Mesh {
|
||
let inside = |p: Pos2| p.x >= 0.0 && p.y >= 0.0 && p.x <= size[0] && p.y <= size[1];
|
||
if mesh.vertices.iter().all(|v| inside(v.pos)) {
|
||
for v in &mut mesh.vertices {
|
||
v.pos = to_screen(v.pos);
|
||
}
|
||
return mesh;
|
||
}
|
||
let mut out = egui::Mesh::with_texture(mesh.texture_id);
|
||
for tri in mesh.indices.as_chunks::<3>().0 {
|
||
let mut poly: Vec<egui::epaint::Vertex> = tri.iter().filter_map(|i| mesh.vertices.get(*i as usize).copied()).collect();
|
||
// Sutherland–Hodgman against the four canvas edges (`dist` >= 0 is the canvas side).
|
||
for edge in 0..4 {
|
||
let dist = |v: &egui::epaint::Vertex| match edge {
|
||
0 => v.pos.x,
|
||
1 => size[0] - v.pos.x,
|
||
2 => v.pos.y,
|
||
_ => size[1] - v.pos.y,
|
||
};
|
||
let mut next = Vec::with_capacity(poly.len() + 2);
|
||
for (k, a) in poly.iter().enumerate() {
|
||
let Some(b) = poly.get((k + 1) % poly.len()) else { continue };
|
||
let (da, db) = (dist(a), dist(b));
|
||
if da >= 0.0 {
|
||
next.push(*a);
|
||
}
|
||
if (da >= 0.0) != (db >= 0.0) && da != db {
|
||
let t = da / (da - db);
|
||
next.push(egui::epaint::Vertex { pos: a.pos.lerp(b.pos, t), uv: a.uv.lerp(b.uv, t), color: a.color });
|
||
}
|
||
}
|
||
poly = next;
|
||
}
|
||
if poly.len() < 3 {
|
||
continue;
|
||
}
|
||
let base = out.vertices.len() as u32;
|
||
out.vertices.extend(poly.iter().map(|v| egui::epaint::Vertex { pos: to_screen(v.pos), ..*v }));
|
||
for k in 1..poly.len() as u32 - 1 {
|
||
out.add_triangle(base, base + k, base + k + 1);
|
||
}
|
||
}
|
||
out
|
||
}
|
||
|
||
/// 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.
|
||
/// How near the pointer (screen px) a handle's anchor or the handle itself must be for the
|
||
/// handle to show. Everything else stays out of the way: only anchors are always drawn.
|
||
const HANDLE_REVEAL_PX: f32 = 56.0;
|
||
|
||
/// The anchor a handle belongs to, as `(i, j)` in the control grid (an anchor is its own).
|
||
fn handle_anchor(i: usize, j: usize) -> (usize, usize) {
|
||
if i.is_multiple_of(3) {
|
||
let aj = match j % 3 {
|
||
0 => j,
|
||
1 => j - 1,
|
||
_ => j + 1,
|
||
};
|
||
(i, aj)
|
||
} else {
|
||
(if i % 3 == 1 { i - 1 } else { i + 1 }, j)
|
||
}
|
||
}
|
||
/// Anchors always, small and quiet. A handle and its arm show only while the pointer is close to
|
||
/// them or they are being dragged, so a dense grid does not bury the picture in dots.
|
||
fn draw_warp_points(painter: &egui::Painter, m: &BezierMesh, scr: impl Fn([f64; 2]) -> Pos2, pv: &TransformPreview, line: Stroke) {
|
||
let (nx, ny) = (m.nx(), m.ny());
|
||
let hover = painter.ctx().input(|i| i.pointer.hover_pos());
|
||
let dragged = match &pv.warp_drag {
|
||
Some(WarpDrag::Point { index, .. }) => Some(handle_anchor(index % nx, index / nx)),
|
||
_ => None,
|
||
};
|
||
let near = |p: Pos2| hover.is_some_and(|h| h.distance(p) <= HANDLE_REVEAL_PX);
|
||
let faint = Stroke::new(0.75, line.color.gamma_multiply(0.8));
|
||
for j in 0..ny {
|
||
for i in 0..nx {
|
||
if !on_section_edge(i, j) {
|
||
continue;
|
||
}
|
||
let p = scr(m.point(i, j));
|
||
if i.is_multiple_of(3) && j.is_multiple_of(3) {
|
||
let r = egui::Rect::from_center_size(p, vec2(5.0, 5.0));
|
||
painter.rect_filled(r, 0.0, Color32::WHITE);
|
||
painter.rect_stroke(r, 0.0, faint, egui::StrokeKind::Inside);
|
||
continue;
|
||
}
|
||
let (ai, aj) = handle_anchor(i, j);
|
||
let a = scr(m.point(ai, aj));
|
||
if !(near(p) || near(a) || dragged == Some((ai, aj))) {
|
||
continue;
|
||
}
|
||
painter.line_segment([a, p], faint);
|
||
painter.circle_filled(p, 2.75, Color32::WHITE);
|
||
painter.circle_stroke(p, 2.75, faint);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// The warped pixels and the layers above them ([`draw_content`] for a warp).
|
||
fn draw_warp_content(painter: &egui::Painter, xf: &ViewXform, t: &TransformSession, w: &Warp, pv: &TransformPreview) {
|
||
let r = t.rect;
|
||
let m = w.to_mesh(1, 1);
|
||
let (us, vs) = crate::warp_preview::samples(&m, xf.zoom as f64);
|
||
let (tex, uv) = pv.texture.pick(painter.ctx(), xf.zoom * painter.ctx().pixels_per_point(), 1.0, t.interpolation == "nearest");
|
||
let mut mesh = egui::Mesh::with_texture(tex);
|
||
let tint = Color32::from_white_alpha((pv.opacity.clamp(0.0, 1.0) * 255.0) as u8);
|
||
for v in &vs {
|
||
for u in &us {
|
||
let (x, y) = w.map(r[0] + u * (r[2] - r[0]), r[1] + v * (r[3] - r[1]));
|
||
mesh.vertices.push(egui::epaint::Vertex { pos: pos2(x as f32, y as f32), uv: pos2(*u as f32 * uv[0], *v as f32 * uv[1]), color: tint });
|
||
}
|
||
}
|
||
let pos: Vec<[f32; 2]> = mesh.vertices.iter().map(|v| [v.pos.x, v.pos.y]).collect();
|
||
let rest: Vec<[f32; 2]> = vs.iter().flat_map(|v| us.iter().map(move |u| [(r[0] + u * (r[2] - r[0])) as f32, (r[1] + v * (r[3] - r[1])) as f32])).collect();
|
||
mesh.indices = crate::warp_preview::triangle_order(&pos, &rest, us.len());
|
||
painter.add(clip_to_canvas(mesh, canvas_size(pv), |p| xf.to_screen(p.x, p.y)));
|
||
if let Some(a) = &pv.above {
|
||
a.draw(painter, xf);
|
||
}
|
||
}
|
||
|
||
/// The warp grid, its control points and split guides ([`draw_overlay`] for a warp).
|
||
fn draw_warp_overlay(painter: &egui::Painter, xf: &ViewXform, 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 m = w.to_mesh(1, 1);
|
||
let accent = crate::theme::Tokens::get(painter.ctx()).accent;
|
||
// Transform Path › Warp: the path drawn through the warp.
|
||
if let Some(path) = &pv.path {
|
||
crate::vector_ui::draw_outline(
|
||
painter,
|
||
path,
|
||
&|q| {
|
||
let (x, y) = w.map(q[0], q[1]);
|
||
xf.to_screen(x as f32, y as f32)
|
||
},
|
||
accent,
|
||
);
|
||
}
|
||
let line = Stroke::new(1.0, accent);
|
||
let thin = Stroke::new(0.75, accent.gamma_multiply(0.7));
|
||
let curve = |fixed_u: Option<f64>, fixed_v: Option<f64>| -> Vec<Pos2> {
|
||
(0..=40)
|
||
.map(|k| {
|
||
let s = k as f64 / 40.0;
|
||
let p = m.eval(fixed_u.unwrap_or(s), fixed_v.unwrap_or(s));
|
||
scr(p)
|
||
})
|
||
.collect()
|
||
};
|
||
// 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() {
|
||
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));
|
||
}
|
||
painter.add(egui::Shape::line(curve(Some(win[1]), None), line));
|
||
}
|
||
for (k, win) in m.vs.windows(2).enumerate() {
|
||
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 {
|
||
draw_warp_points(painter, &m, scr, pv, 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
|
||
/// preview's texture level and filter).
|
||
fn quad_scale(t: &TransformSession) -> f32 {
|
||
let (w0, h0) = ((t.rect[2] - t.rect[0]).max(1e-9), (t.rect[3] - t.rect[1]).max(1e-9));
|
||
let len = |a: [f64; 2], b: [f64; 2]| (b[0] - a[0]).hypot(b[1] - a[1]);
|
||
let q = &t.quad;
|
||
let s = (len(q[0], q[1]) / w0).max(len(q[3], q[2]) / w0).max(len(q[0], q[3]) / h0).max(len(q[1], q[2]) / h0);
|
||
if s.is_finite() { s as f32 } else { 1.0 }
|
||
}
|
||
|
||
/// W and H (%), the angle (°) and the horizontal skew (°) the options bar shows for the box.
|
||
fn readout(t: &TransformSession) -> (f64, f64, f64, f64) {
|
||
let p = Parts::of(t);
|
||
(p.sx * 100.0, p.sy.abs() * 100.0, p.angle.to_degrees(), p.skew_h.to_degrees())
|
||
}
|
||
|
||
/// The box's affine part read as Photoshop's options bar does: the frame scaled (W, H), skewed
|
||
/// (H, V) and then rotated about the reference point. Angles are radians; positive turns
|
||
/// clockwise on screen, so a positive H skew leans the box's sides like the angle field would
|
||
/// turn them, and a positive V skew its top and bottom.
|
||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||
struct Parts {
|
||
sx: f64,
|
||
/// Negative when the box is flipped vertically relative to its rotation.
|
||
sy: f64,
|
||
angle: f64,
|
||
skew_h: f64,
|
||
skew_v: f64,
|
||
}
|
||
|
||
/// The skew fields' reach: a skew of ±90° would flatten the box.
|
||
const SKEW_LIMIT_DEG: f32 = 89.0;
|
||
|
||
impl Parts {
|
||
/// The box's columns: where the frame's unit X and Y edges went (from the top-left corner).
|
||
fn columns(t: &TransformSession) -> Option<[[f64; 2]; 2]> {
|
||
let (w0, h0) = (t.rect[2] - t.rect[0], t.rect[3] - t.rect[1]);
|
||
if w0.abs() < 1e-9 || h0.abs() < 1e-9 {
|
||
return None;
|
||
}
|
||
let q = &t.quad;
|
||
let m = [[(q[1][0] - q[0][0]) / w0, (q[1][1] - q[0][1]) / w0], [(q[3][0] - q[0][0]) / h0, (q[3][1] - q[0][1]) / h0]];
|
||
m.iter().flatten().all(|v| v.is_finite()).then_some(m)
|
||
}
|
||
|
||
/// The box as scale, skew and rotation, keeping the session's typed V skew (any box can be
|
||
/// written with any V skew short of ±90°).
|
||
fn of(t: &TransformSession) -> Parts {
|
||
let skew_v = t.skew_v.to_radians();
|
||
let none = Parts { sx: 1.0, sy: 1.0, angle: 0.0, skew_h: 0.0, skew_v };
|
||
Parts::columns(t).map_or(none, |m| Parts::from_columns(m, skew_v))
|
||
}
|
||
|
||
fn from_columns([c0, c1]: [[f64; 2]; 2], skew_v: f64) -> Parts {
|
||
// X edge = sx · R(angle) · (1, tan v): its direction is the angle plus v.
|
||
let angle = c0[1].atan2(c0[0]) - skew_v;
|
||
let sx = c0[0].hypot(c0[1]) * skew_v.cos();
|
||
// Y edge = sy · R(angle) · (−tan h, 1).
|
||
let (gx, gy) = unturn(c1, angle);
|
||
let skew_h = if gy.abs() < 1e-12 { 0.0 } else { (-gx / gy).atan() };
|
||
Parts { sx, sy: gy, angle, skew_h, skew_v }
|
||
}
|
||
|
||
fn columns_of(self) -> [[f64; 2]; 2] {
|
||
let (sn, cs) = self.angle.sin_cos();
|
||
let rot = |x: f64, y: f64| [x * cs - y * sn, x * sn + y * cs];
|
||
let c0 = rot(self.sx, self.sx * self.skew_v.tan());
|
||
let c1 = rot(-self.sy * self.skew_h.tan(), self.sy);
|
||
[c0, c1]
|
||
}
|
||
}
|
||
|
||
/// `c` turned back by `angle` (radians).
|
||
fn unturn(c: [f64; 2], angle: f64) -> (f64, f64) {
|
||
let (sn, cs) = angle.sin_cos();
|
||
(c[0] * cs + c[1] * sn, -c[0] * sn + c[1] * cs)
|
||
}
|
||
|
||
/// Sets the box's H and V skew (degrees), keeping its scale, angle and reference point: the
|
||
/// options bar's skew fields.
|
||
fn skew_about_pivot(app: &mut PhotocraftApp, skew_h: f64, skew_v: f64) {
|
||
let Some(s) = app.ui.transform.as_mut() else { return };
|
||
let Some(m) = Parts::columns(s) else { return };
|
||
let limit = f64::from(SKEW_LIMIT_DEG);
|
||
let (skew_h, skew_v) = (skew_h.clamp(-limit, limit), skew_v.clamp(-limit, limit));
|
||
let from = Parts::from_columns(m, s.skew_v.to_radians());
|
||
let to = Parts { skew_h: skew_h.to_radians(), skew_v: skew_v.to_radians(), ..from }.columns_of();
|
||
// The map from the old box to the new one: to · m⁻¹.
|
||
let det = m[0][0] * m[1][1] - m[1][0] * m[0][1];
|
||
if det.abs() < 1e-12 {
|
||
return;
|
||
}
|
||
let inv = [[m[1][1] / det, -m[0][1] / det], [-m[1][0] / det, m[0][0] / det]];
|
||
let l = |x: f64, y: f64| {
|
||
let (u, v) = (inv[0][0] * x + inv[1][0] * y, inv[0][1] * x + inv[1][1] * y);
|
||
[to[0][0] * u + to[1][0] * v, to[0][1] * u + to[1][1] * v]
|
||
};
|
||
let c = s.pivot;
|
||
let quad = s.quad.map(|q| {
|
||
let d = l(q[0] - c[0], q[1] - c[1]);
|
||
[c[0] + d[0], c[1] + d[1]]
|
||
});
|
||
if quad.iter().flatten().all(|v| v.is_finite()) {
|
||
s.quad = quad;
|
||
s.skew_v = skew_v;
|
||
}
|
||
}
|
||
|
||
/// Width of the mode, cancel and commit cluster kept on the right of the options bar.
|
||
const ACTIONS_W: f32 = 140.0;
|
||
/// Width of the action buttons when they follow the Warp fields (one more than the box bar: Reset).
|
||
const WARP_ACTIONS_W: f32 = 176.0;
|
||
|
||
/// Reach of the reference point X/Y fields: the largest document side (`image.canvasSize`'s limit).
|
||
const POSITION_LIMIT_PX: f32 = 300_000.0;
|
||
/// Reach of the W/H fields. The engine takes any scale; this lets a 30 px layer span the largest
|
||
/// document (#1267).
|
||
const SCALE_LIMIT_PCT: f32 = 1_000_000.0;
|
||
|
||
/// The W or H field's limit for a box side of `side` px: as far as the result stays within the
|
||
/// largest document side, so a typed scale can't ask for an image too large to allocate.
|
||
fn scale_limit_pct(side: f64) -> f32 {
|
||
let side = side.abs();
|
||
if !side.is_finite() || side < 1e-6 {
|
||
return SCALE_LIMIT_PCT;
|
||
}
|
||
((f64::from(POSITION_LIMIT_PX) / side * 100.0) as f32).clamp(100.0, SCALE_LIMIT_PCT)
|
||
}
|
||
|
||
/// Where Free Transform remembers the unit of its W and H fields, picked by right-clicking either
|
||
/// of them as in Photoshop (Percent until another is picked).
|
||
const SIZE_UNIT_KEY: &str = "edit.freeTransform";
|
||
|
||
/// The W/H unit, with the document's resolution and the points per inch it converts with.
|
||
fn size_unit(app: &PhotocraftApp) -> crate::layer_props_ui::LenUnit {
|
||
use photocraft_engine::prefs::Unit;
|
||
let unit = app.session.prefs().dialogs.get(SIZE_UNIT_KEY).and_then(|v| v.get("sizeUnit")).and_then(serde_json::Value::as_str).and_then(Unit::parse);
|
||
crate::layer_props_ui::LenUnit { unit: unit.unwrap_or(Unit::Percent), ..crate::layer_props_ui::LenUnit::of(app) }
|
||
}
|
||
|
||
fn set_size_unit(app: &mut PhotocraftApp, name: &str) {
|
||
app.session.prefs.edit(|p| {
|
||
let entry = p.dialogs.entry(SIZE_UNIT_KEY.to_string()).or_insert_with(|| json!({}));
|
||
if !entry.is_object() {
|
||
*entry = json!({});
|
||
}
|
||
entry["sizeUnit"] = json!(name);
|
||
});
|
||
}
|
||
|
||
/// A W or H value shown in `u`: the box side `side` px wide at the original size, scaled by `pct`.
|
||
/// Percent stays the scale itself (100 % is the original size), lengths are the side's length.
|
||
fn size_shown(u: &crate::layer_props_ui::LenUnit, pct: f64, side: f64) -> f32 {
|
||
u.shown(pct / 100.0 * side, side)
|
||
}
|
||
|
||
/// The scale (%) a W or H value typed in `u` asks for, for a box side `side` px wide originally.
|
||
fn size_pct(u: &crate::layer_props_ui::LenUnit, v: f32, side: f64) -> f64 {
|
||
let side = side.abs().max(1e-9);
|
||
u.px(v, side) / side * 100.0
|
||
}
|
||
|
||
/// Options bar while transforming: the fields (reference point X/Y, W/H %, angle, interpolation,
|
||
/// or the warp controls), then the mode switch, cancel and commit right after them. A bar too
|
||
/// narrow for both pins the buttons to the right edge and clips the fields, so Commit and Cancel
|
||
/// are always reachable.
|
||
pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
|
||
let Some(t) = app.ui.transform.clone() else { return };
|
||
let bar = ui.available_rect_before_wrap();
|
||
let warping = t.warp.is_some();
|
||
let buttons_w = if warping { WARP_ACTIONS_W } else { ACTIONS_W };
|
||
let split = (bar.right() - buttons_w).max(bar.left());
|
||
let fields = egui::Rect::from_min_max(bar.min, egui::pos2(split, bar.bottom()));
|
||
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 if t.content_aware.is_some() {
|
||
crate::cas_transform::fields(app, &mut row, &t);
|
||
} else {
|
||
transform_fields(app, &mut row, &t);
|
||
}
|
||
crate::widgets::vline(&mut row, 22.0);
|
||
let start = row.min_rect().right().min(split);
|
||
let actions = egui::Rect::from_min_max(egui::pos2(start, bar.top()), egui::pos2(start + buttons_w, bar.bottom()));
|
||
let mut btns = 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 btns, warping, t.content_aware.is_none());
|
||
}
|
||
|
||
fn transform_fields(app: &mut PhotocraftApp, ui: &mut egui::Ui, t: &TransformSession) {
|
||
let tk = crate::theme::Tokens::get(ui.ctx());
|
||
let (_, _, angle, skew_h) = readout(t);
|
||
let lbl = |ui: &mut egui::Ui, s: &str| {
|
||
ui.label(egui::RichText::new(s).color(tk.text_dim).size(12.0));
|
||
};
|
||
position_size_fields(app, ui, t);
|
||
crate::widgets::vline(ui, 22.0);
|
||
let mut a = angle as f32;
|
||
let (r, resp) = ui.allocate_exact_size(vec2(18.0, 22.0), egui::Sense::hover());
|
||
crate::icons::paint(ui, r, "rotate-cw", 13.0, tk.text_dim);
|
||
resp.on_hover_text(tl!("Rotate"));
|
||
if crate::widgets::value_field(ui, &mut a, -180.0..=180.0, "°", 60.0).changed() {
|
||
rotate_about_pivot(app, (a as f64 - angle).to_radians());
|
||
}
|
||
// Photoshop's H and V skew fields: the box's sides lean by H, its top and bottom by V.
|
||
let (mut h, mut v) = (skew_h as f32, t.skew_v as f32);
|
||
let lim = -SKEW_LIMIT_DEG..=SKEW_LIMIT_DEG;
|
||
lbl(ui, "H:");
|
||
let rh = crate::widgets::value_field(ui, &mut h, lim.clone(), "°", 56.0).on_hover_text(tl!("Set horizontal skew"));
|
||
lbl(ui, "V:");
|
||
let rv = crate::widgets::value_field(ui, &mut v, lim, "°", 56.0).on_hover_text(tl!("Set vertical skew"));
|
||
if rh.changed() || rv.changed() {
|
||
skew_about_pivot(app, f64::from(h), f64::from(v));
|
||
}
|
||
crate::widgets::vline(ui, 22.0);
|
||
lbl(ui, tl!("Interpolation:"));
|
||
let mut interp = t.interpolation.clone();
|
||
let opts = [("bicubic".to_string(), tl!("Bicubic")), ("bilinear".to_string(), tl!("Bilinear")), ("nearest".to_string(), tl!("Nearest Neighbor"))];
|
||
if crate::widgets::dropdown(ui, "transform-interp", &mut interp, &opts, 130.0)
|
||
&& let Some(s) = app.ui.transform.as_mut()
|
||
{
|
||
s.interpolation = interp;
|
||
}
|
||
}
|
||
|
||
/// The reference point's X/Y and the W/H scale fields (with the aspect link) of the options bar.
|
||
pub(crate) fn position_size_fields(app: &mut PhotocraftApp, ui: &mut egui::Ui, t: &TransformSession) {
|
||
let tk = crate::theme::Tokens::get(ui.ctx());
|
||
let (sx, sy, _, _) = readout(t);
|
||
let lbl = |ui: &mut egui::Ui, s: &str| {
|
||
ui.label(egui::RichText::new(s).color(tk.text_dim).size(12.0));
|
||
};
|
||
let mut px = t.pivot[0] as f32;
|
||
let mut py = t.pivot[1] as f32;
|
||
lbl(ui, "X:");
|
||
let rx = crate::widgets::value_field(ui, &mut px, -POSITION_LIMIT_PX..=POSITION_LIMIT_PX, "px", 72.0);
|
||
lbl(ui, "Y:");
|
||
let ry = crate::widgets::value_field(ui, &mut py, -POSITION_LIMIT_PX..=POSITION_LIMIT_PX, "px", 72.0);
|
||
if (rx.changed() || ry.changed())
|
||
&& let Some(s) = app.ui.transform.as_mut()
|
||
{
|
||
let (dx, dy) = (px as f64 - s.pivot[0], py as f64 - s.pivot[1]);
|
||
s.quad = s.quad.map(|q| [q[0] + dx, q[1] + dy]);
|
||
s.pivot = [px as f64, py as f64];
|
||
}
|
||
crate::widgets::vline(ui, 22.0);
|
||
// W and H in the unit picked from their right-click menu: the scale in percent, or the box's
|
||
// length (Photoshop's units, at the document's resolution).
|
||
let u = size_unit(app);
|
||
let (side_w, side_h) = (t.rect[2] - t.rect[0], t.rect[3] - t.rect[1]);
|
||
let (wlim, hlim) = (scale_limit_pct(side_w), scale_limit_pct(side_h));
|
||
let (wlim_u, hlim_u) = (size_shown(&u, f64::from(wlim), side_w), size_shown(&u, f64::from(hlim), side_h));
|
||
let (mut w, mut h) = (size_shown(&u, sx, side_w), size_shown(&u, sy, side_h));
|
||
let (suffix, places) = (u.unit.suffix(), u.unit.decimals() as u32);
|
||
// Lengths need more room than a percentage ("2116.67 mm").
|
||
let size_w = if u.unit == photocraft_engine::prefs::Unit::Percent { 66.0 } else { 84.0 };
|
||
lbl(ui, "W:");
|
||
let rw = crate::widgets::value_field_prec(ui, &mut w, -wlim_u..=wlim_u, suffix, size_w, places);
|
||
let link = !app.ui.transform_unlinked;
|
||
if crate::icons::button(ui, if link { "link" } else { "unlink" }, 22.0, link, tl!("Maintain aspect ratio")).clicked() {
|
||
app.ui.transform_unlinked = link;
|
||
}
|
||
lbl(ui, "H:");
|
||
let rh = crate::widgets::value_field_prec(ui, &mut h, -hlim_u..=hlim_u, suffix, size_w, places);
|
||
for r in [&rw, &rh] {
|
||
r.context_menu(|ui| {
|
||
if let Some(name) = crate::rulers::unit_menu(ui, u.unit) {
|
||
set_size_unit(app, name);
|
||
}
|
||
});
|
||
}
|
||
let (w, h) = (size_pct(&u, w, side_w) as f32, size_pct(&u, h, side_h) as f32);
|
||
if rw.changed() || rh.changed() {
|
||
let (kx, ky) = if link {
|
||
let k = if rw.changed() { w as f64 / sx.max(1e-9) } else { h as f64 / sy.max(1e-9) };
|
||
// Linked, the other side follows: keep it within its own limit too.
|
||
let k = k.min(f64::from(wlim) / sx.abs().max(1e-9)).min(f64::from(hlim) / sy.abs().max(1e-9));
|
||
(k, k)
|
||
} else {
|
||
(w as f64 / sx.max(1e-9), h as f64 / sy.max(1e-9))
|
||
};
|
||
scale_about_pivot(app, kx, ky);
|
||
}
|
||
}
|
||
|
||
/// 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));
|
||
};
|
||
// Type takes only the styles (#3004): Split and Grid are greyed, and there is no Custom.
|
||
let on_type = warps_type(app);
|
||
// Split, Grid, then the warp style with its bend fields.
|
||
ui.add_enabled_ui(!on_type, |ui| warp_mesh_fields(app, ui, w));
|
||
crate::widgets::vline(ui, 22.0);
|
||
lbl(ui, tl!("Warp:"));
|
||
let mut style = w.style;
|
||
let opts: Vec<(WarpStyle, &str)> = WarpStyle::all().filter(|s| !(on_type && *s == WarpStyle::Custom)).map(|s| (s, s.label())).collect();
|
||
if crate::widgets::dropdown(ui, "warp-style", &mut style, &opts, 120.0)
|
||
&& let Some(t) = app.ui.transform.as_mut()
|
||
{
|
||
let b = t.rect;
|
||
t.warp = Some(match style {
|
||
WarpStyle::Custom => Warp::custom(w.to_mesh(1, 1), b),
|
||
WarpStyle::None if on_type => Warp::none(b),
|
||
WarpStyle::None => Warp::custom(BezierMesh::identity(b, 1, 1), b),
|
||
s => Warp { style: s, bend: if w.style.is_preset() { w.bend } else { 50.0 }, ..Warp::none(b) },
|
||
});
|
||
}
|
||
// Orientation and the bend fields are always there, and only act on a preset style.
|
||
let preset = w.style.is_preset();
|
||
ui.add_enabled_ui(preset, |ui| {
|
||
let orient = crate::icons::button(ui, "arrow-left-right", 26.0, w.vertical, tl!("Vertical"));
|
||
orient.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, preset, "Change warp orientation"));
|
||
if orient.clicked()
|
||
&& let Some(Some(cur)) = app.ui.transform.as_mut().map(|t| t.warp.as_mut())
|
||
{
|
||
cur.vertical = !cur.vertical;
|
||
}
|
||
let fields: [(&str, f64); 3] = [(tl!("Bend:"), w.bend), ("H:", w.h_distort), ("V:", w.v_distort)];
|
||
for (k, (name, v)) in fields.iter().enumerate() {
|
||
lbl(ui, name);
|
||
let mut f = *v as f32;
|
||
if crate::widgets::value_field(ui, &mut f, -100.0..=100.0, "%", 58.0).changed()
|
||
&& let Some(Some(cur)) = app.ui.transform.as_mut().map(|t| t.warp.as_mut())
|
||
{
|
||
match k {
|
||
0 => cur.bend = f64::from(f),
|
||
1 => cur.h_distort = f64::from(f),
|
||
_ => cur.v_distort = f64::from(f),
|
||
}
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
/// The Split icons and the Grid menu of the Warp options bar.
|
||
fn warp_mesh_fields(app: &mut PhotocraftApp, ui: &mut egui::Ui, w: &Warp) {
|
||
let tk = crate::theme::Tokens::get(ui.ctx());
|
||
let lbl = |ui: &mut egui::Ui, s: &str| {
|
||
ui.label(egui::RichText::new(s).color(tk.text_dim).size(12.0));
|
||
};
|
||
lbl(ui, tl!("Split:"));
|
||
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")),
|
||
] {
|
||
let on = armed == Some(tool);
|
||
if split_icon(ui, tool.icon(), tip, on).clicked()
|
||
&& let Some(pv) = app.transform_preview.as_mut()
|
||
{
|
||
pv.split_tool = if on { None } else { Some(tool) };
|
||
pv.split_placing = false;
|
||
pv.split_pointer = None;
|
||
pv.split_quick = false;
|
||
}
|
||
}
|
||
crate::widgets::vline(ui, 22.0);
|
||
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 }));
|
||
}
|
||
}
|
||
|
||
/// Back to the untouched box: the default one-patch grid over the box, no bend.
|
||
pub fn reset_warp(app: &mut PhotocraftApp) {
|
||
let Some(t) = app.ui.transform.as_mut() else { return };
|
||
if t.warp.is_none() {
|
||
return;
|
||
}
|
||
let b = t.rect;
|
||
t.warp = Some(Warp::custom(BezierMesh::identity(b, 1, 1), b));
|
||
if warps_type(app)
|
||
&& let Some(t) = app.ui.transform.as_mut()
|
||
{
|
||
t.warp = Some(Warp::none(b));
|
||
}
|
||
if let Some(pv) = app.transform_preview.as_mut() {
|
||
pv.warp_drag = None;
|
||
pv.split_tool = None;
|
||
pv.split_pointer = None;
|
||
pv.split_placing = false;
|
||
pv.split_quick = false;
|
||
}
|
||
}
|
||
|
||
/// 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.
|
||
/// `mode_switch`: the Free Transform / Warp switch (not for Content-Aware Scale, which only scales).
|
||
fn transform_actions(app: &mut PhotocraftApp, ui: &mut egui::Ui, warping: bool, mode_switch: 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);
|
||
let label = "Commit transform";
|
||
commit_btn.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tl!(label)));
|
||
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);
|
||
let label = "Cancel transform";
|
||
cancel_btn.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tl!(label)));
|
||
if cancel_btn.clicked() {
|
||
cancel(app);
|
||
}
|
||
if warping {
|
||
let reset = crate::icons::button(ui, "undo-2", 26.0, false, tl!("Reset"));
|
||
reset.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, "Reset warp"));
|
||
if reset.clicked() {
|
||
reset_warp(app);
|
||
}
|
||
}
|
||
if !mode_switch {
|
||
return;
|
||
}
|
||
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, &ui.ctx().clone());
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Scale the box along its own axes about the reference point.
|
||
fn scale_about_pivot(app: &mut PhotocraftApp, kx: f64, ky: f64) {
|
||
let Some(s) = app.ui.transform.as_mut() else { return };
|
||
let Some(h) = Homography::rect_to_quad([0.0, 0.0, 1.0, 1.0], s.quad) else { return };
|
||
let Some(inv) = h.inverse() else { return };
|
||
let (pu, pv) = inv.apply(s.pivot[0], s.pivot[1]);
|
||
let m = |u: f64, v: f64| {
|
||
let (x, y) = h.apply(pu + (u - pu) * kx, pv + (v - pv) * ky);
|
||
[x, y]
|
||
};
|
||
s.quad = [m(0.0, 0.0), m(1.0, 0.0), m(1.0, 1.0), m(0.0, 1.0)];
|
||
}
|
||
|
||
fn rotate_about_pivot(app: &mut PhotocraftApp, da: f64) {
|
||
let Some(s) = app.ui.transform.as_mut() else { return };
|
||
let (sn, cs) = da.sin_cos();
|
||
let c = s.pivot;
|
||
s.quad = s.quad.map(|q| {
|
||
let (x, y) = (q[0] - c[0], q[1] - c[1]);
|
||
[c[0] + x * cs - y * sn, c[1] + x * sn + y * cs]
|
||
});
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
fn session() -> TransformSession {
|
||
TransformSession {
|
||
session: 1,
|
||
layer: 1,
|
||
rect: [0.0, 0.0, 100.0, 50.0],
|
||
quad: corners([0.0, 0.0, 100.0, 50.0]),
|
||
pivot: [50.0, 25.0],
|
||
interpolation: "bicubic".into(),
|
||
warp: None,
|
||
selection: false,
|
||
target: None,
|
||
path: None,
|
||
made: None,
|
||
mode: Default::default(),
|
||
content_aware: None,
|
||
skew_v: 0.0,
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn a_plain_click_far_outside_accepts_transform_but_a_rotation_drag_does_not() {
|
||
let context = egui::Context::default();
|
||
let mut app = app_with_square(64, photocraft_geom::Rect::new(8, 8, 24, 24));
|
||
begin(&mut app, &context).unwrap();
|
||
let before = app.session.active().unwrap().history.past_len();
|
||
let t = app.ui.transform.as_mut().unwrap();
|
||
t.quad = t.quad.map(|[x, y]| [x + 6.0, y]);
|
||
let outside = [44.0, 44.0];
|
||
let none = egui::Modifiers::NONE;
|
||
assert!(pointer(&mut app, ToolEvent::Down { x: outside[0], y: outside[1], pressure: 1.0 }, none));
|
||
assert!(app.ui.transform.is_some(), "press alone does not accept");
|
||
assert!(pointer(&mut app, ToolEvent::Up { x: outside[0], y: outside[1] }, none));
|
||
assert!(app.ui.transform.is_none(), "a release without a drag commits");
|
||
assert_eq!(app.session.active().unwrap().history.past_len(), before + 1, "one undo step for the transform");
|
||
let st = app.session.active().unwrap();
|
||
let layer = st.doc.layer(st.active_layer.unwrap()).unwrap();
|
||
assert_eq!(layer.surface().unwrap().content_bounds(), photocraft_geom::Rect::new(14, 8, 30, 24));
|
||
|
||
// An outside drag remains a rotation gesture; releasing it does not commit.
|
||
let mut app = app_with_square(64, photocraft_geom::Rect::new(8, 8, 24, 24));
|
||
begin(&mut app, &context).unwrap();
|
||
let start = [44.0, 44.0];
|
||
let end = [44.0, 54.0];
|
||
let original = app.ui.transform.as_ref().unwrap().quad;
|
||
pointer(&mut app, ToolEvent::Down { x: start[0], y: start[1], pressure: 1.0 }, none);
|
||
pointer(&mut app, ToolEvent::Move { x: end[0], y: end[1], pressure: 1.0 }, none);
|
||
pointer(&mut app, ToolEvent::Up { x: end[0], y: end[1] }, none);
|
||
assert!(app.ui.transform.is_some(), "rotation stays active after an outside drag");
|
||
assert_ne!(app.ui.transform.as_ref().unwrap().quad, original);
|
||
}
|
||
|
||
#[test]
|
||
fn a_click_near_rotation_handles_accepts_but_drags_and_option_pivot_do_not() {
|
||
let t = session();
|
||
let tol = 12.0;
|
||
let near = Gesture { hit: Hit::Outside, start: [-18.0, -18.0], quad0: t.quad, pivot0: t.pivot };
|
||
assert!(accepts_outside_click(near, near.start, tol), "a click in the rotation zone accepts; a drag still rotates");
|
||
let far = Gesture { start: [500.0, 500.0], ..near };
|
||
assert!(accepts_outside_click(far, far.start, tol));
|
||
assert!(!accepts_outside_click(far, [512.0, 500.0], tol), "drag does not commit");
|
||
let handle = Gesture { hit: Hit::Corner(0), ..near };
|
||
assert!(!accepts_outside_click(handle, handle.start, tol), "resize handles stay interactive");
|
||
|
||
let mut app = app_with_square(64, photocraft_geom::Rect::new(8, 8, 24, 24));
|
||
begin(&mut app, &egui::Context::default()).unwrap();
|
||
let p = [44.0, 44.0];
|
||
pointer(&mut app, ToolEvent::Down { x: p[0], y: p[1], pressure: 1.0 }, egui::Modifiers::ALT);
|
||
pointer(&mut app, ToolEvent::Up { x: p[0], y: p[1] }, egui::Modifiers::ALT);
|
||
assert!(app.ui.transform.is_some(), "Option-click moves the pivot, never accepts");
|
||
assert_eq!(app.ui.transform.as_ref().unwrap().pivot, p);
|
||
}
|
||
|
||
#[test]
|
||
fn rotate_feedback_only_appears_just_outside_corners() {
|
||
let mut t = session();
|
||
assert!(near_rotate_corner(&t, [-18.0, -18.0], 12.0));
|
||
assert!(!near_rotate_corner(&t, [50.0, 25.0], 12.0), "inside moves the box");
|
||
assert!(!near_rotate_corner(&t, [500.0, 500.0], 12.0), "far outside stays generic");
|
||
assert!(!near_rotate_corner(&t, [2.0, 2.0], 12.0), "corner handle resizes");
|
||
t.mode = TransformMode::Distort;
|
||
assert!(!near_rotate_corner(&t, [-18.0, -18.0], 12.0));
|
||
}
|
||
|
||
#[test]
|
||
fn angle_readout_tracks_rotated_quad_and_handles_degenerate_edges() {
|
||
let mut t = session();
|
||
assert_eq!(rotation_degrees(t.quad), 0.0);
|
||
let pivot = t.pivot;
|
||
t.quad = t.quad.map(|q| [pivot[0] - (q[1] - pivot[1]), pivot[1] + (q[0] - pivot[0])]);
|
||
assert!((rotation_degrees(t.quad) - 90.0).abs() < 1e-9);
|
||
t.quad[1] = t.quad[0];
|
||
assert_eq!(rotation_degrees(t.quad), 0.0);
|
||
}
|
||
|
||
#[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());
|
||
}
|
||
|
||
#[test]
|
||
fn free_transform_bar_buttons_follow_the_fields_on_a_wide_bar() {
|
||
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 = 2400.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();
|
||
assert!(commit.right() < width * 0.7, "commit sits next to the fields, not at the far right: {commit:?}");
|
||
}
|
||
|
||
#[test]
|
||
fn warp_bar_buttons_follow_the_fields_on_a_wide_bar() {
|
||
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 = 2000.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);
|
||
h.get_by_label("Switch between free transform and warp modes").click();
|
||
h.run_steps(4);
|
||
assert!(h.state().ui.transform.as_ref().unwrap().warp.is_some());
|
||
let commit = h.get_by_label("Commit transform").rect();
|
||
assert!(commit.right() < width / 2.0, "commit sits next to the fields, not at the far right: {commit:?}");
|
||
let reset = h.get_by_label("Reset warp").rect();
|
||
assert!(reset.right() < commit.left() + 1.0, "reset is left of commit");
|
||
h.get_by_label("Reset warp").click();
|
||
h.run_steps(2);
|
||
assert!(h.state().ui.transform.as_ref().unwrap().warp.as_ref().unwrap().is_identity());
|
||
}
|
||
|
||
fn eq6(a: f64, b: f64) -> bool {
|
||
(a - b).abs() < 1e-6
|
||
}
|
||
|
||
/// Scale, skew and rotation read back exactly from the box they make, flips included.
|
||
#[test]
|
||
fn parts_round_trip() {
|
||
for (sx, sy, angle, h, v) in [(1.0, 1.0, 0.0, 0.0, 0.0), (1.5, 0.5, 30.0, 20.0, 0.0), (0.8, -1.2, -120.0, -35.0, 15.0), (2.0, 1.0, 179.0, 0.0, -60.0)] {
|
||
let p = Parts { sx, sy, angle: f64::to_radians(angle), skew_h: f64::to_radians(h), skew_v: f64::to_radians(v) };
|
||
let back = Parts::from_columns(p.columns_of(), p.skew_v);
|
||
for (a, b) in [(back.sx, p.sx), (back.sy, p.sy), (back.angle, p.angle), (back.skew_h, p.skew_h)] {
|
||
assert!(eq6(a, b), "{p:?} read back as {back:?}");
|
||
}
|
||
}
|
||
}
|
||
|
||
/// A box sheared sideways keeps W, H and the angle; only H skew moves (it used to read as a
|
||
/// taller box).
|
||
#[test]
|
||
fn a_sheared_box_reads_as_horizontal_skew() {
|
||
let mut t = session();
|
||
// The 100×50 frame with its bottom slid 50·tan 20° to the left: a positive H skew.
|
||
let d = 50.0 * 20f64.to_radians().tan();
|
||
t.quad = [[0.0, 0.0], [100.0, 0.0], [100.0 - d, 50.0], [-d, 50.0]];
|
||
let (w, h, angle, skew) = readout(&t);
|
||
assert!(eq6(w, 100.0) && eq6(h, 100.0) && eq6(angle, 0.0), "{w} {h} {angle}");
|
||
assert!(eq6(skew, 20.0), "{skew}");
|
||
}
|
||
|
||
/// Typing H and V skews leans the box about the reference point; the angle, W and H fields keep
|
||
/// their values, and V stays as typed.
|
||
#[test]
|
||
fn skew_fields_lean_the_box_about_the_reference_point() {
|
||
let t = type_into_bar_field(5, "20");
|
||
let (w, h, angle, skew) = readout(&t);
|
||
assert!(eq6(skew, 20.0) && eq6(angle, 0.0) && eq6(w, 100.0) && eq6(h, 100.0), "{w} {h} {angle} {skew}");
|
||
// The pivot (50, 25) stays put: the top edge slid right, the bottom left, by the same amount.
|
||
let d = 25.0 * 20f64.to_radians().tan();
|
||
assert!(eq6(t.quad[0][0], d) && eq6(t.quad[0][1], 0.0) && eq6(t.quad[3][0], -d) && eq6(t.quad[3][1], 50.0), "{:?}", t.quad);
|
||
|
||
let t = type_into_bar_field(6, "-10");
|
||
assert!(eq6(t.skew_v, -10.0));
|
||
let (w, h, angle, skew) = readout(&t);
|
||
assert!(eq6(skew, 0.0) && eq6(angle, 0.0) && eq6(w, 100.0) && eq6(h, 100.0), "{w} {h} {angle} {skew}");
|
||
// The right side rose: tan(−10°) per pixel across, about the pivot.
|
||
let d = 50.0 * (-10f64).to_radians().tan();
|
||
assert!(eq6(t.quad[1][1], d) && eq6(t.quad[0][1], -d), "{:?}", t.quad);
|
||
// A skew past ±90° would flatten the box: the field stops at the limit.
|
||
let t = type_into_bar_field(5, "120");
|
||
assert!(eq6(readout(&t).3, f64::from(SKEW_LIMIT_DEG)), "{}", readout(&t).3);
|
||
}
|
||
|
||
/// Types `text` + Enter into the `n`th numeric field of the transform bar (X, Y, W, H, angle…).
|
||
fn type_into_bar_field(n: usize, text: &str) -> TransformSession {
|
||
type_into_bar_field_with(|_| {}, n, text)
|
||
}
|
||
|
||
/// The transform bar over the test box, after `setup` (e.g. picking the W/H unit). Tall enough
|
||
/// for the fields' unit menu to open below the bar unscrolled.
|
||
fn bar_harness(setup: impl FnOnce(&mut PhotocraftApp)) -> egui_kittest::Harness<'static, PhotocraftApp> {
|
||
use egui_kittest::Harness;
|
||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
|
||
app.ui.transform = Some(session());
|
||
setup(&mut app);
|
||
let mut h = Harness::builder().with_size(vec2(900.0, 360.0)).build_ui_state(
|
||
|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);
|
||
h
|
||
}
|
||
|
||
/// [`type_into_bar_field`] after `setup`.
|
||
fn type_into_bar_field_with(setup: impl FnOnce(&mut PhotocraftApp), n: usize, text: &str) -> TransformSession {
|
||
use egui_kittest::kittest::Queryable;
|
||
let mut h = bar_harness(setup);
|
||
h.get_all_by_role(egui::accesskit::Role::SpinButton).nth(n).unwrap().click();
|
||
h.run();
|
||
for c in text.chars() {
|
||
h.event(egui::Event::Text(c.to_string()));
|
||
h.run();
|
||
}
|
||
h.key_press(egui::Key::Enter);
|
||
h.run_steps(2);
|
||
h.state().ui.transform.clone().unwrap()
|
||
}
|
||
|
||
/// The values the W and H fields show (the 3rd and 4th numeric fields).
|
||
fn shown_size(h: &egui_kittest::Harness<'static, PhotocraftApp>) -> Vec<String> {
|
||
use egui_kittest::kittest::{NodeT, Queryable};
|
||
h.get_all_by_role(egui::accesskit::Role::SpinButton).skip(2).take(2).map(|n| n.accesskit_node().value().unwrap_or_default().to_string()).collect()
|
||
}
|
||
|
||
#[test]
|
||
fn size_fields_convert_between_percent_and_lengths() {
|
||
use crate::layer_props_ui::LenUnit;
|
||
use photocraft_engine::prefs::Unit;
|
||
// A 300 px side at 300 ppi is one inch at 100 %.
|
||
let at = |unit| LenUnit { unit, dpi: 300.0, ppi: 72.0 };
|
||
for (unit, at_100) in [
|
||
(Unit::Percent, 100.0),
|
||
(Unit::Pixels, 300.0),
|
||
(Unit::Inches, 1.0),
|
||
(Unit::Centimeters, 2.54),
|
||
(Unit::Millimeters, 25.4),
|
||
(Unit::Points, 72.0),
|
||
(Unit::Picas, 6.0),
|
||
] {
|
||
let u = at(unit);
|
||
let shown = size_shown(&u, 100.0, 300.0);
|
||
assert!((f64::from(shown) - at_100).abs() < 1e-3, "{unit:?}: {shown}");
|
||
assert!((size_shown(&u, 150.0, 300.0) - shown * 1.5).abs() < 1e-3, "{unit:?} scales with the box");
|
||
assert!((size_pct(&u, shown * 2.0, 300.0) - 200.0).abs() < 1e-3, "{unit:?}: twice the length is 200 %");
|
||
assert!((size_pct(&u, -shown, 300.0) + 100.0).abs() < 1e-3, "{unit:?}: a negative length flips");
|
||
}
|
||
// A degenerate side never divides by zero.
|
||
assert!(size_pct(&at(Unit::Inches), 1.0, 0.0).is_finite());
|
||
}
|
||
|
||
#[test]
|
||
fn w_and_h_take_lengths_in_the_unit_picked_for_them() {
|
||
use photocraft_engine::prefs::Unit;
|
||
// The test box is 100 × 50 px; with no document open, lengths convert at 72 ppi.
|
||
let cm = |app: &mut PhotocraftApp| set_size_unit(app, Unit::Centimeters.name());
|
||
let h = bar_harness(cm);
|
||
assert_eq!(shown_size(&h), ["3.53", "1.76"], "the box's size in cm, not 100 %");
|
||
// 7.06 cm is twice the width; linked, the height follows.
|
||
let t = type_into_bar_field_with(cm, 2, "7.06");
|
||
let (sx, sy, _, _) = readout(&t);
|
||
assert!((sx - 200.0).abs() < 0.2 && (sy - 200.0).abs() < 0.2, "{sx} {sy}");
|
||
// Pixels: an exact width.
|
||
let t = type_into_bar_field_with(|app| set_size_unit(app, Unit::Pixels.name()), 2, "250");
|
||
assert!((t.quad[1][0] - t.quad[0][0] - 250.0).abs() < 0.01, "{:?}", t.quad);
|
||
// Percent is still the default.
|
||
assert_eq!(shown_size(&bar_harness(|_| {})), ["100", "100"]);
|
||
}
|
||
|
||
#[test]
|
||
fn right_clicking_w_or_h_picks_their_unit_and_remembers_it() {
|
||
use egui_kittest::kittest::Queryable;
|
||
let mut h = bar_harness(|_| {});
|
||
h.get_all_by_role(egui::accesskit::Role::SpinButton).nth(2).unwrap().click_secondary();
|
||
h.run_steps(2);
|
||
h.get_by_label("Inches").click();
|
||
h.run_steps(3);
|
||
assert_eq!(size_unit(h.state()).unit, photocraft_engine::prefs::Unit::Inches);
|
||
assert_eq!(h.state().session.prefs().dialogs[SIZE_UNIT_KEY]["sizeUnit"], "inches");
|
||
assert_eq!(shown_size(&h), ["1.389", "0.694"], "100 × 50 px at 72 ppi");
|
||
// H opens the same menu, with the current unit checked.
|
||
h.get_all_by_role(egui::accesskit::Role::SpinButton).nth(3).unwrap().click_secondary();
|
||
h.run_steps(2);
|
||
assert!(h.query_by_label("✓ Inches").is_some(), "the current unit is checked");
|
||
h.get_by_label("Percent").click();
|
||
h.run_steps(3);
|
||
assert_eq!(shown_size(&h), ["100", "100"]);
|
||
// A hand-edited or stale preference falls back to Percent instead of failing.
|
||
h.state_mut().session.prefs.edit(|p| {
|
||
p.dialogs.insert(SIZE_UNIT_KEY.into(), json!({"sizeUnit": "furlongs"}));
|
||
});
|
||
assert_eq!(size_unit(h.state()).unit, photocraft_engine::prefs::Unit::Percent);
|
||
h.state_mut().session.prefs.edit(|p| {
|
||
p.dialogs.insert(SIZE_UNIT_KEY.into(), json!("not an object"));
|
||
});
|
||
assert_eq!(size_unit(h.state()).unit, photocraft_engine::prefs::Unit::Percent);
|
||
set_size_unit(h.state_mut(), "points");
|
||
assert_eq!(size_unit(h.state()).unit, photocraft_engine::prefs::Unit::Points, "a bad entry is replaced, not kept");
|
||
}
|
||
|
||
#[test]
|
||
fn editing_the_contents_of_a_dropped_file_keeps_the_layer() {
|
||
let mut app = app_with_square(64, photocraft_geom::Rect::new(8, 8, 24, 24));
|
||
|
||
app.session.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap();
|
||
app.sync_views();
|
||
|
||
app.session.execute("layer.duplicate", json!({"inPlace": true})).unwrap();
|
||
app.sync_views();
|
||
|
||
let parent_id = app.session.active().unwrap().doc.id;
|
||
let placed_layer = app.session.active().unwrap().active_layer.unwrap();
|
||
|
||
begin_placed(&mut app, &egui::Context::default()).unwrap();
|
||
|
||
app.session.execute("layer.smartObjects.editContents", json!({})).unwrap();
|
||
|
||
assert_ne!(app.session.active().unwrap().doc.id, parent_id, "Edit Contents should activate the child document");
|
||
|
||
end_if_left(&mut app);
|
||
|
||
let parent = app.session.documents().iter().find(|st| st.doc.id == parent_id);
|
||
|
||
assert!(parent.is_some_and(|st| st.doc.layer(placed_layer).is_some()), "the placed Smart Object must remain in the parent document");
|
||
assert!(app.ui.transform.is_none(), "the transform session should end");
|
||
assert!(app.transform_preview.is_none(), "the transform preview should end");
|
||
}
|
||
|
||
/// Large documents need scales past 10000% and positions past 30000 px (#1267).
|
||
#[test]
|
||
fn transform_fields_reach_large_documents() {
|
||
let t = type_into_bar_field(2, "15000");
|
||
let (sx, sy, _, _) = readout(&t);
|
||
assert!((sx - 15000.0).abs() < 0.5 && (sy - 15000.0).abs() < 0.5, "{sx} {sy}");
|
||
assert!((t.quad[1][0] - t.quad[0][0] - 15_000.0).abs() < 1.0, "{:?}", t.quad);
|
||
let t = type_into_bar_field(0, "250000");
|
||
assert!((t.pivot[0] - 250_000.0).abs() < 1.0, "{:?}", t.pivot);
|
||
}
|
||
|
||
/// A typed scale stops where the box reaches the largest document side, not past what can be
|
||
/// allocated: the 100 × 50 px test box tops out at 300000 % wide.
|
||
#[test]
|
||
fn typed_scales_stay_within_the_largest_document() {
|
||
assert_eq!(scale_limit_pct(20.0), SCALE_LIMIT_PCT);
|
||
assert_eq!(scale_limit_pct(3000.0), 10_000.0);
|
||
assert_eq!(scale_limit_pct(1e9), 100.0);
|
||
assert_eq!(scale_limit_pct(0.0), SCALE_LIMIT_PCT);
|
||
assert_eq!(scale_limit_pct(f64::NAN), SCALE_LIMIT_PCT);
|
||
let t = type_into_bar_field(2, "99999999");
|
||
let (sx, sy, _, _) = readout(&t);
|
||
let side = t.rect[2] - t.rect[0];
|
||
assert!(sx <= f64::from(scale_limit_pct(side)) + 0.5 && sy <= f64::from(scale_limit_pct(t.rect[3] - t.rect[1])) + 0.5, "{sx} {sy}");
|
||
}
|
||
|
||
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);
|
||
}
|
||
|
||
/// A handle drag in Edit › Transform `mode`, as the canvas applies it (on a pixel layer).
|
||
fn drag_in(mode: TransformMode, from: [f64; 2], to: [f64; 2], mods: egui::Modifiers) -> [[f64; 2]; 4] {
|
||
drag_on(mode, false, from, to, mods)
|
||
}
|
||
|
||
fn drag_on(mode: TransformMode, type_layer: bool, from: [f64; 2], to: [f64; 2], mods: egui::Modifiers) -> [[f64; 2]; 4] {
|
||
let mut s = session();
|
||
let g = Gesture { hit: hit(&s, from, 8.0), start: from, quad0: s.quad, pivot0: s.pivot };
|
||
apply_mode_drag(&mut s, g, to, mode, type_layer, mods);
|
||
s.quad
|
||
}
|
||
|
||
/// Skew on editable type, checked in Photoshop: a corner can't move alone (type stays a
|
||
/// parallelogram), so it slides its whole edge: the top or bottom edge for a mostly
|
||
/// horizontal drag, the left or right one for a mostly vertical drag. ⌥ moves the opposite
|
||
/// edge the other way. Side handles are as on any layer.
|
||
#[test]
|
||
fn skew_mode_on_type_slides_the_corners_edge() {
|
||
let (skew, none) = (TransformMode::Skew, egui::Modifiers::NONE);
|
||
assert_eq!(drag_on(skew, true, [100.0, 0.0], [110.0, 3.0], none), [[10.0, 0.0], [110.0, 0.0], [100.0, 50.0], [0.0, 50.0]]);
|
||
assert_eq!(drag_on(skew, true, [100.0, 0.0], [102.0, -9.0], none), [[0.0, 0.0], [100.0, -9.0], [100.0, 41.0], [0.0, 50.0]]);
|
||
assert_eq!(drag_on(skew, true, [100.0, 0.0], [110.0, 3.0], egui::Modifiers::ALT), [[10.0, 0.0], [110.0, 0.0], [90.0, 50.0], [-10.0, 50.0]]);
|
||
assert_eq!(drag_on(skew, true, [50.0, 0.0], [62.0, 7.0], none), [[12.0, 0.0], [112.0, 0.0], [100.0, 50.0], [0.0, 50.0]]);
|
||
}
|
||
|
||
/// Edit › Transform › Skew, checked in Photoshop (#3437): a corner moves alone, horizontally or
|
||
/// vertically (whichever way the drag mostly goes); the top and bottom handles slide only
|
||
/// horizontally, the left and right ones only vertically; ⌥ moves the opposite corner or edge
|
||
/// the other way. Nothing scales.
|
||
#[test]
|
||
fn skew_mode_moves_a_corner_or_an_edge_along_one_axis() {
|
||
let skew = TransformMode::Skew;
|
||
let none = egui::Modifiers::NONE;
|
||
// Corner (top right), mostly horizontal, then mostly vertical: only that corner moves.
|
||
assert_eq!(drag_in(skew, [100.0, 0.0], [110.0, 3.0], none), [[0.0, 0.0], [110.0, 0.0], [100.0, 50.0], [0.0, 50.0]]);
|
||
assert_eq!(drag_in(skew, [100.0, 0.0], [102.0, -9.0], none), [[0.0, 0.0], [100.0, -9.0], [100.0, 50.0], [0.0, 50.0]]);
|
||
// Top edge: horizontal only, both its corners.
|
||
assert_eq!(drag_in(skew, [50.0, 0.0], [62.0, 7.0], none), [[12.0, 0.0], [112.0, 0.0], [100.0, 50.0], [0.0, 50.0]]);
|
||
// Left edge: vertical only.
|
||
assert_eq!(drag_in(skew, [0.0, 25.0], [6.0, 33.0], none), [[0.0, 8.0], [100.0, 0.0], [100.0, 50.0], [0.0, 58.0]]);
|
||
// ⌥: the opposite corner, and the opposite edge, go the other way.
|
||
let alt = egui::Modifiers::ALT;
|
||
assert_eq!(drag_in(skew, [100.0, 0.0], [110.0, 3.0], alt), [[0.0, 0.0], [110.0, 0.0], [100.0, 50.0], [-10.0, 50.0]]);
|
||
assert_eq!(drag_in(skew, [50.0, 0.0], [62.0, 7.0], alt), [[12.0, 0.0], [112.0, 0.0], [88.0, 50.0], [-12.0, 50.0]]);
|
||
// ⇧ changes nothing in Skew mode.
|
||
let shift = egui::Modifiers::SHIFT;
|
||
assert_eq!(drag_in(skew, [100.0, 0.0], [110.0, 3.0], shift), drag_in(skew, [100.0, 0.0], [110.0, 3.0], none));
|
||
assert_eq!(drag_in(skew, [50.0, 0.0], [62.0, 7.0], shift), drag_in(skew, [50.0, 0.0], [62.0, 7.0], none));
|
||
// A drag outside the box doesn't rotate it in Skew mode.
|
||
assert_eq!(drag_in(skew, [130.0, 25.0], [120.0, 60.0], none), corners([0.0, 0.0, 100.0, 50.0]));
|
||
// Free Transform is unchanged: a corner scales.
|
||
assert_ne!(drag_in(TransformMode::Free, [100.0, 0.0], [110.0, 3.0], none)[2], [100.0, 50.0]);
|
||
}
|
||
|
||
fn close(a: [[f64; 2]; 4], b: [[f64; 2]; 4]) -> bool {
|
||
a.iter().flatten().zip(b.iter().flatten()).all(|(x, y)| (x - y).abs() < 1e-6)
|
||
}
|
||
|
||
/// On a box smaller than the handles' grab areas the nearest handle wins, so the reference
|
||
/// point at the centre doesn't take presses on the corners and edges.
|
||
#[test]
|
||
fn the_nearest_handle_wins_on_a_small_box() {
|
||
let mut s = session();
|
||
s.quad = corners([0.0, 0.0, 16.0, 16.0]);
|
||
s.pivot = [8.0, 8.0];
|
||
assert_eq!(hit(&s, [0.0, 0.0], 12.0), Hit::Corner(0));
|
||
assert_eq!(hit(&s, [16.0, 17.0], 12.0), Hit::Corner(2));
|
||
assert_eq!(hit(&s, [8.0, -1.0], 12.0), Hit::Edge(0));
|
||
assert_eq!(hit(&s, [8.0, 8.0], 12.0), Hit::Pivot);
|
||
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();
|
||
drag(&mut s, [100.0, 50.0], [200.0, 60.0], egui::Modifiers::NONE);
|
||
assert!(close(s.quad, corners([0.0, 0.0, 200.0, 100.0])), "{:?}", s.quad);
|
||
let mut s = session();
|
||
drag(&mut s, [100.0, 50.0], [200.0, 60.0], egui::Modifiers::SHIFT);
|
||
assert!(close(s.quad, corners([0.0, 0.0, 200.0, 60.0])), "shift = free: {:?}", s.quad);
|
||
}
|
||
|
||
/// Photoshop CC 2019 and later: an edge scales proportionally too, about the middle of the
|
||
/// opposite edge, and ⇧ stretches its one axis instead.
|
||
#[test]
|
||
fn edge_drag_scales_proportionally_about_the_opposite_edge() {
|
||
// Right edge: 100 × 50 → 150 × 75; the left edge's middle (0, 25) stays, and so does the
|
||
// reference point's place in the box (its centre).
|
||
let mut s = session();
|
||
drag(&mut s, [100.0, 25.0], [150.0, 25.0], egui::Modifiers::NONE);
|
||
assert!(close(s.quad, corners([0.0, -12.5, 150.0, 62.5])), "{:?}", s.quad);
|
||
assert!(near(s.pivot, [75.0, 25.0]), "{:?}", s.pivot);
|
||
// Top edge to twice the height: twice the width, about x = 50.
|
||
let mut s = session();
|
||
drag(&mut s, [50.0, 0.0], [50.0, -50.0], egui::Modifiers::NONE);
|
||
assert!(close(s.quad, corners([-50.0, -50.0, 150.0, 50.0])), "{:?}", s.quad);
|
||
// Past the opposite edge: the dragged axis flips, the other only shrinks.
|
||
let mut s = session();
|
||
drag(&mut s, [100.0, 25.0], [-50.0, 25.0], egui::Modifiers::NONE);
|
||
assert!(close(s.quad, corners([0.0, 12.5, -50.0, 37.5])), "{:?}", s.quad);
|
||
// ⇧: one axis.
|
||
let mut s = session();
|
||
drag(&mut s, [50.0, 50.0], [50.0, 80.0], egui::Modifiers::SHIFT);
|
||
assert!(close(s.quad, corners([0.0, 0.0, 100.0, 80.0])), "{:?}", s.quad);
|
||
}
|
||
|
||
#[test]
|
||
fn alt_scales_edges_about_reference_point() {
|
||
let mut s = session();
|
||
drag(&mut s, [100.0, 25.0], [150.0, 25.0], egui::Modifiers::ALT);
|
||
assert!(close(s.quad, corners([-50.0, -25.0, 150.0, 75.0])), "{:?}", s.quad);
|
||
// ⌥⇧: one axis, about the reference point.
|
||
let mut s = session();
|
||
drag(&mut s, [100.0, 25.0], [150.0, 25.0], egui::Modifiers { alt: true, shift: true, ..Default::default() });
|
||
assert!(close(s.quad, corners([-50.0, 0.0, 150.0, 50.0])), "{:?}", s.quad);
|
||
}
|
||
|
||
/// A reference point moved off the centre (⌥-click) is the centre of ⌥-scaling too, not just
|
||
/// of rotation: it stays put and the box scales away from it.
|
||
#[test]
|
||
fn alt_scales_about_a_moved_reference_point() {
|
||
let alt = egui::Modifiers::ALT;
|
||
// Reference point on the top-left corner: the bottom-right corner to (200, 100) doubles
|
||
// the box, top-left fixed (not centred on the reference point).
|
||
let mut s = session();
|
||
s.pivot = [0.0, 0.0];
|
||
drag(&mut s, [100.0, 50.0], [200.0, 100.0], alt);
|
||
assert!(close(s.quad, corners([0.0, 0.0, 200.0, 100.0])), "{:?}", s.quad);
|
||
assert!(near(s.pivot, [0.0, 0.0]), "{:?}", s.pivot);
|
||
// Reference point at (25, 25): the right edge to x = 175 is 2× about x = 25, and
|
||
// proportionally 2× about y = 25 too.
|
||
let mut s = session();
|
||
s.pivot = [25.0, 25.0];
|
||
drag(&mut s, [100.0, 25.0], [175.0, 25.0], alt);
|
||
assert!(close(s.quad, corners([-25.0, -25.0, 175.0, 75.0])), "{:?}", s.quad);
|
||
// ⌥⇧: that edge's axis only.
|
||
let mut s = session();
|
||
s.pivot = [25.0, 25.0];
|
||
drag(&mut s, [100.0, 25.0], [175.0, 25.0], egui::Modifiers { alt: true, shift: true, ..Default::default() });
|
||
assert!(close(s.quad, corners([-25.0, 0.0, 175.0, 50.0])), "{:?}", s.quad);
|
||
// Reference point in line with the dragged corner horizontally: that axis has nothing to
|
||
// scale against and stays, the other doubles about it.
|
||
let mut s = session();
|
||
s.pivot = [100.0, 50.0];
|
||
drag(&mut s, [100.0, 0.0], [150.0, -50.0], egui::Modifiers { alt: true, shift: true, ..Default::default() });
|
||
assert!(close(s.quad, corners([0.0, -50.0, 100.0, 50.0])), "{:?}", s.quad);
|
||
}
|
||
|
||
fn near(a: [f64; 2], b: [f64; 2]) -> bool {
|
||
(a[0] - b[0]).abs() < 1e-6 && (a[1] - b[1]).abs() < 1e-6
|
||
}
|
||
|
||
/// The reference point is attached to the box (#1329): scaling, skewing, distorting and
|
||
/// perspective carry it along to the same place within the box; ⌥-scaling about it and
|
||
/// rotating about it leave it put.
|
||
#[test]
|
||
fn the_reference_point_moves_with_the_box() {
|
||
// Corner scale down: the centre stays the centre.
|
||
let mut s = session();
|
||
drag(&mut s, [100.0, 50.0], [50.0, 25.0], egui::Modifiers::NONE);
|
||
assert!(close(s.quad, corners([0.0, 0.0, 50.0, 25.0])), "{:?}", s.quad);
|
||
assert!(near(s.pivot, [25.0, 12.5]), "{:?}", s.pivot);
|
||
// A reference point set to a corner stays at that corner; an edge drag carries it too.
|
||
let mut s = session();
|
||
s.pivot = [100.0, 0.0];
|
||
drag(&mut s, [50.0, 50.0], [50.0, 80.0], egui::Modifiers::SHIFT);
|
||
assert!(near(s.pivot, [100.0, 0.0]), "{:?}", s.pivot);
|
||
drag(&mut s, [100.0, 40.0], [150.0, 40.0], egui::Modifiers::SHIFT);
|
||
assert!(near(s.pivot, s.quad[1]), "{:?} {:?}", s.pivot, s.quad);
|
||
drag(&mut s, [150.0, 40.0], [180.0, 40.0], egui::Modifiers::NONE);
|
||
assert!(near(s.pivot, s.quad[1]), "proportional edge drag: {:?} {:?}", s.pivot, s.quad);
|
||
// Skew: the centre follows the box's centre.
|
||
let mut s = session();
|
||
drag(&mut s, [50.0, 0.0], [70.0, 0.0], egui::Modifiers::COMMAND);
|
||
assert!(near(s.pivot, [60.0, 25.0]), "{:?}", s.pivot);
|
||
// Distort: a corner reference point stays on its corner.
|
||
let mut s = session();
|
||
s.pivot = [0.0, 50.0];
|
||
drag(&mut s, [0.0, 50.0], [-20.0, 70.0], egui::Modifiers::COMMAND);
|
||
assert!(near(s.pivot, [-20.0, 70.0]), "{:?}", s.pivot);
|
||
// Perspective: the centre stays inside the box, on the box's own centre lines.
|
||
let mut s = session();
|
||
drag(&mut s, [100.0, 0.0], [120.0, 0.0], egui::Modifiers { command: true, alt: true, shift: true, ..Default::default() });
|
||
let h = Homography::rect_to_quad([0.0, 0.0, 1.0, 1.0], s.quad).unwrap();
|
||
let (x, y) = h.apply(0.5, 0.5);
|
||
assert!(near(s.pivot, [x, y]), "{:?} vs {:?}", s.pivot, [x, y]);
|
||
// ⌥ scales about the reference point: it stays where it is.
|
||
let mut s = session();
|
||
drag(&mut s, [100.0, 50.0], [150.0, 75.0], egui::Modifiers::ALT);
|
||
assert!(near(s.pivot, [50.0, 25.0]), "{:?}", s.pivot);
|
||
// Rotation turns about it.
|
||
let mut s = session();
|
||
drag(&mut s, [150.0, 25.0], [50.0, 125.0], egui::Modifiers::NONE);
|
||
assert!(near(s.pivot, [50.0, 25.0]), "{:?}", s.pivot);
|
||
// From a box collapsed to a point there is no place within it to keep: it stays put.
|
||
assert_eq!(carry_pivot([[0.0, 0.0]; 4], corners([0.0, 0.0, 10.0, 10.0]), [5.0, 5.0]), [5.0, 5.0]);
|
||
}
|
||
|
||
#[test]
|
||
fn move_rotate_and_distort() {
|
||
let mut s = session();
|
||
drag(&mut s, [40.0, 20.0], [50.0, 30.0], egui::Modifiers::NONE);
|
||
assert!(close(s.quad, corners([10.0, 10.0, 110.0, 60.0])));
|
||
assert_eq!(s.pivot, [60.0, 35.0]);
|
||
// Rotate 90° about the centre (drag from right of the box to below it).
|
||
let mut s = session();
|
||
drag(&mut s, [150.0, 25.0], [50.0, 125.0], egui::Modifiers::NONE);
|
||
let (_, _, angle, _) = readout(&s);
|
||
assert!((angle - 90.0).abs() < 1e-6, "{angle}");
|
||
// ⇧ snaps: a 50° drag lands on 45°.
|
||
let mut s = session();
|
||
let a = 50f64.to_radians();
|
||
drag(&mut s, [150.0, 25.0], [50.0 + 100.0 * a.cos(), 25.0 + 100.0 * a.sin()], egui::Modifiers::SHIFT);
|
||
assert!((readout(&s).2 - 45.0).abs() < 1e-6);
|
||
// ⌘ corner = distort.
|
||
let mut s = session();
|
||
drag(&mut s, [100.0, 0.0], [120.0, -10.0], egui::Modifiers::COMMAND);
|
||
assert_eq!(s.quad[1], [120.0, -10.0]);
|
||
assert_eq!(s.quad[0], [0.0, 0.0]);
|
||
}
|
||
|
||
#[test]
|
||
fn begin_and_commit_through_the_engine() {
|
||
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, 24, 24), &[1.0, 0.0, 0.0, 1.0]);
|
||
Ok(())
|
||
})
|
||
.unwrap();
|
||
let ctx = egui::Context::default();
|
||
begin(&mut app, &ctx).unwrap();
|
||
assert_eq!(app.ui.transform.as_ref().unwrap().rect, [8.0, 8.0, 24.0, 24.0]);
|
||
scale_about_pivot(&mut app, 2.0, 2.0);
|
||
commit(&mut app);
|
||
let st = app.session.active().unwrap();
|
||
let b = st.doc.layer(st.active_layer.unwrap()).unwrap().surface().unwrap().content_bounds();
|
||
assert!(b.width().abs_diff(32) <= 2 && b.x0.abs_diff(0) <= 1, "{b:?}");
|
||
assert!(app.ui.transform.is_none() && app.transform_preview.is_none());
|
||
}
|
||
|
||
/// A commit the engine refuses (here a collapsed box; a result over the pixel budget, #3773, is
|
||
/// refused the same way) shows the reason and keeps the box open, unchanged; picking another
|
||
/// tool then ends it.
|
||
#[test]
|
||
fn a_refused_commit_keeps_the_box_open() {
|
||
let ctx = egui::Context::default();
|
||
let mut app = app_with_square(64, photocraft_geom::Rect::new(8, 8, 24, 24));
|
||
let (revision, steps) = (app.session.active().unwrap().revision, app.session.active().unwrap().history.past_len());
|
||
begin(&mut app, &ctx).unwrap();
|
||
if let Some(t) = app.ui.transform.as_mut() {
|
||
t.quad = [[16.0, 16.0]; 4];
|
||
}
|
||
commit(&mut app);
|
||
let t = app.ui.transform.as_ref().expect("the box stays open");
|
||
assert_eq!(t.quad, [[16.0, 16.0]; 4], "as the user left it");
|
||
assert!(app.transform_preview.is_some(), "with its preview");
|
||
assert!(app.ui.status.contains("collapses"), "{}", app.ui.status);
|
||
let st = app.session.active().unwrap();
|
||
assert_eq!((st.revision, st.history.past_len()), (revision, steps), "the document was not changed");
|
||
app.ui.tool = if app.ui.tool == Tool::Brush { Tool::Move } else { Tool::Brush };
|
||
end_if_left(&mut app);
|
||
assert!(app.ui.transform.is_none() && app.transform_preview.is_none(), "another tool ends it");
|
||
}
|
||
|
||
/// #352: ⌥⌘T transforms a copy. OK leaves the original and a moved copy as one history step;
|
||
/// Cancel leaves no copy and nothing to redo; with a selection the copy holds the selected
|
||
/// pixels only.
|
||
#[test]
|
||
fn free_transform_a_copy() {
|
||
let ctx = egui::Context::default();
|
||
let bounds = |app: &PhotocraftApp, i: usize| app.session.active().unwrap().doc.layers[i].surface().unwrap().content_bounds();
|
||
let mut app = app_with_square(64, photocraft_geom::Rect::new(8, 8, 24, 24));
|
||
let (layers, steps) = (app.session.active().unwrap().doc.layers.len(), app.session.active().unwrap().history.past_len());
|
||
crate::menus::invoke(&mut app, &ctx, "edit.freeTransformCopy", json!({})).unwrap();
|
||
assert_eq!(app.ui.transform.as_ref().unwrap().made, Some(MadeLayer::Copy));
|
||
assert!(!crate::menus::is_enabled(&app, "edit.freeTransformCopy"), "one transform at a time");
|
||
// Esc: the copy goes too.
|
||
cancel(&mut app);
|
||
let st = app.session.active().unwrap();
|
||
assert_eq!((st.doc.layers.len(), st.history.past_len()), (layers, steps));
|
||
assert!(!st.history.can_redo(), "nothing to redo");
|
||
// OK: original in place, a moved copy, one step.
|
||
crate::menus::invoke(&mut app, &ctx, "edit.freeTransformCopy", json!({})).unwrap();
|
||
if let Some(t) = app.ui.transform.as_mut() {
|
||
t.quad = t.quad.map(|[x, y]| [x + 20.0, y]);
|
||
}
|
||
commit(&mut app);
|
||
let st = app.session.active().unwrap();
|
||
assert_eq!((st.doc.layers.len(), st.history.past_len()), (layers + 1, steps + 1));
|
||
assert_eq!(bounds(&app, layers - 1), photocraft_geom::Rect::new(8, 8, 24, 24), "the original stays");
|
||
assert_eq!(bounds(&app, layers), photocraft_geom::Rect::new(28, 8, 44, 24), "the copy moved");
|
||
app.session.undo();
|
||
assert_eq!(app.session.active().unwrap().doc.layers.len(), layers, "one undo removes copy and move");
|
||
// With a selection: Layer via Copy, so only the selected pixels are copied.
|
||
app.run("select.rect", json!({"x": 8, "y": 8, "width": 8, "height": 16})).unwrap();
|
||
crate::menus::invoke(&mut app, &ctx, "edit.freeTransformCopy", json!({})).unwrap();
|
||
assert_eq!(app.ui.transform.as_ref().unwrap().rect, [8.0, 8.0, 16.0, 24.0]);
|
||
cancel(&mut app);
|
||
assert_eq!(app.session.active().unwrap().doc.layers.len(), layers);
|
||
}
|
||
|
||
/// #1099: cancel, frame cleanup and a commit before the next frame all belong to the
|
||
/// original document, including after tab reordering and with shared layer IDs.
|
||
#[test]
|
||
fn cancelling_a_copy_after_switching_documents_preserves_the_active_document() {
|
||
for reorder in [false, true] {
|
||
for finish in [cancel, end_if_left, commit] {
|
||
let mut app = app_with_square(64, photocraft_geom::Rect::new(8, 8, 24, 24));
|
||
let original = app.session.active().unwrap().doc.clone();
|
||
let steps = app.session.active().unwrap().history.past_len();
|
||
begin_copy(&mut app, &egui::Context::default()).unwrap();
|
||
app.ui.transform.as_mut().unwrap().quad = corners([12.0, 8.0, 28.0, 24.0]);
|
||
// Cloning a document deliberately shares layer IDs, but gets a fresh document ID.
|
||
let other = app.session.active().unwrap().doc.as_ref().clone();
|
||
app.session.add_document(other, None);
|
||
app.run("layer.new.layer", json!({})).unwrap();
|
||
app.run("layer.new.layer", json!({})).unwrap();
|
||
assert!(app.session.undo());
|
||
let st = app.session.active().unwrap();
|
||
let (doc, history, redo, revision, layer) =
|
||
(st.doc.clone(), st.history.entries(), st.history.redo_labels().map(str::to_owned).collect::<Vec<_>>(), st.revision, st.active_layer);
|
||
if reorder {
|
||
app.session.move_document(0, 1);
|
||
}
|
||
finish(&mut app);
|
||
assert!(app.ui.transform.is_none() && app.transform_preview.is_none());
|
||
let st = app.session.active().unwrap();
|
||
assert!(Arc::ptr_eq(&st.doc, &doc), "the selected document's pixels are unchanged");
|
||
assert_eq!((st.history.entries(), st.revision, st.active_layer), (history, revision, layer));
|
||
assert_eq!(st.history.redo_labels().collect::<Vec<_>>(), redo);
|
||
let origin = app.session.documents().iter().find(|st| st.doc.id == original.id).unwrap();
|
||
assert!(Arc::ptr_eq(&origin.doc, &original), "only the originating copy is undone");
|
||
assert_eq!(origin.history.past_len(), steps);
|
||
assert!(!origin.history.can_redo());
|
||
}
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn cancelling_a_copy_after_its_document_closes_does_not_undo_another_document() {
|
||
let mut app = app_with_square(64, photocraft_geom::Rect::new(8, 8, 24, 24));
|
||
begin_copy(&mut app, &egui::Context::default()).unwrap();
|
||
app.run("file.new", json!({"width": 64, "height": 64})).unwrap();
|
||
app.run("layer.new.layer", json!({})).unwrap();
|
||
let doc = app.session.active().unwrap().doc.clone();
|
||
let steps = app.session.active().unwrap().history.past_len();
|
||
app.session.close(0);
|
||
end_if_left(&mut app);
|
||
let st = app.session.active().unwrap();
|
||
assert!(Arc::ptr_eq(&st.doc, &doc));
|
||
assert_eq!(st.history.past_len(), steps);
|
||
assert!(app.ui.transform.is_none() && app.transform_preview.is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn cancelling_an_already_undone_copy_preserves_history_and_redo() {
|
||
let mut app = app_with_square(64, photocraft_geom::Rect::new(8, 8, 24, 24));
|
||
begin_copy(&mut app, &egui::Context::default()).unwrap();
|
||
assert!(app.session.undo());
|
||
let st = app.session.active().unwrap();
|
||
let (doc, history, redo) = (st.doc.clone(), st.history.entries(), st.history.redo_labels().map(str::to_owned).collect::<Vec<_>>());
|
||
end_if_left(&mut app);
|
||
let st = app.session.active().unwrap();
|
||
assert!(Arc::ptr_eq(&st.doc, &doc));
|
||
assert_eq!(st.history.entries(), history);
|
||
assert_eq!(st.history.redo_labels().collect::<Vec<_>>(), redo);
|
||
assert!(app.ui.transform.is_none() && app.transform_preview.is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn cancelling_a_copy_does_not_undo_a_later_edit() {
|
||
let mut app = app_with_square(64, photocraft_geom::Rect::new(8, 8, 24, 24));
|
||
begin_copy(&mut app, &egui::Context::default()).unwrap();
|
||
app.run("layer.new.layer", json!({})).unwrap();
|
||
let st = app.session.active().unwrap();
|
||
let (doc, history) = (st.doc.clone(), st.history.entries());
|
||
cancel(&mut app);
|
||
let st = app.session.active().unwrap();
|
||
assert!(Arc::ptr_eq(&st.doc, &doc));
|
||
assert_eq!(st.history.entries(), history);
|
||
}
|
||
|
||
/// #1384: the Layers panel's opacity, fill, visibility and blend edits to the moving layer
|
||
/// and to the others show while the box is open, and survive the commit.
|
||
#[test]
|
||
fn edits_during_a_transform_show_in_the_preview() {
|
||
let mut app = app_with_square(64, photocraft_geom::Rect::new(8, 8, 24, 24));
|
||
app.ui.tool = Tool::Move;
|
||
let bg = app.session.active().unwrap().doc.layers[0].id;
|
||
let mut h = egui_kittest::Harness::builder().with_size(vec2(1200.0, 800.0)).with_max_steps(64).build_eframe(move |cc| {
|
||
PhotocraftApp::setup_context(&cc.egui_ctx, Default::default());
|
||
app
|
||
});
|
||
h.run_steps(4);
|
||
let ctx = h.ctx.clone();
|
||
crate::menus::invoke(h.state_mut(), &ctx, "edit.freeTransform", json!({})).unwrap();
|
||
if let Some(t) = h.state_mut().ui.transform.as_mut() {
|
||
t.quad = t.quad.map(|[x, y]| [x + 20.0, y]);
|
||
}
|
||
h.run_steps(2);
|
||
let layer = h.state().ui.transform.as_ref().unwrap().layer;
|
||
assert_eq!(h.state().transform_preview.as_ref().unwrap().opacity, 1.0);
|
||
|
||
h.state_mut().run("layer.setProps", json!({"layer": layer, "opacity": 0.5, "fill": 0.6, "blend": "Multiply"})).unwrap();
|
||
h.state_mut().run("layer.setProps", json!({"layer": bg.0, "visible": false})).unwrap();
|
||
h.run_steps(2);
|
||
let app = h.state();
|
||
assert!(app.ui.transform.is_some(), "the edits leave the box open");
|
||
let pv = app.transform_preview.as_ref().unwrap();
|
||
assert!((pv.opacity - 0.3).abs() < 1e-6, "the moving pixels take the new opacity × fill: {}", pv.opacity);
|
||
assert!(!pv.doc.layer(bg).unwrap().visible, "the document under the box follows");
|
||
let moving = pv.doc.layer(LayerId(layer)).unwrap();
|
||
assert!(!moving.visible, "the moving layer stays out of the document under the box");
|
||
assert_eq!(moving.blend, photocraft_color::BlendMode::Multiply);
|
||
|
||
h.state_mut().run("layer.setProps", json!({"layer": layer, "visible": false})).unwrap();
|
||
h.run_steps(2);
|
||
assert_eq!(h.state().transform_preview.as_ref().unwrap().opacity, 0.0, "hiding the layer hides the moving pixels");
|
||
h.state_mut().run("layer.setProps", json!({"layer": layer, "visible": true})).unwrap();
|
||
|
||
commit(h.state_mut());
|
||
let st = h.state().session.active().unwrap();
|
||
let l = st.doc.layer(LayerId(layer)).unwrap();
|
||
assert_eq!((l.opacity, l.fill_opacity, l.blend), (0.5, 0.6, photocraft_color::BlendMode::Multiply));
|
||
assert_eq!(l.surface().unwrap().content_bounds(), photocraft_geom::Rect::new(28, 8, 44, 24));
|
||
}
|
||
|
||
/// #1384 with a selection: the pixels it leaves behind stay in the rebuilt preview without
|
||
/// the lifted ones, and take the layer's new properties.
|
||
#[test]
|
||
fn edits_during_a_selection_transform_keep_the_lifted_pixels_out() {
|
||
let mut app = app_with_square(64, photocraft_geom::Rect::new(8, 8, 24, 24));
|
||
app.ui.tool = Tool::Move;
|
||
app.session.execute("select.rect", json!({"x": 8, "y": 8, "width": 8, "height": 16})).unwrap();
|
||
let mut h = egui_kittest::Harness::builder().with_size(vec2(1200.0, 800.0)).with_max_steps(64).build_eframe(move |cc| {
|
||
PhotocraftApp::setup_context(&cc.egui_ctx, Default::default());
|
||
app
|
||
});
|
||
h.run_steps(4);
|
||
let ctx = h.ctx.clone();
|
||
crate::menus::invoke(h.state_mut(), &ctx, "edit.freeTransform", json!({})).unwrap();
|
||
h.run_steps(2);
|
||
let layer = LayerId(h.state().ui.transform.as_ref().unwrap().layer);
|
||
// Alpha in the preview's copy of the layer: in the lifted half (x 8..16) and the rest (16..24).
|
||
let left = |app: &PhotocraftApp| {
|
||
let s = app.transform_preview.as_ref().unwrap().doc.layer(layer).unwrap().surface().unwrap().clone();
|
||
(s.sample_channel(10, 12, 3), s.sample_channel(20, 12, 3))
|
||
};
|
||
assert_eq!(left(h.state()), (0.0, 1.0), "the selected half is lifted out");
|
||
|
||
h.state_mut().run("layer.setProps", json!({"layer": layer.0, "opacity": 0.4})).unwrap();
|
||
h.run_steps(2);
|
||
let app = h.state();
|
||
assert_eq!(left(app), (0.0, 1.0), "still without the lifted pixels");
|
||
let moving = app.transform_preview.as_ref().unwrap().doc.layer(layer).unwrap();
|
||
assert!(moving.visible && (moving.opacity - 0.4).abs() < 1e-6, "what stays behind is drawn with the new opacity");
|
||
assert!((app.transform_preview.as_ref().unwrap().opacity - 0.4).abs() < 1e-6);
|
||
}
|
||
|
||
/// #670: picking another tool applies the open transform, as one history step.
|
||
#[test]
|
||
fn picking_another_tool_applies_the_transform() {
|
||
let mut app = app_with_square(64, photocraft_geom::Rect::new(8, 8, 24, 24));
|
||
app.ui.tool = Tool::Move;
|
||
let steps = app.session.active().unwrap().history.past_len();
|
||
let mut h = egui_kittest::Harness::builder().with_size(vec2(1200.0, 800.0)).with_max_steps(64).build_eframe(move |cc| {
|
||
PhotocraftApp::setup_context(&cc.egui_ctx, Default::default());
|
||
app
|
||
});
|
||
h.run_steps(4);
|
||
let ctx = h.ctx.clone();
|
||
crate::menus::invoke(h.state_mut(), &ctx, "edit.freeTransform", json!({})).unwrap();
|
||
if let Some(t) = h.state_mut().ui.transform.as_mut() {
|
||
t.quad = t.quad.map(|[x, y]| [x + 20.0, y]);
|
||
}
|
||
h.run_steps(2);
|
||
assert!(h.state().ui.transform.is_some(), "open while the tool stays");
|
||
h.key_press(egui::Key::B);
|
||
h.run_steps(2);
|
||
let app = h.state();
|
||
assert_eq!(app.ui.tool, Tool::Brush);
|
||
assert!(app.ui.transform.is_none() && app.transform_preview.is_none());
|
||
let st = app.session.active().unwrap();
|
||
assert_eq!(st.history.past_len(), steps + 1);
|
||
assert_eq!(st.doc.layer(st.active_layer.unwrap()).unwrap().surface().unwrap().content_bounds(), photocraft_geom::Rect::new(28, 8, 44, 24));
|
||
}
|
||
|
||
/// #1400: selecting another layer while transforming applies the box to the layer it was on,
|
||
/// and the next transform starts on the newly selected layer.
|
||
#[test]
|
||
fn selecting_another_layer_applies_the_transform_and_the_next_one_follows() {
|
||
let ctx = egui::Context::default();
|
||
let mut app = app_with_square(64, photocraft_geom::Rect::new(8, 8, 24, 24));
|
||
let first = app.session.active().unwrap().active_layer.unwrap();
|
||
app.run("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(40, 40, 56, 60), &[0.0, 0.0, 1.0, 1.0]);
|
||
Ok(())
|
||
})
|
||
.unwrap();
|
||
let second = app.session.active().unwrap().active_layer.unwrap();
|
||
app.run("layer.select", json!({"layer": first.0})).unwrap();
|
||
app.ui.tool = Tool::Move;
|
||
crate::menus::invoke(&mut app, &ctx, "edit.freeTransform", json!({})).unwrap();
|
||
if let Some(t) = app.ui.transform.as_mut() {
|
||
t.quad = t.quad.map(|[x, y]| [x + 20.0, y]);
|
||
}
|
||
let steps = app.session.active().unwrap().history.past_len();
|
||
app.run("layer.select", json!({"layer": second.0})).unwrap();
|
||
end_if_left(&mut app);
|
||
assert!(app.ui.transform.is_none() && app.transform_preview.is_none(), "the box left the old layer");
|
||
let st = app.session.active().unwrap();
|
||
assert_eq!(st.active_layer, Some(second));
|
||
assert_eq!(st.history.past_len(), steps + 1, "applied as one step");
|
||
let bounds = |id: LayerId| st.doc.layer(id).unwrap().surface().unwrap().content_bounds();
|
||
assert_eq!(bounds(first), photocraft_geom::Rect::new(28, 8, 44, 24), "applied to the layer it was on");
|
||
assert_eq!(bounds(second), photocraft_geom::Rect::new(40, 40, 56, 60), "the new layer is untouched");
|
||
crate::menus::invoke(&mut app, &ctx, "edit.freeTransform", json!({})).unwrap();
|
||
let t = app.ui.transform.as_ref().unwrap();
|
||
assert_eq!((t.layer, t.rect), (second.0, [40.0, 40.0, 56.0, 60.0]), "the next box is on the selected layer");
|
||
}
|
||
|
||
/// The screen rectangle the marching ants cover as [`draw_selection`] paints them over the
|
||
/// active document's selection; `None` when it leaves them to the canvas.
|
||
fn ants_bounds(app: &PhotocraftApp, xf: &ViewXform) -> Option<egui::Rect> {
|
||
let st = app.session.active().unwrap();
|
||
let sel = st.doc.selection.as_ref().unwrap();
|
||
let segs = crate::outline::outline(sel, sel.content_bounds());
|
||
let ctx = egui::Context::default();
|
||
let mut drawn = false;
|
||
let mut output = ctx.run_ui(Default::default(), |ui| {
|
||
let painter = ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Foreground, egui::Id::new("ants-test")));
|
||
drawn = draw_selection(app, &painter, xf, st.doc.id, &segs, 0.0);
|
||
});
|
||
output.textures_delta.clear();
|
||
// The ants are a mesh of straight dashes and line shapes for the slanted ones.
|
||
let ants = output.shapes.iter().filter(|s| matches!(s.shape, egui::Shape::Mesh(_) | egui::Shape::LineSegment { .. }));
|
||
drawn.then(|| ants.map(|s| s.shape.visual_bounding_rect()).fold(egui::Rect::NOTHING, egui::Rect::union))
|
||
}
|
||
|
||
/// The screen rectangle around document points.
|
||
fn screen_bounds(xf: &ViewXform, points: &[[f64; 2]]) -> egui::Rect {
|
||
egui::Rect::from_points(&points.iter().map(|p| xf.to_screen(p[0] as f32, p[1] as f32)).collect::<Vec<_>>())
|
||
}
|
||
|
||
fn rects_match(a: egui::Rect, b: egui::Rect) -> bool {
|
||
(a.min - b.min).abs().max_elem() <= 2.0 && (a.max - b.max).abs().max_elem() <= 2.0
|
||
}
|
||
|
||
/// #5297: with a selection, Free Transform's box is the selection's own rectangle (wider here
|
||
/// than the pixels it holds), and the marching ants stay on the box through a move, a scale, a
|
||
/// rotation and a distort, before anything is applied. Applying leaves them there.
|
||
#[test]
|
||
fn the_box_is_the_selection_and_the_ants_follow_it() {
|
||
let ctx = gpu_ctx();
|
||
let mut app = app_with_square(64, photocraft_geom::Rect::new(8, 8, 24, 24));
|
||
let xf = ViewXform {
|
||
rect: egui::Rect::from_min_size(egui::Pos2::ZERO, vec2(400.0, 400.0)),
|
||
zoom: 2.0,
|
||
center: [32.0, 32.0],
|
||
flip: false,
|
||
rotation: 0.0,
|
||
aspect: 1.0,
|
||
};
|
||
app.run("select.rect", json!({"x": 4, "y": 4, "width": 30, "height": 26})).unwrap();
|
||
assert_eq!(ants_bounds(&app, &xf), None, "no box open: the canvas draws them where they are");
|
||
begin(&mut app, &ctx).unwrap();
|
||
let rest = corners([4.0, 4.0, 34.0, 30.0]);
|
||
assert_eq!(app.ui.transform.as_ref().unwrap().quad, rest, "the box is the selection, not the 16 px square in it");
|
||
assert!(rects_match(ants_bounds(&app, &xf).unwrap(), screen_bounds(&xf, &rest)), "untouched, the ants are where they were");
|
||
|
||
let on_the_box = |app: &PhotocraftApp, what: &str| {
|
||
let quad = app.ui.transform.as_ref().unwrap().quad;
|
||
let (ants, boxed) = (ants_bounds(app, &xf).unwrap(), screen_bounds(&xf, &quad));
|
||
assert!(rects_match(ants, boxed), "{what}: ants {ants:?}, box {boxed:?}");
|
||
};
|
||
if let Some(t) = app.ui.transform.as_mut() {
|
||
t.quad = t.quad.map(|[x, y]| [x + 20.0, y + 10.0]);
|
||
t.pivot = [t.pivot[0] + 20.0, t.pivot[1] + 10.0];
|
||
}
|
||
on_the_box(&app, "moved");
|
||
let moved = app.ui.transform.as_ref().unwrap().quad;
|
||
scale_about_pivot(&mut app, 0.5, 1.5);
|
||
on_the_box(&app, "scaled");
|
||
rotate_about_pivot(&mut app, 0.6);
|
||
on_the_box(&app, "rotated");
|
||
if let Some(t) = app.ui.transform.as_mut() {
|
||
t.quad[2] = [t.quad[2][0] + 9.0, t.quad[2][1] + 13.0];
|
||
}
|
||
on_the_box(&app, "distorted");
|
||
|
||
// Applied as moved only: the outline is where the ants showed it.
|
||
if let Some(t) = app.ui.transform.as_mut() {
|
||
t.quad = moved;
|
||
}
|
||
commit(&mut app);
|
||
assert!(app.ui.transform.is_none());
|
||
let st = app.session.active().unwrap();
|
||
assert_eq!(st.doc.selection.as_ref().unwrap().content_bounds(), photocraft_geom::Rect::new(24, 14, 54, 40));
|
||
assert_eq!(st.doc.layer(st.active_layer.unwrap()).unwrap().surface().unwrap().content_bounds(), photocraft_geom::Rect::new(28, 18, 44, 34));
|
||
}
|
||
|
||
/// #5297: in Warp mode the ants bend with the mesh, and past a Distort's horizon they stop
|
||
/// rather than run off to infinity.
|
||
#[test]
|
||
fn the_ants_follow_a_warp_and_stop_at_a_distorts_horizon() {
|
||
let ctx = gpu_ctx();
|
||
let mut app = app_with_square(64, photocraft_geom::Rect::new(8, 8, 56, 56));
|
||
let xf = ViewXform {
|
||
rect: egui::Rect::from_min_size(egui::Pos2::ZERO, vec2(400.0, 400.0)),
|
||
zoom: 2.0,
|
||
center: [32.0, 32.0],
|
||
flip: false,
|
||
rotation: 0.0,
|
||
aspect: 1.0,
|
||
};
|
||
app.run("select.rect", json!({"x": 12, "y": 12, "width": 40, "height": 40})).unwrap();
|
||
begin(&mut app, &ctx).unwrap();
|
||
let rest = corners([12.0, 12.0, 52.0, 52.0]);
|
||
|
||
// Two corners dragged across each other put half the box past the horizon.
|
||
if let Some(t) = app.ui.transform.as_mut() {
|
||
t.quad = [[12.0, 12.0], [52.0, 12.0], [20.0, 40.0], [44.0, 40.0]];
|
||
}
|
||
let ants = ants_bounds(&app, &xf).unwrap();
|
||
assert!(ants == egui::Rect::NOTHING || (ants.is_finite() && ants.width() < 1e5 && ants.height() < 1e5), "{ants:?}");
|
||
|
||
if let Some(t) = app.ui.transform.as_mut() {
|
||
t.quad = rest;
|
||
}
|
||
enter_warp(&mut app, &ctx);
|
||
let arc = Warp::preset(WarpStyle::Arc, 50.0, [12.0, 12.0, 52.0, 52.0]);
|
||
app.ui.transform.as_mut().unwrap().warp = Some(arc.clone());
|
||
let ants = ants_bounds(&app, &xf).unwrap();
|
||
assert!(!rects_match(ants, screen_bounds(&xf, &rest)), "the outline left its place: {ants:?}");
|
||
let warped: Vec<[f64; 2]> = rest.iter().map(|p| arc.map(p[0], p[1])).map(|(x, y)| [x, y]).collect();
|
||
assert!(ants.expand(2.0).contains_rect(screen_bounds(&xf, &warped)), "the warped corners are on it: {ants:?}");
|
||
}
|
||
|
||
/// Transform Selection moves the selection outline, not a layer: selecting a layer keeps it open.
|
||
#[test]
|
||
fn selecting_another_layer_keeps_a_transform_selection_open() {
|
||
let ctx = egui::Context::default();
|
||
let mut app = app_with_square(64, photocraft_geom::Rect::new(8, 8, 24, 24));
|
||
let first = app.session.active().unwrap().active_layer.unwrap();
|
||
app.run("layer.new.layer", json!({})).unwrap();
|
||
app.run("select.all", json!({})).unwrap();
|
||
begin_selection(&mut app, &ctx).unwrap();
|
||
app.run("layer.select", json!({"layer": first.0})).unwrap();
|
||
end_if_left(&mut app);
|
||
assert!(app.ui.transform.as_ref().is_some_and(|t| t.selection));
|
||
}
|
||
|
||
/// A control-channel stroke that picks another tool applies the box first, then paints.
|
||
#[test]
|
||
fn a_pointer_request_with_another_tool_applies_the_transform_first() {
|
||
let ctx = egui::Context::default();
|
||
let mut app = app_with_square(64, photocraft_geom::Rect::new(8, 8, 24, 24));
|
||
app.ui.tool = Tool::Move;
|
||
crate::menus::invoke(&mut app, &ctx, "edit.freeTransform", json!({})).unwrap();
|
||
if let Some(t) = app.ui.transform.as_mut() {
|
||
t.quad = t.quad.map(|[x, y]| [x + 20.0, y]);
|
||
}
|
||
let steps = app.session.active().unwrap().history.past_len();
|
||
let ev = |kind: &str, x: f64| json!({"kind": kind, "x": x, "y": 50.0});
|
||
let events = json!([ev("down", 4.0), ev("move", 32.0), ev("move", 60.0), ev("up", 60.0)]);
|
||
let (req, _rx) = crate::control::ControlRequest::new("ui.pointer", json!({"tool": "brush", "events": events}));
|
||
let _ = crate::control::handle(&mut app, &ctx, &req);
|
||
assert!(app.ui.transform.is_none());
|
||
let st = app.session.active().unwrap();
|
||
assert_eq!(st.history.past_len(), steps + 2, "the transform, then the stroke");
|
||
let b = st.doc.layer(st.active_layer.unwrap()).unwrap().surface().unwrap().content_bounds();
|
||
assert!(b.x0 < 8 && b.x1 >= 44 && b.y0 == 8 && b.y1 > 50, "moved square and stroke: {b:?}");
|
||
}
|
||
|
||
/// Drags with real pointer events (snapping off so the drops land exactly).
|
||
fn press_drag(app: &mut PhotocraftApp, from: [f64; 2], to: [f64; 2], m: egui::Modifiers) {
|
||
crate::canvas::tool_event(app, ToolEvent::Down { x: from[0], y: from[1], pressure: 1.0 }, m);
|
||
crate::canvas::tool_event(app, ToolEvent::Up { x: to[0], y: to[1] }, m);
|
||
}
|
||
|
||
#[test]
|
||
fn distort_mode_moves_one_corner_and_ignores_rotate_and_edges() {
|
||
let mut app = app_with_square(64, photocraft_geom::Rect::new(8, 8, 24, 24));
|
||
app.ui.extras.snap = false;
|
||
let ctx = egui::Context::default();
|
||
crate::menus::invoke(&mut app, &ctx, "edit.transform.distort", json!({})).unwrap();
|
||
let q0 = app.ui.transform.as_ref().unwrap().quad;
|
||
assert_eq!(app.ui.transform.as_ref().unwrap().mode, TransformMode::Distort);
|
||
// Outside (rotate) and an edge handle do nothing in Distort.
|
||
press_drag(&mut app, [44.0, 44.0], [54.0, 10.0], egui::Modifiers::NONE);
|
||
press_drag(&mut app, [24.0, 16.0], [34.0, 16.0], egui::Modifiers::NONE);
|
||
assert_eq!(app.ui.transform.as_ref().unwrap().quad, q0);
|
||
// A corner moves alone, with no keys held.
|
||
press_drag(&mut app, q0[1], [q0[1][0] + 7.0, q0[1][1] - 5.0], egui::Modifiers::NONE);
|
||
let q = app.ui.transform.as_ref().unwrap().quad;
|
||
assert_eq!(q[1], [q0[1][0] + 7.0, q0[1][1] - 5.0]);
|
||
assert_eq!([q[0], q[2], q[3]], [q0[0], q0[2], q0[3]]);
|
||
// Free Transform from the menu switches the live box back.
|
||
crate::menus::invoke(&mut app, &ctx, "edit.freeTransform", json!({})).unwrap();
|
||
assert_eq!(app.ui.transform.as_ref().unwrap().mode, TransformMode::Free);
|
||
}
|
||
|
||
/// #3941: Edit › Transform › Rotate must rotate from a corner handle, not scale.
|
||
#[test]
|
||
fn rotate_mode_turns_the_box_from_a_corner_handle() {
|
||
let mut app = app_with_square(64, photocraft_geom::Rect::new(8, 8, 24, 24));
|
||
app.ui.extras.snap = false;
|
||
let ctx = egui::Context::default();
|
||
crate::menus::invoke(&mut app, &ctx, "edit.transform.rotate", json!({})).unwrap();
|
||
assert_eq!(app.ui.transform.as_ref().unwrap().mode, TransformMode::Rotate);
|
||
let q0 = app.ui.transform.as_ref().unwrap().quad;
|
||
press_drag(&mut app, q0[1], [q0[1][0], q0[1][1] + 16.0], egui::Modifiers::NONE);
|
||
let q = app.ui.transform.as_ref().unwrap().quad;
|
||
let w0 = (q0[1][0] - q0[0][0]).hypot(q0[1][1] - q0[0][1]);
|
||
let w = (q[1][0] - q[0][0]).hypot(q[1][1] - q[0][1]);
|
||
assert!((w - w0).abs() < 1e-6, "side length stays {w0}, got {w}: {q:?}");
|
||
assert!((q[0][1] - q[1][1]).abs() > 1.0, "the box rotated, not scaled: {q:?}");
|
||
crate::menus::invoke(&mut app, &ctx, "edit.transform.scale", json!({})).unwrap();
|
||
assert_eq!(app.ui.transform.as_ref().unwrap().mode, TransformMode::Free);
|
||
}
|
||
|
||
fn near2(a: Option<[f64; 2]>, b: [f64; 2]) -> bool {
|
||
a.is_some_and(|a| (a[0] - b[0]).abs() < 1e-9 && (a[1] - b[1]).abs() < 1e-9)
|
||
}
|
||
|
||
/// #1767: just outside a corner is the rotate zone: the OS pointer hides for the drawn curved
|
||
/// arrow, which bends round that corner.
|
||
#[test]
|
||
fn the_preview_leaves_out_cells_past_the_horizon() {
|
||
// #4863: the bottom corners pulled in and one dragged far out: a concave quad whose
|
||
// source straddles the horizon. Its cells there are left out; an ordinary quad keeps all.
|
||
let past = Homography::rect_to_quad([0.0, 0.0, 1.0, 1.0], [[0.0, 0.0], [300.0, 0.0], [60.0, 60.0], [0.0, 300.0]]).unwrap();
|
||
let ahead = front_vertices(&past, 24);
|
||
assert!(ahead.iter().any(|a| *a) && ahead.iter().any(|a| !*a), "some of the grid is past the horizon");
|
||
// Three corners are on one side (drawn); the top-left one is past the horizon.
|
||
assert!(ahead[24 * 25 + 24] && ahead[24] && ahead[24 * 25], "the three corners in front are drawn");
|
||
assert!(!ahead[0], "the corner past the horizon is left out");
|
||
let plain = Homography::rect_to_quad([0.0, 0.0, 1.0, 1.0], [[0.0, 0.0], [300.0, 0.0], [280.0, 260.0], [0.0, 300.0]]).unwrap();
|
||
assert!(front_vertices(&plain, 24).iter().all(|a| *a));
|
||
}
|
||
|
||
#[test]
|
||
fn hovering_just_outside_a_corner_asks_for_the_rotate_cursor() {
|
||
let mut app = app_with_square(256, photocraft_geom::Rect::new(32, 32, 160, 160));
|
||
crate::menus::invoke(&mut app, &egui::Context::default(), "edit.freeTransform", json!({})).unwrap();
|
||
let tol = handle_tolerance(&app);
|
||
let h = std::f64::consts::FRAC_1_SQRT_2;
|
||
let (tl, br) = ([32.0 - tol * 1.5, 32.0 - tol * 1.5], [160.0 + tol * 1.5, 160.0 + tol * 1.5]);
|
||
let xf = ViewXform::active(&app).unwrap();
|
||
assert_eq!(cursor(&app, &xf, tl, false), Some(CursorIcon::None), "the arrow is drawn instead");
|
||
assert!(near2(rotate_cursor_dir(&app, tl), [h, h]), "{:?}", rotate_cursor_dir(&app, tl));
|
||
assert!(near2(rotate_cursor_dir(&app, br), [-h, -h]), "bends round the bottom-right corner");
|
||
assert_eq!(cursor(&app, &xf, [60.0, 60.0], false), Some(CursorIcon::Move));
|
||
assert_eq!(rotate_cursor_dir(&app, [60.0, 60.0]), None, "inside moves");
|
||
assert_eq!(rotate_cursor_dir(&app, [32.0, 32.0]), None, "the corner handle scales");
|
||
assert_eq!(rotate_cursor_dir(&app, [f64::NAN, 32.0]), None);
|
||
}
|
||
|
||
/// The resize arrows follow the box's turn and the view's rotation and flip: each handle
|
||
/// shows the arrow it resizes along on screen, not the one it had on the upright box.
|
||
#[test]
|
||
fn resize_cursors_turn_with_the_box_and_the_view() {
|
||
use CursorIcon::{ResizeHorizontal as H, ResizeNeSw as NeSw, ResizeNwSe as NwSe, ResizeVertical as V};
|
||
// Box turned (degrees), view rotation, flip → corner 0, corner 1, edge 0, edge 1 (corner
|
||
// and edge i + 2 sit opposite, on the same axis).
|
||
let cases = [
|
||
(0.0, 0.0, false, [NwSe, NeSw, V, H]),
|
||
(90.0, 0.0, false, [NeSw, NwSe, H, V]),
|
||
(45.0, 0.0, false, [V, H, NeSw, NwSe]),
|
||
(0.0, 0.0, true, [NeSw, NwSe, V, H]),
|
||
(0.0, 90.0, false, [NeSw, NwSe, H, V]),
|
||
];
|
||
for (deg, view, flip, want) in cases {
|
||
let mut app = app_with_square(256, photocraft_geom::Rect::new(64, 96, 192, 160));
|
||
crate::menus::invoke(&mut app, &egui::Context::default(), "edit.freeTransform", json!({})).unwrap();
|
||
let view_index = app.session.active_index().unwrap();
|
||
app.ui.views[view_index].rotation = view;
|
||
app.ui.view.flip_horizontal = flip;
|
||
let t = app.ui.transform.as_mut().unwrap();
|
||
let (s, c) = f64::to_radians(deg).sin_cos();
|
||
let o = [128.0, 128.0];
|
||
t.quad = t.quad.map(|[x, y]| [o[0] + (x - o[0]) * c - (y - o[1]) * s, o[1] + (x - o[0]) * s + (y - o[1]) * c]);
|
||
let t = app.ui.transform.clone().unwrap();
|
||
let xf = ViewXform::active(&app).unwrap();
|
||
let mid = |i: usize| [(t.quad[i][0] + t.quad[(i + 1) % 4][0]) / 2.0, (t.quad[i][1] + t.quad[(i + 1) % 4][1]) / 2.0];
|
||
let got: Vec<_> = (0..4).map(|i| cursor(&app, &xf, t.quad[i], false)).chain((0..4).map(|i| cursor(&app, &xf, mid(i), false))).collect();
|
||
let want = [want[0], want[1], want[0], want[1], want[2], want[3], want[2], want[3]].map(Some);
|
||
assert_eq!(got, want, "box {deg}°, view {view}°, flip {flip}");
|
||
}
|
||
}
|
||
|
||
/// A warp split's arrow crosses the split line as it looks on screen: it turns with a turned
|
||
/// grid (Warp entered on a rotated box) and with a rotated view.
|
||
#[test]
|
||
fn warp_split_cursors_turn_with_the_grid_and_the_view() {
|
||
use CursorIcon::{ResizeHorizontal as H, ResizeNeSw as NeSw, ResizeNwSe as NwSe, ResizeVertical as V};
|
||
let rect = [0.0, 0.0, 200.0, 100.0];
|
||
let screen = egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0));
|
||
let level = ViewXform { rect: screen, zoom: 1.0, center: [100.0, 50.0], flip: false, rotation: 0.0, aspect: 1.0 };
|
||
// Grid turned (degrees, about its centre), view rotation → Split Vertically, Split Horizontally.
|
||
for (grid, view, want) in [(0.0, 0.0, [H, V]), (0.0, 90.0, [V, H]), (90.0, 0.0, [V, H]), (45.0, 0.0, [NwSe, NeSw])] {
|
||
let (s, c) = f64::to_radians(grid).sin_cos();
|
||
let m = BezierMesh::identity(rect, 1, 1).map_points(|[x, y]| [100.0 + (x - 100.0) * c - (y - 50.0) * s, 50.0 + (x - 100.0) * s + (y - 50.0) * c]);
|
||
let xf = ViewXform { rotation: view, ..level };
|
||
let got = [SplitTool::Vertical, SplitTool::Horizontal].map(|tool| split_cursor(&m, tool, m.param_at([100.0, 50.0]), &xf));
|
||
assert_eq!(got, want, "grid turned {grid}°, view {view}°");
|
||
}
|
||
let m = BezierMesh::identity(rect, 1, 1);
|
||
assert_eq!(split_cursor(&m, SplitTool::Cross, (0.5, 0.5), &level), CursorIcon::Crosshair);
|
||
}
|
||
|
||
/// #1767: the readout beside the pointer during a drag (Photoshop's transformation values):
|
||
/// the angle while rotating, W/H while scaling, ΔX/ΔY while moving; nothing otherwise.
|
||
#[test]
|
||
fn the_live_readout_follows_the_drag() {
|
||
let mut app = app_with_square(256, photocraft_geom::Rect::new(32, 32, 160, 160));
|
||
app.ui.extras.snap = false;
|
||
crate::menus::invoke(&mut app, &egui::Context::default(), "edit.freeTransform", json!({})).unwrap();
|
||
assert_eq!(live_readout(&app), None, "not dragging");
|
||
let ev = |app: &mut PhotocraftApp, e| crate::canvas::tool_event(app, e, egui::Modifiers::NONE);
|
||
// A quarter turn about the centre (96, 96): from right of the box to below it.
|
||
ev(&mut app, ToolEvent::Down { x: 220.0, y: 96.0, pressure: 1.0 });
|
||
ev(&mut app, ToolEvent::Move { x: 96.0, y: 220.0, pressure: 1.0 });
|
||
let Some(Readout::Angle(a)) = live_readout(&app) else { panic!("{:?}", live_readout(&app)) };
|
||
assert!((a - 90.0).abs() < 1e-6, "{a}");
|
||
ev(&mut app, ToolEvent::Up { x: 96.0, y: 220.0 });
|
||
assert_eq!(live_readout(&app), None, "released");
|
||
let mut app = app_with_square(256, photocraft_geom::Rect::new(32, 32, 160, 160));
|
||
app.ui.extras.snap = false;
|
||
crate::menus::invoke(&mut app, &egui::Context::default(), "edit.freeTransform", json!({})).unwrap();
|
||
ev(&mut app, ToolEvent::Down { x: 60.0, y: 60.0, pressure: 1.0 });
|
||
ev(&mut app, ToolEvent::Move { x: 65.0, y: 57.0, pressure: 1.0 });
|
||
assert_eq!(live_readout(&app), Some(Readout::Offset([5.0, -3.0])));
|
||
ev(&mut app, ToolEvent::Up { x: 65.0, y: 57.0 });
|
||
// The right edge doubles the width (proportional by default: the height too).
|
||
ev(&mut app, ToolEvent::Down { x: 165.0, y: 93.0, pressure: 1.0 });
|
||
ev(&mut app, ToolEvent::Move { x: 293.0, y: 93.0, pressure: 1.0 });
|
||
let Some(Readout::Scale([w, h])) = live_readout(&app) else { panic!("{:?}", live_readout(&app)) };
|
||
assert!((w - 200.0).abs() < 1e-6 && (h - 200.0).abs() < 1e-6, "{w} {h}");
|
||
}
|
||
|
||
#[test]
|
||
fn a_distort_corner_stops_before_the_box_folds_over() {
|
||
// #1323: dragging a corner across the opposite diagonal stops short of it, so the box
|
||
// stays convex instead of folding into a bow tie.
|
||
let mut app = app_with_square(64, photocraft_geom::Rect::new(8, 8, 24, 24));
|
||
app.ui.extras.snap = false;
|
||
let ctx = egui::Context::default();
|
||
crate::menus::invoke(&mut app, &ctx, "edit.transform.distort", json!({})).unwrap();
|
||
let q0 = app.ui.transform.as_ref().unwrap().quad;
|
||
// Top-left far past the bottom-right corner.
|
||
press_drag(&mut app, q0[0], [60.0, 60.0], egui::Modifiers::NONE);
|
||
let q = app.ui.transform.as_ref().unwrap().quad;
|
||
let sign = turns(&q0)[0].signum();
|
||
assert!(turns(&q).iter().all(|t| t * sign > 0.0), "still convex: {q:?}");
|
||
assert_eq!([q[1], q[2], q[3]], [q0[1], q0[2], q0[3]], "the other corners stay put");
|
||
// It went as far as it could along the drag: up to the TR–BL diagonal (x + y = 32).
|
||
assert!(q[0][0] > 15.0 && q[0][0] < 16.0 && (q[0][0] - q[0][1]).abs() < 1e-6, "{q:?}");
|
||
// A drag that keeps the box convex is not held back.
|
||
press_drag(&mut app, q[0], [2.0, 4.0], egui::Modifiers::NONE);
|
||
assert_eq!(app.ui.transform.as_ref().unwrap().quad[0], [2.0, 4.0]);
|
||
}
|
||
|
||
#[test]
|
||
fn keep_convex_leaves_a_degenerate_start_alone() {
|
||
let flat = [[0.0, 0.0], [1.0, 0.0], [2.0, 0.0], [3.0, 0.0]];
|
||
let to = [[5.0, 5.0], [1.0, 0.0], [2.0, 0.0], [3.0, 0.0]];
|
||
assert_eq!(keep_convex(flat, to), to);
|
||
let nan = [[f64::NAN, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]];
|
||
let _ = keep_convex(nan, to);
|
||
}
|
||
|
||
#[test]
|
||
fn the_legacy_preference_swaps_corner_proportions_and_stretches_edges() {
|
||
let none = egui::Modifiers::NONE;
|
||
let corner = Hit::Corner(1);
|
||
let edge = Hit::Edge(1);
|
||
// Default: proportional (⇧ frees), so the drag keeps its keys.
|
||
assert!(!legacy_mods(false, corner, none).shift);
|
||
assert!(!legacy_mods(false, edge, none).shift);
|
||
// Legacy: corners free by default, ⇧ keeps proportions; edges always stretch one axis;
|
||
// ⌘ gestures are left alone.
|
||
assert!(legacy_mods(true, corner, none).shift);
|
||
assert!(!legacy_mods(true, corner, egui::Modifiers::SHIFT).shift);
|
||
assert!(legacy_mods(true, edge, none).shift);
|
||
assert!(legacy_mods(true, edge, egui::Modifiers::SHIFT).shift);
|
||
assert!(!legacy_mods(true, corner, egui::Modifiers::COMMAND).shift);
|
||
assert!(!legacy_mods(true, edge, egui::Modifiers::COMMAND).shift);
|
||
// Through the box: a plain corner drag, then a plain right-edge drag, on a 16×16 square.
|
||
for (legacy, proportional) in [(false, true), (true, false)] {
|
||
for at_edge in [false, true] {
|
||
let mut app = app_with_square(64, photocraft_geom::Rect::new(8, 8, 24, 24));
|
||
app.ui.extras.snap = false;
|
||
app.session.prefs.edit(|p| p.general.use_legacy_free_transform = legacy);
|
||
begin(&mut app, &egui::Context::default()).unwrap();
|
||
let q0 = app.ui.transform.as_ref().unwrap().quad;
|
||
let from = if at_edge { [q0[1][0], (q0[1][1] + q0[2][1]) / 2.0] } else { q0[2] };
|
||
press_drag(&mut app, from, [from[0] + 16.0, from[1]], none);
|
||
let q = app.ui.transform.as_ref().unwrap().quad;
|
||
let (w, h) = (q[2][0] - q[0][0], q[2][1] - q[0][1]);
|
||
assert_eq!((w - h).abs() < 1e-6, proportional, "legacy {legacy}, edge {at_edge}: {w} × {h}");
|
||
}
|
||
}
|
||
}
|
||
|
||
/// #4174: turning off the options bar's Maintain Aspect Ratio link frees the handles too, as in
|
||
/// Photoshop: a plain corner or edge drag stretches one way, ⇧ keeps the proportions. Linked
|
||
/// (the default) is unchanged, and so is the legacy preference.
|
||
#[test]
|
||
fn the_aspect_link_decides_whether_handle_drags_keep_proportions() {
|
||
let (none, shift) = (egui::Modifiers::NONE, egui::Modifiers::SHIFT);
|
||
for (unlinked, legacy, mods, proportional) in
|
||
[(false, false, none, true), (true, false, none, false), (true, false, shift, true), (true, true, none, false)]
|
||
{
|
||
for at_edge in [false, true] {
|
||
let mut app = app_with_square(64, photocraft_geom::Rect::new(8, 8, 24, 24));
|
||
app.ui.extras.snap = false;
|
||
app.ui.transform_unlinked = unlinked;
|
||
app.session.prefs.edit(|p| p.general.use_legacy_free_transform = legacy);
|
||
begin(&mut app, &egui::Context::default()).unwrap();
|
||
let q0 = app.ui.transform.as_ref().unwrap().quad;
|
||
let from = if at_edge { [q0[1][0], (q0[1][1] + q0[2][1]) / 2.0] } else { q0[2] };
|
||
press_drag(&mut app, from, [from[0] + 16.0, from[1]], mods);
|
||
let q = app.ui.transform.as_ref().unwrap().quad;
|
||
let (w, h) = (q[2][0] - q[0][0], q[2][1] - q[0][1]);
|
||
let case = format!("unlinked {unlinked}, legacy {legacy}, shift {}, edge {at_edge}: {w} × {h}", mods.shift);
|
||
if legacy && at_edge {
|
||
// Legacy edges always stretch their one axis.
|
||
assert!((w - 32.0).abs() < 1e-6 && (h - 16.0).abs() < 1e-6, "{case}");
|
||
} else {
|
||
assert_eq!((w - h).abs() < 1e-6, proportional, "{case}");
|
||
assert!((w - 32.0).abs() < 1e-6, "{case}");
|
||
}
|
||
}
|
||
}
|
||
// ⌘ gestures (skew, distort) are left alone.
|
||
assert!(!unlinked_mods(true, false, Hit::Corner(0), egui::Modifiers::COMMAND).shift);
|
||
assert!(!unlinked_mods(true, false, Hit::Inside, none).shift);
|
||
}
|
||
|
||
#[test]
|
||
fn right_click_while_transforming_switches_the_mode() {
|
||
let mut app = app_with_square(64, photocraft_geom::Rect::new(8, 8, 24, 24));
|
||
let ctx = egui::Context::default();
|
||
begin(&mut app, &ctx).unwrap();
|
||
assert!(crate::canvas_tool_menu::open_transform(&mut app, &ctx, [10.0, 10.0]));
|
||
let menu = app.ui.canvas_tool_menu.clone().unwrap();
|
||
let ids: Vec<&str> = crate::canvas_tool_menu::rows(&menu).iter().flatten().map(|e| e.1).collect();
|
||
assert!(ids.contains(&"edit.transform.distort") && ids.contains(&"edit.freeTransform"));
|
||
crate::canvas_tool_menu::choose(&mut app, &ctx, "edit.transform.distort");
|
||
assert_eq!(app.ui.transform.as_ref().unwrap().mode, TransformMode::Distort);
|
||
}
|
||
|
||
#[test]
|
||
fn right_click_menu_warp_row_enters_warp_mode() {
|
||
let mut app = app_with_square(64, photocraft_geom::Rect::new(8, 8, 24, 24));
|
||
let ctx = egui::Context::default();
|
||
begin(&mut app, &ctx).unwrap();
|
||
assert!(crate::canvas_tool_menu::open_transform(&mut app, &ctx, [10.0, 10.0]));
|
||
let menu = app.ui.canvas_tool_menu.clone().unwrap();
|
||
assert!(crate::canvas_tool_menu::rows(&menu).iter().flatten().any(|e| e.1 == "edit.transform.warp"));
|
||
assert!(crate::canvas_tool_menu::available(&app, &menu, "edit.transform.warp"));
|
||
// Only the current mode (Free Transform) is checked: not Scale, Rotate or Warp.
|
||
let checked: Vec<&str> =
|
||
crate::canvas_tool_menu::rows(&menu).iter().flatten().map(|e| e.1).filter(|id| crate::canvas_tool_menu::mode_checked(&app, &menu, id)).collect();
|
||
assert_eq!(checked, ["edit.freeTransform"]);
|
||
crate::canvas_tool_menu::choose(&mut app, &ctx, "edit.transform.warp");
|
||
assert!(app.ui.transform.as_ref().unwrap().warp.is_some());
|
||
// Already warping: the row is no longer offered.
|
||
assert!(!crate::canvas_tool_menu::available(&app, &menu, "edit.transform.warp"));
|
||
}
|
||
|
||
#[test]
|
||
fn right_click_menu_offers_warp_for_a_path() {
|
||
// Photoshop's Transform Path menu has Warp too (#3027).
|
||
let mut app = app_with_square(64, photocraft_geom::Rect::new(8, 8, 24, 24));
|
||
let ctx = egui::Context::default();
|
||
begin(&mut app, &ctx).unwrap();
|
||
app.ui.transform.as_mut().unwrap().path = Some(json!({}));
|
||
assert!(crate::canvas_tool_menu::open_transform(&mut app, &ctx, [10.0, 10.0]));
|
||
let menu = app.ui.canvas_tool_menu.clone().unwrap();
|
||
assert!(crate::canvas_tool_menu::available(&app, &menu, "edit.transform.warp"));
|
||
}
|
||
|
||
fn app_with_square(size: u32, fill: photocraft_geom::Rect) -> PhotocraftApp {
|
||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
|
||
app.session.execute("file.new", json!({"width": size, "height": size})).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(fill, &[1.0, 0.0, 0.0, 1.0]);
|
||
Ok(())
|
||
})
|
||
.unwrap();
|
||
app
|
||
}
|
||
|
||
/// #2439: a layer under others keeps its place in the stack while the box is open. The
|
||
/// canvas under the box shows only the layers below it; the ones over it are composited on
|
||
/// their own and drawn over the moving pixels.
|
||
#[test]
|
||
fn free_transform_keeps_the_layer_under_the_layers_over_it() {
|
||
let mut app = app_with_square(64, photocraft_geom::Rect::new(8, 8, 24, 24));
|
||
let red = app.session.active().unwrap().active_layer.unwrap();
|
||
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(16, 16, 40, 40), &[0.0, 0.0, 1.0, 1.0]);
|
||
Ok(())
|
||
})
|
||
.unwrap();
|
||
let blue = app.session.active().unwrap().active_layer.unwrap();
|
||
let bg = app.session.active().unwrap().doc.layers[0].id;
|
||
app.session.execute("layer.select", json!({"layer": red.0})).unwrap();
|
||
let ctx = gpu_ctx();
|
||
begin(&mut app, &ctx).unwrap();
|
||
let pv = app.transform_preview.as_ref().unwrap();
|
||
assert!(pv.doc.layer(bg).unwrap().visible, "the layers below show under the box");
|
||
assert!(!pv.doc.layer(blue).unwrap().visible, "the layer over it is drawn over the box instead");
|
||
let (texture, rect) = pv.above.as_ref().and_then(|a| a.shown.as_ref()).expect("something covers the layer");
|
||
assert_eq!(*rect, photocraft_geom::Rect::new(16, 16, 40, 40));
|
||
assert_eq!(texture.size(), [24, 24]);
|
||
cancel(&mut app);
|
||
|
||
// The top layer: nothing to draw over it.
|
||
app.session.execute("layer.select", json!({"layer": blue.0})).unwrap();
|
||
begin(&mut app, &ctx).unwrap();
|
||
let pv = app.transform_preview.as_ref().unwrap();
|
||
assert!(pv.above.as_ref().is_some_and(|a| a.shown.is_none()));
|
||
assert!(pv.doc.layer(red).unwrap().visible && pv.doc.layer(bg).unwrap().visible);
|
||
cancel(&mut app);
|
||
}
|
||
|
||
#[test]
|
||
fn plain_layers_from_the_top_cover_the_moving_one() {
|
||
use photocraft_color::BlendMode;
|
||
use photocraft_doc::Layer;
|
||
let px = |name: &str| Layer::raster(name, photocraft_color::PixelFormat::RGBA8);
|
||
let covers = |layers: &[Layer], id: LayerId| {
|
||
let mut out = Vec::new();
|
||
covering(layers, id, &mut out);
|
||
out
|
||
};
|
||
// In a group: the group's layers over it, then the ones over the group; never the layers
|
||
// clipped to it.
|
||
let (below, target, mut clip, inner_top, top) = (px("below"), px("target"), px("clip"), px("inner top"), px("top"));
|
||
clip.clipped = true;
|
||
let (t, c, inner, tp) = (target.id, clip.id, inner_top.id, top.id);
|
||
let mut layers = vec![below, Layer::group("group", vec![target, clip, inner_top]), top];
|
||
assert_eq!(covers(&layers, t), [tp, inner]);
|
||
assert_eq!(covers(&layers, c), [tp, inner], "a clipped layer: covered by what is over its clipping group");
|
||
|
||
// Under the box, the covering layers leave the document; over it, they alone show.
|
||
let above = Above { ctx: egui::Context::default(), name: String::new(), max_side: 64, ids: vec![tp, inner], shown: None };
|
||
let mut under = layers.clone();
|
||
above.hide_in(&mut under);
|
||
let vis = |ls: &[Layer]| [ls[0].visible, ls[1].visible, ls[2].visible];
|
||
assert_eq!(vis(&under), [true, true, false]);
|
||
let mut over = layers.clone();
|
||
show_only(&mut over, &above.ids);
|
||
assert_eq!(vis(&over), [false, true, true]);
|
||
let inside: Vec<bool> = match &over[1].content {
|
||
LayerContent::Group(g) => g.children.iter().map(|l| l.visible).collect(),
|
||
_ => Vec::new(),
|
||
};
|
||
assert_eq!(inside, [false, false, true]);
|
||
|
||
// A layer that blends with what is under it stops the search: it and everything under it
|
||
// stay in the document under the box.
|
||
layers[2].blend = BlendMode::Multiply;
|
||
assert!(covers(&layers, t).is_empty());
|
||
layers[2].blend = BlendMode::Normal;
|
||
let mut multiply = px("multiply");
|
||
multiply.blend = BlendMode::Multiply;
|
||
layers.insert(2, multiply);
|
||
assert_eq!(covers(&layers, t), [tp]);
|
||
// A group that isn't Normal or Pass Through keeps what is inside it.
|
||
layers[1].blend = BlendMode::Screen;
|
||
layers.remove(2);
|
||
assert_eq!(covers(&layers, t), [tp]);
|
||
assert_eq!(covers(&layers, LayerId::fresh()), [tp], "not in the stack: down to the first layer that isn't plain");
|
||
}
|
||
|
||
/// A context that reports a real GPU's texture limit (the default reports 2048).
|
||
fn gpu_ctx() -> egui::Context {
|
||
let ctx = egui::Context::default();
|
||
// Clear the font atlas delta: epaint panics on dropping unapplied deltas in debug builds.
|
||
let mut out = ctx.run_ui(egui::RawInput { max_texture_side: Some(16384), ..Default::default() }, |_| {});
|
||
out.textures_delta.clear();
|
||
ctx
|
||
}
|
||
|
||
#[test]
|
||
fn preview_texture_is_full_resolution() {
|
||
// #91: the preview used to be sampled down to 2048 px, so big layers looked blurry at 100%.
|
||
let mut app = app_with_square(3000, photocraft_geom::Rect::new(10, 20, 2810, 2420));
|
||
let ctx = gpu_ctx();
|
||
begin(&mut app, &ctx).unwrap();
|
||
let pv = app.transform_preview.as_ref().unwrap();
|
||
assert_eq!(pv.texture.size(), [2800, 2400], "one texel per layer pixel");
|
||
// At 100% the texture has at least as many texels as the box covers screen pixels.
|
||
let t = app.ui.transform.as_ref().unwrap();
|
||
assert!(pv.texture.size()[0] as f64 >= t.rect[2] - t.rect[0]);
|
||
}
|
||
|
||
#[test]
|
||
fn nested_group_preview_excludes_its_siblings() {
|
||
use photocraft_color::PixelFormat;
|
||
use photocraft_doc::Layer;
|
||
|
||
let mut app = app_with_square(64, photocraft_geom::Rect::new(8, 8, 24, 24));
|
||
let st = app.session.active_mut().unwrap();
|
||
let doc = Arc::make_mut(&mut st.doc);
|
||
let target_pixels = doc.layers.pop().unwrap();
|
||
let mut sibling = Layer::raster("sibling", PixelFormat::RGBA8);
|
||
sibling.surface_mut().unwrap().fill_rect(photocraft_geom::Rect::new(12, 12, 20, 20), &[0.0, 0.0, 1.0, 1.0]);
|
||
let target = Layer::group("target", vec![target_pixels]);
|
||
let target_id = target.id;
|
||
doc.layers.push(Layer::group("parent", vec![target, sibling]));
|
||
|
||
let (image, _) = preview_image(&st.doc, target_id, None, photocraft_geom::Rect::new(8, 8, 24, 24), 4096, false);
|
||
assert_eq!(
|
||
image.pixels[8 * image.size[0] + 8],
|
||
Color32::from_rgb(255, 0, 0),
|
||
"a sibling in the parent group must not be baked into the moving texture"
|
||
);
|
||
}
|
||
|
||
/// The textured meshes `draw_content` paints for the moving pixels (not the box or handles).
|
||
fn preview_meshes(app: &PhotocraftApp, xf: &ViewXform) -> Vec<egui::Mesh> {
|
||
let ctx = gpu_ctx();
|
||
let mut out = ctx.run_ui(egui::RawInput { max_texture_side: Some(16384), ..Default::default() }, |ui| {
|
||
let painter = ui.ctx().layer_painter(egui::LayerId::background());
|
||
draw_content(app, &painter, xf);
|
||
});
|
||
out.textures_delta.clear();
|
||
out.shapes
|
||
.into_iter()
|
||
.filter_map(|s| match s.shape {
|
||
egui::Shape::Mesh(m) if m.texture_id != egui::TextureId::default() => Some((*m).clone()),
|
||
_ => None,
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
#[test]
|
||
fn preview_pixels_past_the_canvas_are_hidden() {
|
||
// #2027: a box scaled past the canvas drew its pixels over the pasteboard.
|
||
let mut app = app_with_square(64, photocraft_geom::Rect::new(8, 8, 56, 56));
|
||
begin(&mut app, &gpu_ctx()).unwrap();
|
||
scale_about_pivot(&mut app, 2.0, 2.0);
|
||
let q = app.ui.transform.as_ref().unwrap().quad;
|
||
assert!(q[0][0] < -10.0 && q[2][1] > 74.0, "the box reaches past the canvas: {q:?}");
|
||
let tol = 1e-3;
|
||
for (rotation, flip) in [(0.0, false), (30.0, false), (-75.0, true)] {
|
||
let xf = ViewXform {
|
||
rect: egui::Rect::from_min_size(egui::Pos2::ZERO, vec2(400.0, 400.0)),
|
||
zoom: 2.0,
|
||
center: [32.0, 32.0],
|
||
flip,
|
||
rotation,
|
||
aspect: 1.0,
|
||
};
|
||
let meshes = preview_meshes(&app, &xf);
|
||
let verts: Vec<[f64; 2]> = meshes.iter().flat_map(|m| m.vertices.iter().map(|v| xf.to_doc(v.pos))).collect();
|
||
assert!(!verts.is_empty(), "the preview still draws (rotation {rotation})");
|
||
for d in &verts {
|
||
assert!(d[0] >= -tol && d[1] >= -tol && d[0] <= 64.0 + tol && d[1] <= 64.0 + tol, "vertex {d:?} off the canvas (rotation {rotation})");
|
||
}
|
||
// The clipped mesh still covers the whole canvas: the corners lie on it.
|
||
for corner in [[0.0, 0.0], [64.0, 0.0], [64.0, 64.0], [0.0, 64.0]] {
|
||
assert!(verts.iter().any(|d| (d[0] - corner[0]).abs() < 1e-2 && (d[1] - corner[1]).abs() < 1e-2), "corner {corner:?} (rotation {rotation})");
|
||
}
|
||
}
|
||
// Inside the canvas the mesh is drawn unchanged (24×24 grid).
|
||
cancel(&mut app);
|
||
begin(&mut app, &gpu_ctx()).unwrap();
|
||
let xf = ViewXform {
|
||
rect: egui::Rect::from_min_size(egui::Pos2::ZERO, vec2(200.0, 200.0)),
|
||
zoom: 1.0,
|
||
center: [32.0, 32.0],
|
||
flip: false,
|
||
rotation: 0.0,
|
||
aspect: 1.0,
|
||
};
|
||
let meshes = preview_meshes(&app, &xf);
|
||
assert_eq!(meshes.iter().map(|m| m.vertices.len()).sum::<usize>(), 25 * 25);
|
||
}
|
||
|
||
#[test]
|
||
fn shift_body_drag_locks_to_eight_directions_and_switches() {
|
||
let mut s = session();
|
||
let g = Gesture { hit: Hit::Inside, start: [40.0, 20.0], quad0: s.quad, pivot0: s.pivot };
|
||
apply_drag(&mut s, g, [60.0, 23.0], egui::Modifiers::SHIFT);
|
||
assert!(close(s.quad, corners([20.0, 0.0, 120.0, 50.0])), "horizontal: {:?}", s.quad);
|
||
apply_drag(&mut s, g, [60.0, 39.0], egui::Modifiers::SHIFT);
|
||
assert!((s.quad[0][0] - s.quad[0][1]).abs() < 1e-9 && s.quad[0][0] > 15.0, "diagonal: {:?}", s.quad[0]);
|
||
apply_drag(&mut s, g, [41.0, 60.0], egui::Modifiers::SHIFT);
|
||
assert!(close(s.quad, corners([0.0, 40.0, 100.0, 90.0])), "vertical: {:?}", s.quad);
|
||
}
|
||
|
||
#[test]
|
||
fn alt_click_moves_the_reference_point_and_edges_skew() {
|
||
let mut app = app_with_square(64, photocraft_geom::Rect::new(8, 8, 24, 24));
|
||
let ctx = egui::Context::default();
|
||
begin(&mut app, &ctx).unwrap();
|
||
assert!(pointer(&mut app, ToolEvent::Down { x: 40.0, y: 40.0, pressure: 1.0 }, egui::Modifiers::ALT));
|
||
pointer(&mut app, ToolEvent::Up { x: 40.0, y: 40.0 }, egui::Modifiers::ALT);
|
||
let t = app.ui.transform.as_ref().unwrap();
|
||
assert_eq!(t.pivot, [40.0, 40.0]);
|
||
assert_eq!(t.quad, corners(t.rect), "the box didn't move");
|
||
// ⌘ on an edge skews: both corners of the top edge move.
|
||
let mut s = session();
|
||
drag(&mut s, [50.0, 0.0], [70.0, 5.0], egui::Modifiers::COMMAND);
|
||
assert_eq!((s.quad[0], s.quad[1], s.quad[2]), ([20.0, 5.0], [120.0, 5.0], [100.0, 50.0]));
|
||
// ⌘⇧: only along the edge.
|
||
let mut s = session();
|
||
drag(&mut s, [50.0, 0.0], [70.0, 5.0], egui::Modifiers::COMMAND | egui::Modifiers::SHIFT);
|
||
assert_eq!((s.quad[0], s.quad[1]), ([20.0, 0.0], [120.0, 0.0]));
|
||
// ⌘⌥⇧ on a corner: perspective (the paired corner mirrors).
|
||
let mut s = session();
|
||
drag(&mut s, [100.0, 0.0], [101.0, -10.0], egui::Modifiers::COMMAND | egui::Modifiers::ALT | egui::Modifiers::SHIFT);
|
||
assert_eq!((s.quad[1], s.quad[2]), ([100.0, -10.0], [100.0, 60.0]));
|
||
}
|
||
|
||
/// `cargo test --release -p photocraft-ui-egui transform_preview_bench -- --ignored --nocapture`
|
||
#[test]
|
||
#[ignore]
|
||
fn transform_preview_bench() {
|
||
let (w, h) = (6000, 4000);
|
||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
|
||
app.session.execute("file.new", json!({"width": w, "height": h})).unwrap();
|
||
app.sync_views();
|
||
app.session.execute("layer.new.layer", json!({})).unwrap();
|
||
app.session.execute("filter.render.clouds", json!({})).unwrap();
|
||
let ctx = gpu_ctx();
|
||
let t0 = std::time::Instant::now();
|
||
begin(&mut app, &ctx).unwrap();
|
||
let begin_ms = t0.elapsed().as_secs_f64() * 1e3;
|
||
// The same with a linked layer mask applied to the texels (#205).
|
||
cancel(&mut app);
|
||
app.session.execute("layer.layerMask.revealAll", json!({})).unwrap();
|
||
app.session.execute("paint.gradient", json!({"from": [0, 0], "to": [w, 0], "colors": ["#000000", "#ffffff"], "target": "mask"})).unwrap();
|
||
let t0 = std::time::Instant::now();
|
||
begin(&mut app, &ctx).unwrap();
|
||
let masked_ms = t0.elapsed().as_secs_f64() * 1e3;
|
||
eprintln!("transform preview begin {w}x{h}: {begin_ms:.1} ms, with a linked mask {masked_ms:.1} ms");
|
||
let size = app.transform_preview.as_ref().unwrap().texture.size();
|
||
rotate_about_pivot(&mut app, 0.05);
|
||
let frame = |zoom: f32| {
|
||
let t0 = std::time::Instant::now();
|
||
let _ = ctx.run_ui(egui::RawInput { max_texture_side: Some(16384), ..Default::default() }, |ui| {
|
||
let painter = ui.ctx().layer_painter(egui::LayerId::background());
|
||
let xf = ViewXform {
|
||
rect: egui::Rect::from_min_size(egui::Pos2::ZERO, vec2(1600.0, 1000.0)),
|
||
zoom,
|
||
center: [3000.0, 2000.0],
|
||
flip: false,
|
||
rotation: 0.0,
|
||
aspect: 1.0,
|
||
};
|
||
draw_content(&app, &painter, &xf);
|
||
draw_overlay(&app, &painter, &xf);
|
||
});
|
||
t0.elapsed().as_secs_f64() * 1e3
|
||
};
|
||
let first: Vec<f64> = [1.0, 0.25, 3.0].iter().map(|z| frame(*z)).collect();
|
||
let n = 50;
|
||
let steady = (0..n).map(|i| frame([1.0, 0.25, 3.0][i % 3])).sum::<f64>() / n as f64;
|
||
eprintln!(
|
||
"transform preview {w}x{h}: texture {size:?}, begin {begin_ms:.1} ms, first frame per level (1x, 0.25x, 3x) {first:.1?} ms, steady frame {steady:.3} ms"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn transform_selection_moves_only_the_outline() {
|
||
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("select.rect", json!({"x": 8, "y": 8, "width": 16, "height": 16})).unwrap();
|
||
let px0 = app.session.active().unwrap().doc.layers[0].surface().unwrap().read_region(photocraft_geom::Rect::new(0, 0, 64, 64));
|
||
let ctx = egui::Context::default();
|
||
crate::menus::invoke(&mut app, &ctx, "select.transformSelection", json!({})).unwrap();
|
||
let t = app.ui.transform.as_ref().unwrap();
|
||
assert!(t.selection && t.rect == [8.0, 8.0, 24.0, 24.0]);
|
||
assert!(app.transform_preview.as_ref().unwrap().doc.selection.is_none(), "ants hidden while transforming");
|
||
scale_about_pivot(&mut app, 2.0, 2.0);
|
||
commit(&mut app);
|
||
let st = app.session.active().unwrap();
|
||
let b = st.doc.selection.as_ref().unwrap().content_bounds();
|
||
assert!(b.width().abs_diff(32) <= 2 && b.x0.abs_diff(0) <= 1, "{b:?}");
|
||
assert_eq!(st.doc.layers[0].surface().unwrap().read_region(photocraft_geom::Rect::new(0, 0, 64, 64)), px0);
|
||
}
|
||
|
||
#[test]
|
||
fn warp_mode_drags_anchor_with_handles_splits_and_commits() {
|
||
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();
|
||
let ctx = egui::Context::default();
|
||
begin_warp(&mut app, &ctx).unwrap();
|
||
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, 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();
|
||
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();
|
||
assert!(b.x1 >= 41 && b.y1 >= 37, "{b:?}");
|
||
assert!(app.ui.transform.is_none());
|
||
// Toggling back to the box drops the warp.
|
||
begin_warp(&mut app, &ctx).unwrap();
|
||
leave_warp(&mut app);
|
||
assert!(app.ui.transform.as_ref().unwrap().warp.is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn a_thin_layer_scaled_then_warped_commits_the_curve() {
|
||
// #5793: a 3 px column stretched wide, switched to Warp and pulled down in the middle.
|
||
// The commit came out straight: the warp's grid was one cell across the 3 source pixels.
|
||
let mut app = app_with_square(128, photocraft_geom::Rect::new(40, 20, 43, 60));
|
||
let ctx = egui::Context::default();
|
||
begin(&mut app, &ctx).unwrap();
|
||
scale_about_pivot(&mut app, 30.0, 1.0);
|
||
enter_warp(&mut app, &ctx);
|
||
warp_pointer(&mut app, ToolEvent::Down { x: 41.5, y: 40.0, pressure: 1.0 }, 2.0, egui::Modifiers::NONE);
|
||
warp_pointer(&mut app, ToolEvent::Move { x: 41.5, y: 70.0, pressure: 1.0 }, 2.0, egui::Modifiers::NONE);
|
||
warp_pointer(&mut app, ToolEvent::Up { x: 41.5, y: 70.0 }, 2.0, egui::Modifiers::NONE);
|
||
commit(&mut app);
|
||
assert!(app.ui.transform.is_none(), "{}", app.ui.status);
|
||
let st = app.session.active().unwrap();
|
||
let s = st.doc.layer(st.active_layer.unwrap()).unwrap().surface().unwrap();
|
||
let alpha = |x: i32, y: i32| {
|
||
let mut p = [0.0f32; 4];
|
||
s.read_pixel(x, y, &mut p);
|
||
p[3]
|
||
};
|
||
// The middle sags below the band's old bottom edge (y 60); the ends stay put.
|
||
assert!(alpha(41, 70) > 0.99, "middle {}", alpha(41, 70));
|
||
assert!(alpha(15, 70) < 0.01 && alpha(15, 40) > 0.99, "end {} {}", alpha(15, 70), alpha(15, 40));
|
||
}
|
||
|
||
fn warping_square() -> PhotocraftApp {
|
||
let mut app = app_with_square(64, photocraft_geom::Rect::new(8, 8, 32, 32));
|
||
begin_warp(&mut app, &egui::Context::default()).unwrap();
|
||
app
|
||
}
|
||
|
||
fn warp_mesh(app: &PhotocraftApp) -> BezierMesh {
|
||
app.ui.transform.as_ref().unwrap().warp.as_ref().unwrap().mesh.clone().unwrap()
|
||
}
|
||
|
||
#[test]
|
||
fn warp_drag_from_inside_the_grid_pulls_the_surface_under_the_pointer() {
|
||
let mut app = warping_square();
|
||
let before = warp_mesh(&app);
|
||
// (20, 20) is mid-cell, far from every anchor and handle.
|
||
warp_pointer(&mut app, ToolEvent::Down { x: 20.0, y: 20.0, pressure: 1.0 }, 2.0, egui::Modifiers::NONE);
|
||
assert!(matches!(app.transform_preview.as_ref().unwrap().warp_drag, Some(WarpDrag::Surface { .. })));
|
||
warp_pointer(&mut app, ToolEvent::Move { x: 30.0, y: 14.0, pressure: 1.0 }, 2.0, egui::Modifiers::NONE);
|
||
warp_pointer(&mut app, ToolEvent::Up { x: 30.0, y: 14.0 }, 2.0, egui::Modifiers::NONE);
|
||
let after = warp_mesh(&app);
|
||
let (s, t) = surface_at(&before, [20.0, 20.0]).unwrap();
|
||
let moved = after.eval(s, t);
|
||
assert!((moved[0] - 30.0).abs() < 1e-6 && (moved[1] - 14.0).abs() < 1e-6, "{moved:?}");
|
||
assert!(app.transform_preview.as_ref().unwrap().warp_drag.is_none());
|
||
// The picture keeps filling its frame: a pull in the middle leaves the box corners put.
|
||
assert!([0, 3, 12, 15].iter().all(|k| after.points[*k] == before.points[*k]));
|
||
assert!(after.points[5] != before.points[5], "the inside of the grid moved");
|
||
}
|
||
|
||
#[test]
|
||
fn a_handle_belongs_to_the_anchor_it_hangs_off() {
|
||
assert_eq!(handle_anchor(0, 1), (0, 0));
|
||
assert_eq!(handle_anchor(0, 2), (0, 3));
|
||
assert_eq!(handle_anchor(1, 0), (0, 0));
|
||
assert_eq!(handle_anchor(5, 3), (6, 3));
|
||
assert_eq!(handle_anchor(3, 3), (3, 3));
|
||
}
|
||
|
||
#[test]
|
||
fn reset_warp_returns_to_the_untouched_grid_and_keeps_the_session() {
|
||
let mut app = warping_square();
|
||
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: 30.0, y: 14.0 }, 2.0, egui::Modifiers::NONE);
|
||
assert!(!app.ui.transform.as_ref().unwrap().warp.as_ref().unwrap().is_identity());
|
||
reset_warp(&mut app);
|
||
let w = app.ui.transform.as_ref().unwrap().warp.as_ref().unwrap();
|
||
assert!(w.is_identity(), "back to the untouched box");
|
||
assert_eq!(w.mesh.as_ref().map(|m| (m.us.len(), m.vs.len())), Some((2, 2)));
|
||
assert!(app.transform_preview.as_ref().unwrap().warp_drag.is_none());
|
||
// Not warping: nothing to reset.
|
||
leave_warp(&mut app);
|
||
reset_warp(&mut app);
|
||
assert!(app.ui.transform.as_ref().unwrap().warp.is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn warp_press_outside_the_grid_grabs_nothing() {
|
||
let mut app = warping_square();
|
||
let before = warp_mesh(&app);
|
||
warp_pointer(&mut app, ToolEvent::Down { x: 55.0, y: 55.0, pressure: 1.0 }, 2.0, egui::Modifiers::NONE);
|
||
assert!(app.transform_preview.as_ref().unwrap().warp_drag.is_none());
|
||
warp_pointer(&mut app, ToolEvent::Up { x: 60.0, y: 60.0 }, 2.0, egui::Modifiers::NONE);
|
||
assert_eq!(warp_mesh(&app), before);
|
||
}
|
||
|
||
#[test]
|
||
fn ctrl_click_cuts_the_grid_both_ways() {
|
||
let mut app = warping_square();
|
||
let ctrl = egui::Modifiers::COMMAND;
|
||
warp_pointer(&mut app, ToolEvent::Down { x: 20.0, y: 20.0, pressure: 1.0 }, 2.0, ctrl);
|
||
warp_pointer(&mut app, ToolEvent::Up { x: 20.0, y: 20.0 }, 2.0, ctrl);
|
||
let m = warp_mesh(&app);
|
||
// A cut each way at the pointer (the default grid's rule-of-thirds guides become real
|
||
// lines too, as with the Cross split tool).
|
||
assert!(m.us.iter().any(|u| (u - 0.5).abs() < 1e-6), "vertical cut: {:?}", m.us);
|
||
assert!(m.vs.iter().any(|v| (v - 0.5).abs() < 1e-6), "horizontal cut: {:?}", m.vs);
|
||
}
|
||
|
||
#[test]
|
||
fn replacing_the_warp_mesh_cancels_a_pending_point_drag() {
|
||
let mut app = app_with_square(64, photocraft_geom::Rect::new(8, 8, 32, 32));
|
||
let ctx = egui::Context::default();
|
||
begin_warp(&mut app, &ctx).unwrap();
|
||
for _ in 0..3 {
|
||
split(&mut app, "edit.transform.splitWarpCrosswise", None).unwrap();
|
||
}
|
||
|
||
pointer(&mut app, ToolEvent::Down { x: 32.0, y: 32.0, pressure: 1.0 }, egui::Modifiers::NONE);
|
||
assert!(matches!(app.transform_preview.as_ref().unwrap().warp_drag, Some(WarpDrag::Point { .. })), "the anchor drag is armed");
|
||
|
||
split(&mut app, "edit.transform.removeWarpSplit", None).unwrap();
|
||
assert!(app.transform_preview.as_ref().unwrap().warp_drag.is_none(), "the old mesh index is discarded");
|
||
|
||
// A later control-channel move belongs to the old gesture and must be harmless.
|
||
pointer(&mut app, ToolEvent::Move { x: 40.0, y: 40.0, pressure: 1.0 }, egui::Modifiers::NONE);
|
||
}
|
||
|
||
/// A press on a preset warp that misses its points leaves the preset alone (no invisible undo
|
||
/// step); grabbing a point turns it into a custom mesh.
|
||
#[test]
|
||
fn a_press_on_a_preset_warp_converts_it_only_when_it_grabs_the_grid() {
|
||
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 t = app.ui.transform.as_mut().unwrap();
|
||
let preset = Warp::preset(WarpStyle::Arc, 50.0, t.rect);
|
||
t.warp = Some(preset.clone());
|
||
// Off the grid altogether: a miss.
|
||
warp_pointer(&mut app, ToolEvent::Down { x: 60.0, y: 60.0, pressure: 1.0 }, 2.0, egui::Modifiers::NONE);
|
||
warp_pointer(&mut app, ToolEvent::Up { x: 60.0, y: 60.0 }, 2.0, egui::Modifiers::NONE);
|
||
assert_eq!(app.ui.transform.as_ref().unwrap().warp.as_ref(), Some(&preset), "a miss changes nothing");
|
||
// Inside the grid, away from every point: the surface is grabbed, so the preset converts.
|
||
warp_pointer(&mut app, ToolEvent::Down { x: 20.0, y: 20.0, pressure: 1.0 }, 2.0, egui::Modifiers::NONE);
|
||
assert_eq!(app.ui.transform.as_ref().unwrap().warp.as_ref().unwrap().style, WarpStyle::Custom);
|
||
warp_pointer(&mut app, ToolEvent::Up { x: 20.0, y: 20.0 }, 2.0, egui::Modifiers::NONE);
|
||
app.ui.transform.as_mut().unwrap().warp = Some(preset.clone());
|
||
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, egui::Modifiers::NONE);
|
||
assert_eq!(app.ui.transform.as_ref().unwrap().warp.as_ref().unwrap().style, WarpStyle::Custom);
|
||
}
|
||
|
||
// ---- Layer masks (#205) ----
|
||
|
||
/// `app_with_square` (red over 8..24) plus a reveal-all mask hiding x < 16.
|
||
fn masked_app(linked: bool) -> PhotocraftApp {
|
||
let mut app = app_with_square(64, photocraft_geom::Rect::new(8, 8, 24, 24));
|
||
app.session.execute("layer.layerMask.revealAll", json!({})).unwrap();
|
||
app.session
|
||
.edit("mask", |doc, a| {
|
||
let m = doc.layer_mut(a.unwrap()).unwrap().mask.as_mut().unwrap();
|
||
m.surface.fill_rect(photocraft_geom::Rect::new(0, 0, 16, 64), &[0.0]);
|
||
m.linked = linked;
|
||
Ok(())
|
||
})
|
||
.unwrap();
|
||
app
|
||
}
|
||
|
||
fn texel_alpha(img: &egui::ColorImage, x: usize, y: usize) -> u8 {
|
||
img.pixels[y * img.size[0] + x].a()
|
||
}
|
||
|
||
#[test]
|
||
fn preview_applies_the_linked_mask() {
|
||
for linked in [true, false] {
|
||
let app = masked_app(linked);
|
||
let st = app.session.active().unwrap();
|
||
let id = st.active_layer.unwrap();
|
||
let b = photocraft_geom::Rect::new(8, 8, 24, 24);
|
||
let (img, _) = preview_image(&st.doc, id, None, b, 4096, false);
|
||
// Texel x = doc x − 8: x 10 is hidden by the mask, x 20 shows.
|
||
assert_eq!(texel_alpha(&img, 12, 4), 255);
|
||
assert_eq!(texel_alpha(&img, 2, 4), if linked { 0 } else { 255 }, "linked={linked}");
|
||
}
|
||
// With a selection the lifted pixels carry the mask too.
|
||
let mut app = masked_app(true);
|
||
app.session.execute("select.rect", json!({"x": 8, "y": 8, "width": 12, "height": 16})).unwrap();
|
||
let ctx = egui::Context::default();
|
||
begin(&mut app, &ctx).unwrap();
|
||
let st = app.session.active().unwrap();
|
||
let sel = st.doc.selection.clone().unwrap();
|
||
let id = st.active_layer.unwrap();
|
||
let (lifted, _) = photocraft_engine::transform_cmds::split_selected(st.doc.layer(id).unwrap().surface().unwrap(), &sel).unwrap();
|
||
let b = photocraft_geom::Rect::new(8, 8, 20, 24);
|
||
let (img, _) = preview_image(&st.doc, id, Some(&lifted), b, 4096, false);
|
||
assert_eq!(texel_alpha(&img, 2, 4), 0, "masked");
|
||
assert_eq!(texel_alpha(&img, 10, 4), 255, "revealed");
|
||
}
|
||
|
||
#[test]
|
||
fn preview_feathers_the_linked_mask() {
|
||
// A feathered pixel mask without a vector mask: the compositor softens the edge at x 16,
|
||
// so the moving texels must be soft there too, not hard until the commit (#4560).
|
||
let mut app = masked_app(true);
|
||
app.session
|
||
.edit("feather", |doc, a| {
|
||
doc.layer_mut(a.unwrap()).unwrap().mask.as_mut().unwrap().feather = 4.0;
|
||
Ok(())
|
||
})
|
||
.unwrap();
|
||
let st = app.session.active().unwrap();
|
||
let id = st.active_layer.unwrap();
|
||
let b = photocraft_geom::Rect::new(8, 8, 24, 24);
|
||
let (img, _) = preview_image(&st.doc, id, None, b, 4096, false);
|
||
// Texel x 7 = doc x 15, just inside the hidden half; texel x 8 = doc x 16, just revealed.
|
||
assert!(texel_alpha(&img, 7, 4) > 0, "the feather reaches into the hidden side");
|
||
assert!(texel_alpha(&img, 8, 4) < 255, "the feather reaches into the revealed side");
|
||
// The same values the compositor masks the committed layer with.
|
||
let m = photocraft_compose::masks::combined_mask(st.doc.layer(id).unwrap(), b).unwrap();
|
||
for x in [6usize, 7, 8, 9, 15] {
|
||
let want = (m.sample_channel(8 + x as i32, 12, 0) * 255.0).round() as i32;
|
||
assert!((i32::from(texel_alpha(&img, x, 4)) - want).abs() <= 1, "texel {x}");
|
||
}
|
||
}
|
||
|
||
/// #5239: a smart object clipped to the layer under it previewed as nothing while the box
|
||
/// was open (its flattened render hid the clipping base too).
|
||
#[test]
|
||
fn preview_of_a_clipped_smart_object_is_not_empty() {
|
||
let mut app = app_with_square(64, photocraft_geom::Rect::new(8, 8, 24, 24));
|
||
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(0, 0, 64, 64), &[0.0, 0.0, 1.0, 1.0]);
|
||
Ok(())
|
||
})
|
||
.unwrap();
|
||
app.session.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap();
|
||
app.session.execute("layer.createClippingMask", json!({})).unwrap();
|
||
let st = app.session.active().unwrap();
|
||
let id = st.active_layer.unwrap();
|
||
assert!(st.doc.layer(id).unwrap().clipped);
|
||
let (img, _) = preview_image(&st.doc, id, None, photocraft_geom::Rect::new(0, 0, 64, 64), 4096, false);
|
||
assert_eq!(texel_alpha(&img, 32, 32), 255, "the moving object shows");
|
||
}
|
||
|
||
#[test]
|
||
fn targeted_unlinked_mask_transforms_alone_through_the_box() {
|
||
let mut app = masked_app(false);
|
||
app.ui.mask_target = true;
|
||
let ctx = egui::Context::default();
|
||
begin(&mut app, &ctx).unwrap();
|
||
let t = app.ui.transform.as_ref().unwrap();
|
||
assert_eq!(t.target, Some(json!("mask")));
|
||
assert_eq!(t.rect, [0.0, 0.0, 16.0, 64.0], "the box frames the mask's content");
|
||
// The preview document shows the mask vacated (revealed).
|
||
let pd = &app.transform_preview.as_ref().unwrap().doc;
|
||
assert!(pd.layer(app.session.active().unwrap().active_layer.unwrap()).unwrap().mask.as_ref().unwrap().surface.sample_channel(4, 4, 0) > 0.99);
|
||
// Move the box 20 px right and commit.
|
||
let t = app.ui.transform.as_mut().unwrap();
|
||
t.quad = t.quad.map(|[x, y]| [x + 20.0, y]);
|
||
commit(&mut app);
|
||
assert!(!app.ui.status_error, "{}", app.ui.status);
|
||
let st = app.session.active().unwrap();
|
||
let l = st.doc.layer(st.active_layer.unwrap()).unwrap();
|
||
let m = &l.mask.as_ref().unwrap().surface;
|
||
assert!(m.sample_channel(4, 30, 0) > 0.99 && m.sample_channel(30, 30, 0) < 0.01, "mask moved 20 px right");
|
||
assert_eq!(l.surface().unwrap().content_bounds(), photocraft_geom::Rect::new(8, 8, 24, 24), "pixels untouched");
|
||
// Warp mode doesn't apply to a lone mask.
|
||
app.ui.mask_target = true;
|
||
begin(&mut app, &ctx).unwrap();
|
||
enter_warp(&mut app, &egui::Context::default());
|
||
assert!(app.ui.transform.as_ref().unwrap().warp.is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn linked_mask_target_transforms_the_layer() {
|
||
let mut app = masked_app(true);
|
||
app.ui.mask_target = true;
|
||
let ctx = egui::Context::default();
|
||
begin(&mut app, &ctx).unwrap();
|
||
assert_eq!(app.ui.transform.as_ref().unwrap().target, None);
|
||
assert_eq!(app.ui.transform.as_ref().unwrap().rect, [8.0, 8.0, 24.0, 24.0]);
|
||
}
|
||
|
||
/// ⌘T with Path Selection and a path transforms the path, not the layer's pixels, as one
|
||
/// undoable step; it works on the Background too.
|
||
#[test]
|
||
fn free_transform_moves_the_selected_path_not_pixels() {
|
||
let ctx = egui::Context::default();
|
||
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.run("path.set", json!({"name": "work", "path": {"subpaths": [{"closed": true, "knots": [[10, 10], [30, 10], [30, 20], [10, 20]]}]}})).unwrap();
|
||
app.ui.tool = Tool::PathSelection;
|
||
assert!(crate::menus::is_enabled(&app, "edit.freeTransform"), "a path is transformable on the Background");
|
||
let pixels = app.session.active().unwrap().doc.layers[0].surface().cloned();
|
||
let steps = app.session.active().unwrap().history.past_len();
|
||
crate::menus::invoke(&mut app, &ctx, "edit.freeTransform", json!({})).unwrap();
|
||
let t = app.ui.transform.as_mut().unwrap();
|
||
assert_eq!(t.rect, [10.0, 10.0, 30.0, 20.0], "the box frames the path");
|
||
assert_eq!(t.path, Some(json!({"name": "work"})));
|
||
// Twice as wide and 5 px down.
|
||
t.quad = [[10.0, 15.0], [50.0, 15.0], [50.0, 25.0], [10.0, 25.0]];
|
||
commit(&mut app);
|
||
let st = app.session.active().unwrap();
|
||
assert_eq!(st.history.past_len(), steps + 1, "one step");
|
||
let wp = st.doc.work_path.as_ref().unwrap();
|
||
let anchors: Vec<[f64; 2]> = wp.subpaths[0].knots.iter().map(|k| [k.anchor.x, k.anchor.y]).collect();
|
||
assert_eq!(anchors, vec![[10.0, 15.0], [50.0, 15.0], [50.0, 25.0], [10.0, 25.0]]);
|
||
assert_eq!(st.doc.layers[0].surface().cloned(), pixels, "the pixels stay put");
|
||
|
||
// ⌘-drag of a corner (Distort) maps the path onto the box (#3027): one step.
|
||
crate::menus::invoke(&mut app, &ctx, "edit.freeTransform", json!({})).unwrap();
|
||
app.ui.transform.as_mut().unwrap().quad[2] = [70.0, 40.0];
|
||
commit(&mut app);
|
||
let st = app.session.active().unwrap();
|
||
assert_eq!(st.history.past_len(), steps + 2, "one step: {}", app.ui.status);
|
||
let anchors: Vec<[f64; 2]> = st.doc.work_path.as_ref().unwrap().subpaths[0].knots.iter().map(|k| [k.anchor.x, k.anchor.y]).collect();
|
||
let want = [[10.0, 15.0], [50.0, 15.0], [70.0, 40.0], [10.0, 25.0]];
|
||
assert!(anchors.iter().zip(want).all(|(a, b)| (a[0] - b[0]).abs() < 1e-9 && (a[1] - b[1]).abs() < 1e-9), "{anchors:?}");
|
||
|
||
// Edit › Transform Path › Warp bends it: one more step.
|
||
crate::menus::invoke(&mut app, &ctx, "edit.transform.warp", json!({})).unwrap();
|
||
let t = app.ui.transform.as_mut().unwrap();
|
||
assert!(t.warp.is_some(), "Warp mode on a path");
|
||
t.warp = Some(Warp { style: WarpStyle::Arc, bend: 50.0, ..Warp::none(t.rect) });
|
||
commit(&mut app);
|
||
let st = app.session.active().unwrap();
|
||
assert_eq!(st.history.past_len(), steps + 3, "one step: {}", app.ui.status);
|
||
assert!(st.doc.work_path.as_ref().unwrap().subpaths[0].knots.len() > 4, "the warp bends the edges");
|
||
|
||
// Without a path tool, ⌘T is the pixel transform again.
|
||
app.ui.tool = Tool::Move;
|
||
app.run("layer.new.layer", json!({})).unwrap();
|
||
assert!(crate::vector_ui::free_transform_path(&app).is_none());
|
||
}
|
||
|
||
/// Edit › Transform › Warp on editable type (#3004), as in Photoshop: no rasterize prompt, no
|
||
/// mesh to drag, only the warp styles; the commit is the type's own Warp Text, one step, and
|
||
/// the layer stays type.
|
||
#[test]
|
||
fn warp_on_type_takes_a_style_and_keeps_the_type_editable() {
|
||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
|
||
app.session.execute("file.new", json!({"width": 200, "height": 100})).unwrap();
|
||
app.sync_views();
|
||
app.run("type.create", json!({"x": 10, "y": 60, "text": "Hello"})).unwrap();
|
||
let ctx = egui::Context::default();
|
||
let past = |app: &PhotocraftApp| app.session.active().unwrap().history.past_len();
|
||
let before = past(&app);
|
||
crate::menus::invoke(&mut app, &ctx, "edit.transform.warp", json!({})).unwrap();
|
||
assert!(app.ui.dialogs.is_empty(), "no prompt");
|
||
let w0 = app.ui.transform.as_ref().and_then(|t| t.warp.clone()).expect("Warp mode");
|
||
assert_eq!(w0.style, WarpStyle::None);
|
||
// A drag on the grid changes nothing: type takes only the styles.
|
||
let c = [(w0.bounds[0] + w0.bounds[2]) / 2.0, (w0.bounds[1] + w0.bounds[3]) / 2.0];
|
||
warp_pointer(&mut app, ToolEvent::Down { x: w0.bounds[2], y: w0.bounds[3], pressure: 1.0 }, 4.0, egui::Modifiers::NONE);
|
||
warp_pointer(&mut app, ToolEvent::Up { x: w0.bounds[2] + 20.0, y: w0.bounds[3] + 15.0 }, 4.0, egui::Modifiers::NONE);
|
||
warp_pointer(&mut app, ToolEvent::Down { x: c[0], y: c[1], pressure: 1.0 }, 4.0, egui::Modifiers::NONE);
|
||
warp_pointer(&mut app, ToolEvent::Up { x: c[0], y: c[1] + 20.0 }, 4.0, egui::Modifiers::NONE);
|
||
assert_eq!(app.ui.transform.as_ref().unwrap().warp.as_ref(), Some(&w0));
|
||
assert!(split(&mut app, "edit.transform.splitWarpCrosswise", None).is_err());
|
||
// Pick Flag in the options bar, then Enter.
|
||
if let Some(w) = app.ui.transform.as_mut().and_then(|t| t.warp.as_mut()) {
|
||
*w = Warp { style: WarpStyle::Flag, bend: 40.0, ..Warp::none(w.bounds) };
|
||
}
|
||
commit(&mut app);
|
||
assert!(!app.ui.status_error, "{}", app.ui.status);
|
||
let st = app.session.active().unwrap();
|
||
let LayerContent::Text(t) = &st.doc.layer(st.active_layer.unwrap()).unwrap().content else { panic!("still type") };
|
||
let tw = t.warp.as_ref().expect("Warp Text set");
|
||
assert_eq!((tw.style.as_str(), tw.value, tw.horizontal), ("warpFlag", 40.0, true));
|
||
assert_eq!(past(&app), before + 1, "one history step");
|
||
// Warp again shows Flag; None takes it off in one more step.
|
||
begin_warp(&mut app, &ctx).unwrap();
|
||
assert_eq!(app.ui.transform.as_ref().unwrap().warp.as_ref().unwrap().style, WarpStyle::Flag);
|
||
if let Some(t) = app.ui.transform.as_mut() {
|
||
t.warp = Some(Warp::none(t.rect));
|
||
}
|
||
commit(&mut app);
|
||
let st = app.session.active().unwrap();
|
||
assert!(matches!(&st.doc.layer(st.active_layer.unwrap()).unwrap().content, LayerContent::Text(t) if t.warp.is_none()));
|
||
assert_eq!(past(&app), before + 2);
|
||
}
|
||
}
|