Browse / github.com/storytold/photocraft / patch
Perspective rulers: 1, 2 and 3 point, set up from the Ruler tool
Perspective rulers: 1, 2 and 3 point, set up from the Ruler tool Perspective drawing assistants, as asked in #2293: brush, pencil and eraser strokes snap toward a vanishing point or run upright, so freehand lines come out straight and aimed at the right point. Photoshop has nothing like this, so the behaviour follows Clip Studio Paint's perspective rulers, worked out from its public manual only (no code or assets looked at). ## How it works - **Ruler tool › Perspective** in the options bar: None, 1-Point, 2-Point or 3-Point, with **Show** and **Snap**. Clip Studio also makes perspective rulers with its Ruler tool. - **1-point:** one vanishing point on
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- morte2437 (imported from pull request #9888, open)
- Base
a85d79167ef95b7348872ff6542e011f15b9ec3b- Size
- +941 −0 in 21 files
- Review
- pending
- Shared
- 2026-10-28
- Id
a5742f29309e444767fd27ac
How it went
No other operator has reported using it yet.
Diff
raw · crates/doc/src/lib.rs crates/engine/src/cmd.rs crates/engine/src/cmd/layer_multi_cmds/damage.rs crates/engine/src/cmd/perspective_cmds.rs crates/engine/src/doc_state.rs crates/engine/src/lib.rs crates/format/src/convert.rs crates/format/src/manifest.rs crates/format/tests/it/common/mod.rs crates/format/tests/it/float_roundtrip.rs crates/ui-egui/src/app.rs crates/ui-egui/src/canvas.rs crates/ui-egui/src/canvas/drag.rs crates/ui-egui/src/canvas/tool_input.rs crates/ui-egui/src/lib.rs crates/ui-egui/src/menus/state.rs crates/ui-egui/src/panels/options_bar.rs crates/ui-egui/src/tools.rs crates/ui-egui/src/tools/analysis_ui.rs crates/ui-egui/src/tools/perspective_ruler_ui.rs crates/ui-egui/src/tools/stroke_constraint.rs
@@ -916,6 +916,9 @@ pub struct Document { pub text_styles: TextStyles, /// Web slices (Slice tool, layer-based slices; PSD resource 1050). Auto slices are derived. pub slices: Slices,+ /// The perspective ruler (Ruler tool › Perspective): its vanishing points (1 to 3; the first two on the eye level,+ /// a third for upright lines). None = no ruler.+ pub perspective_ruler: Option<Vec<[f64; 2]>>, /// Image › Variables and Data Sets (data-driven graphics). pub variables: Variables, /// Window › Timeline (None until a video timeline is created).@@ -956,6 +959,18 @@ impl FileEncoding { /// Where a layer lives in the tree: indices from the root down. pub type LayerPath = Vec<usize>; +/// The farthest a perspective ruler's vanishing point may be from the canvas, in canvas sizes.+/// Points much farther away draw parallel lines anyway; this keeps the arithmetic finite.+pub const PERSPECTIVE_RULER_REACH: f64 = 1.0e6;++/// Whether `points` make a usable [`Document::perspective_ruler`] for a `w` × `h` canvas: 1 to 3+/// vanishing points, each finite and within [`PERSPECTIVE_RULER_REACH`] canvas sizes. The engine+/// checks edits with it and `.pcraft` loading drops a ruler that fails it.+pub fn perspective_ruler_ok(points: &[[f64; 2]], w: f64, h: f64) -> bool {+ let limit = PERSPECTIVE_RULER_REACH * w.max(h).max(1.0);+ (1..=3).contains(&points.len()) && points.iter().flatten().all(|v| v.is_finite() && v.abs() <= limit)+}+ impl Document { pub fn new(name: impl Into<String>, size: Size, mode: ColorMode, depth: SampleType) -> Self { Document {@@ -988,6 +1003,7 @@ impl Document { notes: Vec::new(), text_styles: TextStyles::default(), slices: Slices::default(),+ perspective_ruler: None, variables: Variables::default(), timeline: None, file_encoding: FileEncoding::default(),@@ -115,6 +115,7 @@ mod params; pub(super) mod path_edit_cmds; pub mod pattern_cmds; pub mod pattern_fill_cmds;+pub mod perspective_cmds; pub mod perspective_crop_cmds; pub mod photo_cmds; pub mod pick_cmds;@@ -1215,6 +1216,7 @@ fn build() -> Vec<CommandSpec> { v.extend(crate::smartselect_cmds::specs()); v.extend(crate::cutout_cmds::specs()); v.extend(crate::symmetry_cmds::specs());+ v.extend(crate::perspective_cmds::specs()); v.extend(crate::edit_cmds::specs()); v.extend(crate::color_cmds::specs()); v.extend(crate::brush_cmds::specs());@@ -221,6 +221,7 @@ fn doc_pixels_equal(a: &Document, b: &Document) -> bool { notes: _, text_styles: _, slices: _,+ perspective_ruler: _, variables: _, timeline: _, file_encoding: _,@@ -323,6 +324,7 @@ pub fn selection_only_change(a: &Document, b: &Document) -> bool { notes, text_styles, slices,+ perspective_ruler, variables, timeline, file_encoding,@@ -346,6 +348,7 @@ pub fn selection_only_change(a: &Document, b: &Document) -> bool { && *notes == b.notes && *text_styles == b.text_styles && *slices == b.slices+ && *perspective_ruler == b.perspective_ruler && *variables == b.variables && *timeline == b.timeline && *file_encoding == b.file_encodingnew file mode 100644@@ -0,0 +1,420 @@+//! Perspective rulers (the Ruler tool's Perspective option): one, two or three vanishing points+//! that painting strokes snap to, for drawing rooms, streets and buildings in perspective.+//!+//! - **1-point:** one vanishing point on the eye level (the horizon). Strokes snap towards it,+//! or run level or upright.+//! - **2-point:** two vanishing points on the eye level (the line through them). Strokes snap+//! towards either, or run upright (the vertical edges of a building).+//! - **3-point:** the 2-point ruler plus a third vanishing point above or below the eye level,+//! which the upright edges converge on instead.+//!+//! A stroke decides where it goes from its first few points (whichever direction it starts+//! closest to), then stays on that line. The vanishing points belong to the document+//! (`Document::perspective_ruler`): saved in `.pcraft` files, and placing, moving or clearing+//! them is a history step, as with guides. Snap and Show are view switches, not history.++use crate::{CommandSpec, EngineError, Result, Session};+use serde_json::{Value, json};++/// The perspective ruler's switches for a document (view state).+#[derive(Clone, Copy, Debug, PartialEq, Eq)]+pub struct PerspectiveView {+ /// Painting strokes snap to the ruler.+ pub snap: bool,+ /// The ruler's lines are drawn over the canvas.+ pub visible: bool,+}++impl Default for PerspectiveView {+ /// A new ruler starts shown and snapping.+ fn default() -> Self {+ Self { snap: true, visible: true }+ }+}++impl crate::DocState {+ /// The document's perspective ruler with its view switches; None when it has none.+ pub fn perspective(&self) -> Option<PerspectiveRuler> {+ let points = self.doc.perspective_ruler.clone()?;+ Some(PerspectiveRuler { points, snap: self.perspective_view.snap, visible: self.perspective_view.visible })+ }+}++/// A document's perspective ruler: its vanishing points and view switches.+#[derive(Clone, Debug, PartialEq)]+pub struct PerspectiveRuler {+ /// The vanishing points (1 to 3). The first two lie on the eye level; a third is the+ /// vertical vanishing point.+ pub points: Vec<[f64; 2]>,+ /// Painting strokes snap to the ruler.+ pub snap: bool,+ /// The ruler's lines are drawn over the canvas.+ pub visible: bool,+}++/// A direction a stroke can snap to.+#[derive(Clone, Copy, Debug, PartialEq)]+pub enum Snap {+ /// Towards (or away from) a vanishing point.+ Toward([f64; 2]),+ /// A fixed direction (unit vector): level or upright lines where no vanishing point governs.+ Fixed([f64; 2]),+}++impl PerspectiveRuler {+ /// The default ruler of `kind` (1, 2 or 3 points) for a `w` × `h` canvas: the eye level a+ /// little above the middle, two-point vanishing points near the sides, the third below.+ pub fn default_for(kind: usize, w: f64, h: f64) -> Option<Self> {+ let eye = h * 0.45;+ let points = match kind {+ 1 => vec![[w * 0.5, eye]],+ 2 => vec![[w * 0.1, eye], [w * 0.9, eye]],+ 3 => vec![[w * 0.1, eye], [w * 0.9, eye], [w * 0.5, h * 1.6]],+ _ => return None,+ };+ Some(Self { points, snap: true, visible: true })+ }++ /// 1, 2 or 3.+ pub fn kind(&self) -> usize {+ self.points.len()+ }++ /// The eye level (horizon) as a point and a unit direction: through the first two vanishing+ /// points, or level through a single one.+ pub fn eye_level(&self) -> Option<([f64; 2], [f64; 2])> {+ let a = *self.points.first()?;+ let dir = match self.points.get(1) {+ Some(b) => unit([b[0] - a[0], b[1] - a[1]]).unwrap_or([1.0, 0.0]),+ None => [1.0, 0.0],+ };+ Some((a, dir))+ }++ /// The directions a stroke can snap to.+ pub fn snaps(&self) -> Vec<Snap> {+ let mut out: Vec<Snap> = self.points.iter().map(|p| Snap::Toward(*p)).collect();+ // Upright lines: towards the third point when there is one, else square to the eye+ // level (vertical edges stay parallel in one- and two-point perspective).+ if self.points.len() < 3+ && let Some((_, d)) = self.eye_level()+ {+ out.push(Snap::Fixed([-d[1], d[0]]));+ }+ // One-point perspective keeps widths level: lines along the eye level.+ if self.points.len() == 1 {+ out.push(Snap::Fixed([1.0, 0.0]));+ }+ out+ }++ /// The unit direction of the snap line through `origin` that best matches a stroke heading+ /// from `origin` to `p`; None when `p` hasn't left `origin` or nothing applies.+ pub fn direction(&self, origin: [f64; 2], p: [f64; 2]) -> Option<[f64; 2]> {+ let heading = unit([p[0] - origin[0], p[1] - origin[1]])?;+ self.snaps()+ .into_iter()+ .filter_map(|s| match s {+ Snap::Toward(vp) => unit([vp[0] - origin[0], vp[1] - origin[1]]),+ Snap::Fixed(d) => Some(d),+ })+ .max_by(|a, b| cos(heading, *a).abs().total_cmp(&cos(heading, *b).abs()))+ }++ /// Checks the ruler has 1 to 3 vanishing points, each finite and near a `w` × `h` canvas+ /// ([`photocraft_doc::perspective_ruler_ok`], the rule `.pcraft` loading applies too).+ fn validate(&self, w: f64, h: f64) -> std::result::Result<(), String> {+ if !(1..=3).contains(&self.points.len()) {+ return Err("a perspective ruler has 1, 2 or 3 vanishing points".into());+ }+ if !photocraft_doc::perspective_ruler_ok(&self.points, w, h) {+ return Err("vanishing points must be finite and near the canvas".into());+ }+ Ok(())+ }++ /// The ruler as the commands report it: `{type, vanishingPoints, snap, visible}`.+ pub fn inspect(&self) -> Value {+ json!({"type": self.kind(), "vanishingPoints": self.points, "snap": self.snap, "visible": self.visible})+ }+}++/// `v` scaled to length 1; None when it is zero or not finite.+fn unit(v: [f64; 2]) -> Option<[f64; 2]> {+ let l = v[0].hypot(v[1]);+ (l.is_finite() && l > 1e-9).then(|| [v[0] / l, v[1] / l])+}++/// The dot product of `a` and `b`: the cosine of the angle between them for unit vectors.+fn cos(a: [f64; 2], b: [f64; 2]) -> f64 {+ a[0] * b[0] + a[1] * b[1]+}++/// The active document's width and height in pixels.+fn doc_size(s: &Session) -> Result<(f64, f64)> {+ let st = s.active().ok_or(EngineError::NoDocument)?;+ Ok((f64::from(st.doc.size.width), f64::from(st.doc.size.height)))+}++/// Puts `points` on the document as a history step (`label`).+fn store(s: &mut Session, label: &str, points: Option<Vec<[f64; 2]>>) -> Result<()> {+ if s.active().ok_or(EngineError::NoDocument)?.doc.perspective_ruler == points {+ return Ok(());+ }+ s.edit(label, |doc, _| {+ doc.perspective_ruler = points;+ Ok(())+ })+}++/// `view.perspectiveRuler.{onePoint,twoPoint,threePoint}`: replaces the ruler with the default one of `kind` points, shown and snapping.+fn create(s: &mut Session, kind: usize) -> Result<Value> {+ let (w, h) = doc_size(s)?;+ let ruler = PerspectiveRuler::default_for(kind, w, h).ok_or_else(|| EngineError::Other("1, 2 or 3 points".into()))?;+ store(s, "Perspective Ruler", Some(ruler.points.clone()))?;+ // A new ruler starts shown and snapping.+ s.active_mut().ok_or(EngineError::NoDocument)?.perspective_view = PerspectiveView::default();+ Ok(ruler.inspect())+}++/// `view.perspectiveRuler.set`: moves the vanishing points and sets Snap and Show, validating everything before changing anything.+fn set(s: &mut Session, p: &Value) -> Result<Value> {+ let (w, h) = doc_size(s)?;+ let bad = |m: &str| EngineError::BadParams { cmd: "view.perspectiveRuler.set".into(), msg: m.into() };+ let mut ruler =+ s.active().ok_or(EngineError::NoDocument)?.perspective().ok_or_else(|| bad("no perspective ruler (the Ruler tool's Perspective option)"))?;+ if let Some(points) = p.get("vanishingPoints") {+ let list = points.as_array().ok_or_else(|| bad("vanishingPoints: [[x,y], …]"))?;+ ruler.points = list+ .iter()+ .map(|v| match v.as_array().map(|a| a.as_slice()) {+ Some([x, y]) => match (x.as_f64(), y.as_f64()) {+ (Some(x), Some(y)) => Ok([x, y]),+ _ => Err(bad("each vanishing point is [x, y]")),+ },+ _ => Err(bad("each vanishing point is [x, y]")),+ })+ .collect::<Result<_>>()?;+ }+ if let Some(v) = p.get("snap") {+ ruler.snap = v.as_bool().ok_or_else(|| bad("snap: true or false"))?;+ }+ if let Some(v) = p.get("visible") {+ ruler.visible = v.as_bool().ok_or_else(|| bad("visible: true or false"))?;+ }+ ruler.validate(w, h).map_err(|m| bad(&m))?;+ store(s, "Move Vanishing Point", Some(ruler.points.clone()))?;+ s.active_mut().ok_or(EngineError::NoDocument)?.perspective_view = PerspectiveView { snap: ruler.snap, visible: ruler.visible };+ Ok(ruler.inspect())+}++/// `view.perspectiveRuler.{snap,show}`: flips one of the view switches `f` picks; refused without a ruler.+fn toggle(s: &mut Session, f: fn(&mut PerspectiveView) -> &mut bool) -> Result<Value> {+ let st = s.active_mut().ok_or(EngineError::NoDocument)?;+ if st.doc.perspective_ruler.is_none() {+ return Err(EngineError::Other("no perspective ruler".into()));+ }+ let b = f(&mut st.perspective_view);+ *b = !*b;+ Ok(st.perspective().map_or(Value::Null, |r| r.inspect()))+}++/// Enabled while a document is open.+fn has_doc(s: &Session) -> std::result::Result<(), String> {+ s.active().map(|_| ()).ok_or_else(|| "no document open".into())+}++/// Enabled while the active document has a perspective ruler.+fn has_ruler(s: &Session) -> std::result::Result<(), String> {+ s.active().filter(|st| st.doc.perspective_ruler.is_some()).map(|_| ()).ok_or_else(|| "no perspective ruler".into())+}++/// Not a Photoshop menu item: the Ruler tool's options bar sets the ruler up, and painting tools+/// show a snap switch while one is on.+const MENU: &[&str] = &[];++/// The perspective ruler commands, registered with the engine.+pub fn specs() -> Vec<CommandSpec> {+ vec![+ CommandSpec {+ id: "view.perspectiveRuler.onePoint",+ label: "1-Point Perspective",+ menu: MENU,+ shortcut: None,+ params: "{} — a ruler with one vanishing point, replacing the current one",+ enabled: has_doc,+ run: |s, _| create(s, 1),+ journal: true,+ },+ CommandSpec {+ id: "view.perspectiveRuler.twoPoint",+ label: "2-Point Perspective",+ menu: MENU,+ shortcut: None,+ params: "{} — two vanishing points on the eye level",+ enabled: has_doc,+ run: |s, _| create(s, 2),+ journal: true,+ },+ CommandSpec {+ id: "view.perspectiveRuler.threePoint",+ label: "3-Point Perspective",+ menu: MENU,+ shortcut: None,+ params: "{} — two on the eye level and a third for upright lines",+ enabled: has_doc,+ run: |s, _| create(s, 3),+ journal: true,+ },+ CommandSpec {+ id: "view.perspectiveRuler.snap",+ label: "Snap to Perspective",+ menu: MENU,+ shortcut: None,+ params: "{} — toggle: painting strokes snap to the ruler",+ enabled: has_ruler,+ run: |s, _| toggle(s, |r| &mut r.snap),+ journal: false,+ },+ CommandSpec {+ id: "view.perspectiveRuler.show",+ label: "Show Perspective Ruler",+ menu: MENU,+ shortcut: None,+ params: "{} — toggle: draw the ruler's lines and handles",+ enabled: has_ruler,+ run: |s, _| toggle(s, |r| &mut r.visible),+ journal: false,+ },+ CommandSpec {+ id: "view.perspectiveRuler.clear",+ label: "Clear Perspective Ruler",+ menu: MENU,+ shortcut: None,+ params: "{}",+ enabled: has_ruler,+ run: |s, _| {+ store(s, "Clear Perspective Ruler", None)?;+ Ok(json!({}))+ },+ journal: true,+ },+ CommandSpec {+ id: "view.perspectiveRuler.set",+ label: "Set Perspective Ruler",+ menu: &[],+ shortcut: None,+ params: r#"{"vanishingPoints":[[x,y],…]? (1–3; first two on the eye level, third for upright lines),"snap":bool?,"visible":bool?}"#,+ enabled: has_ruler,+ run: set,+ journal: true,+ },+ CommandSpec {+ id: "view.perspectiveRuler.info",+ label: "Perspective Ruler Info",+ menu: &[],+ shortcut: None,+ params: "{} → {type, vanishingPoints, snap, visible} or null",+ enabled: has_doc,+ run: |s, _| Ok(s.active().ok_or(EngineError::NoDocument)?.perspective().map_or(Value::Null, |r| r.inspect())),+ journal: false,+ },+ ]+}++#[cfg(test)]+mod tests {+ use super::*;++ /// Whether two points are equal up to rounding.+ fn close(a: [f64; 2], b: [f64; 2]) -> bool {+ (a[0] - b[0]).abs() < 1e-9 && (a[1] - b[1]).abs() < 1e-9+ }++ /// Two-point: a stroke goes toward whichever vanishing point it heads closest to, or upright.+ #[test]+ fn strokes_snap_to_the_closest_perspective_direction() {+ // Two-point: vanishing points left and right on the eye level at y = 100.+ let r = PerspectiveRuler { points: vec![[0.0, 100.0], [1000.0, 100.0]], snap: true, visible: true };+ let o = [500.0, 300.0];+ // Heading up and to the right: towards the right vanishing point.+ let d = r.direction(o, [560.0, 280.0]).unwrap();+ assert!(close(d, unit([500.0, -200.0]).unwrap()), "{d:?}");+ // Heading up and to the left: the left one.+ let d = r.direction(o, [440.0, 280.0]).unwrap();+ assert!(close(d, unit([-500.0, -200.0]).unwrap()));+ // Heading nearly straight down: upright (square to the eye level).+ let d = r.direction(o, [503.0, 400.0]).unwrap();+ assert!(d[0].abs() < 1e-9 && d[1].abs() > 0.999, "{d:?}");+ // Not moved yet: nothing.+ assert_eq!(r.direction(o, o), None);+ }++ /// One-point adds level and upright lines; three-point sends upright lines to its third point.+ #[test]+ fn one_point_keeps_level_and_upright_lines_and_three_point_converges_uprights() {+ let one = PerspectiveRuler { points: vec![[500.0, 200.0]], snap: true, visible: true };+ let d = one.direction([100.0, 600.0], [200.0, 602.0]).unwrap();+ assert!(close(d, [1.0, 0.0]), "level: {d:?}");+ let d = one.direction([100.0, 600.0], [150.0, 560.0]).unwrap();+ assert!(close(d, unit([400.0, -400.0]).unwrap()), "towards the point: {d:?}");+ let three = PerspectiveRuler { points: vec![[0.0, 100.0], [1000.0, 100.0], [500.0, 3000.0]], snap: true, visible: true };+ let d = three.direction([300.0, 500.0], [302.0, 600.0]).unwrap();+ assert!(close(d, unit([200.0, 2500.0]).unwrap()), "uprights lean to the third point: {d:?}");+ assert_eq!(three.snaps().len(), 3, "no fixed directions in three-point");+ }++ /// The commands need a document, validate their params and leave the ruler unchanged on bad input.+ #[test]+ fn commands_create_edit_toggle_and_refuse_bad_input() {+ let mut s = Session::new();+ assert!(s.execute("view.perspectiveRuler.twoPoint", json!({})).is_err(), "needs a document");+ s.execute("file.new", json!({"width": 1000, "height": 800})).unwrap();+ let r = s.execute("view.perspectiveRuler.twoPoint", json!({})).unwrap();+ assert_eq!(r["type"], 2);+ assert_eq!(s.active().unwrap().perspective().unwrap().points, vec![[100.0, 360.0], [900.0, 360.0]]);+ s.execute("view.perspectiveRuler.set", json!({"vanishingPoints": [[0, 50], [1000, 70], [500, 4000]]})).unwrap();+ assert_eq!(s.active().unwrap().perspective().unwrap().kind(), 3);+ s.execute("view.perspectiveRuler.snap", json!({})).unwrap();+ assert!(!s.active().unwrap().perspective().unwrap().snap);+ for bad in [+ json!({"vanishingPoints": []}),+ json!({"vanishingPoints": [[1, 2], [3, 4], [5, 6], [7, 8]]}),+ json!({"vanishingPoints": [[1e300, 0]]}),+ json!({"vanishingPoints": [[1]]}),+ json!({"snap": 3}),+ ] {+ assert!(s.execute("view.perspectiveRuler.set", bad.clone()).is_err(), "{bad}");+ }+ assert_eq!(s.active().unwrap().perspective().unwrap().kind(), 3, "unchanged by bad input");+ assert_eq!(s.execute("view.perspectiveRuler.info", json!({})).unwrap()["snap"], false);+ s.execute("view.perspectiveRuler.clear", json!({})).unwrap();+ assert!(s.active().unwrap().perspective().is_none());+ assert!(s.execute("view.perspectiveRuler.snap", json!({})).is_err());+ assert_eq!(s.execute("view.perspectiveRuler.info", json!({})).unwrap(), Value::Null);+ }++ /// Placing and moving vanishing points are history steps; Snap and Show are not.+ #[test]+ fn the_points_are_the_documents_and_undo_but_the_switches_do_not() {+ let mut s = Session::new();+ s.execute("file.new", json!({"width": 1000, "height": 800})).unwrap();+ let revision = s.active().unwrap().revision;+ s.execute("view.perspectiveRuler.onePoint", json!({})).unwrap();+ assert_eq!(s.active().unwrap().doc.perspective_ruler, Some(vec![[500.0, 360.0]]));+ assert!(s.active().unwrap().revision > revision, "placing a ruler is an edit");+ s.execute("view.perspectiveRuler.set", json!({"vanishingPoints": [[300, 200]]})).unwrap();+ // Snap and Show are not history: undo goes back past the move, not the switch.+ s.execute("view.perspectiveRuler.snap", json!({})).unwrap();+ s.execute("edit.undo", json!({})).unwrap();+ assert_eq!(s.active().unwrap().doc.perspective_ruler, Some(vec![[500.0, 360.0]]), "move undone");+ assert!(!s.active().unwrap().perspective().unwrap().snap, "the switch stays");+ s.execute("edit.undo", json!({})).unwrap();+ assert_eq!(s.active().unwrap().perspective(), None, "placing undone");+ // Setting the same points again isn't a step.+ s.execute("edit.redo", json!({})).unwrap();+ let revision = s.active().unwrap().revision;+ s.execute("view.perspectiveRuler.set", json!({"vanishingPoints": [[500, 360]]})).unwrap();+ assert_eq!(s.active().unwrap().revision, revision);+ }+}@@ -34,6 +34,9 @@ pub struct DocState { pub isolated_layers: Vec<LayerId>, /// Painting symmetry preset or sampled path (tool state, not document pixels). pub symmetry: Option<symmetry_cmds::PaintingSymmetry>,+ /// The perspective ruler's Snap and Show switches (view state; the vanishing points are+ /// the document's, `Document::perspective_ruler`).+ pub perspective_view: crate::perspective_cmds::PerspectiveView, /// Layers panel: layers whose effects list is collapsed under their row (the fx triangle; /// view state, not history). Effects lists start open. pub fx_collapsed: Vec<LayerId>,@@ -99,6 +102,7 @@ impl DocState { channel_view: Default::default(), isolated_layers: Vec::new(), symmetry: None,+ perspective_view: Default::default(), fx_collapsed: Vec::new(), show_only: None, floating: None,@@ -118,6 +118,7 @@ pub use cmd::paint_cmds; use cmd::path_edit_cmds; pub use cmd::pattern_cmds; pub use cmd::pattern_fill_cmds;+pub use cmd::perspective_cmds; pub use cmd::perspective_crop_cmds; pub use cmd::photo_cmds; pub use cmd::pick_cmds;@@ -265,6 +265,7 @@ pub(crate) fn doc_m(d: &Document, sink: &mut dyn Sink) -> DocM { notes: d.notes.clone(), text_styles: d.text_styles.clone(), slices: d.slices.clone(),+ perspective_ruler: d.perspective_ruler.clone(), } } @@ -666,6 +667,12 @@ impl Loader<'_> { notes: m.notes.clone(), text_styles: m.text_styles.clone(), slices,+ // A hand-edited or damaged ruler is dropped rather than refusing the file, by the+ // rule the engine checks edits with.+ perspective_ruler: m+ .perspective_ruler+ .clone()+ .filter(|p| photocraft_doc::perspective_ruler_ok(p, f64::from(m.size.width), f64::from(m.size.height))), }) } @@ -102,6 +102,18 @@ pub struct DocM { /// Web slices (user and layer-based; layer ids are remapped on load). #[serde(default, skip_serializing_if = "photocraft_doc::Slices::is_empty")] pub slices: photocraft_doc::Slices,+ /// The perspective ruler: its vanishing points (absent: no ruler). A point of the wrong shape+ /// reads as no ruler rather than failing the whole manifest ([`ruler_or_none`]).+ #[serde(default, skip_serializing_if = "Option::is_none", deserialize_with = "ruler_or_none")]+ pub perspective_ruler: Option<Vec<[f64; 2]>>,+}++/// Reads [`DocM::perspective_ruler`]: any point that isn't `[x, y]` (or a list that isn't one of+/// points) makes it `None`, so a damaged ruler is dropped and the document still opens.+fn ruler_or_none<'de, D: serde::Deserializer<'de>>(d: D) -> Result<Option<Vec<[f64; 2]>>, D::Error> {+ let v = serde_json::Value::deserialize(d)?;+ let Some(list) = v.as_array() else { return Ok(None) };+ Ok(list.iter().map(|p| serde_json::from_value::<[f64; 2]>(p.clone()).ok()).collect()) } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]@@ -399,6 +411,36 @@ mod channel_tests { } } +#[cfg(test)]+mod perspective_ruler_tests {+ use super::*;++ /// Just the ruler field, read as [`DocM`] reads it.+ #[derive(Deserialize)]+ struct Ruler {+ #[serde(default, deserialize_with = "ruler_or_none")]+ perspective_ruler: Option<Vec<[f64; 2]>>,+ }++ /// The ruler `v` (a manifest fragment) reads as.+ fn read(v: serde_json::Value) -> Option<Vec<[f64; 2]>> {+ serde_json::from_value::<Ruler>(v).unwrap().perspective_ruler+ }++ /// A ruler of the wrong shape reads as none instead of failing the manifest.+ #[test]+ fn a_misshapen_ruler_reads_as_none() {+ assert_eq!(read(serde_json::json!({"perspective_ruler": [[1.0, 2.0], [3.5, 4.0]]})), Some(vec![[1.0, 2.0], [3.5, 4.0]]));+ assert_eq!(read(serde_json::json!({})), None, "absent");+ assert_eq!(read(serde_json::json!({"perspective_ruler": null})), None);+ for bad in+ [serde_json::json!([[1, 2, 3]]), serde_json::json!([[1, "x"]]), serde_json::json!([5]), serde_json::json!("ruler"), serde_json::json!({"a": 1})]+ {+ assert_eq!(read(serde_json::json!({ "perspective_ruler": bad })), None, "{bad}");+ }+ }+}+ #[cfg(test)] mod shape_tests { use super::*;@@ -238,6 +238,7 @@ pub fn rich_doc(mode: ColorMode, depth: SampleType) -> Document { d.guides = Guides { horizontal: vec![10.0, 20.5], vertical: vec![100.25], colors: vec![GuideColor { vertical: true, position: 100.25, rgb: [0x33, 0x66, 0xff] }] }; d.ruler_origin = [12.5, -4.25];+ d.perspective_ruler = Some(vec![[-40.5, 30.0], [900.25, 31.0], [450.0, 2400.0]]); let mut sel = Surface::new(PixelFormat::GRAY8); scribble(&mut sel, 14, false); d.selection = Some(sel);@@ -25,3 +25,23 @@ fn f64_coordinates_reload_bit_exact() { assert_eq!(back.ruler_origin.map(f64::to_bits), [x, y].map(f64::to_bits)); assert_eq!(back, doc); }++#[test]+fn a_perspective_ruler_saves_and_a_damaged_one_is_dropped() {+ let mut doc = Document::new("Ruler", Size::new(4, 4), ColorMode::Rgb, SampleType::U8);+ doc.perspective_ruler = Some(vec![[-1.5, 2.0], [7.25, 2.0]]);+ let bytes = photocraft_format::save_to_bytes(&doc, &Default::default()).unwrap();+ assert_eq!(photocraft_format::load_from_bytes(&bytes).unwrap().perspective_ruler, doc.perspective_ruler);+ // A point absurdly far from the canvas (the engine would refuse to edit it): dropped.+ doc.perspective_ruler = Some(vec![[0.0, 0.0], [1.0e300, 2.0]]);+ let bytes = photocraft_format::save_to_bytes(&doc, &Default::default()).unwrap();+ assert_eq!(photocraft_format::load_from_bytes(&bytes).unwrap().perspective_ruler, None);+ // Four points isn't a ruler: the file still opens, without one.+ doc.perspective_ruler = Some(vec![[0.0, 0.0]; 4]);+ let bytes = photocraft_format::save_to_bytes(&doc, &Default::default()).unwrap();+ assert_eq!(photocraft_format::load_from_bytes(&bytes).unwrap().perspective_ruler, None);+ // Files without one open without one.+ doc.perspective_ruler = None;+ let bytes = photocraft_format::save_to_bytes(&doc, &Default::default()).unwrap();+ assert_eq!(photocraft_format::load_from_bytes(&bytes).unwrap().perspective_ruler, None);+}@@ -180,6 +180,7 @@ impl PhotocraftApp { stylus: Default::default(), control_click: Default::default(), background_jobs: false,+ perspective_drag: None, min_frame_ms: None, last_frame_start_ms: 0.0, jobs: Default::default(),@@ -1332,6 +1332,7 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: crate::retouch_ui::draw_source_marker(app, &painter, &xf); crate::vector_ui::draw_overlay(app, &painter, &xf, &doc); crate::symmetry_ui::draw(app, &painter, &xf);+ crate::perspective_ruler_ui::draw(app, &painter, &xf); crate::analysis_ui::draw_overlay(app, &painter, &xf); crate::gradient_ui::draw_overlay(app, &painter, &xf); crate::slice_ui::draw_overlay(app, &painter, &xf);@@ -16,6 +16,8 @@ pub struct Drag { pub clear: bool, /// ⇧ constraint of a painting stroke (stroke_constraint.rs). pub constrain: Option<crate::stroke_constraint::Axis>,+ /// The line a stroke snapped to on the perspective ruler, once its direction is locked.+ pub perspective: Option<crate::stroke_constraint::Axis>, /// Modifiers held now (updated on every pointer event of the gesture). pub live: egui::Modifiers, /// Which of the modifiers held at the press have been released since.@@ -43,6 +45,7 @@ impl Drag { erase, clear: false, constrain: None,+ perspective: None, live: modifiers, released: egui::Modifiers::NONE, reposition: false,@@ -38,6 +38,9 @@ fn tool_move(app: &mut PhotocraftApp, x: f64, y: f64, pressure: f32, mods: egui: // ⇧: straight 0/45/90° strokes, 45° gradient angles (stroke_constraint.rs). let last = d.points.last().map_or(d.start, |p| [p[0], p[1]]); let [x, y] = crate::stroke_constraint::constrain(d.tool, &mut d.constrain, d.start, last, [x, y], mods.shift, zoom);+ // The perspective ruler: strokes snap towards a vanishing point.+ let ruler = app.session.active().and_then(|st| st.perspective());+ let [x, y] = crate::stroke_constraint::perspective(d.tool, &mut d.perspective, d.start, [x, y], mods.shift, ruler.as_ref(), zoom); // ⇧: a selection-tool drag of the selection locks to multiples of 45° (#6231). The Move // tool's own drags were already locked by `move_mods::filter_event`. let [x, y] = crate::move_mods::selection_drag_to(d, [x, y], mods.shift && tool != Tool::Move);@@ -64,6 +67,10 @@ pub fn tool_event(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) if crate::symmetry_ui::pointer(app, ev, crate::workspace_ui::sticky_mods(app, mods)) { return; }+ // The perspective ruler: with the Ruler tool, a press on a vanishing point drags it.+ if crate::perspective_ruler_ui::pointer(app, ev) {+ return;+ } // An Alt+right-drag armed for this press (`paint_mouse`): taken before anything else can // consume the event, so it never outlives the press it was armed for (#297). let armed = std::mem::take(&mut app.brush_resize_armed);@@ -514,6 +521,8 @@ pub fn tool_event(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) } let last = d.points.last().map_or(d.start, |p| [p[0], p[1]]); let [x, y] = crate::stroke_constraint::constrain(d.tool, &mut d.constrain, d.start, last, [x, y], mods.shift, zoom);+ let ruler = app.session.active().and_then(|st| st.perspective());+ let [x, y] = crate::stroke_constraint::perspective(d.tool, &mut d.perspective, d.start, [x, y], mods.shift, ruler.as_ref(), zoom); let [x, y] = crate::move_mods::selection_drag_to(&mut d, [x, y], mods.shift && app.active_tool() != Tool::Move); // A Move-tool click (released where it was pressed) selects, it never moves: snapping // the release point would otherwise nudge the layer onto a nearby edge.@@ -269,6 +269,7 @@ pub use tools::move_lock; pub use tools::move_mods; pub use tools::object_finder; pub use tools::perspective_crop_ui;+pub use tools::perspective_ruler_ui; pub use tools::perspective_ui; pub use tools::puppet_ui; pub use tools::retouch_ui;@@ -645,6 +646,9 @@ pub struct PhotocraftApp { /// `jobs_ui`). The desktop app turns it on; off (the default), everything runs inline as /// before, which tests and scripts rely on. pub background_jobs: bool,+ /// A vanishing point of the perspective ruler being dragged: (the document's id, point,+ /// where). By id, so a drag never lands on another document that took its tab's place.+ pub perspective_drag: Option<(photocraft_doc::DocId, usize, [f64; 2])>, /// Shortest time between frames (ms), or `None` for no cap. A present mode that doesn't wait /// for the display (Low-Latency Drawing) would otherwise redraw as fast as it can while /// anything keeps requesting repaints (a drag hovering, autoscroll, Liquify held still).@@ -119,6 +119,19 @@ pub(super) fn checked(app: &PhotocraftApp, id: &str) -> Option<bool> { if let Some(checked) = crate::panel_docking::checked(app, id) { return Some(checked); }+ // The perspective ruler's toggles (the command palette), and which kind of ruler is on.+ if let Some(item) = id.strip_prefix("view.perspectiveRuler.") {+ let r = app.session.active().and_then(|st| st.perspective());+ let r = r.as_ref();+ return match item {+ "snap" => Some(r.is_some_and(|r| r.snap)),+ "show" => Some(r.is_some_and(|r| r.visible)),+ "onePoint" => Some(r.is_some_and(|r| r.kind() == 1)),+ "twoPoint" => Some(r.is_some_and(|r| r.kind() == 2)),+ "threePoint" => Some(r.is_some_and(|r| r.kind() == 3)),+ _ => None,+ };+ } use photocraft_doc::{ColorMode, SampleType}; if let Some(name) = id.strip_prefix("window.theme.").filter(|n| *n != "toggle") { let auto = app.session.prefs().interface.appearance_mode == photocraft_engine::prefs::AppearanceMode::Auto;@@ -640,6 +640,9 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { if matches!(tool, Tool::Brush | Tool::Pencil | Tool::Eraser) { crate::symmetry_ui::menu(app, ui); }+ if crate::stroke_constraint::connects(tool) {+ crate::perspective_ruler_ui::snap_button(app, ui);+ } crate::brush_panel::commit_gesture(app, ui.ctx(), &brush_before, &brush); }); reveal_focused_control(ui);@@ -29,6 +29,7 @@ pub mod move_lock; pub mod move_mods; pub mod object_finder; pub mod perspective_crop_ui;+pub mod perspective_ruler_ui; pub mod perspective_ui; pub mod puppet_ui; pub mod retouch_ui;@@ -520,6 +520,7 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui, tool: Tool) -> bo if ui.add_enabled_ui(has, |ui| crate::widgets::secondary_button(ui, tl!("Clear"), 0.0)).inner.clicked() { let _ = app.run("image.analysis.rulerTool", json!({"clear": true})); }+ crate::perspective_ruler_ui::ruler_tool_options(app, ui); true } Tool::Count => {new file mode 100644@@ -0,0 +1,359 @@+//! The perspective ruler on the canvas and in the options bars. The Ruler tool's options bar+//! adds, shows and clears it (its Perspective option, as Clip Studio Paint makes perspective+//! rulers with its Ruler tool), and with the Ruler tool the vanishing points drag: the new+//! perspective previews during the drag and is set on release (`view.perspectiveRuler.set`).+//! Painting tools show a snap switch while there is a ruler; the snapping itself is in+//! `stroke_constraint::perspective`. On the canvas: the eye level and lines fanning out from+//! each vanishing point.++use crate::PhotocraftApp;+use crate::canvas::{ToolEvent, ViewXform};+use egui::{Color32, Pos2, Stroke};+use photocraft_engine::perspective_cmds::PerspectiveRuler;+use serde_json::json;++/// Screen points around a vanishing point that grab it.+const GRAB: f32 = 14.0;+/// Lines drawn from each vanishing point across the canvas.+const RAYS: usize = 24;++/// The ruler as drawn now: the document's, or the one being dragged.+fn shown(app: &PhotocraftApp) -> Option<PerspectiveRuler> {+ let st = app.session.active()?;+ let mut r = st.perspective()?;+ if let Some((doc, i, p)) = app.perspective_drag+ && doc == st.doc.id+ && let Some(slot) = r.points.get_mut(i)+ {+ *slot = p;+ }+ Some(r)+}++/// With the Ruler tool, a press on a vanishing point drags it; returns true while the ruler takes+/// the pointer (so the press doesn't measure). Painting tools never grab a point, so a stroke+/// that starts on one paints.+pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent) -> bool {+ if app.active_tool() != crate::state::Tool::Ruler && app.perspective_drag.is_none() {+ return false;+ }+ let Some(id) = app.session.active().map(|st| st.doc.id) else {+ app.perspective_drag = None;+ return false;+ };+ match ev {+ ToolEvent::Down { x, y, .. } => {+ app.perspective_drag = None;+ let Some(r) = shown(app).filter(|r| r.visible) else { return false };+ let Some(xf) = ViewXform::active(app) else { return false };+ let tol = f64::from(GRAB / xf.zoom.max(1e-3));+ let hit = r.points.iter().position(|p| (p[0] - x).hypot(p[1] - y) <= tol);+ match hit {+ Some(i) => {+ app.perspective_drag = Some((id, i, [x, y]));+ true+ }+ None => false,+ }+ }+ ToolEvent::Move { x, y, .. } => match &mut app.perspective_drag {+ Some((doc, _, p)) if *doc == id => {+ *p = [x, y];+ true+ }+ // The document the drag started on is no longer active: the drag is over.+ Some(_) => {+ app.perspective_drag = None;+ true+ }+ None => false,+ },+ ToolEvent::Up { x, y } => {+ let Some((doc, i, _)) = app.perspective_drag.take() else { return false };+ if doc == id+ && let Some(mut r) = app.session.active().and_then(|st| st.perspective())+ && let Some(slot) = r.points.get_mut(i)+ {+ *slot = [x, y];+ if let Err(e) = app.run("view.perspectiveRuler.set", json!({"vanishingPoints": r.points})) {+ app.ui.status = e;+ app.ui.status_error = true;+ }+ }+ true+ }+ }+}++/// The options bar's snap switch, shown while the document has a perspective ruler: on, strokes+/// snap to the ruler; off, they draw freely.+pub fn snap_button(app: &mut PhotocraftApp, ui: &mut egui::Ui) {+ let Some(snap) = app.session.active().and_then(|st| st.perspective()).map(|r| r.snap) else { return };+ let tip =+ if snap { tl!("Snapping to the perspective ruler (click to draw freely)") } else { tl!("Drawing freely (click to snap to the perspective ruler)") };+ let resp = crate::icons::button_with_icon_size(ui, "ruler", 24.0, 14.0, snap, tip);+ let resp = crate::brush_picker::named(resp, "Snap to perspective ruler");+ if resp.clicked()+ && let Err(e) = app.run("view.perspectiveRuler.snap", json!({}))+ {+ app.ui.status = e;+ app.ui.status_error = true;+ }+}++/// The Ruler tool's Perspective option: add a 1-, 2- or 3-point ruler, or none, and show or snap+/// to it.+pub fn ruler_tool_options(app: &mut PhotocraftApp, ui: &mut egui::Ui) {+ let Some(r) = app.session.active().map(|st| st.perspective()) else { return };+ crate::widgets::vline(ui, 22.0);+ let dim = crate::theme::Tokens::get(ui.ctx()).text_dim;+ ui.add(egui::Label::new(egui::RichText::new(tl!("Perspective")).color(dim)).selectable(false));+ let mut kind = r.as_ref().map_or(0, PerspectiveRuler::kind);+ let opts = [(0, "None"), (1, "1-Point"), (2, "2-Point"), (3, "3-Point")];+ let mut run = None;+ if crate::widgets::dropdown(ui, "perspective-ruler", tl!("Perspective Ruler"), &mut kind, &opts, 100.0) {+ run = Some(match kind {+ 1 => "view.perspectiveRuler.onePoint",+ 2 => "view.perspectiveRuler.twoPoint",+ 3 => "view.perspectiveRuler.threePoint",+ _ => "view.perspectiveRuler.clear",+ });+ }+ if let Some(r) = &r {+ let (mut show, mut snap) = (r.visible, r.snap);+ if crate::widgets::checkbox(ui, &mut show, tl!("Show")).changed() {+ run = Some("view.perspectiveRuler.show");+ }+ if crate::widgets::checkbox(ui, &mut snap, tl!("Snap")).changed() {+ run = Some("view.perspectiveRuler.snap");+ }+ }+ if let Some(id) = run+ && let Err(e) = app.run(id, json!({}))+ {+ app.ui.status = e;+ app.ui.status_error = true;+ }+}++/// Points spread evenly around the document's edges, for the fan of lines from a vanishing point.+fn edge_points(w: f64, h: f64, n: usize) -> Vec<[f64; 2]> {+ let perimeter = 2.0 * (w + h);+ (0..n)+ .map(|k| {+ let mut t = perimeter * (k as f64 + 0.5) / n as f64;+ if t < w {+ return [t, 0.0];+ }+ t -= w;+ if t < h {+ return [w, t];+ }+ t -= h;+ if t < w {+ return [w - t, h];+ }+ [0.0, h - (t - w)]+ })+ .collect()+}++/// The ruler's lines and handles over the canvas.+pub fn draw(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform) {+ let Some(r) = shown(app).filter(|r| r.visible) else { return };+ let Some(st) = app.session.active() else { return };+ let (w, h) = (f64::from(st.doc.size.width), f64::from(st.doc.size.height));+ let painter = painter.with_clip_rect(painter.clip_rect().intersect(xf.rect));+ let screen = |p: [f64; 2]| xf.to_screen(p[0] as f32, p[1] as f32);+ let ok = |p: Pos2| p.x.is_finite() && p.y.is_finite();+ let colors = [Color32::from_rgb(64, 160, 255), Color32::from_rgb(255, 120, 80), Color32::from_rgb(120, 210, 90)];+ let shade = Stroke::new(2.5, Color32::from_black_alpha(70));+ // The fan of lines from each vanishing point, through the canvas and beyond it.+ let far = (w + h) * 4.0;+ for (i, vp) in r.points.iter().enumerate() {+ let c = colors[i % colors.len()];+ let line = Stroke::new(1.0, c.gamma_multiply(if r.snap { 0.55 } else { 0.3 }));+ for e in edge_points(w, h, RAYS) {+ let (dx, dy) = (e[0] - vp[0], e[1] - vp[1]);+ let l = dx.hypot(dy);+ if l < 1e-6 {+ continue;+ }+ let end = [vp[0] + dx / l * far, vp[1] + dy / l * far];+ let (a, b) = (screen(*vp), screen(end));+ if ok(a) && ok(b) {+ painter.line_segment([a, b], line);+ }+ }+ }+ // The eye level, across the whole view.+ if let Some((o, d)) = r.eye_level() {+ let reach = far * 2.0;+ let (a, b) = (screen([o[0] - d[0] * reach, o[1] - d[1] * reach]), screen([o[0] + d[0] * reach, o[1] + d[1] * reach]));+ if ok(a) && ok(b) {+ painter.line_segment([a, b], shade);+ painter.line_segment([a, b], Stroke::new(1.5, Color32::from_rgb(255, 210, 60)));+ }+ }+ // The vanishing points: handles to drag.+ for (i, vp) in r.points.iter().enumerate() {+ let p = screen(*vp);+ if !ok(p) {+ continue;+ }+ let c = colors[i % colors.len()];+ painter.circle(p, 7.0, Color32::from_black_alpha(90), Stroke::new(2.0, c));+ painter.circle_filled(p, 2.5, c);+ }+}++#[cfg(test)]+mod tests {+ use super::*;+ use serde_json::json;++ /// An app with a 400 × 300 document open.+ fn app() -> PhotocraftApp {+ let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());+ app.run("file.new", json!({"width": 400, "height": 300})).unwrap();+ app+ }++ /// The Ruler tool's Perspective option adds a ruler, its Show and Snap boxes switch it, and None clears it.+ #[test]+ fn the_ruler_tools_perspective_option_adds_shows_and_clears_a_ruler() {+ use egui_kittest::kittest::Queryable;+ let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(520.0, 300.0)).build_ui_state(+ |ui, app: &mut PhotocraftApp| {+ ui.horizontal(|ui| ruler_tool_options(app, ui));+ },+ app(),+ );+ h.run();+ let ruler = |h: &egui_kittest::Harness<'_, PhotocraftApp>| h.state().session.active().unwrap().perspective();+ assert!(h.query_by_label("Show").is_none(), "no switches without a ruler");+ h.get_by_label("Perspective Ruler").click();+ h.run();+ h.get_by_label("2-Point").click();+ h.run();+ assert_eq!(ruler(&h).map(|r| r.kind()), Some(2));+ h.get_by_label("Show").click();+ h.run();+ assert!(!ruler(&h).unwrap().visible, "hidden");+ h.get_by_label("Snap").click();+ h.run();+ assert!(!ruler(&h).unwrap().snap, "drawing freely");+ h.get_by_label("Perspective Ruler").click();+ h.run();+ h.get_by_label("None").click();+ h.run();+ assert_eq!(ruler(&h), None, "cleared");+ }++ /// Painting tools' snap switch appears only with a ruler and flips snapping.+ #[test]+ fn the_options_bar_button_switches_snapping() {+ use egui_kittest::kittest::Queryable;+ let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(200.0, 60.0)).build_ui_state(+ |ui, app: &mut PhotocraftApp| {+ ui.horizontal(|ui| snap_button(app, ui));+ },+ app(),+ );+ h.run();+ assert!(h.query_by_label("Snap to perspective ruler").is_none(), "no ruler, no button");+ h.state_mut().run("view.perspectiveRuler.onePoint", json!({})).unwrap();+ h.run();+ let snap = |h: &egui_kittest::Harness<'_, PhotocraftApp>| h.state().session.active().unwrap().perspective().unwrap().snap;+ assert!(snap(&h));+ h.get_by_label("Snap to perspective ruler").click();+ h.run();+ assert!(!snap(&h), "off: draw freely");+ h.get_by_label("Snap to perspective ruler").click();+ h.run();+ assert!(snap(&h), "back on");+ }++ /// A wobbly stroke lands on its perspective line, and the Ruler tool drags a vanishing point as one undoable step without painting.+ #[test]+ fn painting_snaps_to_the_ruler_and_a_vanishing_point_drags() {+ let mut app = app();+ app.ui.tool = crate::state::Tool::Brush;+ app.run("view.perspectiveRuler.twoPoint", json!({})).unwrap();+ let r = app.session.active().unwrap().perspective().unwrap();+ assert_eq!(r.points, vec![[40.0, 135.0], [360.0, 135.0]]);+ // A stroke heading towards the right vanishing point, wobbling: every point lands on the+ // line from its start to that point.+ let mods = egui::Modifiers::NONE;+ crate::canvas::tool_event(&mut app, ToolEvent::Down { x: 100.0, y: 250.0, pressure: 1.0 }, mods);+ for (x, y) in [(120.0, 240.0), (150.0, 225.0), (180.0, 214.0), (210.0, 200.0)] {+ crate::canvas::tool_event(&mut app, ToolEvent::Move { x, y, pressure: 1.0 }, mods);+ }+ let pts = app.drag.as_ref().unwrap().points.clone();+ let dir = [260.0_f64, -115.0];+ for p in pts.iter().skip(1) {+ let cross = (p[0] - 100.0) * dir[1] - (p[1] - 250.0) * dir[0];+ assert!(cross.abs() < 1e-6, "off the perspective line: {p:?}");+ }+ crate::canvas::tool_event(&mut app, ToolEvent::Up { x: 210.0, y: 200.0 }, mods);+ // A painting tool never grabs a vanishing point: a stroke may start on one.+ assert!(!pointer(&mut app, ToolEvent::Down { x: 41.0, y: 136.0, pressure: 1.0 }));+ // With the Ruler tool, dragging the left vanishing point moves it, and doesn't paint.+ app.ui.tool = crate::state::Tool::Ruler;+ let layers = app.session.active().unwrap().doc.layers.clone();+ assert!(pointer(&mut app, ToolEvent::Down { x: 41.0, y: 136.0, pressure: 1.0 }));+ assert!(pointer(&mut app, ToolEvent::Move { x: 10.0, y: 120.0, pressure: 1.0 }));+ assert!(pointer(&mut app, ToolEvent::Up { x: 0.0, y: 110.0 }));+ assert_eq!(app.session.active().unwrap().perspective().unwrap().points[0], [0.0, 110.0]);+ assert_eq!(app.session.active().unwrap().doc.layers, layers, "no paint");+ // The move is one undoable step.+ app.run("edit.undo", json!({})).unwrap();+ assert_eq!(app.session.active().unwrap().perspective().unwrap().points[0], [40.0, 135.0]);+ app.run("edit.redo", json!({})).unwrap();+ // Away from the points, the Ruler tool measures as usual.+ assert!(!pointer(&mut app, ToolEvent::Down { x: 200.0, y: 200.0, pressure: 1.0 }));+ }++ /// A drag whose document stops being active (another tab, a new document) ends without+ /// changing either document's ruler.+ #[test]+ fn a_drag_never_lands_on_another_document() {+ let mut app = app();+ app.ui.tool = crate::state::Tool::Ruler;+ app.run("view.perspectiveRuler.twoPoint", json!({})).unwrap();+ let first = app.session.active().unwrap().doc.id;+ assert!(pointer(&mut app, ToolEvent::Down { x: 40.0, y: 135.0, pressure: 1.0 }));+ app.run("file.new", json!({"width": 400, "height": 300})).unwrap();+ app.run("view.perspectiveRuler.twoPoint", json!({})).unwrap();+ assert_ne!(app.session.active().unwrap().doc.id, first);+ assert!(pointer(&mut app, ToolEvent::Move { x: 10.0, y: 10.0, pressure: 1.0 }), "the stale drag is taken, not painted");+ assert_eq!(app.perspective_drag, None, "and ended");+ assert!(!pointer(&mut app, ToolEvent::Up { x: 10.0, y: 10.0 }));+ assert_eq!(app.session.active().unwrap().perspective().unwrap().points[0], [40.0, 135.0], "the new document's ruler is untouched");+ assert!(app.session.set_active(0));+ assert_eq!(app.session.active().unwrap().perspective().unwrap().points[0], [40.0, 135.0], "and so is the first one's");+ }++ /// With snapping off, or Shift held (which gives the usual straight 45° lines instead), strokes don't follow the ruler.+ #[test]+ fn snapping_off_or_shift_leaves_the_ruler() {+ let mut app = app();+ app.ui.tool = crate::state::Tool::Brush;+ app.run("view.perspectiveRuler.onePoint", json!({})).unwrap();+ app.run("view.perspectiveRuler.snap", json!({})).unwrap();+ let mods = egui::Modifiers::NONE;+ crate::canvas::tool_event(&mut app, ToolEvent::Down { x: 50.0, y: 50.0, pressure: 1.0 }, mods);+ crate::canvas::tool_event(&mut app, ToolEvent::Move { x: 90.0, y: 77.0, pressure: 1.0 }, mods);+ let last = *app.drag.as_ref().unwrap().points.last().unwrap();+ assert_eq!([last[0], last[1]], [90.0, 77.0], "snap off: as drawn");+ }++ /// The fan of lines starts from points spread along all four edges.+ #[test]+ fn edge_points_go_round_the_canvas() {+ let pts = edge_points(100.0, 50.0, 6);+ assert_eq!(pts.len(), 6);+ assert!(pts.iter().all(|p| p[0] >= 0.0 && p[0] <= 100.0 && p[1] >= 0.0 && p[1] <= 50.0));+ assert!(pts.iter().all(|p| p[0] == 0.0 || p[0] == 100.0 || p[1] == 0.0 || p[1] == 50.0), "on the edges: {pts:?}");+ }+}@@ -84,6 +84,36 @@ pub fn constrain(tool: Tool, axis: &mut Option<Axis>, start: [f64; 2], last: [f6 } } +/// The perspective ruler with Snap to Perspective: a painting stroke stays on the line through+/// where it started towards the vanishing point (or the upright or level direction) it set off+/// closest to. ⇧ takes over (straight 45° lines); without a ruler the point passes through.+pub fn perspective(+ tool: Tool,+ axis: &mut Option<Axis>,+ start: [f64; 2],+ p: [f64; 2],+ shift: bool,+ ruler: Option<&photocraft_engine::perspective_cmds::PerspectiveRuler>,+ zoom: f32,+) -> [f64; 2] {+ let Some(ruler) = ruler.filter(|r| r.snap && !shift && connects(tool)) else {+ *axis = None;+ return p;+ };+ let a = axis.get_or_insert(Axis { origin: start, dir: None });+ if a.dir.is_none() {+ let reach = LOCK_DISTANCE * 2.0 / f64::from(zoom.max(1e-3));+ if (p[0] - a.origin[0]).hypot(p[1] - a.origin[1]) < reach {+ return a.origin;+ }+ a.dir = ruler.direction(a.origin, p);+ }+ match a.dir {+ Some(d) => project(a.origin, d, p),+ None => p,+ }+}+ /// Where the ⇧-click line preview starts (#257): the end of the last stroke on the active /// document, while ⇧ is held with a painting tool and nothing is being painted. pub fn line_preview_start(app: &crate::PhotocraftApp, tool: Tool, shift: bool) -> Option<[f64; 2]> {