Select and Mask: Output To New Document, with or without a layer mask (#2998) (#3007)

* Select and Mask: Output To New Document, with or without a layer mask (#2998)

select.refineEdge takes output newDocument / newDocumentWithMask: a new
document like the source (size, mode, depth, resolution, profile) with
the active layer cut out by the refined edge, or with the matte as a
layer mask. The source document is left untouched. The dialog's Output
choice lists all six targets, translated in every catalog.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* i18n: drop the duplicate pt-br New Layer row (main already has it)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
Valentin Klein 2026-10-11 21:16:45 +02:00 committed by GitHub
parent 8a72978549
commit 28874bbfad
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 127 additions and 11 deletions

View File

@ -259,9 +259,10 @@ fn refine_strokes(p: &Value) -> Result<Vec<matting::EdgeStroke>> {
}
/// Select and Mask: refines the selection edge, then outputs to the selection, a layer mask on
/// the active layer, or a new layer (with or without a mask). Decontaminating colours needs a
/// new layer, so it upgrades `selection` / `layerMask` output to `newLayerWithMask` (as
/// Photoshop does). New-layer outputs hide the source layer.
/// the active layer, a new layer or a new document (each with or without a mask).
/// Decontaminating colours needs new pixels, so it upgrades `selection` / `layerMask` output to
/// `newLayerWithMask` (as Photoshop does). New-layer outputs hide the source layer; new-document
/// outputs leave the source document untouched.
fn refine_edge(s: &mut Session, p: &Value) -> Result<Value> {
let params = refine_params(p);
let strokes = refine_strokes(p)?;
@ -272,8 +273,8 @@ fn refine_edge(s: &mut Session, p: &Value) -> Result<Value> {
let default_output =
if d.doc.selection.is_none() && active.and_then(|id| d.doc.layer(id)).and_then(|l| l.mask.as_ref()).is_some() { "layerMask" } else { "selection" };
let mut output = p.get("output").and_then(Value::as_str).unwrap_or(default_output).to_string();
if !matches!(output.as_str(), "selection" | "layerMask" | "newLayer" | "newLayerWithMask") {
return Err(bad("select.refineEdge", "output must be selection|layerMask|newLayer|newLayerWithMask"));
if !matches!(output.as_str(), "selection" | "layerMask" | "newLayer" | "newLayerWithMask" | "newDocument" | "newDocumentWithMask") {
return Err(bad("select.refineEdge", "output must be selection|layerMask|newLayer|newLayerWithMask|newDocument|newDocumentWithMask"));
}
if decontaminate && matches!(output.as_str(), "selection" | "layerMask") {
output = "newLayerWithMask".into();
@ -315,12 +316,26 @@ fn refine_edge(s: &mut Session, p: &Value) -> Result<Value> {
let empty = Region { bbox: Rect::EMPTY, mask: Vec::new() };
let reg = region.as_ref().unwrap_or(&empty);
let pixels = if decontaminate { matting::decontaminate(src, reg, params.radius, amount) } else { src.clone() };
let with_mask = output == "newLayerWithMask";
let with_mask = matches!(output.as_str(), "newLayerWithMask" | "newDocumentWithMask");
let (content, mask) = if with_mask {
(pixels, Some(LayerMask { surface: matting::region_surface(reg), ..LayerMask::reveal_all() }))
} else {
(matting::masked_copy(&pixels, reg), None)
};
if output.starts_with("newDocument") {
// Same canvas, colour mode, depth and profile, so the pixels need no conversion.
let mut doc = Document::new("Untitled", d.doc.size, d.doc.mode, d.doc.depth);
doc.resolution_dpi = d.doc.resolution_dpi;
doc.icc_profile = d.doc.icc_profile.clone();
doc.color_table = d.doc.color_table.clone();
doc.duotone = d.doc.duotone.clone();
let mut layer = Layer::new(src_layer.name.clone(), LayerContent::Raster(content));
layer.mask = mask;
let layer_id = layer.id;
doc.layers.push(layer);
let i = s.add_document(doc, None);
return Ok(json!({ "output": output, "document": i, "layer": layer_id.0, "selected": false }));
}
let new_id = s.edit("Select and Mask", |doc, act| {
let mut layer = Layer::new(name, LayerContent::Raster(content));
layer.mask = mask;
@ -374,7 +389,7 @@ pub fn specs() -> Vec<CommandSpec> {
"select.refineEdge",
"Refine Edge",
[],
r##"{"radius":px=0,"smartRadius":bool=false,"smooth":0..100=0,"feather":px=0,"contrast":0..100=0,"shiftEdge":-100..100=0,"decontaminate":bool=false,"amount":0..100=100,"output":"selection|layerMask|newLayer|newLayerWithMask"="selection","sampleAllLayers":bool=false,"refineStrokes":[{"points":[[x,y],…],"size":px=30,"mode":"add|subtract"="add"},…]=[]} (refineStrokes: the Refine Edge Brush, in painting order: add refines the painted area from the image, subtract keeps the original edge there)"##,
r##"{"radius":px=0,"smartRadius":bool=false,"smooth":0..100=0,"feather":px=0,"contrast":0..100=0,"shiftEdge":-100..100=0,"decontaminate":bool=false,"amount":0..100=100,"output":"selection|layerMask|newLayer|newLayerWithMask|newDocument|newDocumentWithMask"="selection","sampleAllLayers":bool=false,"refineStrokes":[{"points":[[x,y],…],"size":px=30,"mode":"add|subtract"="add"},…]=[]} (refineStrokes: the Refine Edge Brush, in painting order: add refines the painted area from the image, subtract keeps the original edge there)"##,
has_selection_or_mask,
refine_edge
),
@ -590,7 +605,11 @@ mod tests {
/// A vertical Gaussian-blurred edge at x = 40 (warm left, cool right) with the left half selected.
fn blurred_edge_selected() -> Session {
let mut s = session_with(80, 40, 8, |x, _| {
blurred_edge_selected_at(8)
}
fn blurred_edge_selected_at(depth: u32) -> Session {
let mut s = session_with(80, 40, depth, |x, _| {
let t = 1.0 / (1.0 + ((x as f32 + 0.5 - 40.0) / 1.8).exp());
[0.9 * t + 0.1 * (1.0 - t), 0.8 * t + 0.2 * (1.0 - t), 0.2 * t + 0.6 * (1.0 - t)]
});
@ -665,6 +684,47 @@ mod tests {
assert!(!s.is_enabled("select.refineEdge"));
}
/// Output To New Document (with or without a layer mask) opens a document like the source,
/// at every depth, and leaves the source document as it was.
#[test]
fn refine_edge_outputs_to_a_new_document() {
for depth in [8, 16, 32] {
let mut s = blurred_edge_selected_at(depth);
s.edit("profile", |doc, _| {
doc.resolution_dpi = 300.0;
Ok(())
})
.unwrap();
let before = s.active().unwrap().doc.clone();
let r = s.execute("select.refineEdge", json!({"radius": 8, "output": "newDocument"})).unwrap();
assert_eq!((r["output"].as_str(), r["document"].as_u64()), (Some("newDocument"), Some(1)), "depth {depth}");
assert_eq!(s.active_index(), Some(1));
let nd = &s.active().unwrap().doc;
assert_eq!((nd.size, nd.mode, nd.depth, nd.resolution_dpi), (before.size, before.mode, before.depth, 300.0));
assert_eq!(nd.layers.len(), 1);
assert!(nd.selection.is_none() && nd.layers[0].mask.is_none());
let surf = nd.layers[0].surface().unwrap();
let a = |x: i32| surf.pixel(x, 20)[3];
assert!(a(5) > 0.99 && a(75) == 0.0 && a(40) > 0.05 && a(40) < 0.95, "depth {depth}: {} {} {}", a(5), a(40), a(75));
// The source is untouched: same layers, still visible, selection kept, no history step.
let src = &s.documents()[0];
assert_eq!(src.doc.layers, before.layers);
assert_eq!(src.doc.selection, before.selection);
assert_eq!(src.history.undo_label(), Some("profile"));
// With a layer mask: full pixels, the refined matte as the mask.
s.execute("file.close", json!({"document": 1})).unwrap();
let r = s.execute("select.refineEdge", json!({"radius": 8, "output": "newDocumentWithMask"})).unwrap();
assert_eq!(r["output"], "newDocumentWithMask");
let nd = &s.active().unwrap().doc;
let l = &nd.layers[0];
assert_eq!(l.surface().unwrap().pixel(75, 20), before.layers[0].surface().unwrap().pixel(75, 20), "depth {depth}");
let m = l.mask.as_ref().unwrap();
assert!(soft_count(&m.surface, 20) >= 4, "depth {depth}");
assert!(m.value(5, 20) > 0.99 && m.value(75, 20) < 0.01, "depth {depth}");
}
}
#[test]
fn refine_edge_shift_and_contrast() {
let mut s = blurred_edge_selected();

View File

@ -1070,6 +1070,21 @@ mod tests {
assert!(missing.is_empty(), "missing Korean dynamic labels: {missing:#?}");
}
/// Select and Mask's Output To lists all six of Photoshop's targets, each translated.
#[test]
fn select_and_mask_output_choices_are_translated() {
let spec = photocraft_engine::commands::find("select.refineEdge").unwrap().params;
let p = parse_spec(spec).into_iter().find(|p| p.key == "output").unwrap();
let Kind::Choice { options: choices, .. } = p.kind else { panic!("output is not a choice") };
let labels: Vec<String> = choices.iter().map(|c| source_label(c)).collect();
assert_eq!(labels, ["Selection", "Layer Mask", "New Layer", "New Layer With Mask", "New Document", "New Document With Mask"]);
for lang in crate::i18n::Lang::all().filter(|l| l.code() != "en") {
for label in &labels {
assert!(crate::i18n::has(lang, label), "{}: untranslated {label}", lang.code());
}
}
}
#[test]
fn preview_runs_engine_command_on_proxy() {
let mut doc = Document::with_background(

View File

@ -1561,6 +1561,9 @@
↑↓ navigate {key} run esc close ↑↓ pohyb {key} spustit esc zavřít
Version {version} Verze {version}
New Document Nový dokument
New Document With Mask Nový dokument s maskou
New Layer With Mask Nová vrstva s maskou
New Layer Nová vrstva
Saved Uložené
Save Preset… Uložit předvolbu…
Preset Name Název předvolby

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

@ -1540,6 +1540,9 @@
↑↓ navigate {key} run esc close ↑↓ Navigieren {key} Ausführen Esc Schließen
Version {version} Version {version}
New Document Neues Dokument
New Document With Mask Neues Dokument mit Maske
New Layer With Mask Neue Ebene mit Maske
New Layer Neue Ebene
Save Preset… Vorgabe speichern…
Preset Name Name der Vorgabe
No saved presets yet. Noch keine gespeicherten Vorgaben.

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

@ -436,6 +436,8 @@
Text Layer Επίπεδο κειμένου
Type Layer Επίπεδο κειμένου
New Document Νέο έγγραφο
New Document With Mask Νέο έγγραφο με μάσκα
New Layer With Mask Νέο επίπεδο με μάσκα
Document Έγγραφο
Documents Έγγραφα
File Name Όνομα αρχείου

Can't render this file because it has a wrong number of fields in line 7.

View File

@ -1546,6 +1546,9 @@
↑↓ navigate {key} run esc close ↑↓ navegar {key} ejecutar esc cerrar
Version {version} Versión {version}
New Document Documento nuevo
New Document With Mask Documento nuevo con máscara
New Layer With Mask Nueva capa con máscara
New Layer Nueva capa
Save Preset… Guardar ajuste preestablecido…
Preset Name Nombre del ajuste preestablecido
No saved presets yet. Todavía no hay ajustes preestablecidos guardados.

Can't render this file because it contains an unexpected character in line 4 and column 47.

View File

@ -1527,6 +1527,9 @@
↑↓ navigate {key} run esc close ↑↓ naviguer {key} exécuter échap fermer
Version {version} Version {version}
New Document Nouveau document
New Document With Mask Nouveau document avec masque
New Layer With Mask Nouveau calque avec masque
New Layer Nouveau calque
Preset Name Nom du préréglage
No saved presets yet. Aucun préréglage enregistré pour le moment.
Error Erreur

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

@ -1521,6 +1521,9 @@
↑↓ navigate {key} run esc close ↑↓ navigasi {key} jalankan esc tutup
Version {version} Versi {version}
New Document Dokumen Baru
New Document With Mask Dokumen Baru dengan Mask
New Layer With Mask Layer Baru dengan Mask
New Layer Layer Baru
Preset Name Nama Preset
No saved presets yet. Belum ada preset tersimpan.
Error Error

Can't render this file because it contains an unexpected character in line 4 and column 47.

View File

@ -1545,6 +1545,9 @@
↑↓ navigate {key} run esc close ↑↓ naviga {key} esegui esc chiudi
Version {version} Versione {version}
New Document Nuovo documento
New Document With Mask Nuovo documento con maschera
New Layer With Mask Nuovo livello con maschera
New Layer Nuovo livello
Save Preset… Salva predefinito…
Preset Name Nome del predefinito
No saved presets yet. Nessun predefinito salvato.

Can't render this file because it has a wrong number of fields in line 16.

View File

@ -1545,6 +1545,8 @@
↑↓ navigate {key} run esc close ↑↓ 移動 {key} 実行 esc 閉じる
Version {version} バージョン {version}
New Document 新規ドキュメント
New Document With Mask 新規ドキュメント(マスク付き)
New Layer With Mask 新規レイヤー(マスク付き)
Saved 保存済み
Save Preset… プリセットを保存…
Preset Name プリセット名

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

@ -1516,6 +1516,8 @@
↑↓ navigate {key} run esc close ↑↓ 이동 {key} 실행 esc 닫기
Version {version} 버전 {version}
New Document 새 문서
New Document With Mask 새 문서(마스크 포함)
New Layer With Mask 새 레이어(마스크 포함)
Saved 저장됨
Save Preset… 사전 설정 저장…
Preset Name 사전 설정 이름

Can't render this file because it has a wrong number of fields in line 11.

View File

@ -1527,6 +1527,8 @@
↑↓ navigate {key} run esc close ↑↓ navigeren {key} uitvoeren esc sluiten
Version {version} Versie {version}
New Document Nieuw document
New Document With Mask Nieuw document met masker
New Layer With Mask Nieuwe laag met masker
Save Preset… Voorinstelling opslaan…
Preset Name Naam voorinstelling
No saved presets yet. Nog geen opgeslagen voorinstellingen.

Can't render this file because it contains an unexpected character in line 8 and column 34.

View File

@ -1540,6 +1540,8 @@
↑↓ navigate {key} run esc close ↑↓ nawigacja {key} uruchom esc zamknij
Version {version} Wersja {version}
New Document Nowy dokument
New Document With Mask Nowy dokument z maską
New Layer With Mask Nowa warstwa z maską
Preset Name Nazwa ustawienia wstępnego
No saved presets yet. Nie ma jeszcze zapisanych ustawień wstępnych.
Error Błąd

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

@ -1525,6 +1525,8 @@
↑↓ navigate {key} run esc close ↑↓ navegar {key} executar esc fechar
Version {version} Versão {version}
New Document Novo documento
New Document With Mask Novo documento com máscara
New Layer With Mask Nova camada com máscara
Preset Name Nome da predefinição
No saved presets yet. Nenhuma predefinição salva ainda.
Error Erro

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

@ -1753,6 +1753,9 @@
Divide Разделить
Result Результат
New Document Новый документ
New Document With Mask Новый документ с маской
New Layer With Mask Новый слой с маской
New Layer Новый слой
Preset Name Имя набора
No saved presets yet. Сохранённых наборов пока нет.
New Channel Новый канал

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

@ -1733,6 +1733,8 @@
Divide Ділення
Result Результат
New Document Новий документ
New Document With Mask Новий документ з маскою
New Layer With Mask Новий шар з маскою
Preset Name Назва набору
No saved presets yet. Збережених наборів ще немає.
New Channel Новий канал

Can't render this file because it has a wrong number of fields in line 14.

View File

@ -1558,6 +1558,9 @@
↑↓ navigate ↵ run esc close ↑↓ 导航 ↵ 运行 esc 关闭
Version {version} 版本 {version}
New Document 新建文档
New Document With Mask 带蒙版的新建文档
New Layer With Mask 带蒙版的新建图层
New Layer 新建图层
Saved 已保存
Save Preset… 保存预设…
Preset Name 预设名称

Can't render this file because it has a wrong number of fields in line 15.

View File

@ -1552,6 +1552,9 @@
↑↓ navigate {key} run esc close ↑↓ 瀏覽 {key} 執行 esc 關閉
Version {version} 版本 {version}
New Document 新增文件
New Document With Mask 含遮色片的新增文件
New Layer With Mask 含遮色片的新增圖層
New Layer 新增圖層
Saved 已儲存
Save Preset… 儲存預設…
Preset Name 預設集名稱

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

@ -65,7 +65,7 @@ what an agent can't finish alone.
- **Estimate:** 60–100 h (2–9 h each, per the calibration).
### G7. Workspaces shown as parameter dialogs
- **Missing:** Select and Mask workspace layout and New Document outputs
- **Missing:** Select and Mask workspace layout
(TOOL-214-1, Refine Hair #2671; its view modes preview live since #1754, Refine Edge Brush #2972); Content-Aware Fill workspace with a paintable sampling area
(TOOL-214-15); Vanishing Point plane editor (TOOL-214-14); Content-Aware Scale through the
transform box (TOOL-214-7); Puppet Warp mesh following the outline (TOOL-214-6); Content-Aware

View File

@ -145,7 +145,7 @@ Corpus floors (`crates/io/tests/corpus.rs`): the enforced minimums; the corpus t
| TOOL-213-13 | Brush regression tests: no seams, hard edge, zero-scatter defaults, ABR never black | done | [#213](https://github.com/storytold/photocraft/issues/213) | Seam, hard-edge and zero-scatter tests in brush_tests.rs; ABR tip colour test in abr_map/tests.rs |
| TOOL-213-14 | Tablet: no OS arrow cursor over the canvas on macOS, Windows and Linux | partial | [#213](https://github.com/storytold/photocraft/issues/213) | Brush tools set CursorIcon::None (canvas.rs:1259-1275); not verified per OS |
| TOOL-213-18 | Pattern Stamp tool: sibling of Clone Stamp in the S flyout | done | [#213](https://github.com/storytold/photocraft/issues/213) | Tool::PatternStamp in the S group; paint.patternStamp stamps the Patterns-panel tile with Aligned and Impressionist (an approximation: it jitters each dab's tile phase, not Photoshop's smeared impressionist look); engine tests cover depths, seams, hostile input |
| TOOL-214-1 | Select and Mask workspace: view modes, Refine Edge brush, 6 outputs | partial | [#214](https://github.com/storytold/photocraft/issues/214) | select.refineEdge dialog with a live preview in Photoshop's view modes: Onion Skin, Marching Ants, Overlay, On Black, On White, Black & White, On Layers, per-view Opacity, Show Edge (J), Show Original (P), F cycles, X shows the image (select_mask_view.rs, #1754); Refine Edge Brush (R, size, Expand Detection Area / Restore Original Edge, Alt swaps) paints select.refineEdge refineStrokes, estimated by colour-line matting (matting/brush.rs, #2972); outputs Selection, Layer Mask, New Layer, New Layer with Mask; no workspace layout or New Document outputs |
| TOOL-214-1 | Select and Mask workspace: view modes, Refine Edge brush, 6 outputs | partial | [#214](https://github.com/storytold/photocraft/issues/214) | select.refineEdge dialog with a live preview in Photoshop's view modes: Onion Skin, Marching Ants, Overlay, On Black, On White, Black & White, On Layers, per-view Opacity, Show Edge (J), Show Original (P), F cycles, X shows the image (select_mask_view.rs, #1754); Refine Edge Brush (R, size, Expand Detection Area / Restore Original Edge, Alt swaps) paints select.refineEdge refineStrokes, estimated by colour-line matting (matting/brush.rs, #2972); outputs Selection, Layer Mask, New Layer, New Layer with Mask, New Document, New Document with Mask (#2998); no workspace layout |
| TOOL-214-2 | Marquee: Shift square, Alt from centre, live W x H readout | done | [#214](https://github.com/storytold/photocraft/issues/214) | canvas.rs marquee_corners: Shift square or circle, Alt from the centre, live W x H readout beside the cursor (marquee_readout); marquee_tests.rs |
| TOOL-214-3 | Color Range test: Sampled Colors at Fuzziness 0 selects the exact match only | partial | [#214](https://github.com/storytold/photocraft/issues/214) | Fuzziness-0 tests cover midtones/shadows only (selection_cmds.rs:444-447) |
| TOOL-214-4 | Move tool: box-select layers, Auto-Select hover outline, double-click fit, drag to a tab | partial | [#214](https://github.com/storytold/photocraft/issues/214) | Auto-Select hover outline (Preferences › Tools › Show Highlight on Rollover, #2834) and box-select layers from an empty spot (move_box.rs, layer.selectInRect, #2844) done; double-click fit and drag to a tab open |

View File

@ -115,7 +115,7 @@ id = "TOOL-214-1"
target = "Select and Mask workspace: view modes, Refine Edge brush, 6 outputs"
status = "partial"
issue = 214
note = "select.refineEdge dialog with a live preview in Photoshop's view modes: Onion Skin, Marching Ants, Overlay, On Black, On White, Black & White, On Layers, per-view Opacity, Show Edge (J), Show Original (P), F cycles, X shows the image (select_mask_view.rs, #1754); Refine Edge Brush (R, size, Expand Detection Area / Restore Original Edge, Alt swaps) paints select.refineEdge refineStrokes, estimated by colour-line matting (matting/brush.rs, #2972); outputs Selection, Layer Mask, New Layer, New Layer with Mask; no workspace layout or New Document outputs"
note = "select.refineEdge dialog with a live preview in Photoshop's view modes: Onion Skin, Marching Ants, Overlay, On Black, On White, Black & White, On Layers, per-view Opacity, Show Edge (J), Show Original (P), F cycles, X shows the image (select_mask_view.rs, #1754); Refine Edge Brush (R, size, Expand Detection Area / Restore Original Edge, Alt swaps) paints select.refineEdge refineStrokes, estimated by colour-line matting (matting/brush.rs, #2972); outputs Selection, Layer Mask, New Layer, New Layer with Mask, New Document, New Document with Mask (#2998); no workspace layout"
[[item]]
id = "TOOL-214-2"