diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs
--- a/crates/doc/src/lib.rs
+++ b/crates/doc/src/lib.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(),
diff --git a/crates/engine/src/cmd.rs b/crates/engine/src/cmd.rs
--- a/crates/engine/src/cmd.rs
+++ b/crates/engine/src/cmd.rs
@@ -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());
diff --git a/crates/engine/src/cmd/layer_multi_cmds/damage.rs b/crates/engine/src/cmd/layer_multi_cmds/damage.rs
--- a/crates/engine/src/cmd/layer_multi_cmds/damage.rs
+++ b/crates/engine/src/cmd/layer_multi_cmds/damage.rs
@@ -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_encoding
diff --git a/crates/engine/src/cmd/perspective_cmds.rs b/crates/engine/src/cmd/perspective_cmds.rs
new file mode 100644
--- /dev/null
+++ b/crates/engine/src/cmd/perspective_cmds.rs
@@ -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);
+    }
+}
diff --git a/crates/engine/src/doc_state.rs b/crates/engine/src/doc_state.rs
--- a/crates/engine/src/doc_state.rs
+++ b/crates/engine/src/doc_state.rs
@@ -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,
diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs
--- a/crates/engine/src/lib.rs
+++ b/crates/engine/src/lib.rs
@@ -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;
diff --git a/crates/format/src/convert.rs b/crates/format/src/convert.rs
--- a/crates/format/src/convert.rs
+++ b/crates/format/src/convert.rs
@@ -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))),
         })
     }
 
diff --git a/crates/format/src/manifest.rs b/crates/format/src/manifest.rs
--- a/crates/format/src/manifest.rs
+++ b/crates/format/src/manifest.rs
@@ -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::*;
diff --git a/crates/format/tests/it/common/mod.rs b/crates/format/tests/it/common/mod.rs
--- a/crates/format/tests/it/common/mod.rs
+++ b/crates/format/tests/it/common/mod.rs
@@ -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);
diff --git a/crates/format/tests/it/float_roundtrip.rs b/crates/format/tests/it/float_roundtrip.rs
--- a/crates/format/tests/it/float_roundtrip.rs
+++ b/crates/format/tests/it/float_roundtrip.rs
@@ -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);
+}
diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs
--- a/crates/ui-egui/src/app.rs
+++ b/crates/ui-egui/src/app.rs
@@ -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(),
diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs
--- a/crates/ui-egui/src/canvas.rs
+++ b/crates/ui-egui/src/canvas.rs
@@ -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);
diff --git a/crates/ui-egui/src/canvas/drag.rs b/crates/ui-egui/src/canvas/drag.rs
--- a/crates/ui-egui/src/canvas/drag.rs
+++ b/crates/ui-egui/src/canvas/drag.rs
@@ -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,
diff --git a/crates/ui-egui/src/canvas/tool_input.rs b/crates/ui-egui/src/canvas/tool_input.rs
--- a/crates/ui-egui/src/canvas/tool_input.rs
+++ b/crates/ui-egui/src/canvas/tool_input.rs
@@ -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.
diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs
--- a/crates/ui-egui/src/lib.rs
+++ b/crates/ui-egui/src/lib.rs
@@ -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).
diff --git a/crates/ui-egui/src/menus/state.rs b/crates/ui-egui/src/menus/state.rs
--- a/crates/ui-egui/src/menus/state.rs
+++ b/crates/ui-egui/src/menus/state.rs
@@ -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;
diff --git a/crates/ui-egui/src/panels/options_bar.rs b/crates/ui-egui/src/panels/options_bar.rs
--- a/crates/ui-egui/src/panels/options_bar.rs
+++ b/crates/ui-egui/src/panels/options_bar.rs
@@ -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);
diff --git a/crates/ui-egui/src/tools.rs b/crates/ui-egui/src/tools.rs
--- a/crates/ui-egui/src/tools.rs
+++ b/crates/ui-egui/src/tools.rs
@@ -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;
diff --git a/crates/ui-egui/src/tools/analysis_ui.rs b/crates/ui-egui/src/tools/analysis_ui.rs
--- a/crates/ui-egui/src/tools/analysis_ui.rs
+++ b/crates/ui-egui/src/tools/analysis_ui.rs
@@ -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 => {
diff --git a/crates/ui-egui/src/tools/perspective_ruler_ui.rs b/crates/ui-egui/src/tools/perspective_ruler_ui.rs
new file mode 100644
--- /dev/null
+++ b/crates/ui-egui/src/tools/perspective_ruler_ui.rs
@@ -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:?}");
+    }
+}
diff --git a/crates/ui-egui/src/tools/stroke_constraint.rs b/crates/ui-egui/src/tools/stroke_constraint.rs
--- a/crates/ui-egui/src/tools/stroke_constraint.rs
+++ b/crates/ui-egui/src/tools/stroke_constraint.rs
@@ -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]> {
