mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-15 03:36:48 -04:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c3ac5f672e | ||
|
|
cc418e0b08 |
No files matched your search
@@ -99,6 +99,11 @@ impl History {
|
||||
self.undo.len()
|
||||
}
|
||||
|
||||
/// Rename the current step (e.g. after folding several steps into one with [`Self::purge_last`]).
|
||||
pub fn set_current_label(&mut self, label: impl Into<String>) {
|
||||
self.current_label = label.into();
|
||||
}
|
||||
|
||||
/// Forget the most recent undo state and every redo state (Edit › Purge › Undo): the
|
||||
/// last step can no longer be undone and its pixels are released.
|
||||
pub fn purge_last(&mut self) -> bool {
|
||||
|
||||
@@ -1538,6 +1538,8 @@ pub fn tool_event(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers)
|
||||
}
|
||||
// Window › Modifier Keys: sticky Shift/⌘/⌥ act as held keys.
|
||||
let mods = crate::workspace_ui::sticky_mods(app, mods);
|
||||
// Move tool: ⇧ locks the axis, ⌥ duplicates (move_mods.rs).
|
||||
let ev = crate::move_mods::filter_event(app, ev, mods);
|
||||
// Ruler, Count and Note tools.
|
||||
if crate::analysis_ui::pointer(app, ev, mods) {
|
||||
return;
|
||||
@@ -1668,6 +1670,7 @@ pub fn tool_event(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers)
|
||||
app.stylus.record_point();
|
||||
}
|
||||
finish_gesture(app, d);
|
||||
crate::move_mods::finish(app);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,6 +124,11 @@ impl GpuCanvas {
|
||||
/// The canvas texture format for a document of `depth` and `size`: `Rgba16Float` for 16/32-bit
|
||||
/// documents when the adapter supports it and the document fits [`F16_BUDGET_PX`], else
|
||||
/// `Rgba8Unorm`.
|
||||
/// The device's largest 2D texture side (egui textures can use it too).
|
||||
pub fn max_texture_side(&self) -> usize {
|
||||
self.rs.device.limits().max_texture_dimension_2d as usize
|
||||
}
|
||||
|
||||
pub fn format_for(&self, depth: photocraft_doc::SampleType, size: [u32; 2]) -> wgpu::TextureFormat {
|
||||
let px = size[0] as u64 * size[1] as u64;
|
||||
let high = match self.high {
|
||||
|
||||
@@ -54,6 +54,7 @@ pub mod liquify_ui;
|
||||
pub mod menu_catalog;
|
||||
pub mod menu_nav;
|
||||
pub mod menus;
|
||||
pub mod move_mods;
|
||||
pub mod new_doc_ui;
|
||||
pub mod notices;
|
||||
pub mod outline;
|
||||
@@ -82,6 +83,7 @@ mod tab_strip;
|
||||
pub mod theme;
|
||||
mod timeline_ui;
|
||||
pub mod tone;
|
||||
pub mod transform_tex;
|
||||
pub mod transform_tool;
|
||||
pub mod type_panels_ui;
|
||||
pub mod type_tool;
|
||||
@@ -251,6 +253,8 @@ pub struct PhotocraftApp {
|
||||
pub(crate) doc_hist: Option<(DocId, u64, f64, std::sync::Arc<tone::Histograms>)>,
|
||||
/// Free Transform preview (document without the moving pixels + their texture).
|
||||
pub(crate) transform_preview: Option<transform_tool::TransformPreview>,
|
||||
/// Move-tool ⇧/⌥ drag state (move_mods).
|
||||
pub(crate) move_mods: move_mods::MoveDrag,
|
||||
/// Live Layer Style dialog preview: (key over revision + style fields, document with the style applied).
|
||||
pub(crate) style_preview: Option<(u64, Option<std::sync::Arc<Document>>)>,
|
||||
/// Liquify dialog, Puppet Warp and Perspective Warp sessions (distort_ui).
|
||||
@@ -344,6 +348,7 @@ impl PhotocraftApp {
|
||||
clip_external: false,
|
||||
clip_read_for_paste: false,
|
||||
transform_preview: None,
|
||||
move_mods: Default::default(),
|
||||
style_preview: None,
|
||||
distort: Default::default(),
|
||||
camera_raw: None,
|
||||
|
||||
@@ -0,0 +1,339 @@
|
||||
//! Move-tool modifier keys, as in Photoshop (#90):
|
||||
//!
|
||||
//! - ⇧ while dragging locks the move to the dominant axis (horizontal or vertical). The axis is
|
||||
//! re-evaluated as the drag goes on, so moving far enough the other way switches it; a small
|
||||
//! hysteresis keeps it from flickering near the diagonal. Free Transform uses the same rule with
|
||||
//! eight directions ([`constrain`]).
|
||||
//! - ⌥ held when the drag starts duplicates the selected layer(s) (groups included) once the
|
||||
//! pointer actually moves, and the drag then moves the copies. Duplicate and move land as one
|
||||
//! history step ("Duplicate + Move"). ⇧⌥ combines both.
|
||||
//! - Arrow keys nudge the selected layers 1 px (⇧: 10 px); ⌥ duplicates first. While Free
|
||||
//! Transform is active they nudge the box instead.
|
||||
//!
|
||||
//! The hooks are small and local: [`filter_event`] rewrites pointer events before the Move tool
|
||||
//! sees them and [`finish`] folds the history after the drag, so the Move drag pipeline itself is
|
||||
//! untouched.
|
||||
|
||||
use serde_json::json;
|
||||
|
||||
use crate::PhotocraftApp;
|
||||
use crate::canvas::ToolEvent;
|
||||
use crate::state::Tool;
|
||||
|
||||
/// Directions ⇧ locks a Move-tool drag to: horizontal and vertical (Photoshop's Move tool).
|
||||
pub const MOVE_DIRECTIONS: u32 = 4;
|
||||
/// Directions ⇧ locks a Free Transform body drag to: the axes and the 45° diagonals.
|
||||
pub const TRANSFORM_DIRECTIONS: u32 = 8;
|
||||
/// Extra angle (degrees) the pointer must pass the half-way line by before the locked axis
|
||||
/// switches, so it doesn't flicker when dragging near the diagonal.
|
||||
const HYSTERESIS_DEG: f64 = 8.0;
|
||||
/// ⌥-drag duplicates only once the pointer has moved this far (screen points), so an ⌥-click
|
||||
/// without a drag doesn't make a copy (Photoshop).
|
||||
const DUPLICATE_THRESHOLD: f64 = 2.0;
|
||||
/// History label of an ⌥-drag / ⌥-nudge.
|
||||
pub const DUPLICATE_MOVE_LABEL: &str = "Duplicate + Move";
|
||||
|
||||
/// Locks the drag offset `d` to the nearest of `n` evenly spaced directions (0° = right). `prev`
|
||||
/// is the previously locked offset: its direction is kept until `d` is clearly nearer another one.
|
||||
pub fn constrain(d: [f64; 2], prev: Option<[f64; 2]>, n: u32) -> [f64; 2] {
|
||||
let len = d[0].hypot(d[1]);
|
||||
if n == 0 || !len.is_finite() || len == 0.0 {
|
||||
return d;
|
||||
}
|
||||
let step = std::f64::consts::TAU / f64::from(n);
|
||||
let a = d[1].atan2(d[0]);
|
||||
let mut k = (a / step).round();
|
||||
if let Some(p) = prev.filter(|p| p[0].hypot(p[1]) > 0.0) {
|
||||
let kp = (p[1].atan2(p[0]) / step).round();
|
||||
// Angular distance from `d` to the previous direction, wrapped to [0, π].
|
||||
let off = (a - kp * step).rem_euclid(std::f64::consts::TAU);
|
||||
let off = off.min(std::f64::consts::TAU - off);
|
||||
if off < step / 2.0 + HYSTERESIS_DEG.to_radians() {
|
||||
k = kp;
|
||||
}
|
||||
}
|
||||
let (s, c) = (k * step).sin_cos();
|
||||
let t = (d[0] * c + d[1] * s).max(0.0);
|
||||
// Snap tiny float noise so axis-locked moves stay exactly on the axis.
|
||||
let clean = |v: f64| if v.abs() < 1e-9 { 0.0 } else { v };
|
||||
[clean(t * c), clean(t * s)]
|
||||
}
|
||||
|
||||
/// Per-drag state (one Move-tool gesture at a time).
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct MoveDrag {
|
||||
/// Where the drag started (document pixels); `None` when no Move drag is in progress.
|
||||
start: Option<[f64; 2]>,
|
||||
/// ⌥ was held when the drag started.
|
||||
alt: bool,
|
||||
/// The last offset after ⇧ locking (keeps the locked axis stable).
|
||||
last: Option<[f64; 2]>,
|
||||
/// History length before the ⌥-drag duplicated (set once it has).
|
||||
dup_from: Option<usize>,
|
||||
}
|
||||
|
||||
fn past_len(app: &PhotocraftApp) -> Option<usize> {
|
||||
app.session.active().map(|st| st.history.past_len())
|
||||
}
|
||||
|
||||
fn moving(app: &PhotocraftApp) -> bool {
|
||||
app.drag.as_ref().is_some_and(|d| d.tool == Tool::Move)
|
||||
}
|
||||
|
||||
/// Duplicates the selected layers (the copies become the selection), remembering the history
|
||||
/// length before so [`fold_history`] can merge the copy and the move into one step.
|
||||
fn duplicate(app: &mut PhotocraftApp) -> Option<usize> {
|
||||
let before = past_len(app)?;
|
||||
match app.run("layer.duplicate", json!({})) {
|
||||
Ok(_) => Some(before),
|
||||
Err(e) => {
|
||||
app.ui.status = e;
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Merges every history step after `from` into one "Duplicate + Move" step.
|
||||
fn fold_history(app: &mut PhotocraftApp, from: usize) {
|
||||
let Some(st) = app.session.active_mut() else { return };
|
||||
while st.history.past_len() > from + 1 {
|
||||
if !st.history.purge_last() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if st.history.past_len() == from + 1 {
|
||||
st.history.set_current_label(DUPLICATE_MOVE_LABEL);
|
||||
}
|
||||
}
|
||||
|
||||
/// Rewrites a Move-tool pointer event for the held modifiers: ⇧ locks it to an axis, and the
|
||||
/// first real movement of an ⌥-drag duplicates the layers being moved. Other tools pass through.
|
||||
pub fn filter_event(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) -> ToolEvent {
|
||||
if app.ui.tool != Tool::Move || app.ui.transform.is_some() {
|
||||
app.move_mods = MoveDrag::default();
|
||||
return ev;
|
||||
}
|
||||
match ev {
|
||||
ToolEvent::Down { x, y, .. } => {
|
||||
app.move_mods = MoveDrag { start: Some([x, y]), alt: mods.alt, last: None, dup_from: None };
|
||||
ev
|
||||
}
|
||||
ToolEvent::Move { x, y, pressure } => {
|
||||
let [x, y] = drag_to(app, [x, y], mods);
|
||||
ToolEvent::Move { x, y, pressure }
|
||||
}
|
||||
ToolEvent::Up { x, y } => {
|
||||
let [x, y] = drag_to(app, [x, y], mods);
|
||||
ToolEvent::Up { x, y }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn drag_to(app: &mut PhotocraftApp, p: [f64; 2], mods: egui::Modifiers) -> [f64; 2] {
|
||||
let Some(start) = app.move_mods.start.filter(|_| moving(app)) else { return p };
|
||||
let mut d = [p[0] - start[0], p[1] - start[1]];
|
||||
if mods.shift {
|
||||
d = constrain(d, app.move_mods.last, MOVE_DIRECTIONS);
|
||||
}
|
||||
app.move_mods.last = Some(d);
|
||||
let zoom = f64::from(app.current_zoom().max(0.01));
|
||||
if app.move_mods.alt && app.move_mods.dup_from.is_none() && d[0].hypot(d[1]) * zoom >= DUPLICATE_THRESHOLD {
|
||||
app.move_mods.dup_from = duplicate(app);
|
||||
// Only once per drag, even if duplicating failed.
|
||||
app.move_mods.alt = false;
|
||||
}
|
||||
[start[0] + d[0], start[1] + d[1]]
|
||||
}
|
||||
|
||||
/// Call after the Move tool finished its drag: an ⌥-drag's copy and move become one step.
|
||||
pub fn finish(app: &mut PhotocraftApp) {
|
||||
let st = std::mem::take(&mut app.move_mods);
|
||||
if let Some(from) = st.dup_from {
|
||||
fold_history(app, from);
|
||||
}
|
||||
}
|
||||
|
||||
/// Arrow keys with the Move tool (or while Free Transform is active): nudge 1 px, ⇧ 10 px;
|
||||
/// ⌥ duplicates the layers first. Returns true when a key was used.
|
||||
pub fn arrow_keys(app: &mut PhotocraftApp, ctx: &egui::Context) -> bool {
|
||||
if app.ui.tool != Tool::Move && app.ui.transform.is_none() {
|
||||
return false;
|
||||
}
|
||||
use egui::{Key, Modifiers};
|
||||
let keys = [(Key::ArrowLeft, -1.0, 0.0), (Key::ArrowRight, 1.0, 0.0), (Key::ArrowUp, 0.0, -1.0), (Key::ArrowDown, 0.0, 1.0)];
|
||||
for (key, ux, uy) in keys {
|
||||
for mods in [Modifiers::NONE, Modifiers::SHIFT, Modifiers::ALT, Modifiers::SHIFT | Modifiers::ALT] {
|
||||
if ctx.input_mut(|i| i.consume_key(mods, key)) {
|
||||
let k = if mods.shift { 10.0 } else { 1.0 };
|
||||
nudge(app, ux * k, uy * k, mods.alt);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Moves the selected layers (or the Free Transform box) by `(dx, dy)` pixels.
|
||||
pub fn nudge(app: &mut PhotocraftApp, dx: f64, dy: f64, duplicate_first: bool) {
|
||||
if let Some(t) = app.ui.transform.as_mut() {
|
||||
if t.warp.is_none() {
|
||||
t.quad = t.quad.map(|q| [q[0] + dx, q[1] + dy]);
|
||||
t.pivot = [t.pivot[0] + dx, t.pivot[1] + dy];
|
||||
}
|
||||
return;
|
||||
}
|
||||
if app.drag.is_some() {
|
||||
return;
|
||||
}
|
||||
let from = if duplicate_first { duplicate(app) } else { None };
|
||||
if let Err(e) = app.run("layer.translate", json!({"dx": dx, "dy": dy})) {
|
||||
app.ui.status = e;
|
||||
}
|
||||
if let Some(from) = from {
|
||||
fold_history(app, from);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn near(a: [f64; 2], b: [f64; 2]) -> bool {
|
||||
(a[0] - b[0]).abs() < 1e-9 && (a[1] - b[1]).abs() < 1e-9
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shift_picks_the_dominant_axis() {
|
||||
assert!(near(constrain([10.0, 3.0], None, 4), [10.0, 0.0]));
|
||||
assert!(near(constrain([-2.0, -9.0], None, 4), [0.0, -9.0]));
|
||||
assert!(near(constrain([0.0, 0.0], None, 4), [0.0, 0.0]));
|
||||
// Eight directions: a near-diagonal drag lands on 45°.
|
||||
let d = constrain([10.0, 9.0], None, 8);
|
||||
assert!((d[0] - d[1]).abs() < 1e-9 && d[0] > 9.0, "{d:?}");
|
||||
assert!(near(constrain([10.0, 1.0], None, 8), [10.0, 0.0]));
|
||||
// Hostile input never panics and stays finite or passes through.
|
||||
let _ = constrain([f64::NAN, 1.0], None, 4);
|
||||
let _ = constrain([f64::INFINITY, 1.0], Some([f64::NAN, 0.0]), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shift_axis_switches_once_the_pointer_is_clearly_nearer_the_other_one() {
|
||||
let h = constrain([20.0, 2.0], None, 4);
|
||||
assert!(near(h, [20.0, 0.0]));
|
||||
// Just past the diagonal: hysteresis keeps horizontal.
|
||||
let still = constrain([20.0, 21.0], Some(h), 4);
|
||||
assert!(near(still, [20.0, 0.0]), "{still:?}");
|
||||
// Far enough the other way: switches to vertical.
|
||||
let v = constrain([20.0, 40.0], Some(h), 4);
|
||||
assert!(near(v, [0.0, 40.0]), "{v:?}");
|
||||
// And back.
|
||||
assert!(near(constrain([30.0, 20.0], Some(v), 4), [30.0, 0.0]));
|
||||
}
|
||||
|
||||
fn app_with_layer() -> PhotocraftApp {
|
||||
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();
|
||||
app.ui.tool = Tool::Move;
|
||||
app.ui.tool_options.move_auto_select = false;
|
||||
// Snapping (to the document centre with a 1:1 test view) would shift the expected offsets.
|
||||
app.ui.extras.snap = false;
|
||||
app.ui.view.show.smart_guides = false;
|
||||
app
|
||||
}
|
||||
|
||||
fn bounds(app: &PhotocraftApp, id: photocraft_doc::LayerId) -> photocraft_geom::Rect {
|
||||
app.session.active().unwrap().doc.layer(id).unwrap().surface().unwrap().content_bounds()
|
||||
}
|
||||
|
||||
fn drag(app: &mut PhotocraftApp, pts: &[[f64; 2]], mods: egui::Modifiers) {
|
||||
crate::canvas::tool_event(app, ToolEvent::Down { x: pts[0][0], y: pts[0][1], pressure: 1.0 }, mods);
|
||||
for p in &pts[1..pts.len() - 1] {
|
||||
crate::canvas::tool_event(app, ToolEvent::Move { x: p[0], y: p[1], pressure: 1.0 }, mods);
|
||||
}
|
||||
let e = pts[pts.len() - 1];
|
||||
crate::canvas::tool_event(app, ToolEvent::Up { x: e[0], y: e[1] }, mods);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shift_drag_moves_along_one_axis_only() {
|
||||
let mut app = app_with_layer();
|
||||
let id = app.session.active().unwrap().active_layer.unwrap();
|
||||
drag(&mut app, &[[16.0, 16.0], [22.0, 18.0], [26.0, 19.0]], egui::Modifiers::SHIFT);
|
||||
assert_eq!(bounds(&app, id), photocraft_geom::Rect::new(18, 8, 34, 24));
|
||||
// Switching axis mid-drag: the end point decides.
|
||||
drag(&mut app, &[[16.0, 16.0], [22.0, 17.0], [18.0, 30.0], [17.0, 36.0]], egui::Modifiers::SHIFT);
|
||||
assert_eq!(bounds(&app, id), photocraft_geom::Rect::new(18, 28, 34, 44));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alt_drag_duplicates_and_moves_the_copy_in_one_step() {
|
||||
let mut app = app_with_layer();
|
||||
let orig = app.session.active().unwrap().active_layer.unwrap();
|
||||
let n0 = app.session.active().unwrap().doc.layers.len();
|
||||
let h0 = app.session.active().unwrap().history.past_len();
|
||||
drag(&mut app, &[[16.0, 16.0], [20.0, 16.0], [26.0, 21.0]], egui::Modifiers::ALT);
|
||||
let st = app.session.active().unwrap();
|
||||
assert_eq!(st.doc.layers.len(), n0 + 1, "one copy");
|
||||
let copy = st.active_layer.unwrap();
|
||||
assert_ne!(copy, orig);
|
||||
assert_eq!(bounds(&app, orig), photocraft_geom::Rect::new(8, 8, 24, 24), "original untouched");
|
||||
assert_eq!(bounds(&app, copy), photocraft_geom::Rect::new(18, 13, 34, 29));
|
||||
let st = app.session.active().unwrap();
|
||||
assert_eq!(st.history.past_len(), h0 + 1, "one undo step");
|
||||
assert_eq!(st.history.undo_label(), Some(DUPLICATE_MOVE_LABEL));
|
||||
assert!(app.session.undo());
|
||||
assert_eq!(app.session.active().unwrap().doc.layers.len(), n0);
|
||||
assert_eq!(bounds(&app, orig), photocraft_geom::Rect::new(8, 8, 24, 24));
|
||||
// ⌥-click without a drag makes no copy.
|
||||
drag(&mut app, &[[16.0, 16.0], [16.0, 16.0]], egui::Modifiers::ALT);
|
||||
assert_eq!(app.session.active().unwrap().doc.layers.len(), n0);
|
||||
// ⇧⌥: a copy moved along one axis.
|
||||
drag(&mut app, &[[16.0, 16.0], [30.0, 19.0]], egui::Modifiers::SHIFT | egui::Modifiers::ALT);
|
||||
let st = app.session.active().unwrap();
|
||||
assert_eq!(st.doc.layers.len(), n0 + 1);
|
||||
assert_eq!(bounds(&app, st.active_layer.unwrap()), photocraft_geom::Rect::new(22, 8, 38, 24));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alt_drag_duplicates_every_selected_layer() {
|
||||
let mut app = app_with_layer();
|
||||
let a = app.session.active().unwrap().active_layer.unwrap();
|
||||
app.session.execute("layer.new.layer", json!({})).unwrap();
|
||||
let b = app.session.active().unwrap().active_layer.unwrap();
|
||||
app.session.execute("layer.select", json!({"layer": a.0, "mode": "add"})).unwrap();
|
||||
let n0 = app.session.active().unwrap().doc.layers.len();
|
||||
let h0 = app.session.active().unwrap().history.past_len();
|
||||
drag(&mut app, &[[16.0, 16.0], [26.0, 16.0]], egui::Modifiers::ALT);
|
||||
let st = app.session.active().unwrap();
|
||||
assert_eq!(st.doc.layers.len(), n0 + 2);
|
||||
assert_eq!(st.history.past_len(), h0 + 1);
|
||||
assert_eq!(bounds(&app, a), photocraft_geom::Rect::new(8, 8, 24, 24));
|
||||
assert!(st.doc.layer(b).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nudge_moves_layers_or_the_transform_box() {
|
||||
let mut app = app_with_layer();
|
||||
let id = app.session.active().unwrap().active_layer.unwrap();
|
||||
nudge(&mut app, 10.0, 0.0, false);
|
||||
nudge(&mut app, 0.0, -1.0, false);
|
||||
assert_eq!(bounds(&app, id), photocraft_geom::Rect::new(18, 7, 34, 23));
|
||||
let n0 = app.session.active().unwrap().doc.layers.len();
|
||||
nudge(&mut app, 1.0, 0.0, true);
|
||||
assert_eq!(app.session.active().unwrap().doc.layers.len(), n0 + 1);
|
||||
assert_eq!(bounds(&app, id), photocraft_geom::Rect::new(18, 7, 34, 23));
|
||||
let ctx = egui::Context::default();
|
||||
crate::transform_tool::begin(&mut app, &ctx).unwrap();
|
||||
let q0 = app.ui.transform.as_ref().unwrap().quad;
|
||||
nudge(&mut app, 0.0, 10.0, false);
|
||||
assert_eq!(app.ui.transform.as_ref().unwrap().quad[0], [q0[0][0], q0[0][1] + 10.0]);
|
||||
}
|
||||
}
|
||||
@@ -200,6 +200,10 @@ pub fn handle(app: &mut PhotocraftApp, ctx: &egui::Context) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Move tool / Free Transform: arrows nudge (⇧ ×10, ⌥ duplicates first).
|
||||
if focus == Focus::None && crate::move_mods::arrow_keys(app, ctx) {
|
||||
return;
|
||||
}
|
||||
// Pen path in progress: ↩ finishes (open path), Esc cancels.
|
||||
if app.ui.pen.is_some() {
|
||||
if ctx.input_mut(|i| i.consume_key(Modifiers::NONE, Key::Enter)) {
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
//! Textures for the Free Transform live preview (#91).
|
||||
//!
|
||||
//! The moving pixels are uploaded at full resolution (up to the GPU's texture limit) and drawn on
|
||||
//! a mesh through the box's homography. What the preview samples depends on how big a source
|
||||
//! pixel ends up on screen (view zoom × transform scale):
|
||||
//!
|
||||
//! - shrinking (< ½ screen px per source px): a box-filtered level of a lazily built mip chain, so
|
||||
//! zoomed-out previews neither shimmer nor alias;
|
||||
//! - around 1:1 or enlarging: the full-resolution level, bilinear;
|
||||
//! - Nearest Neighbor interpolation, or a view zoomed in to ≥ 200% on pixels that aren't
|
||||
//! enlarged by the transform: the full-resolution level with nearest filtering, so pixels stay
|
||||
//! crisp like the canvas around them.
|
||||
//!
|
||||
//! Levels and filter variants are uploaded on first use and cached for the session.
|
||||
|
||||
use std::sync::{Arc, Mutex, PoisonError};
|
||||
|
||||
use egui::{Color32, ColorImage, TextureHandle, TextureId, TextureOptions};
|
||||
|
||||
/// One uploaded texture: its mip level, filter and the uv extent covering the source.
|
||||
struct Level {
|
||||
level: u32,
|
||||
nearest: bool,
|
||||
tex: TextureHandle,
|
||||
uv: [f32; 2],
|
||||
}
|
||||
|
||||
pub struct PreviewTextures {
|
||||
name: String,
|
||||
/// CPU copies of the levels built so far (level 0 = full resolution), premultiplied.
|
||||
images: Mutex<Vec<Arc<ColorImage>>>,
|
||||
/// uv extent of level 0 that covers the source exactly (< 1 when level 0 was padded).
|
||||
base_uv: [f32; 2],
|
||||
uploaded: Mutex<Vec<Level>>,
|
||||
}
|
||||
|
||||
/// A source pixel shown at this many screen pixels or more counts as "zoomed in" (nearest).
|
||||
const CRISP_ZOOM: f32 = 2.0;
|
||||
|
||||
impl PreviewTextures {
|
||||
/// `image` covers the source with `base_uv` of its extent; `name` keys the texture in egui.
|
||||
pub fn new(ctx: &egui::Context, name: String, image: ColorImage, base_uv: [f32; 2]) -> Self {
|
||||
let image = Arc::new(image);
|
||||
let tex = ctx.load_texture(format!("{name}-0-linear"), Arc::clone(&image), TextureOptions::LINEAR);
|
||||
let base_uv = [base_uv[0].clamp(0.0, 1.0), base_uv[1].clamp(0.0, 1.0)];
|
||||
Self { name, images: Mutex::new(vec![image]), base_uv, uploaded: Mutex::new(vec![Level { level: 0, nearest: false, tex, uv: base_uv }]) }
|
||||
}
|
||||
|
||||
/// Size of the full-resolution level in texels.
|
||||
pub fn size(&self) -> [usize; 2] {
|
||||
self.images.lock().unwrap_or_else(PoisonError::into_inner).first().map_or([0, 0], |i| i.size)
|
||||
}
|
||||
|
||||
/// The level and filter for a source pixel shown `screen_px` screen pixels wide, where the
|
||||
/// transform itself scales by `scale` and the view zoom is `zoom`.
|
||||
pub fn choose(zoom: f32, scale: f32, nearest_interpolation: bool) -> (u32, bool) {
|
||||
let s = zoom * scale;
|
||||
if !s.is_finite() || s <= 0.0 {
|
||||
return (0, false);
|
||||
}
|
||||
if s < 0.5 {
|
||||
// log2(1/s) levels down, at most 12 (4096× smaller).
|
||||
return ((1.0 / s).log2().floor().clamp(0.0, 12.0) as u32, nearest_interpolation);
|
||||
}
|
||||
let crisp = nearest_interpolation || (zoom >= CRISP_ZOOM && scale <= 1.05);
|
||||
(0, crisp)
|
||||
}
|
||||
|
||||
/// Texture and uv extent to draw with (uploading a level / filter on first use).
|
||||
pub fn pick(&self, ctx: &egui::Context, zoom: f32, scale: f32, nearest_interpolation: bool) -> (TextureId, [f32; 2]) {
|
||||
let (level, nearest) = Self::choose(zoom, scale, nearest_interpolation);
|
||||
let mut up = self.uploaded.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
if let Some(l) = up.iter().find(|l| l.level == level && l.nearest == nearest) {
|
||||
return (l.tex.id(), l.uv);
|
||||
}
|
||||
let Some((img, uv)) = self.level_image(level) else {
|
||||
// Can't build it (shouldn't happen): fall back to the first upload.
|
||||
return up.first().map_or((TextureId::default(), [1.0, 1.0]), |l| (l.tex.id(), l.uv));
|
||||
};
|
||||
let opts = if nearest { TextureOptions::NEAREST } else { TextureOptions::LINEAR };
|
||||
let tex = ctx.load_texture(format!("{}-{level}-{}", self.name, if nearest { "nearest" } else { "linear" }), img, opts);
|
||||
let id = tex.id();
|
||||
up.push(Level { level, nearest, tex, uv });
|
||||
(id, uv)
|
||||
}
|
||||
|
||||
/// Level `n` of the mip chain (built on demand by 2×2 box filtering) and its uv extent.
|
||||
fn level_image(&self, n: u32) -> Option<(Arc<ColorImage>, [f32; 2])> {
|
||||
let mut imgs = self.images.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
while imgs.len() <= n as usize {
|
||||
let prev = imgs.last()?;
|
||||
if prev.size[0] <= 1 && prev.size[1] <= 1 {
|
||||
break;
|
||||
}
|
||||
let next = Arc::new(halve(prev));
|
||||
imgs.push(next);
|
||||
}
|
||||
let k = (imgs.len() - 1).min(n as usize);
|
||||
let img = imgs.get(k)?.clone();
|
||||
let base = imgs.first()?.size;
|
||||
// Level k covers size·2^k base texels (the last row/column may be padding).
|
||||
let f = (1u64 << k) as f32;
|
||||
let uv = [self.base_uv[0] * base[0] as f32 / (img.size[0] as f32 * f).max(1.0), self.base_uv[1] * base[1] as f32 / (img.size[1] as f32 * f).max(1.0)];
|
||||
Some((img, [uv[0].min(1.0), uv[1].min(1.0)]))
|
||||
}
|
||||
}
|
||||
|
||||
/// 2×2 box filter of a premultiplied image (odd edges repeat the last row/column).
|
||||
fn halve(img: &ColorImage) -> ColorImage {
|
||||
let [w, h] = img.size;
|
||||
let (nw, nh) = (w.div_ceil(2).max(1), h.div_ceil(2).max(1));
|
||||
let mut out = vec![Color32::TRANSPARENT; nw * nh];
|
||||
let px = |x: usize, y: usize| img.pixels.get(y.min(h.saturating_sub(1)) * w + x.min(w.saturating_sub(1))).copied().unwrap_or_default();
|
||||
for (y, row) in out.chunks_mut(nw).enumerate() {
|
||||
for (x, o) in row.iter_mut().enumerate() {
|
||||
let q = [px(2 * x, 2 * y), px(2 * x + 1, 2 * y), px(2 * x, 2 * y + 1), px(2 * x + 1, 2 * y + 1)];
|
||||
let avg = |c: fn(&Color32) -> u8| ((q.iter().map(|p| u32::from(c(p))).sum::<u32>() + 2) / 4) as u8;
|
||||
*o = Color32::from_rgba_premultiplied(avg(Color32::r), avg(Color32::g), avg(Color32::b), avg(Color32::a));
|
||||
}
|
||||
}
|
||||
ColorImage::new([nw, nh], out)
|
||||
}
|
||||
|
||||
/// Reads `b` of `surf` as premultiplied texels, at full resolution when it fits in `max_side`
|
||||
/// (else box-filtered down by the smallest integer factor that fits). Returns the image and the
|
||||
/// uv extent of it that covers `b`.
|
||||
pub fn read_surface(surf: &photocraft_raster::Surface, b: photocraft_geom::Rect, max_side: usize) -> (ColorImage, [f32; 2]) {
|
||||
let (w, h) = (b.width() as usize, b.height() as usize);
|
||||
if w == 0 || h == 0 {
|
||||
return (ColorImage::new([1, 1], vec![Color32::TRANSPARENT]), [1.0, 1.0]);
|
||||
}
|
||||
let k = w.max(h).div_ceil(max_side.max(1)).max(1);
|
||||
let (tw, th) = (w.div_ceil(k), h.div_ceil(k));
|
||||
let mut px = vec![Color32::TRANSPARENT; tw * th];
|
||||
// One band of `k` source rows per output row, in parallel on native targets.
|
||||
let band = |ty: usize, out: &mut [Color32]| {
|
||||
let y0 = b.y0 + (ty * k) as i32;
|
||||
let y1 = (y0 + k as i32).min(b.y1);
|
||||
let rows = (y1 - y0).max(0) as usize;
|
||||
let mut buf = vec![[0u8; 4]; w * rows];
|
||||
surf.read_rgba8_into(photocraft_geom::Rect::new(b.x0, y0, b.x1, y1), &mut buf);
|
||||
if k == 1 {
|
||||
for (o, p) in out.iter_mut().zip(&buf) {
|
||||
*o = if p[3] == 255 { Color32::from_rgb(p[0], p[1], p[2]) } else { Color32::from_rgba_unmultiplied(p[0], p[1], p[2], p[3]) };
|
||||
}
|
||||
return;
|
||||
}
|
||||
for (tx, o) in out.iter_mut().enumerate() {
|
||||
let (x0, x1) = (tx * k, ((tx + 1) * k).min(w));
|
||||
let mut acc = [0u32; 4];
|
||||
let mut n = 0u32;
|
||||
for r in 0..rows {
|
||||
for p in buf.get(r * w + x0..r * w + x1).unwrap_or(&[]) {
|
||||
let c = Color32::from_rgba_unmultiplied(p[0], p[1], p[2], p[3]);
|
||||
for (a, v) in acc.iter_mut().zip(c.to_array()) {
|
||||
*a += u32::from(v);
|
||||
}
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
let n = n.max(1);
|
||||
let v = acc.map(|a| ((a + n / 2) / n) as u8);
|
||||
*o = Color32::from_rgba_premultiplied(v[0], v[1], v[2], v[3]);
|
||||
}
|
||||
};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
use rayon::prelude::*;
|
||||
px.par_chunks_mut(tw).enumerate().for_each(|(ty, out)| band(ty, out));
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
for (ty, out) in px.chunks_mut(tw).enumerate() {
|
||||
band(ty, out);
|
||||
}
|
||||
let uv = [w as f32 / (tw * k) as f32, h as f32 / (th * k) as f32];
|
||||
(ColorImage::new([tw, th], px), uv)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn level_and_filter_follow_the_on_screen_size() {
|
||||
assert_eq!(PreviewTextures::choose(1.0, 1.0, false), (0, false));
|
||||
assert_eq!(PreviewTextures::choose(3.0, 1.0, false), (0, true), "zoomed in: crisp texels");
|
||||
assert_eq!(PreviewTextures::choose(3.0, 2.0, false), (0, false), "enlarged by the transform: smooth");
|
||||
assert_eq!(PreviewTextures::choose(1.0, 1.0, true), (0, true));
|
||||
assert_eq!(PreviewTextures::choose(0.25, 1.0, false), (2, false));
|
||||
assert_eq!(PreviewTextures::choose(0.3, 1.0, false), (1, false));
|
||||
assert_eq!(PreviewTextures::choose(f32::NAN, 1.0, false), (0, false));
|
||||
assert_eq!(PreviewTextures::choose(1e-9, 1.0, false).0, 12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_resolution_unless_over_the_texture_limit() {
|
||||
let mut s = photocraft_raster::Surface::new(photocraft_color::PixelFormat::RGBA8);
|
||||
// A one-pixel checkerboard: any downsampling would turn it grey.
|
||||
for y in 0..300 {
|
||||
for x in 0..500 {
|
||||
if (x + y) % 2 == 0 {
|
||||
s.fill_rect(photocraft_geom::Rect::new(x, y, x + 1, y + 1), &[1.0, 1.0, 1.0, 1.0]);
|
||||
}
|
||||
}
|
||||
}
|
||||
let r = photocraft_geom::Rect::new(0, 0, 500, 300);
|
||||
let (img, uv) = read_surface(&s, r, 8192);
|
||||
assert_eq!(img.size, [500, 300]);
|
||||
assert_eq!(uv, [1.0, 1.0]);
|
||||
assert_eq!(img.pixels[0], Color32::WHITE);
|
||||
assert_eq!(img.pixels[1].a(), 0);
|
||||
// Over the limit: box-filtered (grey-ish average), uv covers the padded edge.
|
||||
let (small, uv) = read_surface(&s, r, 256);
|
||||
assert_eq!(small.size, [250, 150]);
|
||||
assert!((100..=155).contains(&small.pixels[0].a()), "{:?}", small.pixels[0]);
|
||||
assert_eq!(uv, [1.0, 1.0]);
|
||||
let (odd, uv) = read_surface(&s, photocraft_geom::Rect::new(0, 0, 301, 3), 256);
|
||||
assert_eq!(odd.size, [151, 2]);
|
||||
assert!(uv[0] < 1.0 && uv[1] < 1.0);
|
||||
// Empty rect: a placeholder, no panic.
|
||||
assert_eq!(read_surface(&s, photocraft_geom::Rect::new(5, 5, 5, 9), 256).0.size, [1, 1]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mip_levels_are_built_on_demand() {
|
||||
let ctx = egui::Context::default();
|
||||
let img = ColorImage::new([5, 3], vec![Color32::WHITE; 15]);
|
||||
let t = PreviewTextures::new(&ctx, "t".into(), img, [1.0, 1.0]);
|
||||
assert_eq!(t.size(), [5, 3]);
|
||||
let (_, uv) = t.pick(&ctx, 0.5, 0.5, false);
|
||||
// Level 2 is 2×1 texels covering 8×4 base texels.
|
||||
assert!((uv[0] - 5.0 / 8.0).abs() < 1e-6 && (uv[1] - 3.0 / 4.0).abs() < 1e-6, "{uv:?}");
|
||||
let (a, _) = t.pick(&ctx, 1.0, 1.0, false);
|
||||
let (b, _) = t.pick(&ctx, 4.0, 1.0, false);
|
||||
assert_ne!(a, b, "a nearest-filtered copy for zoomed-in views");
|
||||
assert_eq!(t.pick(&ctx, 4.0, 1.0, false).0, b, "cached");
|
||||
// Asking for more levels than exist stops at 1×1.
|
||||
let _ = t.pick(&ctx, 1e-6, 1.0, false);
|
||||
}
|
||||
}
|
||||
@@ -2,8 +2,10 @@
|
||||
//! engine's `edit.transform` (one quad for scale/rotate/skew/distort/perspective).
|
||||
//!
|
||||
//! Gestures follow Photoshop CC: corner drag scales proportionally (⇧ for free), edges scale one
|
||||
//! axis, ⌥ scales about the reference point, ⌘-drag a corner distorts, drag outside rotates
|
||||
//! (⇧ snaps to 15°), drag inside moves, the reference point can be dragged. ↩ commits, Esc cancels.
|
||||
//! 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.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -17,12 +19,12 @@ use crate::PhotocraftApp;
|
||||
use crate::canvas::{ToolEvent, ViewXform};
|
||||
use crate::state::TransformSession;
|
||||
|
||||
/// Preview state that isn't serialisable: the document without the transformed pixels, and a
|
||||
/// texture of those pixels.
|
||||
/// 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: egui::TextureHandle,
|
||||
pub texture: crate::transform_tex::PreviewTextures,
|
||||
pub opacity: f32,
|
||||
gesture: Option<Gesture>,
|
||||
/// Warp-mode drag: (control point, pointer start, mesh points at the start).
|
||||
@@ -61,8 +63,10 @@ pub fn begin(app: &mut PhotocraftApp, ctx: &egui::Context) -> Result<(), String>
|
||||
} else if let Some(l) = pd.layer_mut(id) {
|
||||
l.visible = false;
|
||||
}
|
||||
let image = preview_image(&doc, id, lifted.as_ref(), b);
|
||||
let texture = ctx.load_texture(format!("transform-{session}"), image, egui::TextureOptions::LINEAR);
|
||||
// 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()));
|
||||
let (image, uv) = preview_image(&doc, id, lifted.as_ref(), 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: layer.opacity * layer.fill_opacity, gesture: None, warp_drag: None });
|
||||
app.ui.transform = Some(TransformSession {
|
||||
@@ -102,7 +106,8 @@ pub fn begin_selection(app: &mut PhotocraftApp, ctx: &egui::Context) -> Result<(
|
||||
px[ty * tw + tx] = Color32::from_rgba_unmultiplied(120, 170, 255, (m * 110.0) as u8);
|
||||
}
|
||||
}
|
||||
let texture = ctx.load_texture(format!("transform-{session}"), egui::ColorImage::new([tw, th], px), egui::TextureOptions::LINEAR);
|
||||
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, gesture: None, warp_drag: None });
|
||||
@@ -162,12 +167,16 @@ pub fn leave_warp(app: &mut PhotocraftApp) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Texture of the moving pixels over `b`, at most 2048 px on the long side.
|
||||
fn preview_image(doc: &Document, id: LayerId, lifted: Option<&photocraft_raster::Surface>, b: photocraft_geom::Rect) -> egui::ColorImage {
|
||||
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];
|
||||
/// 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`.
|
||||
fn preview_image(
|
||||
doc: &Document,
|
||||
id: LayerId,
|
||||
lifted: Option<&photocraft_raster::Surface>,
|
||||
b: photocraft_geom::Rect,
|
||||
max_side: usize,
|
||||
) -> (egui::ColorImage, [f32; 2]) {
|
||||
let layer = doc.layer(id);
|
||||
// Groups and type/fill layers preview from a flattened render of just that layer.
|
||||
let rendered;
|
||||
@@ -189,17 +198,8 @@ fn preview_image(doc: &Document, id: LayerId, lifted: Option<&photocraft_raster:
|
||||
Some(&rendered)
|
||||
}
|
||||
};
|
||||
let Some(surf) = surf else { return egui::ColorImage::new([tw, th], px) };
|
||||
let mut row = vec![[0u8; 4]; w];
|
||||
for ty in 0..th {
|
||||
let y = b.y0 + (ty * k) as i32;
|
||||
surf.read_rgba8_into(photocraft_geom::Rect::new(b.x0, y, b.x1, y + 1), &mut row);
|
||||
for tx in 0..tw {
|
||||
let p = row[(tx * k).min(w - 1)];
|
||||
px[ty * tw + tx] = Color32::from_rgba_unmultiplied(p[0], p[1], p[2], p[3]);
|
||||
}
|
||||
}
|
||||
egui::ColorImage::new([tw, th], px)
|
||||
let Some(surf) = surf else { return (egui::ColorImage::new([1, 1], vec![Color32::TRANSPARENT]), [1.0, 1.0]) };
|
||||
crate::transform_tex::read_surface(surf, b, max_side)
|
||||
}
|
||||
|
||||
fn contains(l: &photocraft_doc::Layer, id: LayerId) -> bool {
|
||||
@@ -303,7 +303,18 @@ pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) ->
|
||||
}
|
||||
let Some(pv) = app.transform_preview.as_mut() else { return false };
|
||||
match ev {
|
||||
ToolEvent::Down { x, y, .. } => pv.gesture = Some(Gesture { hit: hit(&t, [x, y], tol), start: [x, y], quad0: t.quad, pivot0: t.pivot }),
|
||||
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;
|
||||
}
|
||||
pv.gesture = 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 { .. }) {
|
||||
@@ -321,6 +332,14 @@ fn apply_drag(s: &mut TransformSession, g: Gesture, p: [f64; 2], mods: egui::Mod
|
||||
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];
|
||||
}
|
||||
@@ -342,11 +361,52 @@ fn apply_drag(s: &mut TransformSession, g: Gesture, p: [f64; 2], mods: egui::Mod
|
||||
[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) };
|
||||
s.quad[i] = [g.quad0[i][0] + mx, g.quad0[i][1] + my];
|
||||
s.quad[j] = [g.quad0[j][0] - mx, g.quad0[j][1] - my];
|
||||
}
|
||||
Hit::Corner(i) if mods.command => {
|
||||
// Distort: move the corner freely.
|
||||
s.quad = g.quad0;
|
||||
s.quad[i] = [g.quad0[i][0] + dx, g.quad0[i][1] + dy];
|
||||
}
|
||||
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];
|
||||
}
|
||||
}
|
||||
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 };
|
||||
@@ -524,13 +584,14 @@ pub fn draw_overlay(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform
|
||||
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 mut mesh = egui::Mesh::with_texture(pv.texture.id());
|
||||
let (tex, uv) = pv.texture.pick(painter.ctx(), xf.zoom * painter.ctx().pixels_per_point(), 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);
|
||||
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: xf.to_screen(x as f32, y as f32), uv: pos2(u as f32, v as f32), color: tint });
|
||||
mesh.vertices.push(egui::epaint::Vertex { pos: xf.to_screen(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 {
|
||||
@@ -565,13 +626,14 @@ fn draw_warp(painter: &egui::Painter, xf: &ViewXform, t: &TransformSession, w: &
|
||||
let scr = |q: [f64; 2]| xf.to_screen(q[0] as f32, q[1] as f32);
|
||||
let r = t.rect;
|
||||
let n = 32;
|
||||
let mut mesh = egui::Mesh::with_texture(pv.texture.id());
|
||||
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 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) = w.map(r[0] + u * (r[2] - r[0]), r[1] + v * (r[3] - r[1]));
|
||||
mesh.vertices.push(egui::epaint::Vertex { pos: xf.to_screen(x as f32, y as f32), uv: pos2(u as f32, v as f32), color: tint });
|
||||
mesh.vertices.push(egui::epaint::Vertex { pos: xf.to_screen(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 {
|
||||
@@ -640,6 +702,16 @@ fn draw_warp(painter: &egui::Painter, xf: &ViewXform, t: &TransformSession, w: &
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 }
|
||||
}
|
||||
|
||||
/// Scale (%), angle (°) and translation implied by the current quad (affine readout).
|
||||
fn readout(t: &TransformSession) -> (f64, f64, f64, f64) {
|
||||
let (w0, h0) = (t.rect[2] - t.rect[0], t.rect[3] - t.rect[1]);
|
||||
@@ -914,6 +986,111 @@ mod tests {
|
||||
assert!(app.ui.transform.is_none() && app.transform_preview.is_none());
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
/// 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 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;
|
||||
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 };
|
||||
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());
|
||||
|
||||
Reference in new issue
Block a user