diff --git a/crates/ui-egui/src/dialogs/wide_angle_ui.rs b/crates/ui-egui/src/dialogs/wide_angle_ui.rs
--- a/crates/ui-egui/src/dialogs/wide_angle_ui.rs
+++ b/crates/ui-egui/src/dialogs/wide_angle_ui.rs
@@ -11,7 +11,7 @@
 
 use egui::{Align2, Color32, FontId, Pos2, Rect as ERect, Sense, Stroke, TextureHandle, pos2, vec2};
 use photocraft_algo::transform::Interp;
-use photocraft_algo::wideangle::{self, Camera, Constraint, Orientation, WideAngle, WideModel};
+use photocraft_algo::wideangle::{self, Camera, Constraint, Orientation, WideAngle, WideMesh, WideModel};
 use photocraft_color::PixelFormat;
 use photocraft_doc::LayerId;
 use photocraft_geom::Rect;
@@ -38,6 +38,8 @@ pub struct WideAngleDialog {
     ph: usize,
     src_tex: Option<TextureHandle>,
     out_tex: Option<TextureHandle>,
+    /// The proxy warp used by both the rendered preview and its constraint overlays.
+    mesh: Option<WideMesh>,
     dirty: bool,
     pub preview: bool,
     drag: Option<([f64; 2], [f64; 2])>,
@@ -86,6 +88,7 @@ impl WideAngleDialog {
         if self.src_tex.is_none() {
             self.src_tex = Some(ctx.load_texture("awa-src", tex_image(&self.proxy, self.pw, self.ph), egui::TextureOptions::LINEAR));
         }
+        self.mesh = Some(mesh);
         self.render_ms = crate::gpu_canvas::now_ms() - t0;
         self.dirty = false;
     }
@@ -143,6 +146,7 @@ pub fn open(app: &mut PhotocraftApp, ctx: &egui::Context) -> Result<(), String>
         ph,
         src_tex: None,
         out_tex: None,
+        mesh: None,
         dirty: true,
         preview: true,
         drag: None,
@@ -334,9 +338,9 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) {
             Orientation::Vertical => t.danger,
         };
         let pf = Rect::new(0, 0, d.pw as i32, d.ph as i32);
-        let mesh = (!show_src && !d.params.constraints.is_empty()).then(|| wideangle::solve(&d.proxy_params(), pf));
+        let mesh = if show_src { None } else { d.mesh.as_ref() };
         for c in &d.params.constraints {
-            let pts: Vec<Pos2> = match &mesh {
+            let pts: Vec<Pos2> = match mesh {
                 Some(m) => cam
                     .arc(c.a, c.b, 24)
                     .iter()
@@ -508,6 +512,65 @@ mod tests {
         menu(app, ctx, "filter.adaptiveWideAngle", &json!({"ui": {"add": {"a": [20, 50], "b": [140, 50]}}})).unwrap().unwrap();
     }
 
+    #[test]
+    fn constraint_overlay_reuses_the_preview_mesh_on_redraw() {
+        fn constraint_line(shape: &egui::Shape) -> Option<&[Pos2]> {
+            match shape {
+                egui::Shape::Path(p) if p.points.len() == 25 => Some(&p.points),
+                egui::Shape::Vec(shapes) => shapes.iter().find_map(constraint_line),
+                _ => None,
+            }
+        }
+        let mut app = grey_layer();
+        let ctx = egui::Context::default();
+        PhotocraftApp::setup_context(&ctx, Default::default());
+        open_with_a_constraint(&mut app, &ctx);
+        let input = || egui::RawInput { screen_rect: Some(ERect::from_min_size(Pos2::ZERO, vec2(1000.0, 600.0))), ..Default::default() };
+        ctx.run_ui(input(), |ui| show(&mut app, ui.ctx())).textures_delta.clear();
+        // Move only the cached warp. A fresh solve would draw the original line instead.
+        let d = app.wide_angle.as_mut().unwrap();
+        for (out, _) in &mut d.mesh.as_mut().unwrap().verts {
+            out[0] += 10.0;
+        }
+        let verts = d.mesh.as_ref().unwrap().verts.clone();
+        let render_ms = d.render_ms;
+        for (preview, dragging, shifted) in [(true, false, true), (false, false, false), (true, true, false), (true, false, true)] {
+            let d = app.wide_angle.as_mut().unwrap();
+            d.preview = preview;
+            d.drag = dragging.then_some(([20.0, 30.0], [140.0, 30.0]));
+            let mut output = ctx.run_ui(input(), |ui| show(&mut app, ui.ctx()));
+            output.textures_delta.clear();
+            let line = output.shapes.iter().find_map(|s| constraint_line(&s.shape)).expect("painted constraint line");
+            // The 160×100 image fits the 668-point-wide viewport. The source line is centred;
+            // the altered cached warp moves its centre ten proxy pixels to the right.
+            let centre_x = (line.first().unwrap().x + line.last().unwrap().x) * 0.5;
+            let expected_x = 350.0 + if shifted { 10.0 * (668.0 / 160.0) } else { 0.0 };
+            assert!((centre_x - expected_x).abs() < 0.1, "preview={preview}, dragging={dragging}: centre={centre_x}, expected={expected_x}");
+            let d = app.wide_angle.as_ref().unwrap();
+            assert_eq!(d.mesh.as_ref().unwrap().verts, verts, "redraw retains the cached warp");
+            assert_eq!(d.render_ms, render_ms, "redraw does not render the preview again");
+        }
+    }
+
+    #[test]
+    fn preview_mesh_refreshes_after_edits_and_history_steps() {
+        let mut app = grey_layer();
+        let ctx = egui::Context::default();
+        open_with_a_constraint(&mut app, &ctx);
+        let original = app.wide_angle.as_ref().unwrap().mesh.as_ref().unwrap().verts.clone();
+        menu(&mut app, &ctx, "filter.adaptiveWideAngle", &json!({"ui": {"set": {"scale": 120.0}}})).unwrap().unwrap();
+        let edited = app.wide_angle.as_ref().unwrap().mesh.as_ref().unwrap().verts.clone();
+        assert_ne!(edited, original);
+        for (step, expected) in [(Step::Undo, &original), (Step::Redo, &edited)] {
+            let d = app.wide_angle.as_mut().unwrap();
+            d.step(step);
+            assert!(d.dirty);
+            d.render(&ctx);
+            assert!(!d.dirty);
+            assert_eq!(&d.mesh.as_ref().unwrap().verts, expected);
+        }
+    }
+
     /// `orient` sets a constraint's orientation, as the right-click menu does; a bad index is
     /// an error that changes nothing.
     #[test]
