mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-24 09:57:08 -06:00
The Select and Mask dialog had no preview of the refined selection. It now previews live in Photoshop's view modes: Onion Skin (default), Marching Ants, Overlay, On Black, On White, Black & White and On Layers, with a per-view Opacity, Show Edge (J) and Show Original (P). F cycles the views, O/M/V/A/T/K/Y pick one and holding X shows the image without a view. The view state lives in the dialog's fields, so ui.dialog.set drives it. The preview runs select.refineEdge on the proxy with output=selection and builds the view from it (veil fill layers, a layer mask, or the refined selection, whose marching ants the canvas draws scaled back up), through the existing filter-preview worker. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
d67e28c118
commit
0aaf5b60fe
@@ -1403,7 +1403,14 @@ pub(crate) fn ensure_filter_preview(app: &mut PhotocraftApp, idx: usize, ctx: &e
|
||||
}
|
||||
let cmd = d.fields.get("__command")?.as_str()?.to_string();
|
||||
// Previews edit what the command will: a targeted layer mask included (#780).
|
||||
let params = app.with_mask_target(&cmd, crate::filter_dialog::params_of(&d.fields));
|
||||
let mut params = app.with_mask_target(&cmd, crate::filter_dialog::params_of(&d.fields));
|
||||
// Select and Mask previews in its view mode; holding X shows the image without one.
|
||||
if crate::select_mask_view::owns(&d.fields) {
|
||||
if crate::select_mask_view::hides_view(&d.fields) {
|
||||
return None;
|
||||
}
|
||||
params = crate::select_mask_view::request_params(&d.fields, params);
|
||||
}
|
||||
let st = app.session.documents().get(idx)?;
|
||||
// Filters whose features don't scale with a proxy preview at full resolution (#2063).
|
||||
let k = crate::filter_dialog::preview_factor(&cmd, ¶ms, crate::proxy::preview_factor(&st.doc, crate::proxy::reduced_previews(app)));
|
||||
@@ -2636,13 +2643,21 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect:
|
||||
// in (`layer_edges_rect`), so a press that Auto-Selects a layer outlines that layer.
|
||||
let layer_edges = painter.add(egui::Shape::Noop);
|
||||
|
||||
// Select and Mask outlines its refined selection (on its preview proxy, reduced by `k`) in
|
||||
// the Marching Ants view and hides the ants in the others.
|
||||
let refined = crate::select_mask_view::ants(app, idx);
|
||||
let (outlined, k) = match &refined {
|
||||
None => (doc.selection.as_ref(), 1),
|
||||
Some(None) => (None, 1),
|
||||
Some(Some((proxy, k))) => (proxy.selection.as_ref(), (*k).max(1)),
|
||||
};
|
||||
// Selection outline: true boundary, animated marching ants (cached per revision).
|
||||
if let Some(sel) = doc.selection.as_ref().filter(|_| app.ui.view.shows(app.ui.view.show.selection_edges) && !polygon_replaces_selection(app)) {
|
||||
if let Some(sel) = outlined.filter(|_| app.ui.view.shows(app.ui.view.show.selection_edges) && !polygon_replaces_selection(app)) {
|
||||
// Trace at display resolution over the visible part only; key by the mask's tile identity
|
||||
// (not the document revision) so unrelated edits don't re-trace it. The step is in
|
||||
// device pixels per document pixel (`View::zoom`, not the point zoom), so a 1 px
|
||||
// selection still traces at 100% on a scaled display.
|
||||
let step = (1.0 / view.zoom.max(1e-3)).log2().floor().exp2().clamp(1.0, 64.0) as u32;
|
||||
let step = (1.0 / (view.zoom * k as f32).max(1e-3)).log2().floor().exp2().clamp(1.0, 64.0) as u32;
|
||||
let corners = [rect.min, pos2(rect.max.x, rect.min.y), rect.max, pos2(rect.min.x, rect.max.y)];
|
||||
let docs = [xf.to_doc(corners[0]), xf.to_doc(corners[1]), xf.to_doc(corners[2]), xf.to_doc(corners[3])];
|
||||
let mut x0 = docs[0][0];
|
||||
@@ -2655,6 +2670,7 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect:
|
||||
x1 = x1.max(d[0]);
|
||||
y1 = y1.max(d[1]);
|
||||
}
|
||||
let (x0, y0, x1, y1) = (x0 / f64::from(k), y0 / f64::from(k), x1 / f64::from(k), y1 / f64::from(k));
|
||||
let q = 256 * step as i32; // quantise the region so small pans reuse the cache
|
||||
// The view centre can be any finite number (`ui.set`, #980). Past ±2³⁰ there is no document
|
||||
// to outline, and the padding below (at most 2 · 256 · 64) can't overflow i32 from there.
|
||||
@@ -2671,15 +2687,22 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect:
|
||||
^ (vis.x0 as u64) << 8
|
||||
^ (vis.y0 as u64) << 24
|
||||
^ (vis.x1 as u64) << 36
|
||||
^ (vis.y1 as u64) << 48;
|
||||
^ (vis.y1 as u64) << 48
|
||||
^ u64::from(k).rotate_left(5);
|
||||
let fresh = matches!(&app.outline_cache, Some((d, k, _)) if *d == doc.id && *k == key);
|
||||
if !fresh {
|
||||
let t0 = crate::gpu_canvas::now_ms();
|
||||
let b = app.cached_bounds(u64::MAX - doc.id.0, sel).intersect(&vis);
|
||||
let bounds_key = if k > 1 { u64::MAX / 2 - doc.id.0 } else { u64::MAX - doc.id.0 };
|
||||
let b = app.cached_bounds(bounds_key, sel).intersect(&vis);
|
||||
// Filters can occupy every worker in Rayon's global pool. A parallel scan here would
|
||||
// block the UI thread waiting for those workers, preventing job cancellation (#1554).
|
||||
let segs =
|
||||
let mut segs =
|
||||
if app.session.has_jobs() { crate::outline::outline_scaled_serial(sel, b, step) } else { crate::outline::outline_scaled(sel, b, step) };
|
||||
if k > 1 {
|
||||
let s = i32::try_from(k).unwrap_or(1);
|
||||
let up = |p: [i32; 2]| [p[0].saturating_mul(s), p[1].saturating_mul(s)];
|
||||
segs.iter_mut().for_each(|(a, b)| (*a, *b) = (up(*a), up(*b)));
|
||||
}
|
||||
app.perf.span("outline", crate::gpu_canvas::now_ms() - t0);
|
||||
app.outline_cache = Some((doc.id, key, std::sync::Arc::new(segs)));
|
||||
}
|
||||
|
||||
@@ -254,9 +254,15 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) {
|
||||
// Long parameter lists (Flame, Lighting Effects) scroll, so the title and the
|
||||
// OK / Cancel buttons stay inside a small window.
|
||||
let room = (ctx.content_rect().height() - DIALOG_CHROME).max(120.0);
|
||||
first_field_focused(ui, id, |ui| {
|
||||
egui::ScrollArea::vertical().id_salt(id.with("body")).max_height(room).show(ui, |ui| crate::filter_dialog::body(ui, &mut fields));
|
||||
});
|
||||
let body = |ui: &mut egui::Ui, fields: &mut serde_json::Map<String, Value>| {
|
||||
egui::ScrollArea::vertical().id_salt(id.with("body")).max_height(room).show(ui, |ui| crate::filter_dialog::body(ui, fields));
|
||||
};
|
||||
// Select and Mask leaves the keyboard to its view keys (F, X, O, V…).
|
||||
if crate::select_mask_view::owns(&fields) {
|
||||
body(ui, &mut fields);
|
||||
} else {
|
||||
first_field_focused(ui, id, |ui| body(ui, &mut fields));
|
||||
}
|
||||
}
|
||||
DialogKind::Command if fields.contains_key("__form") => crate::view_cmds::form_body(ui, &mut fields),
|
||||
DialogKind::Command => {}
|
||||
|
||||
@@ -330,7 +330,8 @@ pub fn open(app: &mut PhotocraftApp, command: &str) -> Option<u64> {
|
||||
fields.insert("__command".into(), json!(command));
|
||||
fields.insert("__label".into(), json!(spec.label));
|
||||
fields.insert("__filter".into(), json!(true));
|
||||
if command.starts_with("filter.") || PREVIEWED.contains(&command) {
|
||||
// Select and Mask previews in its view modes (`select_mask_view`), not as the command's result.
|
||||
if command.starts_with("filter.") || PREVIEWED.contains(&command) || command == crate::select_mask_view::COMMAND {
|
||||
fields.insert("__preview".into(), json!(true));
|
||||
}
|
||||
for p in parse_spec(spec.params) {
|
||||
@@ -476,6 +477,9 @@ pub fn body(ui: &mut egui::Ui, f: &mut Map<String, Value>) {
|
||||
None => return,
|
||||
},
|
||||
};
|
||||
if crate::select_mask_view::owns(f) {
|
||||
crate::select_mask_view::controls(ui, f);
|
||||
}
|
||||
for p in parse_spec(&spec) {
|
||||
match p.kind {
|
||||
Kind::Range { min, max, default } => {
|
||||
@@ -646,6 +650,9 @@ pub fn preview_document_with(
|
||||
k: u32,
|
||||
cancel: Option<&photocraft_engine::jobs::JobCtx>,
|
||||
) -> Option<Document> {
|
||||
if command == crate::select_mask_view::COMMAND {
|
||||
return crate::select_mask_view::preview_document(doc, active, params, k, cancel);
|
||||
}
|
||||
// Warp Text renders the type again from the layer model (font size, position), which a
|
||||
// reduced proxy doesn't scale: it runs at full size and the result is reduced (#218).
|
||||
let (run_k, reduce_k) = if command == "type.warpText" { (1, k) } else { (k, 1) };
|
||||
|
||||
@@ -2562,4 +2562,13 @@ cameraData Lens Objektiv
|
||||
Snap to Origin Přichytit k počátku
|
||||
Search settings… Hledat nastavení…
|
||||
No matching settings Žádná odpovídající nastavení
|
||||
# Select and Mask view modes (#1754)
|
||||
Onion Skin Cibulová slupka
|
||||
Marching Ants Pochodující mravenci
|
||||
On Black Na černé
|
||||
On White Na bílé
|
||||
On Layers Na vrstvách
|
||||
Show Edge Zobrazit okraj
|
||||
Show Original Zobrazit originál
|
||||
F cycles the views; hold X to see the image F přepíná zobrazení; podržením X uvidíte obraz
|
||||
Show rulers Zobrazit pravítka
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -2593,4 +2593,13 @@ cameraData Lens Objektiv
|
||||
Snap to Origin An Ursprung ausrichten
|
||||
Search settings… Einstellungen durchsuchen…
|
||||
No matching settings Keine passenden Einstellungen
|
||||
# Select and Mask view modes (#1754)
|
||||
Onion Skin Zwiebelschicht
|
||||
Marching Ants Laufende Ameisen
|
||||
On Black Auf Schwarz
|
||||
On White Auf Weiß
|
||||
On Layers Auf Ebenen
|
||||
Show Edge Kante anzeigen
|
||||
Show Original Original anzeigen
|
||||
F cycles the views; hold X to see the image F wechselt die Ansicht; X gedrückt halten zeigt das Bild
|
||||
Show rulers Lineale anzeigen
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -3654,4 +3654,13 @@ cameraData Lens Φακός
|
||||
Snap to Origin Προσκόλληση στην αρχή
|
||||
Search settings… Αναζήτηση ρυθμίσεων…
|
||||
No matching settings Δεν βρέθηκαν ρυθμίσεις
|
||||
# Select and Mask view modes (#1754)
|
||||
Onion Skin Φλούδα κρεμμυδιού
|
||||
Marching Ants Κινούμενο περίγραμμα
|
||||
On Black Σε μαύρο
|
||||
On White Σε λευκό
|
||||
On Layers Σε επίπεδα
|
||||
Show Edge Εμφάνιση άκρης
|
||||
Show Original Εμφάνιση αρχικού
|
||||
F cycles the views; hold X to see the image Το F εναλλάσσει τις προβολές· κρατήστε το X για να δείτε την εικόνα
|
||||
Show rulers Εμφάνιση χαράκων
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 991 and column 18.
|
@@ -2634,4 +2634,13 @@ cameraData Lens Objetivo
|
||||
Snap to Origin Ajustar a origen
|
||||
Search settings… Buscar ajustes…
|
||||
No matching settings No hay ajustes que coincidan
|
||||
# Select and Mask view modes (#1754)
|
||||
Onion Skin Papel cebolla
|
||||
Marching Ants Hormigas en marcha
|
||||
On Black Sobre negro
|
||||
On White Sobre blanco
|
||||
On Layers Sobre capas
|
||||
Show Edge Mostrar borde
|
||||
Show Original Mostrar original
|
||||
F cycles the views; hold X to see the image F alterna las vistas; mantén X para ver la imagen
|
||||
Show rulers Mostrar reglas
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 4 and column 47.
|
@@ -2971,4 +2971,13 @@ cameraData Lens Objectif
|
||||
Snap to Origin Aligner sur l'origine
|
||||
Search settings… Rechercher des réglages…
|
||||
No matching settings Aucun réglage correspondant
|
||||
# Select and Mask view modes (#1754)
|
||||
Onion Skin Pelure d'oignon
|
||||
Marching Ants Fourmis en marche
|
||||
On Black Sur fond noir
|
||||
On White Sur fond blanc
|
||||
On Layers Sur les calques
|
||||
Show Edge Afficher le bord
|
||||
Show Original Afficher l'original
|
||||
F cycles the views; hold X to see the image F fait défiler les vues ; maintenez X pour voir l'image
|
||||
Show rulers Afficher les règles
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -2958,4 +2958,13 @@ cameraData Lens Lensa
|
||||
Snap to Origin Jepret ke Titik Asal
|
||||
Search settings… Cari pengaturan…
|
||||
No matching settings Tidak ada pengaturan yang cocok
|
||||
# Select and Mask view modes (#1754)
|
||||
Onion Skin Kulit Bawang
|
||||
Marching Ants Semut Berbaris
|
||||
On Black Di Atas Hitam
|
||||
On White Di Atas Putih
|
||||
On Layers Di Atas Lapisan
|
||||
Show Edge Tampilkan Tepi
|
||||
Show Original Tampilkan Asli
|
||||
F cycles the views; hold X to see the image F berganti tampilan; tahan X untuk melihat gambar
|
||||
Show rulers Tampilkan Penggaris
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 4 and column 47.
|
@@ -2625,4 +2625,13 @@ cameraData Lens Obiettivo
|
||||
Snap to Origin Aggancia all'origine
|
||||
Search settings… Cerca impostazioni…
|
||||
No matching settings Nessuna impostazione corrispondente
|
||||
# Select and Mask view modes (#1754)
|
||||
Onion Skin Velina
|
||||
Marching Ants Formiche in marcia
|
||||
On Black Su nero
|
||||
On White Su bianco
|
||||
On Layers Su livelli
|
||||
Show Edge Mostra bordo
|
||||
Show Original Mostra originale
|
||||
F cycles the views; hold X to see the image F scorre le viste; tieni premuto X per vedere l'immagine
|
||||
Show rulers Mostra righelli
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2573 and column 53.
|
@@ -3568,4 +3568,13 @@ cameraData Lens レンズ
|
||||
Snap to Origin 原点にスナップ
|
||||
Search settings… 設定を検索…
|
||||
No matching settings 一致する設定はありません
|
||||
# Select and Mask view modes (#1754)
|
||||
Onion Skin オニオンスキン
|
||||
Marching Ants 点線の境界
|
||||
On Black 黒地
|
||||
On White 白地
|
||||
On Layers レイヤー上
|
||||
Show Edge エッジを表示
|
||||
Show Original 元の状態を表示
|
||||
F cycles the views; hold X to see the image F で表示を切り替え、X を押している間は画像を表示
|
||||
Show rulers 定規を表示
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -3137,4 +3137,13 @@ cameraData Lens 렌즈
|
||||
Snap to Origin 원점에 스냅
|
||||
Search settings… 설정 검색…
|
||||
No matching settings 일치하는 설정 없음
|
||||
# Select and Mask view modes (#1754)
|
||||
Onion Skin 어니언 스킨
|
||||
Marching Ants 점선 윤곽
|
||||
On Black 검정 배경
|
||||
On White 흰색 배경
|
||||
On Layers 레이어 위
|
||||
Show Edge 가장자리 표시
|
||||
Show Original 원본 표시
|
||||
F cycles the views; hold X to see the image F로 보기 전환, X를 누르고 있으면 이미지 표시
|
||||
Show rulers 눈금자 표시
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 3083 and column 53.
|
@@ -3714,6 +3714,15 @@ cameraData Lens Lens
|
||||
Search settings… Instellingen zoeken…
|
||||
No matching settings Geen overeenkomende instellingen
|
||||
PhotoCraft — Screen Color Picker PhotoCraft — Schermkleurkiezer
|
||||
# Select and Mask view modes (#1754)
|
||||
Onion Skin Uienschil
|
||||
Marching Ants Mierenmars
|
||||
On Black Op zwart
|
||||
On White Op wit
|
||||
On Layers Op lagen
|
||||
Show Edge Rand tonen
|
||||
Show Original Origineel tonen
|
||||
F cycles the views; hold X to see the image F wisselt de weergaven; houd X ingedrukt om de afbeelding te zien
|
||||
Show rulers Linialen tonen
|
||||
Frame Tool Kadergereedschap
|
||||
Create a new rectangular frame Nieuw rechthoekig kader maken
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 8 and column 34.
|
@@ -3569,4 +3569,13 @@ cameraData Lens Obiektyw
|
||||
Snap to Origin Przyciągnij do początku
|
||||
Search settings… Szukaj ustawień…
|
||||
No matching settings Brak pasujących ustawień
|
||||
# Select and Mask view modes (#1754)
|
||||
Onion Skin Skórka cebuli
|
||||
Marching Ants Maszerujące mrówki
|
||||
On Black Na czarnym
|
||||
On White Na białym
|
||||
On Layers Na warstwach
|
||||
Show Edge Pokaż krawędź
|
||||
Show Original Pokaż oryginał
|
||||
F cycles the views; hold X to see the image F przełącza widoki; przytrzymaj X, aby zobaczyć obraz
|
||||
Show rulers Pokaż linijki
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -2978,4 +2978,13 @@ cameraData Lens Lente
|
||||
Snap to Origin Ajustar à origem
|
||||
Search settings… Pesquisar configurações…
|
||||
No matching settings Nenhuma configuração encontrada
|
||||
# Select and Mask view modes (#1754)
|
||||
Onion Skin Papel vegetal
|
||||
Marching Ants Formigas marchando
|
||||
On Black Sobre preto
|
||||
On White Sobre branco
|
||||
On Layers Sobre camadas
|
||||
Show Edge Mostrar borda
|
||||
Show Original Mostrar original
|
||||
F cycles the views; hold X to see the image F alterna as visualizações; segure X para ver a imagem
|
||||
Show rulers Mostrar réguas
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -2894,6 +2894,15 @@ cameraData Lens Объектив
|
||||
Rasterize Layer Style Растрировать стиль слоя
|
||||
Search settings… Поиск настроек…
|
||||
No matching settings Нет подходящих настроек
|
||||
# Select and Mask view modes (#1754)
|
||||
Onion Skin Калька
|
||||
Marching Ants Бегущие муравьи
|
||||
On Black На черном
|
||||
On White На белом
|
||||
On Layers На слоях
|
||||
Show Edge Показать край
|
||||
Show Original Показать исходное
|
||||
F cycles the views; hold X to see the image F переключает виды; удерживайте X, чтобы увидеть изображение
|
||||
Show rulers Показать линейки
|
||||
New Crop Preset… Новый набор параметров кадрирования…
|
||||
Delete Crop Preset… Удалить набор параметров кадрирования…
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -3644,6 +3644,15 @@ cameraData Lens Об'єктив
|
||||
Search settings… Пошук налаштувань…
|
||||
No matching settings Немає відповідних налаштувань
|
||||
PhotoCraft — Screen Color Picker PhotoCraft — Піпетка кольору екрана
|
||||
# Select and Mask view modes (#1754)
|
||||
Onion Skin Калька
|
||||
Marching Ants Мурашки
|
||||
On Black На чорному
|
||||
On White На білому
|
||||
On Layers На шарах
|
||||
Show Edge Показати край
|
||||
Show Original Показати початкове
|
||||
F cycles the views; hold X to see the image F перемикає види; утримуйте X, щоб побачити зображення
|
||||
Show rulers Показати лінійки
|
||||
Frame Tool Інструмент «Рамка»
|
||||
Create a new rectangular frame Створити прямокутну рамку
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 1348 and column 18.
|
@@ -2563,6 +2563,15 @@ cameraData Lens 镜头
|
||||
Snap to Origin 贴紧原点
|
||||
Search settings… 搜索设置…
|
||||
No matching settings 没有匹配的设置
|
||||
# Select and Mask view modes (#1754)
|
||||
Onion Skin 洋葱皮
|
||||
Marching Ants 蚂蚁线
|
||||
On Black 黑底
|
||||
On White 白底
|
||||
On Layers 图层上
|
||||
Show Edge 显示边缘
|
||||
Show Original 显示原始
|
||||
F cycles the views; hold X to see the image 按 F 切换视图;按住 X 查看图像
|
||||
Show rulers 显示标尺
|
||||
New Crop Preset… 新建裁剪预设…
|
||||
Delete Crop Preset… 删除裁剪预设…
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2515 and column 53.
|
@@ -2533,6 +2533,15 @@ cameraData Lens 鏡頭
|
||||
Snap to Origin 靠齊原點
|
||||
Search settings… 搜尋設定…
|
||||
No matching settings 沒有符合的設定
|
||||
# Select and Mask view modes (#1754)
|
||||
Onion Skin 洋蔥皮
|
||||
Marching Ants 螞蟻線
|
||||
On Black 黑底
|
||||
On White 白底
|
||||
On Layers 圖層上
|
||||
Show Edge 顯示邊緣
|
||||
Show Original 顯示原始
|
||||
F cycles the views; hold X to see the image 按 F 切換檢視;按住 X 檢視影像
|
||||
Show rulers 顯示尺標
|
||||
New Crop Preset… 新增裁切預設集…
|
||||
Delete Crop Preset… 刪除裁切預設集…
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -149,6 +149,7 @@ pub mod rotate_view;
|
||||
pub mod rulers;
|
||||
pub mod screen_picker;
|
||||
pub mod scrollbars;
|
||||
pub mod select_mask_view;
|
||||
pub mod served_fonts;
|
||||
pub mod shape_dialog;
|
||||
pub(crate) mod shape_fill_ui;
|
||||
|
||||
@@ -0,0 +1,488 @@
|
||||
//! Select and Mask view modes (#1754): how the live preview shows the refined selection.
|
||||
//!
|
||||
//! Photoshop's View Mode list: Onion Skin (O), Marching Ants (M), Overlay (V), On Black (A),
|
||||
//! On White (T), Black & White (K) and On Layers (Y), with an Opacity for the first four that
|
||||
//! veil the unselected area. F cycles the views, holding X shows the image without one, J toggles
|
||||
//! Show Edge (the band the edge detection radius refines) and P Show Original (the selection
|
||||
//! before refinement).
|
||||
//!
|
||||
//! The view state lives in the dialog's fields (`__view`, `__opacity`, `__showEdge`,
|
||||
//! `__showOriginal`, `__hideView`), so `ui.dialog.set` drives it like any other field. The preview
|
||||
//! runs `select.refineEdge` on the proxy document with `output: selection`, then builds the view
|
||||
//! from that proxy (veil fill layers, a layer mask, the refined selection for the ants) and the
|
||||
//! normal filter-preview path composites it.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use photocraft_color::{Color, PixelFormat};
|
||||
use photocraft_doc::{Document, Fill, Layer, LayerContent, LayerId, LayerMask};
|
||||
use photocraft_geom::Rect;
|
||||
use photocraft_raster::Surface;
|
||||
use serde_json::{Map, Value, json};
|
||||
|
||||
use crate::PhotocraftApp;
|
||||
use crate::theme::Tokens;
|
||||
|
||||
/// The engine command behind Select › Select and Mask….
|
||||
pub const COMMAND: &str = "select.refineEdge";
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Mode {
|
||||
OnionSkin,
|
||||
MarchingAnts,
|
||||
Overlay,
|
||||
OnBlack,
|
||||
OnWhite,
|
||||
BlackWhite,
|
||||
OnLayers,
|
||||
}
|
||||
|
||||
impl Mode {
|
||||
/// In menu order, which is also the order F cycles through.
|
||||
pub const ALL: [Mode; 7] = [Mode::OnionSkin, Mode::MarchingAnts, Mode::Overlay, Mode::OnBlack, Mode::OnWhite, Mode::BlackWhite, Mode::OnLayers];
|
||||
|
||||
pub fn id(self) -> &'static str {
|
||||
match self {
|
||||
Mode::OnionSkin => "onionSkin",
|
||||
Mode::MarchingAnts => "marchingAnts",
|
||||
Mode::Overlay => "overlay",
|
||||
Mode::OnBlack => "onBlack",
|
||||
Mode::OnWhite => "onWhite",
|
||||
Mode::BlackWhite => "blackWhite",
|
||||
Mode::OnLayers => "onLayers",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_id(id: &str) -> Option<Mode> {
|
||||
Mode::ALL.into_iter().find(|m| m.id() == id)
|
||||
}
|
||||
|
||||
fn label(self) -> &'static str {
|
||||
match self {
|
||||
Mode::OnionSkin => tl!("Onion Skin"),
|
||||
Mode::MarchingAnts => tl!("Marching Ants"),
|
||||
Mode::Overlay => tl!("Overlay"),
|
||||
Mode::OnBlack => tl!("On Black"),
|
||||
Mode::OnWhite => tl!("On White"),
|
||||
Mode::BlackWhite => tl!("Black & White"),
|
||||
Mode::OnLayers => tl!("On Layers"),
|
||||
}
|
||||
}
|
||||
|
||||
/// The single-letter key that picks this view.
|
||||
fn key(self) -> egui::Key {
|
||||
match self {
|
||||
Mode::OnionSkin => egui::Key::O,
|
||||
Mode::MarchingAnts => egui::Key::M,
|
||||
Mode::Overlay => egui::Key::V,
|
||||
Mode::OnBlack => egui::Key::A,
|
||||
Mode::OnWhite => egui::Key::T,
|
||||
Mode::BlackWhite => egui::Key::K,
|
||||
Mode::OnLayers => egui::Key::Y,
|
||||
}
|
||||
}
|
||||
|
||||
/// The view's opacity (percent) until it is changed; `None` for views without one.
|
||||
fn default_opacity(self) -> Option<f32> {
|
||||
match self {
|
||||
Mode::OnionSkin | Mode::Overlay => Some(50.0),
|
||||
Mode::OnBlack | Mode::OnWhite => Some(100.0),
|
||||
Mode::MarchingAnts | Mode::BlackWhite | Mode::OnLayers => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn next(self) -> Mode {
|
||||
let i = Mode::ALL.iter().position(|m| *m == self).unwrap_or(0);
|
||||
Mode::ALL.get(i + 1).copied().unwrap_or(Mode::OnionSkin)
|
||||
}
|
||||
}
|
||||
|
||||
/// The view the dialog shows (Onion Skin, Photoshop's default, until another is picked).
|
||||
pub fn mode(fields: &Map<String, Value>) -> Mode {
|
||||
fields.get("__view").and_then(Value::as_str).and_then(Mode::from_id).unwrap_or(Mode::OnionSkin)
|
||||
}
|
||||
|
||||
fn flag(fields: &Map<String, Value>, key: &str) -> bool {
|
||||
fields.get(key).and_then(Value::as_bool).unwrap_or(false)
|
||||
}
|
||||
|
||||
/// The opacity (percent) of `mode`: each view keeps its own, as in Photoshop.
|
||||
fn opacity(fields: &Map<String, Value>, mode: Mode) -> f32 {
|
||||
let set = fields.get("__opacity").and_then(|o| o.get(mode.id())).and_then(Value::as_f64).filter(|v| v.is_finite());
|
||||
set.map(|v| v as f32).or(mode.default_opacity()).unwrap_or(100.0).clamp(0.0, 100.0)
|
||||
}
|
||||
|
||||
fn set_opacity(fields: &mut Map<String, Value>, mode: Mode, value: f32) {
|
||||
let mut all = fields.get("__opacity").and_then(Value::as_object).cloned().unwrap_or_default();
|
||||
all.insert(mode.id().into(), json!(value));
|
||||
fields.insert("__opacity".into(), Value::Object(all));
|
||||
}
|
||||
|
||||
/// Holding X shows the image without the view.
|
||||
pub fn hides_view(fields: &Map<String, Value>) -> bool {
|
||||
flag(fields, "__hideView")
|
||||
}
|
||||
|
||||
/// Whether `fields` belong to the Select and Mask dialog.
|
||||
pub fn owns(fields: &Map<String, Value>) -> bool {
|
||||
fields.get("__command").and_then(Value::as_str) == Some(COMMAND)
|
||||
}
|
||||
|
||||
/// A view key pressed in the dialog: a view letter picks that view, F the next one, J toggles
|
||||
/// Show Edge and P Show Original. Returns whether the key was one of them.
|
||||
pub fn apply_key(fields: &mut Map<String, Value>, key: egui::Key) -> bool {
|
||||
if key == egui::Key::F {
|
||||
fields.insert("__view".into(), json!(mode(fields).next().id()));
|
||||
} else if let Some(m) = Mode::ALL.into_iter().find(|m| m.key() == key) {
|
||||
fields.insert("__view".into(), json!(m.id()));
|
||||
} else if key == egui::Key::J || key == egui::Key::P {
|
||||
let field = if key == egui::Key::J { "__showEdge" } else { "__showOriginal" };
|
||||
fields.insert(field.into(), json!(!flag(fields, field)));
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// The view keys, unless a text field or a popup (the View dropdown) has the keyboard.
|
||||
fn keys(ctx: &egui::Context, fields: &mut Map<String, Value>) {
|
||||
if ctx.text_edit_focused() || egui::Popup::is_any_open(ctx) {
|
||||
fields.insert("__hideView".into(), json!(false));
|
||||
return;
|
||||
}
|
||||
let (pressed, hold_x) = ctx.input(|i| {
|
||||
let plain = !i.modifiers.command && !i.modifiers.ctrl && !i.modifiers.alt;
|
||||
let keys: Vec<egui::Key> = i
|
||||
.events
|
||||
.iter()
|
||||
.filter_map(|e| match e {
|
||||
egui::Event::Key { key, pressed: true, repeat: false, .. } if plain => Some(*key),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
(keys, plain && i.key_down(egui::Key::X))
|
||||
});
|
||||
for key in pressed {
|
||||
apply_key(fields, key);
|
||||
}
|
||||
fields.insert("__hideView".into(), json!(hold_x));
|
||||
}
|
||||
|
||||
/// The View Mode controls at the top of the Select and Mask dialog.
|
||||
pub fn controls(ui: &mut egui::Ui, fields: &mut Map<String, Value>) {
|
||||
keys(ui.ctx(), fields);
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let mut current = mode(fields);
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(egui::RichText::new(tl!("View")).color(t.text_dim));
|
||||
let options: Vec<(Mode, &str)> = Mode::ALL.iter().map(|m| (*m, m.label())).collect();
|
||||
crate::widgets::dropdown(ui, "select-mask-view", &mut current, &options, 170.0);
|
||||
});
|
||||
fields.insert("__view".into(), json!(current.id()));
|
||||
if current.default_opacity().is_some() {
|
||||
let mut v = opacity(fields, current);
|
||||
crate::widgets::slider_row(ui, tl!("Opacity"), &mut v, 0.0..=100.0, "%", None);
|
||||
set_opacity(fields, current, v.round());
|
||||
}
|
||||
for (field, label) in [("__showEdge", tl!("Show Edge")), ("__showOriginal", tl!("Show Original"))] {
|
||||
let mut on = flag(fields, field);
|
||||
crate::widgets::checkbox(ui, &mut on, label);
|
||||
fields.insert(field.into(), json!(on));
|
||||
}
|
||||
ui.label(egui::RichText::new(tl!("F cycles the views; hold X to see the image")).color(t.text_dim).small());
|
||||
ui.add_space(4.0);
|
||||
crate::widgets::hairline(ui);
|
||||
ui.add_space(6.0);
|
||||
}
|
||||
|
||||
/// The preview request: the command's params plus the view (`__view`), so a view change
|
||||
/// recomputes the preview like a parameter change.
|
||||
pub fn request_params(fields: &Map<String, Value>, mut params: Value) -> Value {
|
||||
let m = mode(fields);
|
||||
if let Some(o) = params.as_object_mut() {
|
||||
o.insert(
|
||||
"__view".into(),
|
||||
json!({
|
||||
"mode": m.id(),
|
||||
"opacity": opacity(fields, m) / 100.0,
|
||||
"showEdge": flag(fields, "__showEdge"),
|
||||
"showOriginal": flag(fields, "__showOriginal"),
|
||||
}),
|
||||
);
|
||||
}
|
||||
params
|
||||
}
|
||||
|
||||
/// What the canvas outlines while Select and Mask previews `idx`: `None` = the document's own
|
||||
/// selection (no Select and Mask preview); `Some(None)` = nothing (every view but Marching Ants
|
||||
/// hides the ants, as in Photoshop); `Some(Some((proxy, k)))` = the refined selection of the
|
||||
/// preview proxy, reduced by `k`.
|
||||
pub(crate) fn ants(app: &PhotocraftApp, idx: usize) -> Option<Option<(Arc<Document>, u32)>> {
|
||||
if app.session.active_index() != Some(idx) {
|
||||
return None;
|
||||
}
|
||||
let d = app.ui.dialogs.iter().find(|d| d.fields.contains_key("__filter"))?;
|
||||
if !owns(&d.fields) || !flag(&d.fields, "__preview") || hides_view(&d.fields) {
|
||||
return None;
|
||||
}
|
||||
if mode(&d.fields) != Mode::MarchingAnts {
|
||||
return Some(None);
|
||||
}
|
||||
let p = app.filter_preview.as_ref().filter(|p| p.key.command == COMMAND && p.key.dialog == d.id)?;
|
||||
Some(Some((p.result.clone()?, p.key.k)))
|
||||
}
|
||||
|
||||
/// A one-channel coverage over the canvas: `values` inside `area`, `outside` elsewhere.
|
||||
struct Coverage {
|
||||
area: Rect,
|
||||
values: Vec<f32>,
|
||||
outside: f32,
|
||||
}
|
||||
|
||||
impl Coverage {
|
||||
fn empty() -> Coverage {
|
||||
Coverage { area: Rect::EMPTY, values: Vec::new(), outside: 0.0 }
|
||||
}
|
||||
|
||||
/// `s` (a selection or a layer mask) read over the part of `canvas` that isn't its default.
|
||||
fn read(s: Option<&Surface>, canvas: Rect) -> Coverage {
|
||||
let Some(s) = s else { return Coverage::empty() };
|
||||
let outside = s.default_pixel().first().copied().unwrap_or(0.0).clamp(0.0, 1.0);
|
||||
let area = if outside == 0.0 { s.content_bounds().intersect(&canvas) } else { canvas };
|
||||
Coverage { area, values: photocraft_algo::selection::mask_from_surface(Some(s), area), outside }
|
||||
}
|
||||
|
||||
/// The band `r` px either side of the edge of `s`: where grown and shrunk coverage differ.
|
||||
fn edge_band(s: Option<&Surface>, canvas: Rect, r: usize) -> Coverage {
|
||||
let Some(s) = s else { return Coverage::empty() };
|
||||
let reach = i32::try_from(r).unwrap_or(i32::MAX).saturating_add(1);
|
||||
let outside = s.default_pixel().first().copied().unwrap_or(0.0);
|
||||
let area = if outside == 0.0 { s.content_bounds().inflate(reach).intersect(&canvas) } else { canvas };
|
||||
let (w, h) = (area.width() as usize, area.height() as usize);
|
||||
let values = photocraft_algo::selection::mask_from_surface(Some(s), area);
|
||||
let grown = photocraft_algo::matting::morph(&values, w, h, r, true);
|
||||
let shrunk = photocraft_algo::matting::morph(&values, w, h, r, false);
|
||||
if grown.len() != values.len() || shrunk.len() != values.len() {
|
||||
return Coverage::empty();
|
||||
}
|
||||
Coverage { area, values: grown.iter().zip(&shrunk).map(|(g, s)| (g - s).clamp(0.0, 1.0)).collect(), outside: 0.0 }
|
||||
}
|
||||
|
||||
/// A GRAY8 mask surface holding `f(coverage)`.
|
||||
fn surface(&self, f: impl Fn(f32) -> f32) -> Surface {
|
||||
let g = |v: f32| f(v).clamp(0.0, 1.0);
|
||||
let mut s = Surface::with_default(PixelFormat::GRAY8, &[g(self.outside)]);
|
||||
if !self.area.is_empty() && self.values.len() == self.area.width() as usize * self.area.height() as usize {
|
||||
let bytes: Vec<u8> = self.values.iter().map(|v| (g(*v) * 255.0).round() as u8).collect();
|
||||
s.write_interleaved(self.area, &bytes);
|
||||
}
|
||||
s
|
||||
}
|
||||
}
|
||||
|
||||
/// The view parameters a preview request carries (see [`request_params`]).
|
||||
struct View {
|
||||
mode: Mode,
|
||||
opacity: f32,
|
||||
show_edge: bool,
|
||||
show_original: bool,
|
||||
}
|
||||
|
||||
impl View {
|
||||
fn of(params: &Value) -> View {
|
||||
let v = params.get("__view");
|
||||
let b = |key: &str| v.and_then(|v| v.get(key)).and_then(Value::as_bool).unwrap_or(false);
|
||||
View {
|
||||
mode: v.and_then(|v| v.get("mode")).and_then(Value::as_str).and_then(Mode::from_id).unwrap_or(Mode::OnionSkin),
|
||||
opacity: v.and_then(|v| v.get("opacity")).and_then(Value::as_f64).filter(|o| o.is_finite()).unwrap_or(0.5).clamp(0.0, 1.0) as f32,
|
||||
show_edge: b("showEdge"),
|
||||
show_original: b("showOriginal"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A pixel length param (`radius`, `feather`) of `params`, reduced by `k`.
|
||||
fn length(params: &Value, key: &str, k: u32) -> f64 {
|
||||
params.get(key).and_then(Value::as_f64).filter(|v| v.is_finite()).unwrap_or(0.0).max(0.0) / f64::from(k.max(1))
|
||||
}
|
||||
|
||||
/// The preview of the Select and Mask dialog for `doc`: the proxy reduced by `k`, shown in the
|
||||
/// requested view. `None` when the refinement fails or is cancelled.
|
||||
pub fn preview_document(doc: &Document, active: Option<LayerId>, params: &Value, k: u32, cancel: Option<&photocraft_engine::jobs::JobCtx>) -> Option<Document> {
|
||||
let view = View::of(params);
|
||||
let k = k.max(1);
|
||||
let mut proxy = crate::proxy::proxy_document(doc, k);
|
||||
let active = active.filter(|id| proxy.layer(*id).is_some());
|
||||
// What Select and Mask starts from: the selection, else the active layer's mask.
|
||||
let original = proxy.selection.clone().or_else(|| active.and_then(|id| proxy.layer(id)).and_then(|l| l.mask.as_ref()).map(|m| m.surface.clone()));
|
||||
let canvas = Rect::new(0, 0, i32::try_from(proxy.size.width).unwrap_or(i32::MAX), i32::try_from(proxy.size.height).unwrap_or(i32::MAX));
|
||||
let coverage = if view.show_edge {
|
||||
Coverage::edge_band(original.as_ref(), canvas, length(params, "radius", k).round().clamp(1.0, 250.0) as usize)
|
||||
} else if view.show_original {
|
||||
Coverage::read(original.as_ref(), canvas)
|
||||
} else {
|
||||
Coverage::read(refine(&proxy, active, params, k, cancel)?.as_ref(), canvas)
|
||||
};
|
||||
show(&mut proxy, active, &view, &coverage);
|
||||
Some(proxy)
|
||||
}
|
||||
|
||||
/// `select.refineEdge` on the proxy, its output forced to the selection: the refined coverage.
|
||||
fn refine(proxy: &Document, active: Option<LayerId>, params: &Value, k: u32, cancel: Option<&photocraft_engine::jobs::JobCtx>) -> Option<Option<Surface>> {
|
||||
let mut s = photocraft_engine::Session::new();
|
||||
s.set_inline_job_ctx(cancel.cloned());
|
||||
s.add_document(proxy.clone(), None);
|
||||
if let Some(id) = active {
|
||||
s.select_layer(id).ok()?;
|
||||
}
|
||||
let mut p = params.as_object().cloned().unwrap_or_default();
|
||||
p.remove("__view");
|
||||
p.insert("output".into(), json!("selection"));
|
||||
p.insert("decontaminate".into(), json!(false));
|
||||
for key in ["radius", "feather"] {
|
||||
if p.contains_key(key) {
|
||||
p.insert(key.into(), json!(length(params, key, k)));
|
||||
}
|
||||
}
|
||||
s.execute(COMMAND, Value::Object(p)).ok()?;
|
||||
Some(s.active()?.doc.selection.clone())
|
||||
}
|
||||
|
||||
/// A solid fill layer named for the view.
|
||||
fn veil(name: &str, color: Color, opacity: f32, mask: Option<Surface>) -> Layer {
|
||||
let mut l = Layer::new(name, LayerContent::Fill(Fill::Solid(color)));
|
||||
l.opacity = opacity;
|
||||
l.mask = mask.map(|surface| LayerMask { surface, ..LayerMask::reveal_all() });
|
||||
l
|
||||
}
|
||||
|
||||
/// Turn `doc` into `view` of `coverage`.
|
||||
fn show(doc: &mut Document, active: Option<LayerId>, view: &View, coverage: &Coverage) {
|
||||
let o = view.opacity;
|
||||
doc.selection = Some(coverage.surface(|v| v));
|
||||
match view.mode {
|
||||
Mode::MarchingAnts => {}
|
||||
Mode::Overlay => doc.layers.push(veil("Overlay", Color::rgb(1.0, 0.0, 0.0), o, Some(coverage.surface(|v| 1.0 - v)))),
|
||||
Mode::OnBlack => doc.layers.push(veil("On Black", Color::BLACK, o, Some(coverage.surface(|v| 1.0 - v)))),
|
||||
Mode::OnWhite => doc.layers.push(veil("On White", Color::WHITE, o, Some(coverage.surface(|v| 1.0 - v)))),
|
||||
Mode::BlackWhite => {
|
||||
doc.layers.push(veil("Black", Color::BLACK, 1.0, None));
|
||||
doc.layers.push(veil("White", Color::WHITE, 1.0, Some(coverage.surface(|v| v))));
|
||||
}
|
||||
Mode::OnLayers | Mode::OnionSkin => {
|
||||
let Some(id) = active else { return };
|
||||
if view.mode == Mode::OnionSkin
|
||||
&& let Some(path) = doc.path_of(id)
|
||||
{
|
||||
// Only the layer being masked (with its groups and contents) stays visible.
|
||||
let others: Vec<LayerId> =
|
||||
doc.walk().into_iter().filter(|(p, _, _)| !path.starts_with(p) && !p.starts_with(&path)).map(|(_, _, l)| l.id).collect();
|
||||
for other in others {
|
||||
if let Some(l) = doc.layer_mut(other) {
|
||||
l.visible = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
let keep = if view.mode == Mode::OnionSkin { 1.0 - o } else { 0.0 };
|
||||
let surface = coverage.surface(|v| v + (1.0 - v) * keep);
|
||||
if let Some(l) = doc.layer_mut(id) {
|
||||
l.mask = Some(LayerMask { surface, ..LayerMask::reveal_all() });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use photocraft_color::{ColorMode, SampleType};
|
||||
use photocraft_doc::Size;
|
||||
|
||||
/// A white 40×20 document with its left half selected.
|
||||
fn half_selected() -> Document {
|
||||
let mut doc = Document::with_background("d", Size::new(40, 20), ColorMode::Rgb, SampleType::U8, Color::WHITE);
|
||||
let mut sel = Surface::new(PixelFormat::GRAY8);
|
||||
sel.fill_rect(Rect::new(0, 0, 20, 20), &[1.0]);
|
||||
doc.selection = Some(sel);
|
||||
doc
|
||||
}
|
||||
|
||||
fn view(mode: Mode) -> Value {
|
||||
json!({"radius": 0, "__view": {"mode": mode.id(), "opacity": 0.5, "showEdge": false, "showOriginal": false}})
|
||||
}
|
||||
|
||||
fn pixel(doc: &Document, x: i32, y: i32) -> Vec<f32> {
|
||||
let s = photocraft_compose::flatten_to_surface(doc, PixelFormat::RGBA8, None);
|
||||
s.pixel(x, y)
|
||||
}
|
||||
|
||||
/// Every view shows the selected half as the image and marks the rest its own way.
|
||||
#[test]
|
||||
fn each_view_marks_the_unselected_area() {
|
||||
let doc = half_selected();
|
||||
let active = doc.layers.first().map(|l| l.id);
|
||||
let near = |a: &[f32], b: &[f32]| a.iter().zip(b).all(|(a, b)| (a - b).abs() < 0.02);
|
||||
let cases = [
|
||||
(Mode::Overlay, [1.0, 0.5, 0.5, 1.0]),
|
||||
(Mode::OnBlack, [0.5, 0.5, 0.5, 1.0]),
|
||||
(Mode::OnWhite, [1.0, 1.0, 1.0, 1.0]),
|
||||
(Mode::BlackWhite, [0.0, 0.0, 0.0, 1.0]),
|
||||
(Mode::OnLayers, [0.0, 0.0, 0.0, 0.0]),
|
||||
(Mode::OnionSkin, [1.0, 1.0, 1.0, 0.5]),
|
||||
(Mode::MarchingAnts, [1.0, 1.0, 1.0, 1.0]),
|
||||
];
|
||||
for (mode, unselected) in cases {
|
||||
// Through the filter dialog's preview, which every Select and Mask preview takes.
|
||||
let shown = crate::filter_dialog::preview_document(&doc, active, COMMAND, &view(mode), 1).expect("a preview");
|
||||
let inside = pixel(&shown, 5, 10);
|
||||
assert!(near(&inside, &[1.0, 1.0, 1.0, 1.0]), "{mode:?}: selected area {inside:?}");
|
||||
let outside = pixel(&shown, 35, 10);
|
||||
assert!(near(&outside, &unselected), "{mode:?}: unselected area {outside:?}, want {unselected:?}");
|
||||
// The refined selection rides along for the canvas's marching ants.
|
||||
assert!(shown.selection.as_ref().is_some_and(|s| s.pixel(5, 10)[0] > 0.99 && s.pixel(35, 10)[0] < 0.01), "{mode:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn view_keys_pick_cycle_and_toggle() {
|
||||
let mut f = Map::new();
|
||||
assert_eq!(mode(&f), Mode::OnionSkin, "Photoshop's default view");
|
||||
assert!(apply_key(&mut f, egui::Key::V));
|
||||
assert_eq!(mode(&f), Mode::Overlay);
|
||||
for want in [Mode::OnBlack, Mode::OnWhite, Mode::BlackWhite, Mode::OnLayers, Mode::OnionSkin, Mode::MarchingAnts] {
|
||||
apply_key(&mut f, egui::Key::F);
|
||||
assert_eq!(mode(&f), want, "F cycles");
|
||||
}
|
||||
apply_key(&mut f, egui::Key::J);
|
||||
apply_key(&mut f, egui::Key::P);
|
||||
assert!(flag(&f, "__showEdge") && flag(&f, "__showOriginal"));
|
||||
apply_key(&mut f, egui::Key::J);
|
||||
assert!(!flag(&f, "__showEdge"));
|
||||
assert!(!apply_key(&mut f, egui::Key::Q), "other keys are left alone");
|
||||
// Each view keeps its own opacity; hostile values fall back to the default.
|
||||
set_opacity(&mut f, Mode::Overlay, 80.0);
|
||||
assert_eq!((opacity(&f, Mode::Overlay), opacity(&f, Mode::OnBlack)), (80.0, 100.0));
|
||||
f.insert("__opacity".into(), json!({"overlay": "x"}));
|
||||
assert_eq!(opacity(&f, Mode::Overlay), 50.0);
|
||||
// The view travels with the preview request, and a hostile one still previews.
|
||||
let p = request_params(&f, json!({"radius": 2}));
|
||||
assert_eq!(p["__view"]["mode"], json!("marchingAnts"));
|
||||
let doc = half_selected();
|
||||
let bad = json!({"radius": -5, "feather": "x", "__view": {"mode": 7, "opacity": "nan"}});
|
||||
assert!(preview_document(&doc, None, &bad, 3, None).is_some());
|
||||
}
|
||||
|
||||
/// Show Edge shows the band around the selection's edge; Show Original the unrefined selection.
|
||||
#[test]
|
||||
fn show_edge_and_show_original() {
|
||||
let doc = half_selected();
|
||||
let active = doc.layers.first().map(|l| l.id);
|
||||
let edge = json!({"radius": 3, "smooth": 100, "__view": {"mode": "blackWhite", "opacity": 0.5, "showEdge": true}});
|
||||
let shown = preview_document(&doc, active, &edge, 1, None).expect("a preview");
|
||||
assert!(pixel(&shown, 20, 10)[0] > 0.99, "on the edge");
|
||||
assert!(pixel(&shown, 5, 10)[0] < 0.01 && pixel(&shown, 35, 10)[0] < 0.01, "away from it");
|
||||
let original = json!({"radius": 3, "shiftEdge": 100, "__view": {"mode": "blackWhite", "showOriginal": true}});
|
||||
let shown = preview_document(&doc, active, &original, 1, None).expect("a preview");
|
||||
assert!(pixel(&shown, 19, 10)[0] > 0.99 && pixel(&shown, 21, 10)[0] < 0.01, "the selection as it was");
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -65,8 +65,8 @@ 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 with view modes, Refine Edge brush and outputs
|
||||
(TOOL-214-1, Refine Hair #2671); Content-Aware Fill workspace with a paintable sampling area
|
||||
- **Missing:** Select and Mask workspace layout, Refine Edge brush and outputs
|
||||
(TOOL-214-1, Refine Hair #2671; its view modes preview live since #1754); 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
|
||||
Move's Transform On Drop (TOOL-213-4).
|
||||
|
||||
+1
-1
@@ -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) | Opens the generic select.refineEdge dialog (menus.rs:308) |
|
||||
| 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); no workspace layout or Refine Edge brush |
|
||||
| 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 |
|
||||
|
||||
@@ -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 = "Opens the generic select.refineEdge dialog (menus.rs:308)"
|
||||
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); no workspace layout or Refine Edge brush"
|
||||
|
||||
[[item]]
|
||||
id = "TOOL-214-2"
|
||||
|
||||
Reference in New Issue
Block a user