diff --git a/crates/algo/src/selection.rs b/crates/algo/src/selection.rs
--- a/crates/algo/src/selection.rs
+++ b/crates/algo/src/selection.rs
@@ -761,10 +761,12 @@ pub fn tone_range(px: &[[f32; 4]], lo: f32, hi: f32, falloff: f32) -> Vec<f32> {
 }
 
 mod distance;
+mod inverse;
 mod lasso;
 mod marquee;
 
 pub use distance::edt;
+pub use inverse::inverse_surface;
 pub(crate) use lasso::clip_segment;
 pub use lasso::{MAX_PAINTED_TILES, polygon_edge_tiles, polygon_surface};
 pub use marquee::{combine_sparse, combine_surfaces, ellipse_surface};
diff --git a/crates/algo/src/selection/inverse.rs b/crates/algo/src/selection/inverse.rs
new file mode 100644
--- /dev/null
+++ b/crates/algo/src/selection/inverse.rs
@@ -0,0 +1,104 @@
+//! Tile-wise selection inversion, with shared solid tiles and no canvas-sized buffers.
+
+use std::collections::HashMap;
+use std::sync::{Arc, OnceLock};
+
+use photocraft_color::{PixelFormat, read_sample};
+use photocraft_geom::{Rect, TILE_SIZE, TileCoord};
+use photocraft_raster::{Surface, Tile};
+
+use crate::photo_util::par_map;
+
+use super::coverage8;
+
+/// Invert coverage within `area`, returning a zero-default GRAY8 selection with nothing
+/// outside it. Missing tiles become shared solid tiles; selected solid tiles disappear.
+/// Each distinct interior source tile is inverted only once, preserving sharing and snapshots.
+/// Selections are GRAY8; other formats use their first channel, quantized to selection coverage.
+pub fn inverse_surface(source: &Surface, area: Rect) -> Surface {
+    let mut out = Surface::new(PixelFormat::GRAY8);
+    if area.is_empty() {
+        return out;
+    }
+    let (blank, full) = (out.solid_tile(&[0.0]), out.solid_tile(&[1.0]));
+    let default = coverage8(1.0 - source.default_pixel().first().copied().unwrap_or(0.0));
+    let missing = match default {
+        0 => None,
+        255 => Some(Arc::clone(&full)),
+        v => Some(out.solid_tile(&[f32::from(v) / 255.0])),
+    };
+    // Solid fills share one payload across many coordinates. Cache by payload, rather than
+    // scanning/copying the same 64 KiB for every tile. OnceLock allows parallel tile workers.
+    let cache: HashMap<_, OnceLock<Option<Arc<Tile>>>> =
+        source.tiles().filter(|(c, _)| area.contains_rect(&c.rect())).map(|(_, t)| (Arc::as_ptr(t) as usize, OnceLock::new())).collect();
+    let format = source.format();
+    let value = |t: &Tile, i: usize| {
+        if format == PixelFormat::GRAY8 {
+            t.bytes().get(i).map_or(default, |b| 255 - b)
+        } else {
+            let bpp = format.bytes_per_pixel();
+            t.bytes().get(i * bpp..(i + 1) * bpp).map_or(default, |pixel| coverage8(1.0 - read_sample(pixel, format.sample, 0)))
+        }
+    };
+    let coords: Vec<TileCoord> = area.tiles().collect();
+    let built = par_map(coords.len(), |k| {
+        let tc = *coords.get(k)?;
+        let rect = tc.rect();
+        let cut = rect.intersect(&area);
+        let input = source.tile(tc);
+        // At i32::MAX the coordinate rectangle is saturated and omits the tile's last
+        // column/row, so it must still take the clipped path.
+        if cut == rect && cut.width() == TILE_SIZE as u32 && cut.height() == TILE_SIZE as u32 {
+            let Some(input) = input else { return missing.clone().map(|t| (tc, t)) };
+            let cell = cache.get(&(Arc::as_ptr(input) as usize))?;
+            let tile = cell.get_or_init(|| {
+                if format == PixelFormat::GRAY8 {
+                    if input.bytes() == full.bytes() {
+                        return None;
+                    }
+                    if input.bytes() == blank.bytes() {
+                        return Some(Arc::clone(&full));
+                    }
+                    let mut tile = Tile::clone(input);
+                    for b in tile.bytes_mut() {
+                        *b = 255 - *b;
+                    }
+                    return Some(Arc::new(tile));
+                }
+                let mut tile = Tile::clone(&blank);
+                for (i, b) in tile.bytes_mut().iter_mut().enumerate() {
+                    *b = value(input, i);
+                }
+                canonical(tile, &blank, &full)
+            });
+            return tile.clone().map(|t| (tc, t));
+        }
+        // Boundary tiles start at zero so no inverted coverage leaks past the canvas.
+        let mut tile = Tile::clone(&blank);
+        let (x0, x1) = ((cut.x0 - rect.x0) as usize, (cut.x1 - rect.x0) as usize);
+        for y in (cut.y0 - rect.y0) as usize..(cut.y1 - rect.y0) as usize {
+            let start = y * TILE_SIZE as usize + x0;
+            if let Some(row) = tile.bytes_mut().get_mut(start..start + x1 - x0) {
+                for (i, b) in (start..).zip(row) {
+                    *b = input.map_or(default, |t| value(t, i));
+                }
+            }
+        }
+        canonical(tile, &blank, &full).map(|t| (tc, t))
+    });
+    out.put_tiles(built.into_iter().flatten());
+    out
+}
+
+fn canonical(tile: Tile, blank: &Arc<Tile>, full: &Arc<Tile>) -> Option<Arc<Tile>> {
+    if tile.bytes() == blank.bytes() {
+        None
+    } else if tile.bytes() == full.bytes() {
+        Some(Arc::clone(full))
+    } else {
+        Some(Arc::new(tile))
+    }
+}
+
+#[cfg(test)]
+mod tests;
diff --git a/crates/algo/src/selection/inverse/tests.rs b/crates/algo/src/selection/inverse/tests.rs
new file mode 100644
--- /dev/null
+++ b/crates/algo/src/selection/inverse/tests.rs
@@ -0,0 +1,85 @@
+use super::*;
+use photocraft_color::{ColorMode, SampleType};
+
+fn reference(source: &Surface, area: Rect) -> Surface {
+    let data: Vec<f32> = source.read_region(area).chunks_exact(source.channels()).map(|p| 1.0 - p[0]).collect();
+    let mut out = Surface::new(PixelFormat::GRAY8);
+    out.write_region(area, &data);
+    out.prune();
+    out
+}
+
+#[test]
+fn inverse_matches_float_reference_and_clips_boundary_tiles() {
+    // Negative, misaligned and empty bounds, including stored pixels beyond the canvas.
+    for area in [Rect::new(-17, -11, 530, 270), Rect::new(0, 0, 512, 256), Rect::new(3, 5, 4, 6), Rect::EMPTY] {
+        for sample in SampleType::ALL {
+            for default in [0.0, 0.25, 1.0] {
+                let fmt = PixelFormat::new(ColorMode::Grayscale, sample, false);
+                let mut source = Surface::with_default(fmt, &[default]);
+                source.fill_rect(Rect::new(-100, -100, 260, 120), &[1.0]);
+                // Every encoded GRAY8 value, including feathered coverage at tile seams.
+                let values: Vec<f32> = (0..256).map(|v| v as f32 / 255.0).collect();
+                source.write_region(Rect::new(240, 125, 496, 126), &values);
+                source.write_pixel(750, 280, &[0.7]);
+                let got = inverse_surface(&source, area);
+                let want = reference(&source, area);
+                let window = Rect::new(-30, -20, 800, 290);
+                assert_eq!(got.to_interleaved(window), want.to_interleaved(window), "{sample:?} {default} {area:?}");
+                assert_eq!(got.default_bytes(), &[0]);
+                assert_eq!(got.tile_count(), want.tile_count(), "zero tiles must be pruned");
+            }
+        }
+    }
+}
+
+#[test]
+fn inverse_preserves_sparse_storage_and_shared_payloads() {
+    let area = Rect::new(0, 0, 2048, 1024);
+    let empty = Surface::new(PixelFormat::GRAY8);
+    let selected = inverse_surface(&empty, area);
+    assert_eq!(selected.tile_count(), 32);
+    let full = selected.tile(TileCoord::new(0, 0)).unwrap();
+    assert!(selected.tiles().all(|(_, t)| Arc::ptr_eq(t, full)), "missing tiles share one solid payload");
+    let cleared = inverse_surface(&selected, area);
+    assert_eq!(cleared.tile_count(), 0, "fully selected tiles disappear");
+
+    let mut mixed = Surface::new(PixelFormat::GRAY8);
+    mixed.write_pixel(10, 11, &[0.25]);
+    let payload = mixed.tile(TileCoord::new(0, 0)).unwrap().clone();
+    mixed.put_tiles(area.tiles().map(|c| (c, payload.clone())));
+    let snapshot = mixed.clone();
+    let inverted = inverse_surface(&mixed, area);
+    let result = inverted.tile(TileCoord::new(0, 0)).unwrap();
+    assert!(inverted.tiles().all(|(_, t)| Arc::ptr_eq(t, result)), "shared mixed tiles are inverted once");
+    assert!(!Arc::ptr_eq(result, &payload));
+    assert_eq!(mixed.to_interleaved(area), snapshot.to_interleaved(area));
+    assert_eq!(inverse_surface(&inverted, area).to_interleaved(area), mixed.to_interleaved(area));
+}
+
+#[test]
+fn inverse_of_unpruned_zero_tiles_is_shared_full() {
+    let mut source = Surface::new(PixelFormat::GRAY8);
+    let area = Rect::new(0, 0, 768, 256);
+    source.fill_rect(Rect::new(0, 0, 256, 256), &[0.0]);
+    source.fill_rect(Rect::new(256, 0, 512, 256), &[1.0]);
+    let out = inverse_surface(&source, area);
+    assert_eq!(out.tile_count(), 2);
+    assert!(out.tile(TileCoord::new(1, 0)).is_none());
+    assert!(Arc::ptr_eq(out.tile(TileCoord::new(0, 0)).unwrap(), out.tile(TileCoord::new(2, 0)).unwrap()));
+}
+
+#[test]
+fn inverse_clips_at_coordinate_limits_and_uses_first_channel() {
+    for area in [Rect::new(i32::MAX - 255, 0, i32::MAX, 256), Rect::new(0, i32::MAX - 255, 256, i32::MAX), Rect::new(i32::MIN, -1, i32::MIN + 3, 2)] {
+        let source = Surface::new(PixelFormat::GRAY8);
+        let out = inverse_surface(&source, area);
+        assert_eq!(out.to_interleaved(area), reference(&source, area).to_interleaved(area));
+        assert_eq!(out.sample_channel(area.x1, area.y0, 0), 0.0);
+        assert_eq!(out.sample_channel(area.x0, area.y1, 0), 0.0);
+    }
+    let area = Rect::new(0, 0, 513, 260);
+    let mut source = Surface::with_default(PixelFormat::RGBA32F, &[0.25, 0.5, 0.75, 1.0]);
+    source.fill_rect(Rect::new(10, 10, 400, 200), &[0.75, 1.0, 0.0, 0.5]);
+    assert_eq!(inverse_surface(&source, area).to_interleaved(area), reference(&source, area).to_interleaved(area));
+}
diff --git a/crates/engine/examples/bench_selection_inverse.rs b/crates/engine/examples/bench_selection_inverse.rs
new file mode 100644
--- /dev/null
+++ b/crates/engine/examples/bench_selection_inverse.rs
@@ -0,0 +1,109 @@
+//! End-to-end timing of `select.inverse` on a 6000×4000 document.
+//!
+//! ```sh
+//! cargo run --release -p photocraft-engine --example bench_selection_inverse -- [--quick] [--reps 11]
+//! ```
+//!
+//! Three rows: inverting a rectangular marquee, an elliptical marquee (central 2/3 of the
+//! canvas) and `select.all`. Setup is never timed — only the `execute("select.inverse")`
+//! call between `Instant`s. After the timed op (before deselect) the resulting selection
+//! surface is inspected for storage diagnostics: unique tile payloads (distinct `Arc`
+//! allocations behind the coverage surface) and allocated tile count.
+
+use std::collections::HashSet;
+use std::time::Instant;
+
+use photocraft_engine::Session;
+use serde_json::{Value, json};
+
+type Res = Result<(), Box<dyn std::error::Error>>;
+
+fn arg<'a>(args: &'a [String], name: &str) -> Option<&'a String> {
+    args.iter().position(|a| a == name).and_then(|i| args.get(i + 1))
+}
+
+/// Nearest-rank percentile of a pre-sorted slice.
+fn pct(sorted: &[f64], p: f64) -> f64 {
+    let n = sorted.len();
+    let idx = ((p / 100.0) * n as f64).ceil() as usize;
+    sorted.get(idx.saturating_sub(1).min(n.saturating_sub(1))).copied().unwrap_or(0.0)
+}
+
+/// Selection setup for one row; each previous sample already deselected.
+fn setup(s: &mut Session, w: i32, h: i32, ellipse: Option<bool>) -> Result<Value, Box<dyn std::error::Error>> {
+    let params = match ellipse {
+        Some(ellipse) => {
+            // Central 2/3 of the canvas.
+            let x = w / 6;
+            let y = h / 6;
+            json!({
+                "x": x,
+                "y": y,
+                "width": w.saturating_mul(2) / 3,
+                "height": h.saturating_mul(2) / 3,
+                "ellipse": ellipse,
+            })
+        }
+        None => json!({}), // select.all
+    };
+    let id = if ellipse.is_some() { "select.rect" } else { "select.all" };
+    let v = s.execute(id, params)?;
+    Ok(v)
+}
+
+/// Storage diagnostics of the current selection surface: distinct tile payloads and tiles held.
+fn diagnostics(s: &Session) -> Result<(usize, usize), Box<dyn std::error::Error>> {
+    let Some(state) = s.active() else {
+        return Err("no active document after select.inverse".into());
+    };
+    let Some(sel) = state.doc.selection.as_ref() else {
+        return Err("no selection after select.inverse".into());
+    };
+    let mut payloads: HashSet<usize> = HashSet::new();
+    let mut count = 0usize;
+    for (_, tile) in sel.tiles() {
+        payloads.insert(std::sync::Arc::as_ptr(tile) as usize);
+        count += 1;
+    }
+    Ok((payloads.len(), count))
+}
+
+/// One measured sample: setup, time ONLY `select.inverse`, print diagnostics, deselect.
+fn sample(s: &mut Session, w: i32, h: i32, ellipse: Option<bool>) -> Result<(f64, usize, usize), Box<dyn std::error::Error>> {
+    setup(s, w, h, ellipse)?;
+    let t = Instant::now();
+    s.execute("select.inverse", json!({}))?;
+    let ms = t.elapsed().as_secs_f64() * 1000.0;
+    let (payloads, tiles) = diagnostics(s)?;
+    s.execute("select.deselect", json!({}))?;
+    Ok((ms, payloads, tiles))
+}
+
+fn main() -> Res {
+    let args: Vec<String> = std::env::args().collect();
+    let (w, h) = if args.iter().any(|a| a == "--quick") { (1200, 800) } else { (6000, 4000) };
+    let reps = arg(&args, "--reps").and_then(|v| v.parse::<usize>().ok()).unwrap_or(11).clamp(1, 100);
+
+    let mut s = Session::new();
+    s.execute("file.new", json!({"width": w, "height": h, "background": "transparent"}))?;
+
+    let rows: [(&str, Option<bool>); 3] = [("Inverse rectangular", Some(false)), ("Inverse ellipse", Some(true)), ("Inverse all", None)];
+    for (label, ellipse) in rows {
+        // Warm-up: one untimed sample to page in allocations and caches.
+        sample(&mut s, w, h, ellipse)?;
+        let mut times: Vec<f64> = Vec::with_capacity(reps);
+        for _ in 0..reps {
+            let (ms, payloads, tiles) = sample(&mut s, w, h, ellipse)?;
+            times.push(ms);
+            println!("sample: {ms:8.2} ms (selection tiles: {payloads} unique payloads, {tiles} allocated)");
+        }
+        times.sort_by(|a, b| a.total_cmp(b));
+        println!(
+            "{label:<22} ({w}×{h}, {reps} reps)  p50 {p50:8.2} ms   p95 {p95:8.2} ms   max {max:8.2} ms",
+            p50 = pct(&times, 50.0),
+            p95 = pct(&times, 95.0),
+            max = pct(&times, 100.0),
+        );
+    }
+    Ok(())
+}
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
@@ -363,12 +363,7 @@ fn build() -> Vec<CommandSpec> {
         cmd!("select.inverse", "Inverse", ["Select"], Some("Cmd+Shift+I"), "{}", has_selection, |s, _| {
             s.edit("Inverse", |doc, _| {
                 let old = doc.selection.take().unwrap_or_else(|| Surface::new(photocraft_color::PixelFormat::GRAY8));
-                let b = doc.bounds();
-                let data: Vec<f32> = old.read_region(b).into_iter().map(|v| 1.0 - v).collect();
-                let mut m = Surface::new(photocraft_color::PixelFormat::GRAY8);
-                m.write_region(b, &data);
-                m.prune();
-                doc.selection = Some(m);
+                doc.selection = Some(photocraft_algo::selection::inverse_surface(&old, doc.bounds()));
                 Ok(())
             })?;
             Ok(Value::Null)
diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs
--- a/crates/engine/src/tests.rs
+++ b/crates/engine/src/tests.rs
@@ -506,6 +506,41 @@ fn undo_and_redo_restore_the_targeted_layers() {
     assert_eq!(target(&s), copies);
 }
 
+#[test]
+fn selection_inverse_preserves_coverage_history_and_empty_selection() {
+    for depth in [8, 16, 32] {
+        let mut s = Session::new();
+        s.execute("file.new", json!({"width": 530, "height": 270, "depth": depth})).unwrap();
+        s.execute("select.rect", json!({"x": 250, "y": 10, "width": 30, "height": 30, "ellipse": true})).unwrap();
+        let before = s.active().unwrap().doc.clone();
+        let area = before.bounds();
+        let original = before.selection.as_ref().unwrap().to_interleaved(area);
+        s.execute("select.inverse", json!({})).unwrap();
+        let after = s.active().unwrap();
+        let inverted = after.doc.selection.as_ref().unwrap();
+        assert_eq!(inverted.to_interleaved(area), original.iter().map(|b| 255 - b).collect::<Vec<_>>());
+        assert_eq!(inverted.sample_channel(530, 269, 0), 0.0);
+        assert_eq!(inverted.sample_channel(529, 270, 0), 0.0);
+        assert_eq!(after.last_damage, Some(photocraft_geom::Rect::EMPTY));
+        assert_eq!(before.selection.as_ref().unwrap().to_interleaved(area), original);
+        assert!(s.undo());
+        assert_eq!(s.active().unwrap().doc.selection.as_ref().unwrap().to_interleaved(area), original);
+        assert!(s.redo());
+        s.execute("select.inverse", json!({})).unwrap();
+        assert_eq!(s.active().unwrap().doc.selection.as_ref().unwrap().to_interleaved(area), original);
+
+        s.execute("select.all", json!({})).unwrap();
+        s.execute("select.inverse", json!({})).unwrap();
+        let empty = s.active().unwrap().doc.selection.as_ref().unwrap();
+        assert_eq!(empty.tile_count(), 0);
+        // Keep Some(empty), as before: the next Inverse selects the whole canvas again.
+        s.execute("select.inverse", json!({})).unwrap();
+        assert_eq!(s.active().unwrap().doc.selection.as_ref().unwrap().content_bounds(), area);
+    }
+    let mut s = Session::new();
+    assert!(s.execute("select.inverse", json!({})).is_err());
+}
+
 #[test]
 fn selection_modes() {
     let mut s = session_with_doc();
