diff --git a/crates/algo/src/vanishing.rs b/crates/algo/src/vanishing.rs index 5661d66c9..77b7bb107 100644 --- a/crates/algo/src/vanishing.rs +++ b/crates/algo/src/vanishing.rs @@ -114,6 +114,73 @@ impl VpPlane { }); Lifted { focal: f, center: c, pts } } + + /// Turn of each corner (`sin` of the angle between consecutive edges, positive when the + /// corners run clockwise on screen); `None` when an edge is shorter than a pixel or a corner + /// is not finite. + fn turns(&self) -> Option<[f64; 4]> { + let q = &self.corners; + if q.iter().flatten().any(|v| !v.is_finite() || v.abs() > 1e9) { + return None; + } + let e = |i: usize| [q[(i + 1) % 4][0] - q[i][0], q[(i + 1) % 4][1] - q[i][1]]; + let mut out = [0.0; 4]; + for (i, t) in out.iter_mut().enumerate() { + let (a, b) = (e(i), e((i + 1) % 4)); + let (la, lb) = (a[0].hypot(a[1]), b[0].hypot(b[1])); + if la < 1.0 || lb < 1.0 { + return None; + } + *t = (a[0] * b[1] - a[1] * b[0]) / (la * lb); + } + Some(out) + } + + /// How usable the plane is, the way Photoshop colours its grid: [`PlaneCheck::Invalid`] for + /// a quad that isn't convex (self-intersecting, a corner pushed inside, a near-straight or + /// near-zero corner), [`PlaneCheck::Questionable`] for a convex quad whose perspective can't + /// come from a camera centred at `center` (or with a corner sharper than 10°), else + /// [`PlaneCheck::Valid`]. + pub fn check(&self, center: [f64; 2]) -> PlaneCheck { + let Some(t) = self.turns() else { return PlaneCheck::Invalid }; + let same_sign = t.iter().all(|v| *v > 0.0) || t.iter().all(|v| *v < 0.0); + let min = t.iter().fold(f64::MAX, |m, v| m.min(v.abs())); + if !same_sign || min < 1f64.to_radians().sin() || self.homography().is_none() { + return PlaneCheck::Invalid; + } + if min < 10f64.to_radians().sin() { + return PlaneCheck::Questionable; + } + // Both vanishing points finite but no real focal length fits them. + let q = &self.corners; + let (v1, v2) = (meet(q[0], q[1], q[3], q[2]), meet(q[0], q[3], q[1], q[2])); + let finite = |v: V3| v[2].abs() > 1e-9 * (v[0].abs() + v[1].abs()).max(1.0); + if finite(v1) && finite(v2) && self.focal(center).is_none() { + return PlaneCheck::Questionable; + } + PlaneCheck::Valid + } + + /// The same quad with its corners clockwise on screen, starting at the top-left one + /// (the order [`paste`] and [`Scene::tear_off`] expect), however they were clicked. + pub fn normalized(&self) -> VpPlane { + let mut q = self.corners; + if self.turns().is_some_and(|t| t.iter().sum::() < 0.0) { + q = [q[0], q[3], q[2], q[1]]; + } + let first = (0..4).min_by(|&a, &b| (q[a][0] + q[a][1]).total_cmp(&(q[b][0] + q[b][1]))).unwrap_or(0); + VpPlane { corners: [q[first], q[(first + 1) % 4], q[(first + 2) % 4], q[(first + 3) % 4]] } + } +} + +/// Plane validity ([`VpPlane::check`]): Photoshop draws a valid plane's grid blue, a doubtful +/// one yellow and an invalid one red. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum PlaneCheck { + Valid, + Questionable, + Invalid, } impl Lifted { @@ -363,6 +430,25 @@ mod tests { assert!((lu / lv - 2.0).abs() < 0.01, "aspect {}", lu / lv); } + #[test] + fn plane_check_and_corner_order() { + let c = [400.0, 300.0]; + assert_eq!(floor_quad(500.0, c).check(c), PlaneCheck::Valid); + let square = VpPlane { corners: [[10.0, 10.0], [110.0, 10.0], [110.0, 110.0], [10.0, 110.0]] }; + assert_eq!(square.check(c), PlaneCheck::Valid, "fronto-parallel"); + let bowtie = VpPlane { corners: [[10.0, 10.0], [110.0, 110.0], [110.0, 10.0], [10.0, 110.0]] }; + assert_eq!(bowtie.check(c), PlaneCheck::Invalid); + let dent = VpPlane { corners: [[10.0, 10.0], [110.0, 10.0], [40.0, 40.0], [10.0, 110.0]] }; + assert_eq!(dent.check(c), PlaneCheck::Invalid, "concave"); + let sliver = VpPlane { corners: [[10.0, 10.0], [300.0, 10.0], [300.0, 40.0], [200.0, 40.0]] }; + assert_eq!(sliver.check(c), PlaneCheck::Questionable, "a corner sharper than 10°"); + let nan = VpPlane { corners: [[f64::NAN, 10.0], [110.0, 10.0], [110.0, 110.0], [10.0, 110.0]] }; + assert_eq!(nan.check(c), PlaneCheck::Invalid); + // Clicked counter-clockwise from the bottom-right: clockwise from the top-left. + let ccw = VpPlane { corners: [[110.0, 110.0], [110.0, 10.0], [10.0, 10.0], [10.0, 110.0]] }; + assert_eq!(ccw.normalized(), square); + } + #[test] fn tear_off_is_perpendicular_and_shares_the_edge() { let c = [400.0, 300.0]; diff --git a/crates/engine/src/vp_cmds.rs b/crates/engine/src/vp_cmds.rs index 4d386d60d..185930298 100644 --- a/crates/engine/src/vp_cmds.rs +++ b/crates/engine/src/vp_cmds.rs @@ -3,7 +3,7 @@ //! ([`photocraft_algo::vanishing`]). The edit lands on the active pixel layer, or on a new layer //! with `"newLayer": true` (Photoshop's advice for non-destructive Vanishing Point work). -use photocraft_algo::vanishing::{Edge, Scene, VpPlane, clone_stroke, paste}; +use photocraft_algo::vanishing::{Edge, PlaneCheck, Scene, VpPlane, clone_stroke, paste}; use photocraft_doc::{Document, Layer, LayerContent, LayerId}; use photocraft_geom::Rect; use photocraft_raster::Surface; @@ -34,12 +34,22 @@ pub fn scene(p: &Value, canvas: Rect) -> Result { let a = v.get("corners")?.as_array()?; Some([pt(a.first()?)?, pt(a.get(1)?)?, pt(a.get(2)?)?, pt(a.get(3)?)?]) }; - let first = corners(&planes[0]).ok_or_else(|| bad("the first plane needs four `corners`"))?; + // A drawn quad must be convex (Photoshop's red plane): a bow-tie or dented quad has no + // perspective to paste or clone along. + let drawn = |i: usize, c: [[f64; 2]; 4]| -> Result { + let pl = VpPlane { corners: c }; + if pl.check(center) == PlaneCheck::Invalid { + return Err(bad(format!("plane {i}: the four corners must form a convex quad"))); + } + Ok(pl) + }; + let first = planes.first().and_then(corners).ok_or_else(|| bad("the first plane needs four `corners`"))?; + let first = drawn(0, first)?; let focal = p.get("focalLength").and_then(Value::as_f64).filter(|f| *f > 0.0); - let mut sc = Scene::new(VpPlane { corners: first }, center, focal, canvas.width().max(canvas.height()) as f64); + let mut sc = Scene::new(first, center, focal, canvas.width().max(canvas.height()) as f64); for (i, pl) in planes.iter().enumerate().skip(1) { if let Some(c) = corners(pl) { - sc.planes.push(VpPlane { corners: c }); + sc.planes.push(drawn(i, c)?); continue; } let from = pl @@ -257,4 +267,19 @@ mod tests { let r = s.execute(VP, json!({"planes": planes, "newLayer": true})).unwrap(); assert_ne!(r["layer"].as_u64().unwrap(), floor.0); } + + #[test] + fn rejects_planes_that_are_not_convex() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 200, "height": 200, "depth": 16})).unwrap(); + s.execute("layer.new.layer", json!({})).unwrap(); + let bowtie = json!([[20, 20], [180, 180], [180, 20], [20, 180]]); + let dent = json!([[20, 20], [180, 20], [60, 60], [20, 180]]); + let square = json!([[20, 20], [180, 20], [180, 180], [20, 180]]); + for planes in [json!([{"corners": bowtie}]), json!([{"corners": square}, {"corners": dent}])] { + let e = s.execute(VP, json!({"planes": planes})).unwrap_err().to_string(); + assert!(e.contains("convex"), "{e}"); + } + assert!(s.execute(VP, json!({"planes": [{"corners": square}]})).is_ok()); + } } diff --git a/crates/ui-egui/src/distort_ui.rs b/crates/ui-egui/src/distort_ui.rs index b3bda3a7f..0a0d7914b 100644 --- a/crates/ui-egui/src/distort_ui.rs +++ b/crates/ui-egui/src/distort_ui.rs @@ -1,10 +1,11 @@ -//! Interactive Liquify (full-window dialog), Puppet Warp and Perspective Warp (on-canvas modes). +//! Interactive Liquify (full-window dialog), Puppet Warp, Perspective Warp and Vanishing Point +//! (on-canvas modes). //! //! All three are previews over plain data (strokes, pins, planes); committing runs the engine -//! command with that data (`filter.liquify`, `edit.puppetWarp`, `edit.perspectiveWarp`, or the -//! smart-object twins), so the result is one history step and exactly replayable. This module +//! command with that data (`filter.liquify`, `edit.puppetWarp`, `edit.perspectiveWarp`, +//! `filter.vanishingPoint`, or the smart-object twins), so the result is one history step and exactly replayable. This module //! holds the shared runtime state and the hooks the shell calls (menus, pointer, keys, overlay, -//! options bar); the modes live in `liquify_ui`, `puppet_ui` and `perspective_ui`. +//! options bar); the modes live in `liquify_ui`, `puppet_ui`, `perspective_ui` and `vp_ui`. use std::sync::Arc; @@ -23,13 +24,14 @@ pub struct Distort { pub liquify: Option, pub puppet: Option, pub perspective: Option, + pub vanishing: Option, /// Filter Gallery dialog (gallery_ui). pub gallery: Option, } impl Distort { pub fn active(&self) -> bool { - self.liquify.is_some() || self.puppet.is_some() || self.perspective.is_some() + self.liquify.is_some() || self.puppet.is_some() || self.perspective.is_some() || self.vanishing.is_some() } /// Short description for `ui.inspect`. @@ -38,6 +40,7 @@ impl Distort { "liquify": self.liquify.as_ref().map(|d| d.describe()), "puppet": self.puppet.as_ref().map(|p| p.describe()), "perspective": self.perspective.as_ref().map(|p| p.describe()), + "vanishingPoint": self.vanishing.as_ref().map(|v| v.describe()), "gallery": self.gallery.as_ref().map(|g| g.describe()), }) } @@ -118,6 +121,8 @@ pub fn menu(app: &mut PhotocraftApp, ctx: &egui::Context, id: &str, params: &Val "edit.perspectiveWarp" | "layer.smartObjects.perspectiveWarp" if ui.is_some() && app.distort.perspective.is_some() => { Some(crate::perspective_ui::control(app, ui.unwrap_or(&Value::Null))) } + "filter.vanishingPoint" if empty => Some(crate::vp_ui::begin(app, ctx).map(|_| json!({"vanishingPoint": app.distort.describe()["vanishingPoint"]}))), + "filter.vanishingPoint" if ui.is_some() && app.distort.vanishing.is_some() => Some(crate::vp_ui::control(app, ui.unwrap_or(&Value::Null))), _ => None, } } @@ -136,6 +141,10 @@ pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) -> crate::perspective_ui::pointer(app, ev, mods); return true; } + if app.distort.vanishing.is_some() { + crate::vp_ui::pointer(app, ev, mods); + return true; + } false } @@ -179,6 +188,16 @@ pub fn keys(app: &mut PhotocraftApp, ctx: &egui::Context) -> bool { } return true; } + if app.distort.vanishing.is_some() { + if enter(ctx) { + let _ = crate::vp_ui::commit(app); + } else if esc(ctx) { + crate::vp_ui::escape(app); + } else { + crate::vp_ui::keys(app, ctx); + } + return true; + } false } @@ -190,6 +209,9 @@ pub fn draw_overlay(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform if let Some(p) = &app.distort.perspective { crate::perspective_ui::draw(p, painter, xf); } + if let Some(v) = &app.distort.vanishing { + crate::vp_ui::draw(v, painter, xf); + } } /// Options bar for the on-canvas modes. True when drawn. @@ -202,6 +224,10 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) -> bool { crate::perspective_ui::options_bar(app, ui); return true; } + if app.distort.vanishing.is_some() { + crate::vp_ui::options_bar(app, ui); + return true; + } false } diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 62e843e99..6e1bbf768 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -3450,6 +3450,13 @@ layerLabel No Color Žádná barva curves Smooth Vyhladit Smooth the curve values Vyhladit hodnoty křivky Drag to draw the curve · {key}-click draws a straight line Tažením nakreslíte křivku · {key}-klik nakreslí rovnou čáru + Edit Plane Upravit rovinu + Create Plane Vytvořit rovinu + Grid Size: Velikost mřížky: + Vanishing Point: click four corners to create a plane Úběžník: klepnutím na čtyři rohy vytvoříte rovinu + Vanishing Point is not active Úběžník není aktivní + Apply Vanishing Point ({key}) Použít úběžník ({key}) + Fix the red plane first: its corners must form a convex quad Nejprve opravte červenou rovinu: její rohy musí tvořit konvexní čtyřúhelník # Select and Mask view modes (#1754) Onion Skin Cibulová slupka Marching Ants Pochodující mravenci diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index c3f5d6789..7bd2b3352 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2626,6 +2626,13 @@ cameraData Lens Objektiv curves Smooth Glätten Smooth the curve values Kurvenwerte glätten Drag to draw the curve · {key}-click draws a straight line Ziehen, um die Kurve zu zeichnen · {key}-Klick zeichnet eine gerade Linie + Edit Plane Ebene bearbeiten + Create Plane Ebene erstellen + Grid Size: Rastergröße: + Vanishing Point: click four corners to create a plane Fluchtpunkt: Klicken Sie auf vier Ecken, um eine Ebene zu erstellen + Vanishing Point is not active Fluchtpunkt ist nicht aktiv + Apply Vanishing Point ({key}) Fluchtpunkt anwenden ({key}) + Fix the red plane first: its corners must form a convex quad Korrigieren Sie zuerst die rote Ebene: Ihre Ecken müssen ein konvexes Viereck bilden # Select and Mask view modes (#1754) Onion Skin Zwiebelschicht Marching Ants Laufende Ameisen diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index ebdd56fc4..d79879027 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -3687,6 +3687,11 @@ cameraData Lens Φακός curves Smooth Εξομάλυνση Smooth the curve values Εξομάλυνση των τιμών της καμπύλης Drag to draw the curve · {key}-click draws a straight line Σύρετε για να σχεδιάσετε την καμπύλη · {key}-κλικ σχεδιάζει ευθεία γραμμή + Grid Size: Μέγεθος πλέγματος: + Vanishing Point: click four corners to create a plane Σημείο φυγής: κάντε κλικ σε τέσσερις γωνίες για να δημιουργήσετε ένα επίπεδο + Vanishing Point is not active Το Σημείο φυγής δεν είναι ενεργό + Apply Vanishing Point ({key}) Εφαρμογή σημείου φυγής ({key}) + Fix the red plane first: its corners must form a convex quad Διορθώστε πρώτα το κόκκινο επίπεδο: οι γωνίες του πρέπει να σχηματίζουν κυρτό τετράπλευρο # Select and Mask view modes (#1754) Onion Skin Φλούδα κρεμμυδιού Marching Ants Κινούμενο περίγραμμα diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 025ccee27..4690524ce 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2667,6 +2667,13 @@ cameraData Lens Objetivo curves Smooth Suavizar Smooth the curve values Suavizar los valores de la curva Drag to draw the curve · {key}-click draws a straight line Arrastre para dibujar la curva · {key}-clic traza una línea recta + Edit Plane Editar plano + Create Plane Crear plano + Grid Size: Tamaño de rejilla: + Vanishing Point: click four corners to create a plane Punto de fuga: haga clic en cuatro esquinas para crear un plano + Vanishing Point is not active Punto de fuga no está activo + Apply Vanishing Point ({key}) Aplicar punto de fuga ({key}) + Fix the red plane first: its corners must form a convex quad Corrija primero el plano rojo: sus esquinas deben formar un cuadrilátero convexo # Select and Mask view modes (#1754) Onion Skin Papel cebolla Marching Ants Hormigas en marcha diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index bfe437394..1b86bce63 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -3004,6 +3004,11 @@ cameraData Lens Objectif curves Smooth Lisser Smooth the curve values Lisser les valeurs de la courbe Drag to draw the curve · {key}-click draws a straight line Faites glisser pour dessiner la courbe · {key}-clic trace une ligne droite + Grid Size: Taille de la grille : + Vanishing Point: click four corners to create a plane Point de fuite : cliquez sur quatre coins pour créer un plan + Vanishing Point is not active Point de fuite n’est pas actif + Apply Vanishing Point ({key}) Appliquer le point de fuite ({key}) + Fix the red plane first: its corners must form a convex quad Corrigez d’abord le plan rouge : ses coins doivent former un quadrilatère convexe # Select and Mask view modes (#1754) Onion Skin Pelure d'oignon Marching Ants Fourmis en marche diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 825976fb3..79582888e 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -2991,6 +2991,11 @@ cameraData Lens Lensa curves Smooth Haluskan Smooth the curve values Haluskan nilai kurva Drag to draw the curve · {key}-click draws a straight line Seret untuk menggambar kurva · {key}-klik menggambar garis lurus + Grid Size: Ukuran Grid: + Vanishing Point: click four corners to create a plane Vanishing Point: klik empat sudut untuk membuat bidang + Vanishing Point is not active Vanishing Point tidak aktif + Apply Vanishing Point ({key}) Terapkan Vanishing Point ({key}) + Fix the red plane first: its corners must form a convex quad Perbaiki bidang merah dulu: sudut-sudutnya harus membentuk segi empat cembung # Select and Mask view modes (#1754) Onion Skin Kulit Bawang Marching Ants Semut Berbaris diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 219bb4a92..10ffef8b6 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -2658,6 +2658,13 @@ cameraData Lens Obiettivo curves Smooth Leviga Smooth the curve values Leviga i valori della curva Drag to draw the curve · {key}-click draws a straight line Trascina per disegnare la curva · {key}-clic traccia una linea retta + Edit Plane Modifica piano + Create Plane Crea piano + Grid Size: Dimensione griglia: + Vanishing Point: click four corners to create a plane Punto di fuga: fai clic su quattro angoli per creare un piano + Vanishing Point is not active Punto di fuga non è attivo + Apply Vanishing Point ({key}) Applica punto di fuga ({key}) + Fix the red plane first: its corners must form a convex quad Correggi prima il piano rosso: i suoi angoli devono formare un quadrilatero convesso # Select and Mask view modes (#1754) Onion Skin Velina Marching Ants Formiche in marcia diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index b0f7e074c..59bd32688 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -3598,6 +3598,11 @@ cameraData Lens レンズ curves Smooth スムーズ Smooth the curve values カーブの値を滑らかにする Drag to draw the curve · {key}-click draws a straight line ドラッグしてカーブを描画 · {key}+クリックで直線を描画 + Grid Size: グリッドサイズ: + Vanishing Point: click four corners to create a plane 消点:4 つの角をクリックして面を作成します + Vanishing Point is not active 消点はアクティブではありません + Apply Vanishing Point ({key}) 消点を適用({key}) + Fix the red plane first: its corners must form a convex quad 先に赤い面を修正してください:角が凸四角形になる必要があります # Select and Mask view modes (#1754) Onion Skin オニオンスキン Marching Ants 点線の境界 diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 1eaf557cf..30a7ca878 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -3171,6 +3171,13 @@ cameraData Lens 렌즈 curves Smooth 매끄럽게 Smooth the curve values 곡선 값을 매끄럽게 하기 Drag to draw the curve · {key}-click draws a straight line 드래그하여 곡선 그리기 · {key}-클릭으로 직선 그리기 + Edit Plane 평면 편집 + Create Plane 평면 만들기 + Grid Size: 격자 크기: + Vanishing Point: click four corners to create a plane 소실점: 네 모서리를 클릭하여 평면을 만드십시오 + Vanishing Point is not active 소실점이 활성화되어 있지 않습니다 + Apply Vanishing Point ({key}) 소실점 적용 ({key}) + Fix the red plane first: its corners must form a convex quad 먼저 빨간색 평면을 수정하십시오: 모서리가 볼록 사각형을 이루어야 합니다 # Select and Mask view modes (#1754) Onion Skin 어니언 스킨 Marching Ants 점선 윤곽 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index b0880cb21..b4874cecd 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -3743,6 +3743,11 @@ cameraData Lens Lens curves Smooth Vloeiend maken Smooth the curve values Curvewaarden vloeiend maken Drag to draw the curve · {key}-click draws a straight line Sleep om de curve te tekenen · {key}-klik tekent een rechte lijn + Grid Size: Rastergrootte: + Vanishing Point: click four corners to create a plane Verdwijnpunt: klik op vier hoeken om een vlak te maken + Vanishing Point is not active Verdwijnpunt is niet actief + Apply Vanishing Point ({key}) Verdwijnpunt toepassen ({key}) + Fix the red plane first: its corners must form a convex quad Herstel eerst het rode vlak: de hoeken moeten een convexe vierhoek vormen # Select and Mask view modes (#1754) Onion Skin Uienschil Marching Ants Mierenmars diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 887ef7c2d..d62fdb253 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -3600,6 +3600,11 @@ cameraData Lens Obiektyw curves Smooth Wygładź Smooth the curve values Wygładź wartości krzywej Drag to draw the curve · {key}-click draws a straight line Przeciągnij, aby narysować krzywą · {key}-klik rysuje linię prostą + Grid Size: Rozmiar siatki: + Vanishing Point: click four corners to create a plane Punkt zbieżności: kliknij cztery narożniki, aby utworzyć płaszczyznę + Vanishing Point is not active Punkt zbieżności nie jest aktywny + Apply Vanishing Point ({key}) Zastosuj punkt zbieżności ({key}) + Fix the red plane first: its corners must form a convex quad Najpierw popraw czerwoną płaszczyznę: jej narożniki muszą tworzyć czworokąt wypukły # Select and Mask view modes (#1754) Onion Skin Skórka cebuli Marching Ants Maszerujące mrówki diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index b84cf2c6a..f65dc784b 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -3009,6 +3009,12 @@ cameraData Lens Lente curves Smooth Suavizar Smooth the curve values Suavizar os valores da curva Drag to draw the curve · {key}-click draws a straight line Arraste para desenhar a curva · {key}-clique traça uma linha reta + Grid Size: Tamanho da grade: + New Layer Nova camada + Vanishing Point: click four corners to create a plane Ponto de fuga: clique em quatro cantos para criar um plano + Vanishing Point is not active Ponto de fuga não está ativo + Apply Vanishing Point ({key}) Aplicar ponto de fuga ({key}) + Fix the red plane first: its corners must form a convex quad Corrija primeiro o plano vermelho: seus cantos devem formar um quadrilátero convexo # Select and Mask view modes (#1754) Onion Skin Papel vegetal Marching Ants Formigas marchando @@ -3075,7 +3081,6 @@ layerLabel No Color Sem cor Workspaces Áreas de trabalho Show All Mostrar tudo Hidden Oculto - New Layer Nova camada Duplicate Layer Duplicar camada Ungroup Desagrupar Lock Transparent Pixels Bloquear pixels transparentes diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index e314fc4f8..e7164a9ce 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2927,6 +2927,11 @@ cameraData Lens Объектив curves Smooth Сгладить Smooth the curve values Сгладить значения кривой Drag to draw the curve · {key}-click draws a straight line Перетащите, чтобы нарисовать кривую · {key}-щелчок рисует прямую линию + Grid Size: Размер сетки: + Vanishing Point: click four corners to create a plane Точка схода: щелкните четыре угла, чтобы создать плоскость + Vanishing Point is not active Точка схода не активна + Apply Vanishing Point ({key}) Применить точку схода ({key}) + Fix the red plane first: its corners must form a convex quad Сначала исправьте красную плоскость: ее углы должны образовывать выпуклый четырехугольник # Select and Mask view modes (#1754) Onion Skin Калька Marching Ants Бегущие муравьи diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 6e6ca92dc..df71532d1 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -3674,6 +3674,11 @@ cameraData Lens Об'єктив curves Smooth Згладити Smooth the curve values Згладити значення кривої Drag to draw the curve · {key}-click draws a straight line Перетягніть, щоб намалювати криву · {key}-клацання малює пряму лінію + Grid Size: Розмір сітки: + Vanishing Point: click four corners to create a plane Точка сходження: клацніть чотири кути, щоб створити площину + Vanishing Point is not active Точка сходження не активна + Apply Vanishing Point ({key}) Застосувати точку сходження ({key}) + Fix the red plane first: its corners must form a convex quad Спершу виправте червону площину: її кути мають утворювати опуклий чотирикутник # Select and Mask view modes (#1754) Onion Skin Калька Marching Ants Мурашки diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index dbdbe8589..c0218eef1 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2600,6 +2600,13 @@ cameraData Lens 镜头 curves Smooth 平滑 Smooth the curve values 平滑曲线值 Drag to draw the curve · {key}-click draws a straight line 拖动以绘制曲线 · 按住 {key} 单击可绘制直线 + Edit Plane 编辑平面 + Create Plane 创建平面 + Grid Size: 网格大小: + Vanishing Point: click four corners to create a plane 消失点:单击四个角以创建平面 + Vanishing Point is not active 消失点未处于活动状态 + Apply Vanishing Point ({key}) 应用消失点({key}) + Fix the red plane first: its corners must form a convex quad 请先修正红色平面:其四个角必须构成凸四边形 # Select and Mask view modes (#1754) Onion Skin 洋葱皮 Marching Ants 蚂蚁线 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 4010050be..adc1f1c5a 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2570,6 +2570,13 @@ cameraData Lens 鏡頭 curves Smooth 平滑 Smooth the curve values 平滑曲線數值 Drag to draw the curve · {key}-click draws a straight line 拖曳以繪製曲線 · 按住 {key} 按一下可繪製直線 + Edit Plane 編輯平面 + Create Plane 建立平面 + Grid Size: 格點大小: + Vanishing Point: click four corners to create a plane 消失點:按一下四個角以建立平面 + Vanishing Point is not active 消失點未啟用 + Apply Vanishing Point ({key}) 套用消失點 ({key}) + Fix the red plane first: its corners must form a convex quad 請先修正紅色平面:其四個角必須構成凸四邊形 # Select and Mask view modes (#1754) Onion Skin 洋蔥皮 Marching Ants 螞蟻線 diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index ba2f565cb..0fe1945e7 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -198,6 +198,7 @@ mod type_transform; mod variables_ui; pub mod vector_ui; pub mod view_cmds; +pub mod vp_ui; pub mod warp_preview; pub mod wheel_nav; pub mod wide_angle_ui; diff --git a/crates/ui-egui/src/vp_ui.rs b/crates/ui-egui/src/vp_ui.rs new file mode 100644 index 000000000..bdaad601c --- /dev/null +++ b/crates/ui-egui/src/vp_ui.rs @@ -0,0 +1,611 @@ +//! Filter › Vanishing Point on the canvas (dialog mode). +//! +//! **Create Plane** (C): four clicks set a plane's corners, in any order. **Edit Plane** (V): +//! drag a corner node, or an edge node to slide that edge along the plane's own perspective; +//! click a plane to select it, Delete removes it. The plane's grid is drawn at the Grid Size and, +//! like Photoshop's, in the accent colour when the plane is valid, yellow when its perspective is +//! doubtful and red when it is invalid ([`VpPlane::check`]). With an image on the clipboard the +//! selected plane shows it pasted in perspective; drag it to move it on the plane. ↩ / OK runs +//! `filter.vanishingPoint` with the planes and the paste, so it is one history step and an +//! action or agent can replay it exactly. + +use egui::{Color32, Pos2, Stroke, TextureHandle, pos2}; +use photocraft_algo::transform::Homography; +use photocraft_algo::vanishing::{PlaneCheck, Scene, VpPlane}; +use photocraft_doc::{LayerContent, LayerId}; +use photocraft_engine::vp_cmds::VP; +use photocraft_geom::Rect; +use serde_json::{Value, json}; + +use crate::PhotocraftApp; +use crate::canvas::{ToolEvent, ViewXform}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub enum VpTool { + EditPlane, + CreatePlane, +} + +enum Drag { + /// A corner node, and the pointer offset from it. + Corner { plane: usize, corner: usize, offset: [f64; 2] }, + /// An edge node (0 top, 1 right, 2 bottom, 3 left): the edge slides along the plane's + /// perspective as it was at the press. + Edge { plane: usize, edge: usize, h: Homography, inv: Homography }, + /// The pasted image, and the pointer offset from its top-left in plane coordinates. + Paste { offset: [f64; 2] }, +} + +/// The clipboard image shown pasted onto the plane. +struct Clip { + texture: TextureHandle, + width: f64, + height: f64, +} + +pub struct VpSession { + pub layer: LayerId, + canvas: Rect, + pub planes: Vec, + pub tool: VpTool, + /// Corners clicked so far with Create Plane. + pending: Vec<[f64; 2]>, + pub selected: Option, + /// Grid cell size in px at the plane's nearest edge. + pub grid_size: f64, + /// Paste the clipboard onto the selected plane on OK. + pub paste: bool, + /// Top-left of the pasted image in plane coordinates (0..1). + pub paste_at: [f64; 2], + /// Width of the pasted image as a fraction of the plane's width. + pub paste_width: f64, + pub new_layer: bool, + clip: Option, + drag: Option, +} + +/// Most planes a session holds (the control channel can't make it allocate without bound). +const MAX_PLANES: usize = 64; +/// Most grid lines drawn per direction. +const MAX_LINES: usize = 200; + +impl VpSession { + pub fn describe(&self) -> Value { + let center = self.center(); + let planes: Vec = self.planes.iter().map(|p| json!({"corners": p.corners, "check": p.check(center)})).collect(); + json!({ + "layer": self.layer.0, + "tool": self.tool, + "planes": planes, + "pending": self.pending, + "selected": self.selected, + "gridSize": self.grid_size, + "paste": self.paste && self.clip.is_some(), + "pasteAt": self.paste_at, + "pasteWidth": self.paste_width, + "newLayer": self.new_layer, + }) + } + + fn center(&self) -> [f64; 2] { + let c = self.canvas; + [f64::from(c.x0 + c.x1) / 2.0, f64::from(c.y0 + c.y1) / 2.0] + } + + /// The planes with the shared camera the engine derives from the first one. + fn scene(&self) -> Option { + let first = self.planes.first()?.clone(); + let fallback = f64::from(self.canvas.width().max(self.canvas.height())); + let mut sc = Scene::new(first, self.center(), None, fallback); + sc.planes.extend(self.planes.iter().skip(1).cloned()); + Some(sc) + } + + /// Plane `i`'s unit square → image map and metric side lengths. + fn frame(&self, i: usize) -> Option<(Homography, f64, f64)> { + if i >= self.planes.len() { + return None; + } + let (h, lu, lv) = self.scene()?.frame(i)?; + (lu.is_finite() && lv.is_finite() && lu > 1e-12 && lv > 1e-12).then_some((h, lu, lv)) + } + + /// The plane the paste lands on. + fn paste_plane(&self) -> Option { + if !self.paste || self.clip.is_none() { + return None; + } + self.selected.filter(|i| *i < self.planes.len()).or_else(|| (!self.planes.is_empty()).then_some(0)) + } + + /// Size of the pasted image in plane coordinates (the engine keeps its metric aspect). + fn paste_extent(&self, lu: f64, lv: f64) -> Option<[f64; 2]> { + let c = self.clip.as_ref()?; + Some([self.paste_width, self.paste_width * lu / lv * c.height / c.width.max(1.0)]) + } + + /// The command's params: the planes, and the paste when there is one. + pub fn params(&self) -> Value { + let planes: Vec = self.planes.iter().map(|p| json!({"corners": p.corners})).collect(); + let mut p = json!({"planes": planes}); + if let Some(i) = self.paste_plane() { + p["paste"] = json!([{"plane": i, "at": self.paste_at, "width": self.paste_width}]); + } + if self.new_layer { + p["newLayer"] = json!(true); + } + p + } + + fn add_corner(&mut self, p: [f64; 2]) { + if self.planes.len() >= MAX_PLANES { + return; + } + self.pending.push(p); + if let Ok(corners) = <[[f64; 2]; 4]>::try_from(self.pending.as_slice()) { + self.pending.clear(); + self.planes.push(VpPlane { corners }.normalized()); + self.selected = Some(self.planes.len() - 1); + // Photoshop hands over to Edit Plane once a plane is closed. + self.tool = VpTool::EditPlane; + } + } + + fn delete_selected(&mut self) { + if let Some(i) = self.selected.take().filter(|i| *i < self.planes.len()) { + self.planes.remove(i); + self.drag = None; + } + } + + /// Image positions of the edge nodes of plane `i`. + fn edge_nodes(&self, i: usize) -> Option<[[f64; 2]; 4]> { + let h = self.planes.get(i)?.homography()?; + let at = |u: f64, v: f64| { + let (x, y) = h.apply(u, v); + [x, y] + }; + Some([at(0.5, 0.0), at(1.0, 0.5), at(0.5, 1.0), at(0.0, 0.5)]) + } + + fn press(&mut self, p: [f64; 2], tol: f64) { + if self.tool == VpTool::CreatePlane { + self.add_corner(p); + return; + } + let near = |c: [f64; 2]| (c[0] - p[0]).hypot(c[1] - p[1]) <= tol; + // The selected plane's nodes win over the others'. + let order: Vec = self.selected.into_iter().chain((0..self.planes.len()).rev()).filter(|i| *i < self.planes.len()).collect(); + for &i in &order { + if let Some(ci) = self.planes[i].corners.iter().position(|c| near(*c)) { + let c = self.planes[i].corners[ci]; + self.selected = Some(i); + self.drag = Some(Drag::Corner { plane: i, corner: ci, offset: [c[0] - p[0], c[1] - p[1]] }); + return; + } + if let Some(e) = self.edge_nodes(i).and_then(|n| n.iter().position(|c| near(*c))) + && let Some(h) = self.planes[i].homography() + && let Some(inv) = h.inverse() + { + self.selected = Some(i); + self.drag = Some(Drag::Edge { plane: i, edge: e, h, inv }); + return; + } + } + if let Some(i) = self.paste_plane() + && let Some((h, lu, lv)) = self.frame(i) + && let Some(ext) = self.paste_extent(lu, lv) + && let Some(inv) = h.inverse() + { + let (u, v) = inv.apply(p[0], p[1]); + let (a, b) = (self.paste_at, [self.paste_at[0] + ext[0], self.paste_at[1] + ext[1]]); + if (a[0]..=b[0]).contains(&u) && (a[1]..=b[1]).contains(&v) { + self.drag = Some(Drag::Paste { offset: [a[0] - u, a[1] - v] }); + return; + } + } + self.selected = self.scene().and_then(|sc| sc.plane_at(p)); + } + + fn drag_to(&mut self, p: [f64; 2]) { + match &self.drag { + Some(Drag::Corner { plane, corner, offset }) => { + if let Some(c) = self.planes.get_mut(*plane).and_then(|pl| pl.corners.get_mut(*corner)) { + *c = [p[0] + offset[0], p[1] + offset[1]]; + } + } + Some(Drag::Edge { plane, edge, h, inv }) => { + let (u, v) = inv.apply(p[0], p[1]); + // The plane's (u, v) rectangle after the move. + let (u0, v0, u1, v1) = match edge { + 0 => (0.0, v.min(0.99), 1.0, 1.0), + 1 => (0.0, 0.0, u.max(0.01), 1.0), + 2 => (0.0, 0.0, 1.0, v.max(0.01)), + _ => (u.min(0.99), 0.0, 1.0, 1.0), + }; + if let Some(q) = slid_quad(h, u0, v0, u1, v1) + && let Some(pl) = self.planes.get_mut(*plane) + { + pl.corners = q; + } + } + Some(Drag::Paste { offset }) => { + let offset = *offset; + if let Some(i) = self.paste_plane() + && let Some(inv) = self.planes.get(i).and_then(VpPlane::homography).and_then(|h| h.inverse()) + { + let (u, v) = inv.apply(p[0], p[1]); + let at = [(u + offset[0]).clamp(-1.0, 2.0), (v + offset[1]).clamp(-1.0, 2.0)]; + if at.iter().all(|x| x.is_finite()) { + self.paste_at = at; + } + } + } + None => {} + } + } +} + +/// Corners of the image of `[u0, u1] × [v0, v1]` under `h`, or `None` when part of it would lie +/// on or beyond the plane's horizon (where the edge can't follow the perspective). +fn slid_quad(h: &Homography, u0: f64, v0: f64, u1: f64, v1: f64) -> Option<[[f64; 2]; 4]> { + let m = &h.0; + let w = |u: f64, v: f64| m[6] * u + m[7] * v + m[8]; + let side = w(0.5, 0.5).signum(); + let uv = [[u0, v0], [u1, v0], [u1, v1], [u0, v1]]; + if uv.iter().any(|c| w(c[0], c[1]) * side <= 1e-9 * w(0.5, 0.5).abs()) { + return None; + } + let q = uv.map(|c| { + let (x, y) = h.apply(c[0], c[1]); + [x, y] + }); + (VpPlane { corners: q }.check([0.0, 0.0]) != PlaneCheck::Invalid).then_some(q) +} + +/// Starts Vanishing Point on the active pixel layer. +pub fn begin(app: &mut PhotocraftApp, ctx: &egui::Context) -> Result<(), String> { + if app.ui.transform.is_some() { + return Err("finish Free Transform first".into()); + } + let st = app.session.active().ok_or("no document")?; + let layer = st.active_layer.ok_or("no active layer")?; + match st.doc.layer(layer).map(|l| &l.content) { + Some(LayerContent::Raster(_)) => {} + Some(other) => return Err(format!("Vanishing Point needs a pixel layer (active layer is {} {} layer)", other.article(), other.kind_name())), + None => return Err("no active layer".into()), + } + let canvas = st.doc.bounds(); + let key = app.ui.alloc_id(); + let clip = app.session.clipboard.as_ref().and_then(|c| { + let r = c.surface.content_bounds(); + (!r.is_empty()).then(|| Clip { + texture: ctx.load_texture(format!("vp-paste-{key}"), crate::distort_ui::surface_image(&c.surface, r, 1024), egui::TextureOptions::LINEAR), + width: f64::from(r.width()), + height: f64::from(r.height()), + }) + }); + app.distort.vanishing = Some(VpSession { + layer, + canvas, + planes: Vec::new(), + tool: VpTool::CreatePlane, + pending: Vec::new(), + selected: None, + grid_size: 100.0, + paste: clip.is_some(), + paste_at: [0.25, 0.25], + paste_width: 0.5, + new_layer: false, + clip, + drag: None, + }); + app.ui.status = tl!("Vanishing Point: click four corners to create a plane").into(); + Ok(()) +} + +/// OK: runs `filter.vanishingPoint` with the planes (and the paste). An invalid plane keeps the +/// mode open; with nothing to paste the mode just closes. +pub fn commit(app: &mut PhotocraftApp) -> Result { + let Some(s) = app.distort.vanishing.as_ref() else { return Ok(Value::Null) }; + let center = s.center(); + if s.planes.iter().any(|p| p.check(center) == PlaneCheck::Invalid) { + let msg = tl!("Fix the red plane first: its corners must form a convex quad"); + app.ui.status = msg.into(); + return Err(msg.into()); + } + let params = s.params(); + app.distort.vanishing = None; + if params.get("paste").is_none() { + return Ok(json!({"committed": false})); + } + app.run(VP, params).map(|r| json!({"committed": true, "result": r})) +} + +/// Control channel: `filter.vanishingPoint {"ui": {...}}` while the mode is active. +pub fn control(app: &mut PhotocraftApp, ui: &Value) -> Result { + if ui.get("commit").and_then(Value::as_bool) == Some(true) { + return commit(app); + } + if ui.get("cancel").and_then(Value::as_bool) == Some(true) { + app.distort.vanishing = None; + return Ok(json!({"cancelled": true})); + } + let s = app.distort.vanishing.as_mut().ok_or(tl!("Vanishing Point is not active"))?; + if let Some(t) = ui.get("tool").and_then(Value::as_str) { + s.tool = match t { + "edit" | "editPlane" => VpTool::EditPlane, + "create" | "createPlane" => VpTool::CreatePlane, + other => return Err(format!("tool: edit|create, not `{other}`")), + }; + s.pending.clear(); + } + if let Some(p) = ui.get("planes") { + let planes: Vec = serde_json::from_value(p.clone()).map_err(|e| format!("bad planes: {e}"))?; + if planes.len() > MAX_PLANES { + return Err(format!("at most {MAX_PLANES} planes")); + } + s.planes = planes; + // A drag holds indices into the old list. + s.drag = None; + s.selected = (!s.planes.is_empty()).then(|| s.planes.len() - 1); + } + if let Some(i) = ui.get("select").and_then(Value::as_u64) { + s.selected = usize::try_from(i).ok().filter(|i| *i < s.planes.len()); + } + if let Some(g) = ui.get("gridSize").and_then(Value::as_f64).filter(|g| g.is_finite()) { + s.grid_size = g.clamp(2.0, 2000.0); + } + if let Some(b) = ui.get("paste").and_then(Value::as_bool) { + s.paste = b; + } + if let Some(at) = ui.get("pasteAt").and_then(|v| Some([v.get(0)?.as_f64()?, v.get(1)?.as_f64()?])) { + s.paste_at = at.map(|x| if x.is_finite() { x.clamp(-1.0, 2.0) } else { 0.25 }); + } + if let Some(w) = ui.get("pasteWidth").and_then(Value::as_f64).filter(|w| w.is_finite()) { + s.paste_width = w.clamp(0.01, 10.0); + } + if let Some(b) = ui.get("newLayer").and_then(Value::as_bool) { + s.new_layer = b; + } + Ok(s.describe()) +} + +pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, _mods: egui::Modifiers) { + let tol = crate::distort_ui::tolerance(app); + let Some(s) = app.distort.vanishing.as_mut() else { return }; + match ev { + ToolEvent::Down { x, y, .. } => s.press([x, y], tol), + ToolEvent::Move { x, y, .. } => s.drag_to([x, y]), + ToolEvent::Up { x, y } => { + s.drag_to([x, y]); + s.drag = None; + } + } +} + +/// Keys while the mode is active (↩ and Esc are handled by `distort_ui::keys`). +pub fn keys(app: &mut PhotocraftApp, ctx: &egui::Context) { + use egui::{Key, Modifiers}; + if ctx.memory(|m| m.focused().is_some()) { + return; + } + let Some(s) = app.distort.vanishing.as_mut() else { return }; + let key = |k: Key| ctx.input_mut(|i| i.consume_key(Modifiers::NONE, k)); + if key(Key::V) { + s.tool = VpTool::EditPlane; + s.pending.clear(); + } else if key(Key::C) { + s.tool = VpTool::CreatePlane; + } else if key(Key::Delete) || key(Key::Backspace) { + s.delete_selected(); + } +} + +/// Esc: drops a half-clicked plane first, then leaves the mode. +pub fn escape(app: &mut PhotocraftApp) { + match app.distort.vanishing.as_mut() { + Some(s) if !s.pending.is_empty() => s.pending.clear(), + _ => app.distort.vanishing = None, + } +} + +pub fn draw(s: &VpSession, painter: &egui::Painter, xf: &ViewXform) { + let scr = |p: [f64; 2]| xf.to_screen(p[0] as f32, p[1] as f32); + let tokens = crate::theme::Tokens::get(painter.ctx()); + let center = s.center(); + // The paste first, under the grids. + if let Some(i) = s.paste_plane() + && s.planes.get(i).is_some_and(|p| p.check(center) != PlaneCheck::Invalid) + && let (Some((h, lu, lv)), Some(c)) = (s.frame(i), s.clip.as_ref()) + && let Some(ext) = s.paste_extent(lu, lv) + { + draw_paste(painter, &scr, &h, s.paste_at, ext, &c.texture); + } + for (i, p) in s.planes.iter().enumerate() { + let check = p.check(center); + let colour = match check { + PlaneCheck::Valid => tokens.accent, + PlaneCheck::Questionable => tokens.warning, + PlaneCheck::Invalid => tokens.danger, + }; + let selected = s.selected == Some(i); + // An invalid plane has no perspective to grid: just its red outline, as in Photoshop. + if check != PlaneCheck::Invalid + && let Some((h, lu, lv)) = s.frame(i) + { + let thin = Stroke::new(0.75, colour.gamma_multiply(0.6)); + let at = |u: f64, v: f64| { + let (x, y) = h.apply(u, v); + scr([x, y]) + }; + for u in grid_steps(p, lu, lv, s.grid_size, true) { + painter.line_segment([at(u, 0.0), at(u, 1.0)], thin); + } + for v in grid_steps(p, lu, lv, s.grid_size, false) { + painter.line_segment([at(0.0, v), at(1.0, v)], thin); + } + } + let pts: Vec = p.corners.iter().map(|c| scr(*c)).collect(); + painter.add(egui::Shape::closed_line(pts.clone(), Stroke::new(if selected { 2.0 } else { 1.25 }, colour))); + if selected { + let nodes = pts.into_iter().chain(s.edge_nodes(i).into_iter().flatten().map(scr)); + for c in nodes { + let r = egui::Rect::from_center_size(c, egui::vec2(7.0, 7.0)); + painter.rect_filled(r, 0.0, Color32::WHITE); + painter.rect_stroke(r, 0.0, Stroke::new(1.25, colour), egui::StrokeKind::Inside); + } + } + } + // Create Plane: the corners so far and a rubber band to the pointer. + if !s.pending.is_empty() { + let mut pts: Vec = s.pending.iter().map(|c| scr(*c)).collect(); + if let Some(hover) = painter.ctx().pointer_hover_pos() { + pts.push(hover); + } + painter.add(egui::Shape::line(pts.clone(), Stroke::new(1.25, tokens.accent))); + for c in pts.iter().take(s.pending.len()) { + painter.rect_filled(egui::Rect::from_center_size(*c, egui::vec2(6.0, 6.0)), 0.0, tokens.accent); + } + } +} + +/// Plane coordinates (0..1, exclusive) of the grid lines across `u` (or `v`) for cells of +/// `size` px at the plane's nearest edge, square in the plane's metric. +fn grid_steps(p: &VpPlane, lu: f64, lv: f64, size: f64, along_u: bool) -> Vec { + let q = &p.corners; + let d = |a: [f64; 2], b: [f64; 2]| (a[0] - b[0]).hypot(a[1] - b[1]); + // Image px per metric unit at the nearest (longest-looking) edge. + let k = [d(q[0], q[1]) / lu, d(q[3], q[2]) / lu, d(q[0], q[3]) / lv, d(q[1], q[2]) / lv].into_iter().fold(0.0, f64::max); + let cell = size.max(1.0) / k; + let len = if along_u { lu } else { lv }; + if !cell.is_finite() || cell <= 0.0 || !len.is_finite() { + return Vec::new(); + } + let step = cell / len; + (1..MAX_LINES).map(|j| j as f64 * step).take_while(|t| *t < 1.0 - 1e-9).collect() +} + +/// The clipboard image on the plane: a mesh through the plane's homography, cells outside the +/// plane left out (the engine clips the paste to the plane). +fn draw_paste(painter: &egui::Painter, scr: &dyn Fn([f64; 2]) -> Pos2, h: &Homography, at: [f64; 2], ext: [f64; 2], tex: &TextureHandle) { + const N: usize = 24; + let mut mesh = egui::Mesh::with_texture(tex.id()); + let tint = Color32::from_white_alpha(230); + for j in 0..=N { + for i in 0..=N { + let (s, t) = (i as f64 / N as f64, j as f64 / N as f64); + let (x, y) = h.apply(at[0] + s * ext[0], at[1] + t * ext[1]); + mesh.vertices.push(egui::epaint::Vertex { pos: scr([x, y]), uv: pos2(s as f32, t as f32), color: tint }); + } + } + for j in 0..N { + for i in 0..N { + let (u, v) = (at[0] + (i as f64 + 0.5) / N as f64 * ext[0], at[1] + (j as f64 + 0.5) / N as f64 * ext[1]); + if !(0.0..=1.0).contains(&u) || !(0.0..=1.0).contains(&v) { + continue; + } + let a = (j * (N + 1) + i) as u32; + mesh.add_triangle(a, a + 1, a + N as u32 + 2); + mesh.add_triangle(a, a + N as u32 + 2, a + N as u32 + 1); + } + } + painter.add(mesh); +} + +pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { + let Some(s) = app.distort.vanishing.as_mut() else { return }; + if crate::widgets::pill_tab(ui, tl!("Edit Plane"), s.tool == VpTool::EditPlane).on_hover_text("V").clicked() { + s.tool = VpTool::EditPlane; + s.pending.clear(); + } + if crate::widgets::pill_tab(ui, tl!("Create Plane"), s.tool == VpTool::CreatePlane).on_hover_text("C").clicked() { + s.tool = VpTool::CreatePlane; + } + crate::widgets::vline(ui, 22.0); + ui.label(tl!("Grid Size:")); + let mut g = s.grid_size as f32; + if crate::widgets::value_field(ui, &mut g, 2.0..=2000.0, "px", 56.0).changed() { + s.grid_size = f64::from(g); + } + if s.clip.is_some() { + crate::widgets::vline(ui, 22.0); + crate::widgets::checkbox(ui, &mut s.paste, tl!("Paste")); + if s.paste { + ui.label(tl!("Width:")); + let mut w = (s.paste_width * 100.0) as f32; + if crate::widgets::value_field(ui, &mut w, 1.0..=1000.0, "%", 56.0).changed() { + s.paste_width = f64::from(w) / 100.0; + } + } + crate::widgets::checkbox(ui, &mut s.new_layer, tl!("New Layer")); + } + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if crate::widgets::primary_button(ui, "✓", 32.0) + .on_hover_text(crate::i18n::fmt(tl!("Apply Vanishing Point ({key})"), &[("key", &crate::shortcuts::pretty("Enter"))])) + .clicked() + { + let _ = commit(app); + } else if crate::widgets::secondary_button(ui, "⊘", 32.0).on_hover_text(tl!("Cancel (Esc)")).clicked() { + app.distort.vanishing = None; + } + }); +} + +#[cfg(test)] +mod tests { + use super::*; + + fn app_with_layer() -> (egui::Context, PhotocraftApp) { + let ctx = egui::Context::default(); + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + app.session.execute("file.new", json!({"width": 240, "height": 160, "depth": 16})).unwrap(); + app.session.execute("layer.new.layer", json!({})).unwrap(); + app.session.execute("edit.fill", json!({"color": "#4080c0"})).unwrap(); + app.sync_views(); + (ctx, app) + } + + #[test] + fn four_clicks_make_a_plane_and_ok_pastes_in_one_step() { + let (ctx, mut app) = app_with_layer(); + // Something to paste: a red square on the clipboard. + app.session.execute("select.rect", json!({"x": 0, "y": 0, "width": 20, "height": 20})).unwrap(); + app.session.execute("edit.fill", json!({"color": "#ff0000"})).unwrap(); + app.session.execute("edit.copy", json!({})).unwrap(); + app.session.execute("select.deselect", json!({})).unwrap(); + crate::distort_ui::menu(&mut app, &ctx, VP, &json!({})).unwrap().unwrap(); + let click = |app: &mut PhotocraftApp, x: f64, y: f64| { + crate::distort_ui::pointer(app, ToolEvent::Down { x, y, pressure: 1.0 }, egui::Modifiers::NONE); + crate::distort_ui::pointer(app, ToolEvent::Up { x, y }, egui::Modifiers::NONE); + }; + // A floor receding to the top, clicked counter-clockwise from the bottom-left. + for (x, y) in [(20.0, 150.0), (220.0, 150.0), (170.0, 60.0), (70.0, 60.0)] { + click(&mut app, x, y); + } + let s = app.distort.vanishing.as_ref().unwrap(); + assert_eq!(s.planes.len(), 1); + assert_eq!(s.planes[0].corners, [[70.0, 60.0], [170.0, 60.0], [220.0, 150.0], [20.0, 150.0]], "clockwise from the top-left"); + assert_eq!(s.tool, VpTool::EditPlane); + assert_eq!(s.describe()["planes"][0]["check"], "valid"); + // Edit Plane: drag the top edge node up; the edge slides along the perspective. + let top = s.edge_nodes(0).unwrap()[0]; + crate::distort_ui::pointer(&mut app, ToolEvent::Down { x: top[0], y: top[1], pressure: 1.0 }, egui::Modifiers::NONE); + crate::distort_ui::pointer(&mut app, ToolEvent::Up { x: top[0], y: top[1] - 15.0 }, egui::Modifiers::NONE); + let c = app.distort.vanishing.as_ref().unwrap().planes[0].corners; + assert!(c[0][1] < 60.0 && c[1][1] < 60.0 && c[1][0] - c[0][0] < 100.0, "taller and narrower at the far edge: {c:?}"); + assert_eq!(c[2], [220.0, 150.0]); + let before = app.session.active().unwrap().history.past_len(); + commit(&mut app).unwrap(); + assert!(app.distort.vanishing.is_none()); + assert_eq!(app.session.active().unwrap().history.past_len(), before + 1); + // The paste landed on the plane: red at its middle, blue outside it. + let st = app.session.active().unwrap(); + let l = st.doc.layer(st.active_layer.unwrap()).unwrap().surface().unwrap(); + let h = VpPlane { corners: c }.homography().unwrap(); + let (x, y) = h.apply(0.5, 0.35); + assert!(l.rgba(x as i32, y as i32)[0] > 0.9, "{:?}", l.rgba(x as i32, y as i32)); + assert!(l.rgba(230, 20)[0] < 0.5, "outside the plane"); + } +} diff --git a/docs/scorecard.md b/docs/scorecard.md index 32099f753..144baba3f 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -158,7 +158,7 @@ Corpus floors (`crates/io/tests/corpus.rs`): the enforced minimums; the corpus t | TOOL-214-11 | Stroke Path dialog: pick any brush-based tool, Simulate Pressure | done | [#214](https://github.com/storytold/photocraft/issues/214) | path.stroke tool: Brush, Pencil, Eraser and every other brush-based tool (Background Eraser, Clone/Pattern Stamp, Healing/Spot Healing Brush, History Brush, Blur, Sharpen, Smudge, Dodge, Burn, Sponge, Color Replacement, Mixer Brush: their paint.* command along the path, one step) plus simulatePressure (taper both ends); Stroke Path dialog from the Pen menu, the Paths panel menu and Alt-click on its Stroke button (#2978) | | TOOL-214-12 | Shapes: on-canvas corner radius handles, shape feather, selection to shape with tolerance | partial | [#214](https://github.com/storytold/photocraft/issues/214) | select.convertToShape with tolerance (vector_cmds.rs:1256); radius as a field only | | TOOL-214-13 | Mask Properties: Density and Feather controls | partial | [#214](https://github.com/storytold/photocraft/issues/214) | Vector-mask density/feather in the engine (vector_cmds.rs:948); no UI controls | -| TOOL-214-14 | Vanishing Point window: draw and edit planes, paste, clone in perspective | partial | [#214](https://github.com/storytold/photocraft/issues/214) | filter.vanishingPoint (vp_cmds.rs:15) behind a parameter dialog | +| TOOL-214-14 | Vanishing Point window: draw and edit planes, paste, clone in perspective | partial | [#214](https://github.com/storytold/photocraft/issues/214) | filter.vanishingPoint (vp_cmds.rs:15); on-canvas plane editor (vp_ui.rs: Create/Edit Plane, edge nodes, valid/doubtful/invalid grid colours, Grid Size, clipboard pasted onto the plane on OK); clone stamp, marquee and tear-off planes in the window still missing | | TOOL-214-15 | Content-Aware Fill workspace: paint the sampling area, preview, rotate/scale/mirror, new layer | partial | [#214](https://github.com/storytold/photocraft/issues/214) | Engine sampling auto, rect, custom (edit_menu_cmds.rs:324); generic dialog | | TOOL-214-16 | Liquify: redo (Cmd+Shift+Z) | done | [#214](https://github.com/storytold/photocraft/issues/214) | liquify_ui.rs supports bounded redo through Cmd+Shift+Z and the control channel; new strokes clear redo | | TOOL-213-17 | Red Eye tool: click a pupil to neutralize the red reflection | done | [#213](https://github.com/storytold/photocraft/issues/213) | paint.redEye (redeye_cmds.rs) + Tool::RedEye in the J flyout; Pupil Size and Darken Amount; catchlights kept; a miss is an error | diff --git a/scorecard/tools.toml b/scorecard/tools.toml index 7f7e7ebc3..ec9bc2a7e 100644 --- a/scorecard/tools.toml +++ b/scorecard/tools.toml @@ -206,7 +206,7 @@ id = "TOOL-214-14" target = "Vanishing Point window: draw and edit planes, paste, clone in perspective" status = "partial" issue = 214 -note = "filter.vanishingPoint (vp_cmds.rs:15) behind a parameter dialog" +note = "filter.vanishingPoint (vp_cmds.rs:15); on-canvas plane editor (vp_ui.rs: Create/Edit Plane, edge nodes, valid/doubtful/invalid grid colours, Grid Size, clipboard pasted onto the plane on OK); clone stamp, marquee and tear-off planes in the window still missing" [[item]] id = "TOOL-214-15"