diff --git a/crates/engine/src/cmd/masks.rs b/crates/engine/src/cmd/masks.rs
--- a/crates/engine/src/cmd/masks.rs
+++ b/crates/engine/src/cmd/masks.rs
@@ -551,7 +551,7 @@ pub fn specs() -> Vec<CommandSpec> {
             "AI Mask Model Status",
             [],
             None,
-            "{} — whether this build has AI masks, whether the SAM 3 model is installed (and where), loaded, busy, and the download's progress → {available, installed, dir, loaded, busy, analyzing, sizeBytes, license, licenseUrl, mirrors, download: {running, done, total, file, error, finished}}",
+            "{} — whether this build has AI masks, whether the SAM 3 model is installed (and where), loaded, busy, and the download's progress → {available, installed, dir, loaded, busy, analyzing, sizeBytes, diskBytes (what the model's files and partial downloads take now), license, licenseUrl, mirrors, download: {running, done, total, file, error, finished}}",
             super::always,
             |s, _| {
                 let g = &s.segmenter;
@@ -564,6 +564,7 @@ pub fn specs() -> Vec<CommandSpec> {
                     "busy": g.busy(),
                     "analyzing": g.analyzing(),
                     "sizeBytes": crate::segment::MODEL_BYTES,
+                    "diskBytes": g.disk_bytes(),
                     "license": crate::segment::LICENSE_NAME,
                     "licenseUrl": crate::segment::LICENSE_URL,
                     "mirrors": g.mirrors().len(),
@@ -604,6 +605,22 @@ pub fn specs() -> Vec<CommandSpec> {
             super::always,
             |s, _| Ok(json!({"cancelled": s.segmenter.cancel_download()}))
         ),
+        cmd!(
+            query "segment.model.remove",
+            "Remove AI Mask Model",
+            [],
+            None,
+            "{id: \"sam3\"} — delete the SAM 3 model's files and partial downloads from the model folder, nothing else; refused while it downloads or computes a mask; masks already made stay, and the next Object or Describe selection offers the download again → {removed: [paths], bytes}",
+            super::always,
+            |s, p| {
+                let c = "segment.model.remove";
+                if p.get("id").and_then(Value::as_str) != Some("sam3") {
+                    return Err(bad(c, "needs `id`: \"sam3\" (the only mask model); nothing was removed"));
+                }
+                let (removed, bytes) = s.segmenter.remove_files().map_err(|e| bad(c, e))?;
+                Ok(json!({"removed": removed, "bytes": bytes}))
+            }
+        ),
         cmd!(
             "mask.refine",
             "Refine Mask Edges",
diff --git a/crates/engine/src/segment.rs b/crates/engine/src/segment.rs
--- a/crates/engine/src/segment.rs
+++ b/crates/engine/src/segment.rs
@@ -35,6 +35,9 @@ pub const MODEL_BYTES: u64 = 3_439_938_512;
 /// The model's licence (not LightCraft's): shown before downloading.
 pub const LICENSE_NAME: &str = "SAM License (Meta)";
 pub const LICENSE_URL: &str = "https://github.com/facebookresearch/sam3/blob/main/LICENSE";
+/// The files a download puts in the model folder (`facebook/sam3`; a download in progress is `<name>.part`). Named here
+/// so builds without `sam` can still report and remove a downloaded model.
+pub const MODEL_FILES: [&str; 3] = ["model.safetensors", "vocab.json", "merges.txt"];
 /// How errors about a missing model start (frontends decide on [`crate::ErrorKind::ModelMissing`], not on this text).
 pub const NOT_INSTALLED: &str = "The SAM 3 model is not installed";
 /// Longest a waiting command (CLI, MCP) waits for the model (loading on a CPU can take minutes).
@@ -176,6 +179,54 @@ impl Segmenter {
         false
     }
 
+    /// Bytes the model's files and partial downloads take in the model folder (0 when there is no folder or nothing in it).
+    pub fn disk_bytes(&self) -> u64 {
+        let Some(dir) = &self.dir else { return 0 };
+        MODEL_FILES
+            .iter()
+            .flat_map(|n| [dir.join(n), dir.join(format!("{n}.part"))])
+            .filter_map(|p| std::fs::symlink_metadata(p).ok())
+            // (the scope of remove_files: a file or a link, counted by its own size and never followed; a folder is left alone)
+            .filter(|m| !m.is_dir())
+            .fold(0u64, |sum, m| sum.saturating_add(m.len()))
+    }
+
+    /// Delete the model's files and partial downloads from the model folder, nothing else (`segment.model.remove`):
+    /// `(paths removed, bytes freed)`. Refused while the model downloads or computes a mask. A file that cannot be
+    /// deleted (another program holds it open) is an error that names it; the others are gone by then.
+    pub fn remove_files(&self) -> Result<(Vec<String>, u64), String> {
+        if self.busy() || self.download_status().running {
+            return Err(
+                "the SAM 3 model is in use (a download or an AI mask is running); wait for it to finish (or stop the download), then remove it"
+                    .into(),
+            );
+        }
+        let dir = self.dir.as_ref().ok_or("no folder is set for the SAM 3 model")?;
+        let (mut removed, mut bytes, mut failed) = (Vec::new(), 0u64, Vec::new());
+        for name in MODEL_FILES.iter().flat_map(|n| [n.to_string(), format!("{n}.part")]) {
+            let path = dir.join(&name);
+            // (a link is removed itself, never what it points to; a folder of that name is left alone)
+            let meta = match std::fs::symlink_metadata(&path) {
+                Ok(meta) if meta.is_dir() => continue,
+                Ok(meta) => meta,
+                // (not there: nothing to remove; any other error means we cannot tell, so it is reported)
+                Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
+                Err(e) => {
+                    failed.push(format!("{}: {e}", path.display()));
+                    continue;
+                }
+            };
+            match std::fs::remove_file(&path) {
+                Ok(()) => {
+                    removed.push(path.display().to_string());
+                    bytes = bytes.saturating_add(meta.len());
+                }
+                Err(e) => failed.push(format!("{}: {e}", path.display())),
+            }
+        }
+        if failed.is_empty() { Ok((removed, bytes)) } else { Err(format!("could not remove {}", failed.join("; "))) }
+    }
+
     /// Whether requests are queued or running (loading, analyzing the photo, a click…).
     pub fn busy(&self) -> bool {
         #[cfg(feature = "sam")]
diff --git a/crates/engine/src/tests/segment.rs b/crates/engine/src/tests/segment.rs
--- a/crates/engine/src/tests/segment.rs
+++ b/crates/engine/src/tests/segment.rs
@@ -42,6 +42,91 @@ fn settle(s: &mut Session, limit: Duration) -> Vec<String> {
     }
 }
 
+/// `segment.model.remove` deletes the model's files and partial downloads, nothing else, and says how much it freed.
+#[test]
+fn removing_the_model_deletes_its_files_and_nothing_else() {
+    let mut s = Session::with_demo();
+    // no folder, no id, another id: nothing to remove, said plainly
+    let e = s.execute("segment.model.remove", &json!({"id": "sam3"})).unwrap_err().to_string();
+    assert!(e.contains("no folder"), "{e}");
+    let dir = damaged_model("remove");
+    std::fs::write(dir.join("model.safetensors.part"), b"half").unwrap();
+    std::fs::write(dir.join("notes.txt"), b"mine").unwrap();
+    s.segmenter.dir = Some(dir.clone());
+    let size = |names: &[&str]| names.iter().map(|n| std::fs::metadata(dir.join(n)).unwrap().len()).sum::<u64>();
+    let mine = size(&["model.safetensors", "vocab.json", "merges.txt", "model.safetensors.part"]);
+    assert_eq!(s.execute("segment.model.status", &json!({})).unwrap()["diskBytes"], mine);
+    for p in [json!({}), json!({"id": "x"}), json!({"id": ["sam3"]})] {
+        let e = s.execute("segment.model.remove", &p).unwrap_err().to_string();
+        assert!(e.contains("sam3") && e.contains("nothing was removed"), "{p}: {e}");
+    }
+    assert!(dir.join("model.safetensors").is_file(), "a refused call removes nothing");
+    let r = s.execute("segment.model.remove", &json!({"id": "sam3"})).unwrap();
+    assert_eq!(r["removed"].as_array().unwrap().len(), 4, "{r}");
+    assert_eq!(r["bytes"], mine);
+    assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 1, "only notes.txt is left");
+    assert_eq!(std::fs::read(dir.join("notes.txt")).unwrap(), b"mine");
+    assert_eq!(s.execute("segment.model.status", &json!({})).unwrap()["diskBytes"], 0);
+    // again: nothing there, nothing to say but so
+    assert_eq!(s.execute("segment.model.remove", &json!({"id": "sam3"})).unwrap()["bytes"], 0);
+    let _ = std::fs::remove_dir_all(dir);
+}
+
+/// A model file that is a symbolic link counts by the link's own size (never followed) and is removed as a link, so the size
+/// shown and what Remove deletes agree; what the link points to survives.
+#[test]
+fn a_linked_model_file_is_counted_and_removed_as_a_link() {
+    let dir = tmp("remove-link");
+    let outside = tmp("remove-link-target");
+    std::fs::create_dir_all(&dir).unwrap();
+    std::fs::create_dir_all(&outside).unwrap();
+    let target = outside.join("weights.bin");
+    std::fs::write(&target, vec![7u8; 4096]).unwrap();
+    let link = dir.join("model.safetensors");
+    #[cfg(unix)]
+    let made = std::os::unix::fs::symlink(&target, &link);
+    #[cfg(windows)]
+    let made = std::os::windows::fs::symlink_file(&target, &link);
+    #[cfg(not(any(unix, windows)))]
+    let made: std::io::Result<()> = Err(std::io::Error::other("no symbolic links on this platform"));
+    if let Err(e) = made {
+        lightcraft_testkit::skip!(System, "cannot create a symbolic link here: {e}");
+        let _ = std::fs::remove_dir_all(&dir);
+        let _ = std::fs::remove_dir_all(&outside);
+        return;
+    }
+    std::fs::write(dir.join("vocab.json"), b"{}").unwrap();
+    let link_len = std::fs::symlink_metadata(&link).unwrap().len();
+    let mut s = Session::with_demo();
+    s.segmenter.dir = Some(dir.clone());
+    assert_eq!(s.segmenter.disk_bytes(), link_len + 2, "the link counts by its own size");
+    #[cfg(unix)]
+    assert!(s.segmenter.disk_bytes() > 2, "a link is never zero bytes on unix");
+    let r = s.execute("segment.model.remove", &json!({"id": "sam3"})).unwrap();
+    assert_eq!(r["removed"].as_array().unwrap().len(), 2, "{r}");
+    assert_eq!(r["bytes"], link_len + 2);
+    assert!(std::fs::symlink_metadata(&link).is_err(), "the link is gone");
+    assert_eq!(std::fs::read(&target).unwrap().len(), 4096, "what it pointed to is untouched");
+    let _ = std::fs::remove_dir_all(&dir);
+    let _ = std::fs::remove_dir_all(&outside);
+}
+
+/// A model folder that cannot be inspected (any error but "not found") is an error naming it, not a silent success.
+#[test]
+fn an_unreadable_model_folder_is_an_error_that_names_it() {
+    // a NUL in the path is rejected by the OS with an error that is not NotFound, on every platform
+    let dir = std::env::temp_dir().join(format!("lc-sam3-bad\0dir-{}", std::process::id()));
+    let kind = std::fs::symlink_metadata(dir.join("model.safetensors")).unwrap_err().kind();
+    assert_ne!(kind, std::io::ErrorKind::NotFound, "the trigger must not be a missing file");
+    let mut s = Session::with_demo();
+    s.segmenter.dir = Some(dir.clone());
+    let e = s.execute("segment.model.remove", &json!({"id": "sam3"})).unwrap_err().to_string();
+    assert!(e.contains("could not remove") && e.contains("model.safetensors"), "{e}");
+    // a folder that simply is not there is still nothing to remove, not an error
+    s.segmenter.dir = Some(tmp("never-created"));
+    assert_eq!(s.execute("segment.model.remove", &json!({"id": "sam3"})).unwrap()["bytes"], 0);
+}
+
 #[test]
 fn status_and_model_commands_without_the_model() {
     let mut s = Session::with_demo();
diff --git a/crates/ui-egui/src/dialogs/settings.rs b/crates/ui-egui/src/dialogs/settings.rs
--- a/crates/ui-egui/src/dialogs/settings.rs
+++ b/crates/ui-egui/src/dialogs/settings.rs
@@ -57,6 +57,32 @@ pub const TABS: &[(&str, &str)] = &[
     ("denoise", "AI Denoise"),
 ];
 
+/// Settings ▸ AI Denoise, below its models: the model behind Object and Describe masks (SAM 3) takes up to 3.4 GB, and its
+/// Remove gives the room back (`segment.model.remove`, as a face or denoise model's Remove does). Nothing shows while
+/// no file of it is on disk.
+fn ai_mask_model(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) {
+    let st = app.session.execute("segment.model.status", &json!({})).unwrap_or(Value::Null);
+    let bytes = st["diskBytes"].as_u64().unwrap_or(0);
+    if st["available"] != true || bytes == 0 {
+        return;
+    }
+    heading(ui, t, crate::i18n::tr("AI masks"));
+    hint(
+        ui,
+        t,
+        crate::i18n::tr(
+            "Object and Describe masks use the SAM 3 model. Removing it frees the room it takes: masks already made stay, and the next new selection offers the download again.",
+        ),
+    );
+    let in_use = st["busy"] == true || st["download"]["running"] == true;
+    let label = crate::i18n::tr_format!("Remove SAM 3 ({size})", size = crate::panels::denoise::mb(Some(bytes)));
+    let r = ui.add_enabled(!in_use, egui::Button::new(label));
+    register(ui, "settings:sam3Remove", r.rect);
+    if r.clicked() {
+        let _ = app.run("segment.model.remove", json!({"id": "sam3"}));
+    }
+}
+
 /// Thumbnail cache sizes offered (MB).
 const CACHE_SIZES: [u32; 5] = [512, 1024, 2048, 4096, 8192];
 
@@ -72,7 +98,10 @@ pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, tab: &str) {
         "interface" => interface_tab(app, ui, &t),
         "slideshow" => slideshow_tab(app, ui, &t, true),
         "faces" => crate::panels::faces::settings_tab(app, ui, &t),
-        "denoise" => crate::panels::denoise::settings_tab(app, ui, &t),
+        "denoise" => {
+            crate::panels::denoise::settings_tab(app, ui, &t);
+            ai_mask_model(app, ui, &t);
+        }
         _ => general_tab(app, ui, &t),
     }
 }
diff --git a/crates/ui-egui/src/panels/denoise.rs b/crates/ui-egui/src/panels/denoise.rs
--- a/crates/ui-egui/src/panels/denoise.rs
+++ b/crates/ui-egui/src/panels/denoise.rs
@@ -633,7 +633,7 @@ fn pace_for(focused: bool, minimized: bool, worked: f64, moved: f64, watching: b
     }
 }
 
-pub(super) fn mb(bytes: Option<u64>) -> String {
+pub(crate) fn mb(bytes: Option<u64>) -> String {
     match bytes {
         Some(b) if b >= 10_000_000 => tr_format!("{} MB", (b as f64 / 1e6).round() as u64),
         Some(b) if b >= 1_000_000 => tr_format!("{:.1} MB", b as f64 / 1e6),
diff --git a/crates/ui-egui/src/tests.rs b/crates/ui-egui/src/tests.rs
--- a/crates/ui-egui/src/tests.rs
+++ b/crates/ui-egui/src/tests.rs
@@ -66,6 +66,7 @@ mod rating;
 mod recheck;
 mod rename_person;
 mod rules_editor;
+mod sam_remove;
 mod sam_setup;
 mod scroll;
 mod slider_reset;
diff --git a/crates/ui-egui/src/tests/sam_remove.rs b/crates/ui-egui/src/tests/sam_remove.rs
new file mode 100644
--- /dev/null
+++ b/crates/ui-egui/src/tests/sam_remove.rs
@@ -0,0 +1,65 @@
+//! Settings ▸ AI Denoise ▸ AI masks: the button that removes the SAM 3 model shows its size, runs
+//! `segment.model.remove` and goes away with the files. No model is downloaded or loaded; the files are stand-ins.
+
+use std::time::Duration;
+
+use lightcraft_engine::segment::Segmenter;
+use serde_json::json;
+
+use crate::headless::Headless;
+use crate::{LightcraftApp, Services};
+
+const T: Duration = Duration::from_secs(20);
+
+/// The model folder of a test, removed again when the test ends, passed or not.
+struct Folder(std::path::PathBuf);
+
+impl Drop for Folder {
+    fn drop(&mut self) {
+        let _ = std::fs::remove_dir_all(&self.0);
+    }
+}
+
+fn has(h: &Headless, id: &str) -> bool {
+    h.app.widgets.iter().any(|(w, _)| w == id)
+}
+
+#[test]
+fn settings_removes_the_mask_model_and_the_button_shows_its_size() {
+    let dir = Folder(std::env::temp_dir().join(format!("lc-ui-sam-remove-{}", std::process::id())));
+    let _ = std::fs::remove_dir_all(&dir.0);
+    std::fs::create_dir_all(&dir.0).unwrap();
+    for name in ["model.safetensors", "vocab.json", "merges.txt"] {
+        std::fs::write(dir.0.join(name), vec![0u8; 2_000_000]).unwrap();
+    }
+    std::fs::write(dir.0.join("notes.txt"), b"mine").unwrap();
+    let mut session = lightcraft_engine::Session::with_demo();
+    session.segmenter.dir = Some(dir.0.clone());
+    let mut h = Headless::new(LightcraftApp::new(session, Services::default()), [1200.0, 800.0], 1.0);
+    h.settle(T);
+    let open = h.request("engine.execute", json!({"command": "app.settings", "params": {"tab": "denoise"}}), T);
+    assert_eq!(open["ok"], true, "{open}");
+    h.settle(T);
+    for _ in 0..4 {
+        h.step();
+    }
+    if !Segmenter::AVAILABLE {
+        // a build without AI masks offers nothing to remove
+        assert!(!has(&h, "settings:sam3Remove"));
+        return;
+    }
+    // Given the model's files, the button names their size
+    assert!(has(&h, "settings:sam3Remove"), "{:?}", h.app.widgets);
+    assert!(h.painted_text().iter().any(|t| t.contains("Remove SAM 3 (6.0 MB)")), "{:?}", h.painted_text());
+    // When it is pressed
+    let r = h.request("ui.clickWidget", json!({"id": "settings:sam3Remove"}), T);
+    assert_eq!(r["ok"], true, "{r}");
+    h.settle(T);
+    for _ in 0..4 {
+        h.step();
+    }
+    // Then the model's files are gone, the others stay, and the button with them
+    assert_eq!(std::fs::read_dir(&dir.0).unwrap().count(), 1);
+    assert!(dir.0.join("notes.txt").is_file());
+    assert!(!has(&h, "settings:sam3Remove"));
+}
diff --git a/docs/ai-masks.md b/docs/ai-masks.md
--- a/docs/ai-masks.md
+++ b/docs/ai-masks.md
@@ -104,7 +104,9 @@ dialog says why and LightCraft carries on without the model.
 Agents and scripts use the same commands (control channel, `lightcraft-cli mcp --connect`):
 `segment.model.status`, `segment.model.download {"acknowledged": true}` (refused without the
 explicit `acknowledged`: pass it only after the user agreed to the download and the licence),
-`segment.model.cancel`.
+`segment.model.cancel`, `segment.model.remove {"id": "sam3"}` (deletes the model's files and partial downloads
+from the model folder, nothing else, to get the room back; Settings ▸ AI Denoise ▸ AI masks has it as a button that
+names the size).
 
 ### Download locations
 
@@ -190,9 +192,10 @@ Therefore:
 | `mask.objectPoint` | `{x, y, exclude?: bool, id?}` | one click on the selected mask's Object selection (≤ 64 clicks) |
 | `mask.refineDetail` | `{id?, component?}` | the zoomed-in detail pass → `{started}` |
 | `segment.prepare` | `{}` | load the model and analyze the active photo → `{busy}` |
-| `segment.model.status` | `{}` | `{available, installed, remote, dir, loaded, busy, analyzing, sizeBytes, license, licenseUrl, mirrors, download}` |
+| `segment.model.status` | `{}` | `{available, installed, remote, dir, loaded, busy, analyzing, sizeBytes, diskBytes, license, licenseUrl, mirrors, download}` |
 | `segment.model.download` | `{acknowledged: true}` | start the download in the background → `{started}` |
 | `segment.model.cancel` | `{}` | stop it (it resumes next time) → `{cancelled}` |
+| `segment.model.remove` | `{id: "sam3"}` | delete the model's files and partial downloads from the model folder, nothing else; refused while it downloads or computes a mask → `{removed, bytes}` |
 
 `remote` reports the configured endpoint, not its health. `installed` describes local weights only.
 `download` is `{running, done, total, file, error, cancelled, finished}`: `cancelled` is true when the last
diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md
--- a/docs/parity-checklist.md
+++ b/docs/parity-checklist.md
@@ -293,7 +293,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts).
 | LR-MASK-SUBJECT | Select subject | P2 | 🟡 | `cmd:mask.add` (`subject`), `crates/pipeline/src/masks.rs` | saliency heuristic, no segmentation model; a photo that carries person mattes (DNG semantic masks: iPhone ProRAW portrait / skin / hair / teeth / glasses mattes) uses them instead (synthetic files only: no corpus file carries person mattes) |
 | LR-MASK-SKY | Select sky | P2 | 🟡 | `cmd:mask.add` (`sky`), `crates/pipeline/src/masks.rs` (`Mattes`), `crates/raw/src/semantic.rs` | heuristic; a photo that carries a sky matte (DNG semantic mask, e.g. iPhone ProRAW) uses it instead, through orientation / crop / lens corrections, preview = export (checked on one CC0 iPhone 12 Pro ProRAW and synthetic files) |
 | LR-MASK-BACKGROUND | Select background | P2 | 🟡 | `cmd:mask.add` (`background`) | inverse of the subject mask (heuristic, or the photo's person mattes) |
-| LR-MASK-OBJECTS | Object selection | P2 | 🟡 | `cmd:mask.add` (`object`, `prompt`), `cmd:mask.objectPoint`, `cmd:mask.refineDetail`, `cmd:segment.prepare`, `cmd:segment.model.status`, `cmd:segment.model.download`, `cmd:segment.model.cancel`, `cmd:app.openSamModelFolder`, `crates/segment`, `crates/engine/src/segment.rs` | SAM 3 in pure Rust (candle; Metal on macOS, CPU elsewhere): Object tile → click to include, ⌥-click to leave out; Describe tile → a text prompt selects every instance ("sky", "the red car"); both also as Add/Subtract/Intersect components; + / − under the selected mask; comma lists (`car, road`); a zoomed-in detail pass for 5–10× finer edges on small objects; per-selection Edge (hard ↔ soft). The model runs on its own worker thread (the UI never waits; panics become errors; a job that fails on the Mac's GPU runs again on the CPU, which then serves the rest of the session, #1974), is unloaded after 10 min idle, and is optional: the segmentation is stored with the mask (288² logits), so renders and exports never need it. Weights are never bundled (SAM License): without them the app offers a consented background download (mirrors, resume, timeouts, SHA-256) — **but no default download location is configured yet** (users set `LIGHTCRAFT_SAM3_MIRRORS` or install by hand: the dialog names the three files, opens the configured model folder with the host opener, preserves setup on an open error, and links docs/ai-masks.md). Not verified against Lightroom's Select Object; no brush/box object mode; the first click on a photo waits for its analysis (~4 s on an M4 Pro, much longer on CPU) |
+| LR-MASK-OBJECTS | Object selection | P2 | 🟡 | `cmd:mask.add` (`object`, `prompt`), `cmd:mask.objectPoint`, `cmd:mask.refineDetail`, `cmd:segment.prepare`, `cmd:segment.model.status`, `cmd:segment.model.download`, `cmd:segment.model.cancel`, `cmd:segment.model.remove`, `cmd:app.openSamModelFolder`, `crates/segment`, `crates/engine/src/segment.rs` | SAM 3 in pure Rust (candle; Metal on macOS, CPU elsewhere): Object tile → click to include, ⌥-click to leave out; Describe tile → a text prompt selects every instance ("sky", "the red car"); both also as Add/Subtract/Intersect components; + / − under the selected mask; comma lists (`car, road`); a zoomed-in detail pass for 5–10× finer edges on small objects; per-selection Edge (hard ↔ soft). The model runs on its own worker thread (the UI never waits; panics become errors; a job that fails on the Mac's GPU runs again on the CPU, which then serves the rest of the session, #1974), is unloaded after 10 min idle, and is optional: the segmentation is stored with the mask (288² logits), so renders and exports never need it. Weights are never bundled (SAM License; Settings ▸ AI Denoise ▸ AI masks removes them again, with their size on the button): without them the app offers a consented background download (mirrors, resume, timeouts, SHA-256) — **but no default download location is configured yet** (users set `LIGHTCRAFT_SAM3_MIRRORS` or install by hand: the dialog names the three files, opens the configured model folder with the host opener, preserves setup on an open error, and links docs/ai-masks.md). Not verified against Lightroom's Select Object; no brush/box object mode; the first click on a photo waits for its analysis (~4 s on an M4 Pro, much longer on CPU) |
 | LR-MASK-PEOPLE | People parts | P2 | ⬜ | | shape exists (no UI): uses the photo's person / skin / hair / teeth / glasses mattes when it carries them (DNG semantic masks), else the subject heuristic |
 | LR-MASK-LANDSCAPE | Landscape classes | P2 | ⬜ | | shape exists, evaluates empty |
 | LR-MASK-BRUSH | Brush mask | P0 | 🟡 | `cmd:tool.brush`, `cmd:mask.brushStroke` (`autoMask`), `crates/pipeline/src/masks.rs`, `crates/ui-egui/src/brush_live.rs` | size/feather/flow/density/erase; Create New Mask starts a separate brush in paint mode; the stroke shows while the button is held with the committed stroke's dab spacing, feather and flow, so fast strokes have no gaps (#517); coverage builds up by passes, not hold time (holding the brush still adds nothing, live or committed, #518); Auto Mask: dabs weighted by similarity to the colour under the dab centre, refined by a guided filter on luminance (CPU + GPU); ⇧-click draws a straight stroke from where the last stroke ended (`connect`), tapering size, feather and flow from that stroke's values when they changed; no A/B brushes; pen pressure (Brush ▸ Pen Pressure, on by default; `pressure` per point in `mask.brushStroke`) scales each dab's size (a tenth to all of it) and flow, live and committed alike, where winit reports a pen force (Windows; not macOS / Linux tablets yet); no tilt |
diff --git a/docs/whats-new.md b/docs/whats-new.md
--- a/docs/whats-new.md
+++ b/docs/whats-new.md
@@ -1,5 +1,10 @@
 # What's new in LightCraft
 
+## 2026-10-26
+
+### Remove the AI mask model from Settings
+- The SAM 3 model behind Object and Describe masks takes up to 3.4 GB. Settings ▸ AI Denoise now has an AI masks section with a Remove SAM 3 button that shows the size it frees; `segment.model.remove` does the same for scripts. Only the model's files and its partial downloads go; masks already made stay, and the next new selection offers the download again.
+
 ## 2026-10-24
 
 ### Relative-date filters and smart albums follow the clock
