From 9b3e2d873b8adabd7861c47fe5d653d9ba95c707 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 30 Oct 2026 05:35:02 -0700 Subject: [PATCH 1/3] Pathfinder: Cmd+4 repeats the last Pathfinder operation --- crates/ui-egui/src/app.rs | 1 + crates/ui-egui/src/i18n/bg.tsv | 1 + crates/ui-egui/src/i18n/cs.tsv | 1 + crates/ui-egui/src/i18n/da.tsv | 1 + crates/ui-egui/src/i18n/de.tsv | 1 + crates/ui-egui/src/i18n/el.tsv | 1 + crates/ui-egui/src/i18n/es.tsv | 1 + crates/ui-egui/src/i18n/fi.tsv | 1 + crates/ui-egui/src/i18n/fr.tsv | 1 + crates/ui-egui/src/i18n/hu.tsv | 1 + crates/ui-egui/src/i18n/id.tsv | 1 + crates/ui-egui/src/i18n/it.tsv | 1 + crates/ui-egui/src/i18n/ja.tsv | 1 + crates/ui-egui/src/i18n/ka.tsv | 1 + crates/ui-egui/src/i18n/ko.tsv | 1 + crates/ui-egui/src/i18n/nl.tsv | 1 + crates/ui-egui/src/i18n/pl.tsv | 1 + crates/ui-egui/src/i18n/pt-br.tsv | 1 + crates/ui-egui/src/i18n/ru.tsv | 1 + crates/ui-egui/src/i18n/sv.tsv | 1 + crates/ui-egui/src/i18n/uk.tsv | 1 + crates/ui-egui/src/i18n/vi.tsv | 1 + crates/ui-egui/src/i18n/zh-hans.tsv | 1 + crates/ui-egui/src/i18n/zh-hant.tsv | 1 + crates/ui-egui/src/lib.rs | 3 + crates/ui-egui/src/menus/run.rs | 1 + crates/ui-egui/src/menus/state.rs | 1 + crates/ui-egui/src/menus/ui_commands.rs | 6 + crates/ui-egui/src/panels/pathfinder.rs | 161 ++++++++++++++++++++++-- docs/mcp.md | 2 +- docs/target-app-parity.md | 1 + docs/ui-parity.md | 4 +- 32 files changed, 190 insertions(+), 13 deletions(-) diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index 8e937c45c..0e5475b66 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -79,6 +79,7 @@ impl VectorcraftApp { dock_rt: Default::default(), titlebar_inset: 0.0, last_effect: None, + last_pathfinder: None, last_warp: None, character_focus: None, run_count: 0, diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index bb7116720..530dc7b5d 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -4041,6 +4041,7 @@ axis Both И двете @msg no document няма документ @msg no document open няма отворен документ @msg no effect applied yet все още няма приложен ефект +@msg no Pathfinder operation applied yet все още няма приложена операция на Pathfinder @msg no effect at index {k} няма ефект на индекс {k} @msg no end record няма краен запис @msg no file manager here няма файлов мениджър тук diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index f92459d60..30ebe1e7f 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -4464,6 +4464,7 @@ imageTrace Outline Obrys @msg no document žádný dokument @msg no document open není otevřen žádný dokument @msg no effect applied yet zatím nebyl použit žádný efekt +@msg no Pathfinder operation applied yet zatím nebyla použita žádná operace s cestami @msg no effect at index {k} na indexu {k} není žádný efekt @msg no end record chybí koncový záznam @msg no file manager here zde není správce souborů diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index e735ddd99..1c67176a2 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -4151,6 +4151,7 @@ imageTrace Outline Kontur @msg no document intet dokument @msg no document open intet åbent dokument @msg no effect applied yet ingen effekt er anvendt endnu +@msg no Pathfinder operation applied yet ingen Pathfinder-handling er anvendt endnu @msg no effect at index {k} ingen effekt ved indeks {k} @msg no end record ingen slutpost @msg no file manager here ingen filhåndtering her diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 9a41a838c..cf6c8aec5 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -4078,6 +4078,7 @@ theme Appearance Erscheinungsbild @msg no document kein Dokument @msg no document open kein Dokument geöffnet @msg no effect applied yet noch kein Effekt angewendet +@msg no Pathfinder operation applied yet noch keine Pathfinder-Operation angewendet @msg no effect at index {k} kein Effekt bei Index {k} @msg no end record kein Endeintrag @msg no file manager here hier gibt es keinen Dateimanager diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index fed8acea2..91c001e7a 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -4117,6 +4117,7 @@ theme Appearance Εμφάνιση @msg no document δεν υπάρχει έγγραφο @msg no document open δεν υπάρχει ανοιχτό έγγραφο @msg no effect applied yet δεν έχει εφαρμοστεί ακόμη εφέ +@msg no Pathfinder operation applied yet δεν έχει εφαρμοστεί ακόμη λειτουργία Pathfinder @msg no effect at index {k} δεν υπάρχει εφέ στον δείκτη {k} @msg no end record λείπει η εγγραφή τέλους @msg no file manager here δεν υπάρχει διαχειριστής αρχείων εδώ diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 2956f03ab..8eaac3bb7 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -4157,6 +4157,7 @@ axis Both Ambos @msg no document no hay documento @msg no document open no hay ningún documento abierto @msg no effect applied yet todavía no se aplicó ningún efecto +@msg no Pathfinder operation applied yet todavía no se aplicó ninguna operación de Buscatrazos @msg no effect at index {k} no hay ningún efecto en el índice {k} @msg no end record no hay registro final @msg no file manager here aquí no hay administrador de archivos diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index 5942d952e..3b0bb739e 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -4373,6 +4373,7 @@ imageTrace Outline Ääriviiva @msg no document ei asiakirjaa @msg no document open asiakirjaa ei ole auki @msg no effect applied yet tehostetta ei ole vielä käytetty +@msg no Pathfinder operation applied yet polunetsimen toimintoa ei ole vielä käytetty @msg no effect at index {k} ei tehostetta indeksissä {k} @msg no end record ei loppumerkintää @msg no file manager here täällä ei ole tiedostonhallintaa diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 9d12a9422..f90d02f87 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -4154,6 +4154,7 @@ axis Both Les deux @msg no document aucun document @msg no document open aucun document ouvert @msg no effect applied yet aucun effet appliqué pour l’instant +@msg no Pathfinder operation applied yet aucune opération Pathfinder appliquée pour l’instant @msg no effect at index {k} aucun effet à l’indice {k} @msg no end record aucun enregistrement de fin @msg no file manager here aucun gestionnaire de fichiers ici diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index dbb6993ce..b8035f4c2 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -4814,6 +4814,7 @@ imageTrace Outline Körvonal @msg no document nincs dokumentum @msg no document open nincs megnyitott dokumentum @msg no effect applied yet még nincs alkalmazott effektus +@msg no Pathfinder operation applied yet még nincs alkalmazott útvonalkezelő-művelet @msg no effect at index {k} ezen az indexen nincs effektus: {k} @msg no end record nincs záró rekord @msg no file manager here itt nincs fájlkezelő diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 084f32a74..995e57458 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -4151,6 +4151,7 @@ imageTrace Outline Kontur @msg no document tidak ada dokumen @msg no document open tidak ada dokumen yang terbuka @msg no effect applied yet belum ada efek yang diterapkan +@msg no Pathfinder operation applied yet belum ada operasi Pathfinder yang diterapkan @msg no effect at index {k} tidak ada efek pada indeks {k} @msg no end record tidak ada rekaman akhir @msg no file manager here tidak ada pengelola file di sini diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index ec98d42ba..ffcb2a32a 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -4197,6 +4197,7 @@ axis Both Entrambi @msg no document nessun documento @msg no document open nessun documento aperto @msg no effect applied yet nessun effetto ancora applicato +@msg no Pathfinder operation applied yet nessuna operazione di elaborazione tracciati ancora applicata @msg no effect at index {k} nessun effetto all'indice {k} @msg no end record nessun record finale @msg no file manager here nessun gestore file disponibile qui diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 41e730518..5a8d91242 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -4543,6 +4543,7 @@ swatch Bright Violet ビビッドバイオレット @msg no PNG encoder PNG エンコーダーがありません @msg no document open ドキュメントが開かれていません @msg no effect applied yet まだ効果を適用していません +@msg no Pathfinder operation applied yet まだパスファインダーの操作を適用していません @msg no such perspective grid preset 指定された遠近グリッドプリセットが見つかりません @msg no such recent file 指定された最近使ったファイルが見つかりません @msg no such recent font 指定された最近使ったフォントが見つかりません diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 545958500..d150719a2 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -3934,6 +3934,7 @@ font-technology Variable ცვლადი @msg no document დოკუმენტი არ არის @msg no document open გახსნილი დოკუმენტი არ არის @msg no effect applied yet ეფექტი ჯერ არ გამოყენებულა +@msg no Pathfinder operation applied yet ტრაექტორიების ოპერაცია ჯერ არ გამოყენებულა @msg no effect at index {k} ინდექსზე {k} ეფექტი არ არის @msg no end record დასასრულის ჩანაწერი არ არის @msg no file manager here აქ ფაილების მენეჯერი არ არის diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 6ba3b91b2..e956dc4a3 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -4468,6 +4468,7 @@ theme Appearance 외관 @msg no PNG encoder PNG 인코더가 없습니다 @msg no document open 열린 문서가 없습니다 @msg no effect applied yet 아직 적용된 효과가 없습니다 +@msg no Pathfinder operation applied yet 아직 적용된 패스파인더 작업이 없습니다 @msg no such perspective grid preset 해당 원근 격자 프리셋이 없습니다 @msg no such recent file 해당 최근 파일이 없습니다 @msg no such recent font 해당 최근 글꼴이 없습니다 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 6b78604c6..fbe5d843c 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -4163,6 +4163,7 @@ imageTrace Outline Contour @msg no document geen document @msg no document open geen document geopend @msg no effect applied yet nog geen effect toegepast +@msg no Pathfinder operation applied yet nog geen Pathfinder-bewerking toegepast @msg no effect at index {k} geen effect op index {k} @msg no end record geen eindrecord @msg no file manager here hier is geen bestandsbeheerder diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index cacdfbe82..618cf8553 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -4288,6 +4288,7 @@ imageTrace Outline Kontur @msg no document brak dokumentu @msg no document open brak otwartego dokumentu @msg no effect applied yet nie zastosowano jeszcze żadnego efektu +@msg no Pathfinder operation applied yet nie zastosowano jeszcze żadnej operacji odnajdywania ścieżek @msg no effect at index {k} brak efektu o indeksie {k} @msg no end record brak rekordu końcowego @msg no file manager here brak menedżera plików diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 1418bc61b..a38135298 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -3734,6 +3734,7 @@ axis Both Ambos @msg no document nenhum documento @msg no document open nenhum documento aberto @msg no effect applied yet nenhum efeito aplicado ainda +@msg no Pathfinder operation applied yet nenhuma operação do Localizador de Caminhos aplicada ainda @msg no effect at index {k} nenhum efeito no índice {k} @msg no end record sem registro final @msg no file manager here nenhum gerenciador de arquivos aqui diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index ee4bb67aa..b1c4c01be 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -4193,6 +4193,7 @@ axis Both Оба @msg no document нет документа @msg no document open нет открытого документа @msg no effect applied yet эффект ещё не применён +@msg no Pathfinder operation applied yet операция обработки контуров ещё не применена @msg no effect at index {k} нет эффекта с индексом {k} @msg no end record нет конечной записи @msg no file manager here здесь нет файлового менеджера diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 3864fa5e2..3593d08d9 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -4068,6 +4068,7 @@ axis Both Båda @msg no document inget dokument @msg no document open inget dokument är öppet @msg no effect applied yet ingen effekt har använts än +@msg no Pathfinder operation applied yet ingen banfinnaråtgärd har använts än @msg no effect at index {k} ingen effekt vid index {k} @msg no end record ingen slutpost @msg no file manager here ingen filhanterare här diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 3d87bbb52..535cbeacc 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -4181,6 +4181,7 @@ axis Both Обидві @msg no document немає документа @msg no document open немає відкритого документа @msg no effect applied yet ефект ще не застосований +@msg no Pathfinder operation applied yet операцію обробки контурів ще не застосовано @msg no effect at index {k} немає ефекту з індексом {k} @msg no end record немає кінцевого запису @msg no file manager here тут немає файлового менеджера diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 51092dc6a..baf4a60d9 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -4462,6 +4462,7 @@ theme Appearance Giao diện @msg no document không có tài liệu @msg no document open không có tài liệu nào đang mở @msg no effect applied yet chưa áp dụng hiệu ứng +@msg no Pathfinder operation applied yet chưa áp dụng thao tác Pathfinder @msg no effect at index {k} không có hiệu ứng tại chỉ mục {k} @msg no end record không có bản ghi kết thúc @msg no file manager here không có trình quản lý tệp ở đây diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 5085e1a31..d49842dbc 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -4257,6 +4257,7 @@ imageTrace Outline 轮廓 @msg no PNG encoder 没有 PNG 编码器 @msg no document open 没有打开的文档 @msg no effect applied yet 尚未应用任何效果 +@msg no Pathfinder operation applied yet 尚未应用任何路径查找器操作 @msg no such perspective grid preset 没有此透视网格预设 @msg no such recent file 没有此最近使用的文件 @msg no such recent font 没有此最近使用的字体 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 6e0c66dd6..e305bc445 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2198,6 +2198,7 @@ file-filter Templates 範本 @msg no PNG encoder 沒有 PNG 編碼器 @msg no document open 沒有開啟的文件 @msg no effect applied yet 尚未套用任何效果 +@msg no Pathfinder operation applied yet 尚未套用任何路徑管理員操作 @msg no such perspective grid preset 沒有這個透視格點預設集 @msg no such recent file 沒有這個最近使用的檔案 @msg no such recent font 沒有這個最近使用的字體 diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index cafbbfb6d..a888114dd 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -134,6 +134,9 @@ pub struct VectorcraftApp { pub titlebar_inset: f32, /// Last applied effect (Effect → Apply Last Effect). pub last_effect: Option<(String, serde_json::Value)>, + /// The operation a Pathfinder panel button ran last (`unite`, `divide`, …) and the button's + /// label, which Repeat Pathfinder runs again ([`panels::pathfinder::repeat`]). + pub last_pathfinder: Option<(String, String)>, /// Last confirmed Warp Options (bend, distortion, orientation) for the next new warp dialog. pub last_warp: Option, /// The Character panel field that takes the keyboard focus when the panel next draws it diff --git a/crates/ui-egui/src/menus/run.rs b/crates/ui-egui/src/menus/run.rs index 22ddb543d..339682c94 100644 --- a/crates/ui-egui/src/menus/run.rs +++ b/crates/ui-egui/src/menus/run.rs @@ -544,6 +544,7 @@ fn run_ui_part6(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option app.run("effect.apply", json!({"effect": e, "params": params})), None => Err("no effect applied yet".into()), }, + "pathfinder.repeat" => crate::panels::pathfinder::repeat(app), "file.export.pdf" if p.as_object().is_none_or(|o| o.is_empty()) => crate::dialogs::open_save_pdf(app, &json!({})), "file.export.pdf" => io::export_pdf(app, p.clone()), _ => return None, diff --git a/crates/ui-egui/src/menus/state.rs b/crates/ui-egui/src/menus/state.rs index 4b009fbf4..d66e3e76c 100644 --- a/crates/ui-egui/src/menus/state.rs +++ b/crates/ui-egui/src/menus/state.rs @@ -217,6 +217,7 @@ pub fn enabled(app: &VectorcraftApp, id: &str) -> bool { "effect.dialog" | "ui.recolorDialog" | "ui.effectGalleryDialog" => app.session.active().is_some_and(|d| !d.selection.is_empty()), "ui.cropImage" => selected_image(app, |_| true), "effect.applyLast" | "effect.last" => app.last_effect.is_some() && app.session.active().is_some_and(|d| !d.selection.is_empty()), + "pathfinder.repeat" => app.last_pathfinder.as_ref().is_some_and(|(op, _)| enabled(app, &format!("object.pathfinder.{op}"))), "file.export.pdf" | "ui.savePdfDialog" | "ui.fileInfoDialog" | "ui.rasterEffectsSettingsDialog" => app.session.active().is_some(), "ui.missingFontsDialog" => !cfg!(target_arch = "wasm32") && app.session.active().is_some(), "ui.findFontsInFolder" => crate::picks::can(app, &crate::picks::PickRequest::Folder) && app.session.active().is_some(), diff --git a/crates/ui-egui/src/menus/ui_commands.rs b/crates/ui-egui/src/menus/ui_commands.rs index 51ba642c1..6eca8e9df 100644 --- a/crates/ui-egui/src/menus/ui_commands.rs +++ b/crates/ui-egui/src/menus/ui_commands.rs @@ -575,6 +575,12 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "{} show a crop box on the selected image (tool `cropImage`): it starts on the part over the image's artboard; drag its handles or inside it, then Enter or the Control bar's Apply crops the image to it (object.cropImage {rect}), Escape or Cancel leaves it as it is, both back to the Selection tool. tool.setOption {key: \"rect\", value: [x, y, width, height]} sets the box", ), ("effect.applyLast", "Apply Last Effect", "Cmd+Shift+E", "{}"), + ( + "pathfinder.repeat", + "Repeat Pathfinder", + "Cmd+4", + "{} repeat the last Pathfinder operation run from the Pathfinder panel on the selection; an error until a button has run", + ), ( "file.export.pdf", "Save as PDF…", diff --git a/crates/ui-egui/src/panels/pathfinder.rs b/crates/ui-egui/src/panels/pathfinder.rs index 13008e064..a888ebbba 100644 --- a/crates/ui-egui/src/panels/pathfinder.rs +++ b/crates/ui-egui/src/panels/pathfinder.rs @@ -3,10 +3,10 @@ //! sets theirs. use egui::Ui; -use serde_json::json; +use serde_json::{Value, json}; use vectorcraft_engine::doc::ShapeMode; -use super::{pstate, selection_len, set_pstate}; +use super::selection_len; use crate::VectorcraftApp; use crate::widgets::{self, menu_item}; @@ -26,9 +26,18 @@ pub const PATHFINDERS: [(&str, &str, &str); 6] = [ ("dc-pf-minus-back", "Minus Back", "minusBack"), ]; -fn run(app: &mut VectorcraftApp, ui: &Ui, op: &str, label: &str) { +fn run(app: &mut VectorcraftApp, _ui: &Ui, op: &str, label: &str) { if app.run(&format!("object.pathfinder.{op}"), json!({})).is_ok() { - set_pstate(ui.ctx(), "pf-last", Some((op.to_string(), label.to_string()))); + app.last_pathfinder = Some((op.to_string(), label.to_string())); + } +} + +/// Repeat Pathfinder (`pathfinder.repeat`, Cmd/Ctrl+4, the panel menu's Repeat item): the +/// operation a button of the panel ran last, on the selection. +pub fn repeat(app: &mut VectorcraftApp) -> Result { + match app.last_pathfinder.clone() { + Some((op, _)) => app.run(&format!("object.pathfinder.{op}"), json!({})), + None => Err("no Pathfinder operation applied yet".into()), } } @@ -110,13 +119,13 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { if menu_item(ui, tl!("Trap…"), operands(app) >= 2, false) { open_trap(app); } - let last: Option<(String, String)> = pstate(ui.ctx(), "pf-last"); - let label = - last.as_ref().map(|(_, l)| crate::i18n::fmt(tl!("Repeat {name}"), &[("name", tl!(l))])).unwrap_or_else(|| tl!("Repeat Pathfinder").into()); - if menu_item(ui, &label, last.is_some() && selection_len(app) >= 1, false) - && let Some((op, l)) = last - { - run(app, ui, &op, &l); + let label = app + .last_pathfinder + .as_ref() + .map(|(_, l)| crate::i18n::fmt(tl!("Repeat {name}"), &[("name", tl!(l))])) + .unwrap_or_else(|| tl!("Repeat Pathfinder").into()); + if menu_item(ui, &label, crate::menus::enabled(app, "pathfinder.repeat"), false) { + crate::menus::invoke(app, "pathfinder.repeat", json!({})); } if menu_item(ui, tl!("Pathfinder Options…"), true, false) { crate::menus::invoke(app, "object.pathfinder.options", json!({})); @@ -281,4 +290,134 @@ mod tests { assert_eq!(pieces.len(), 1, "only the square's piece outside the unfilled rectangle"); assert!(pieces.iter().all(|p| !p.appearance.fill_paint().is_none())); } + + /// A document with two overlapping 50 pt squares, both selected. + fn two_squares() -> VectorcraftApp { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({"width": 300, "height": 200})).unwrap(); + select_two_squares(&mut app, 10.0); + app + } + + /// Draw two overlapping 50 pt squares, the first at (`x`, 10), and select only them. + fn select_two_squares(app: &mut VectorcraftApp, x: f64) { + let a = app.run("shape.rectangle", json!({"x": x, "y": 10, "width": 50, "height": 50})).unwrap()["id"].clone(); + let b = app.run("shape.rectangle", json!({"x": x + 30.0, "y": 40, "width": 50, "height": 50})).unwrap()["id"].clone(); + app.run("select.set", json!({"ids": [a, b]})).unwrap(); + } + + /// The kind and geometric bounds of each object on the first layer. + fn objects(app: &VectorcraftApp) -> Vec<(&'static str, [f64; 4])> { + let doc = &app.session.active().unwrap().doc; + let kind = |n: &vectorcraft_engine::doc::Node| if matches!(n.kind, NodeKind::Path { .. }) { "path" } else { "other" }; + let bounds = |n: &vectorcraft_engine::doc::Node| n.geometric_bounds().map_or([0.0; 4], |b| [b.x0, b.y0, b.x1, b.y1]); + doc.layers[0].children().unwrap().iter().map(|n| (kind(n), bounds(n))).collect() + } + + /// Cmd/Ctrl+4 pressed through the shortcut handler. + fn press_cmd_4(app: &mut VectorcraftApp) { + let ctx = egui::Context::default(); + let ev = egui::Event::Key { key: egui::Key::Num4, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::COMMAND }; + let mut out = ctx.run_ui(egui::RawInput { events: vec![ev], ..Default::default() }, |ui| crate::shortcuts::handle(app, ui.ctx())); + out.textures_delta.clear(); + } + + /// A click on the panel button that screen readers name `label`. egui finds what the pointer + /// is over from the frame before, so the move gets a frame of its own. + fn click_button(app: &mut VectorcraftApp, label: &str) { + crate::i18n::set_current(crate::i18n::Lang::EN); + let ctx = egui::Context::default(); + ctx.enable_accesskit(); + crate::theme::install_fonts(&ctx); + let mut time = 0.0; + let mut frame = |app: &mut VectorcraftApp, events: Vec| { + time += 0.1; + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(300.0, 200.0)); + let input = egui::RawInput { events, time: Some(time), screen_rect: Some(screen), ..Default::default() }; + let mut out = ctx.run_ui(input, |ui| show(app, ui)); + out.textures_delta.clear(); + out + }; + frame(app, vec![]); + let out = frame(app, vec![]); + let nodes = &out.platform_output.accesskit_update.as_ref().expect("an accessibility tree").nodes; + let node = nodes.iter().find(|(_, n)| n.label() == Some(label) && n.role() == egui::accesskit::Role::Button); + let b = node.unwrap_or_else(|| panic!("no button named {label:?}")).1.bounds().expect("bounds"); + let at = egui::pos2((b.x0 + b.x1) as f32 * 0.5, (b.y0 + b.y1) as f32 * 0.5); + let button = + |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE }; + frame(app, vec![egui::Event::PointerMoved(at)]); + frame(app, vec![button(true)]); + frame(app, vec![button(false)]); + } + + /// A click on the panel menu item labeled `label`. + fn click_menu_item(app: &mut VectorcraftApp, label: &str) { + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let frame = |app: &mut VectorcraftApp, events: Vec| { + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(236.0, 400.0)); + let mut out = ctx.run_ui(egui::RawInput { screen_rect: Some(screen), events, ..Default::default() }, |ui| menu(app, ui)); + out.textures_delta.clear(); + crate::tests_removeanchors::shapes_text(&out.shapes.iter().map(|c| c.shape.clone()).collect::>()) + }; + let labels = frame(app, vec![]); + let at = labels.iter().find(|(t, _)| t.trim() == label).map(|(_, r)| r.center()).unwrap_or_else(|| panic!("no {label:?} in {labels:?}")); + let button = + |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE }; + frame(app, vec![egui::Event::PointerMoved(at), button(true)]); + frame(app, vec![button(false)]); + } + + /// Cmd/Ctrl+4 is Repeat Pathfinder's default key. Keyboard Shortcuts lists the command with + /// it, and no other default has the key. + #[test] + fn cmd_4_is_repeat_pathfinder() { + use crate::shortcut_editor::{command_for_key, default_command_shortcut}; + assert_eq!(default_command_shortcut("pathfinder.repeat"), Some("Cmd+4")); + assert_eq!(command_for_key("Cmd+4"), Some("pathfinder.repeat")); + let chord = crate::shortcuts::parse("Cmd+4").expect("parses"); + assert_eq!((chord.modifiers, chord.logical_key), (egui::Modifiers::COMMAND, egui::Key::Num4)); + assert_eq!(crate::menus::pretty_shortcut("Cmd+4"), if cfg!(target_os = "macos") { "⌘4" } else { "Ctrl+4" }); + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + let listed = app.run("shortcuts.list", json!({"query": "pathfinder.repeat"})).unwrap(); + let rows: Vec<_> = listed.as_array().expect("rows").iter().map(|r| (r["label"].clone(), r["default"].clone())).collect(); + assert_eq!(rows, [(json!("Repeat Pathfinder"), json!("Cmd+4"))], "Keyboard Shortcuts lists it"); + assert_eq!(app.run("shortcuts.conflicts", json!({})).unwrap(), json!([]), "no other default has the key"); + } + + /// After Unite from the panel, Cmd/Ctrl+4 unites the two squares selected next, and the panel + /// menu's Repeat Unite item, which runs the same command, unites a third pair. + #[test] + fn cmd_4_repeats_the_operation_a_panel_button_ran_last() { + let mut app = two_squares(); + click_button(&mut app, "Unite"); + let first = ("path", [10.0, 10.0, 90.0, 90.0]); + assert_eq!(objects(&app), [first], "the panel's Unite merged the first pair"); + select_two_squares(&mut app, 110.0); + assert!(crate::menus::enabled(&app, "pathfinder.repeat")); + press_cmd_4(&mut app); + let second = ("path", [110.0, 10.0, 190.0, 90.0]); + assert_eq!(objects(&app), [first, second], "Cmd+4 merged the second pair"); + select_two_squares(&mut app, 210.0); + click_menu_item(&mut app, "Repeat Unite"); + assert_eq!(objects(&app), [first, second, ("path", [210.0, 10.0, 290.0, 90.0])], "the menu item merged the third pair"); + } + + /// Before a button of the panel has run, Repeat Pathfinder is disabled and Cmd/Ctrl+4 leaves + /// the selected squares as they are, with a message in the status bar. After a button has + /// run, the command is disabled again while nothing is selected. + #[test] + fn cmd_4_does_nothing_before_a_panel_button_has_run() { + let mut app = two_squares(); + let before = objects(&app); + assert_eq!(before.len(), 2); + assert!(!crate::menus::enabled(&app, "pathfinder.repeat")); + press_cmd_4(&mut app); + assert_eq!(objects(&app), before, "Cmd+4 changed nothing"); + assert_eq!(app.ui.status, "no Pathfinder operation applied yet"); + click_button(&mut app, "Unite"); + app.run("select.none", json!({})).unwrap(); + assert!(!crate::menus::enabled(&app, "pathfinder.repeat"), "nothing selected"); + } } diff --git a/docs/mcp.md b/docs/mcp.md index 205079441..f12412322 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -431,7 +431,7 @@ objects' fills or strokes differ (`fillMixed` / `strokeMixed`, drawn as a "?" pr | `export` | `{path?, format?, scale?, artboard?, range?, selection?, outlineText?, options?}` | `svg`, `svgz`, `pdf`, `ai` (the PDF-compatible `.ai` that Save writes, which carries the document when every artboard is written in order), `eps`, `dxf`, `emf`, `wmf`, `png`, `jpg`, `webp`, `gif`, `png8` (an indexed `.png`), `tiff`, `bmp`, `tga`, `psd` (layered), `txt` (the document's text), `vectorcraft` or `template` (a native template). The tool's `format` enum and `document.formats` list them, generated from the engine's format table. When `format` is omitted, it comes from the path's extension. PDF and `.ai` write one page per artboard: all of them, or `artboard` (0-based) / `range` (`"1-3, 5"`, 1-based). SVG and SVGZ write one file per chosen artboard. EPS writes one file of the visible art; with `artboard`, `range`, `options.artboards` or `options: {"useArtboards": true}` it writes one file per chosen artboard, after a central file of the visible art when there are two or more, and `useArtboards: false` keeps the one file. Rasters, DXF, EMF and WMF write one artboard, or one file per chosen artboard with `options: {"useArtboards": true}`. Rasters also take `options: {"rect": [x, y, width, height]}` (page points, y down): that area of the page at the given `ppi`/`scale`, without an artboard and without changing the document; it is refused with `artboard`, `artboards`, `range` or `useArtboards`, with a non-positive or non-finite size, and for non-raster formats. `options` carries more format options (e.g. `{"quality": 80}` for JPEG). `selection: true` exports the selected objects cropped to their bounds (the reply adds their `bounds`, and reports `format` and the encoder's `warnings` as a whole-document export does); `outlineText: true` writes SVG text as paths. Template layers are left out, live effects are kept, and exporting `vectorcraft` never changes the document's path. Without `path` the bytes come back as `dataBase64`. Both backends run the same `document.export` call. | | `add_text` | `{text, x?, y?, width?, height?, fit?, fitMinPercent?, path?, mode?, pathEffect?, size?, font?, color?, justify?, vertical?}` | Point type at (x, y) (`justify`: `left`, `center` or `right` starts, centres or ends each line on x); area type with `width`/`height` (`fit`: `none`, `autoHeight`, `shrinkText`, as `text.areaOptions`); or `path` + `mode` (`area`/`onPath`) to flow text in or along a path, with `pathEffect` (`rainbow`, `skew`, `3dRibbon`, `stairStep`, `gravity`); `justify` (also `auto`, `justifyAll`) aligns the paragraphs of every kind, as `text.setStyle` does later. `vertical: true` uses the existing vertical writing mode for every kind (default `false`, horizontal). Returns `{id}`, with `overflow: true` when the text doesn't fit its frame or path. | | `apply_effect` | `{effect?, params?, ids?}` | Appends a live effect. Without `effect`, returns the effect catalogue with parameters and defaults. | -| `pathfinder` | `{operation, ids?}` | `unite`, `minusFront`, `intersect`, `exclude`, `divide`, `trim`, `merge`, `crop`, `outline`, `minusBack`. For a live version, apply the `pathfinder.*` effect to a group (`effect.apply` groups several loose selected objects first). | +| `pathfinder` | `{operation, ids?}` | `unite`, `minusFront`, `intersect`, `exclude`, `divide`, `trim`, `merge`, `crop`, `outline`, `minusBack`. For a live version, apply the `pathfinder.*` effect to a group (`effect.apply` groups several loose selected objects first). In the desktop app, the UI command `pathfinder.repeat` (Repeat Pathfinder, Cmd/Ctrl+4) takes no params and runs the operation a Pathfinder panel button ran last on the selection; before a button has run it returns the error "no Pathfinder operation applied yet". | | `transform` | `{ids?, dx?, dy?, rotate?, scale?, scaleX?, scaleY?, reflect?, shear?, origin?, copy?}` | Runs move, rotate, scale, reflect, shear in that order. With `copy`, the first step duplicates. | | `create_graph` | `{type?, x, y, width, height, csv? \| series?, categories?, rows?}` | The nine Illustrator graph types: column, stacked column, bar, stacked bar, line, area, scatter, pie and radar. An empty CSV cell or a `null` in `rows` is a blank value; a number in straight quotes is a label. Edit later with `graph.setData` / `graph.setType` via `run_command`. `graph.column {design, columnType: vertical\|uniform\|repeating\|sliding, each, fractions: chop\|scale}` draws the columns or bars of the series picked with Group Selection (or `seriesIndexes`, or all of them) with a graph design saved by `graph.design`; with no `design` it returns the series' current design and options. Type in the design that holds a `%` followed by two digits shows each column's value, with at least the first digit's places before the decimal point and the second digit's places after it. | | `text_wrap` | `{ids?, offset?, invert?, release?}` | Area type below the objects (same layer) flows around them. | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index c34242da1..a5c1c349c 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -615,6 +615,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - While the Type tool edits text, a character typed with AltGr is typed and runs no command. Firefox on Windows reports AltGr as Ctrl and Alt held, so in the web build AltGr+L (ł on a Polish layout) does not also open Layer Options for a new layer, and AltGr+Q (@ on a German layout) does not also run Reset Tracking and Kerning. A Ctrl+Alt chord that types no character runs its command. - With the Artboard tool, Cmd/Ctrl+A after a click on a row of the Artboards panel selects every artboard, as the macOS menu bar's Select All does then; the active artboard stays active, and the art's selection does not change. After a press outside the panel's list, Cmd/Ctrl+A selects every object again. It used to select every object after a click in the panel too. Public reference: [Select artboards](https://helpx.adobe.com/illustrator/desktop/create-manage-artboards/add-edit-artboards/select-artboards.html). - Cmd/Ctrl+/ (`appearance.addFill`) and Cmd/Ctrl+Alt+/ (`appearance.addStroke`) add a new fill and a new stroke to the selected objects, as the Appearance panel's Add New Fill and Add New Stroke buttons do, on the keys the reference app's [Default keyboard shortcuts](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html) page lists for adding a new fill and a new stroke. Keyboard Shortcuts lists both under Window › Appearance. + - Cmd/Ctrl+4 (`pathfinder.repeat`, Repeat Pathfinder) runs the operation a Pathfinder panel button ran last on the selected objects, as the panel menu's Repeat item does. Until a button has run, the command is disabled and the key leaves the art as it is, with "no Pathfinder operation applied yet" in the status bar. - Cmd/Ctrl+Alt+Shift+. and , (`type.size.increaseByFive`, `type.size.decreaseByFive`) step the font size of the selected type, or of the text the Type tool has selected, by five times the Size/Leading increment (Preferences › Type); Cmd/Ctrl+Shift+. and , still step it by one increment. - Cmd/Ctrl+Alt+Q (`type.resetTracking`) sets the tracking of the selected type, or of the text the Type tool has selected, to 0 and its kerning to Auto (0 in the Character panel), in one undo step; at a caret it resets the kerning of the character before it. - Cmd/Ctrl+Alt+K (`type.highlightKerning`) and Cmd/Ctrl+Alt+Shift+F (`type.highlightFontName`) show the Character panel and give its Kerning field the keyboard focus with the value selected, or open its font family menu with the search field focused, on the keys the reference app's [Default keyboard shortcuts](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html) page lists for highlighting kerning and the font name field. The page lists the same key for selecting the tracking value; `type.highlightTracking` focuses the Tracking field the same way and has no default key in Keyboard Shortcuts. diff --git a/docs/ui-parity.md b/docs/ui-parity.md index ffd993efb..6b8c0b1c3 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -244,7 +244,9 @@ tools, never checked side by side) isn't captured row by row. ## Keyboard shortcuts Cmd/Ctrl+/ (`appearance.addFill`) and Cmd/Ctrl+Alt+/ (`appearance.addStroke`) add a new fill and a new stroke to the -selected objects, as the Appearance panel's Add New Fill and Add New Stroke buttons do. +selected objects, as the Appearance panel's Add New Fill and Add New Stroke buttons do. Cmd/Ctrl+4 +(`pathfinder.repeat`, Repeat Pathfinder) runs the operation a Pathfinder panel button ran last on the selected +objects, as the panel menu's Repeat item does. ~110 distinct modified shortcuts on menu commands plus single-key tool shortcuts for the default toolbar (`crates/tools/src/catalog.rs`), a shortcut editor with chords, and saved sets. Cmd/Ctrl+Alt+L (`ui.newLayer`) opens Layer Options for a new layer, as an Alt-click on the Layers panel's New Layer button does, and Cmd/Ctrl+L still adds a layer without the dialog. F8 (`ui.symbolOptions`) opens Symbol Options for a new symbol made from the selection, as the Symbols panel's New Symbol button does. Not yet diffed item by item From bad34b711580ca544e0e0c5a5bf29ec53add6575 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 30 Oct 2026 08:46:17 -0700 Subject: [PATCH 2/3] Pathfinder: direct commands set the operation Cmd+4 repeats Every successful Shape Mode or Pathfinder operation now sets the operation that Repeat Pathfinder runs, including direct object.pathfinder.* commands, not only the panel's buttons. Trap, compound-shape edits and live effects leave it unchanged, and a failed operation keeps the earlier one. The panel menu names the remembered operation, and Repeat runs it on the current selection with the current Pathfinder Options. With too few objects selected, Cmd/Ctrl+4 leaves the art as it is and shows the panel's selection message in the status bar. Adds the "no Pathfinder operation applied yet" rows to the Catalan and Turkish catalogs, and tests for direct and failed operations, Trap, compound shapes, current options, a one-path selection and the default keys. --- crates/ui-egui/src/app/commands.rs | 6 + crates/ui-egui/src/i18n/ca.tsv | 1 + crates/ui-egui/src/i18n/tr.tsv | 1 + crates/ui-egui/src/lib.rs | 5 +- crates/ui-egui/src/menus/state.rs | 5 +- crates/ui-egui/src/menus/ui_commands.rs | 2 +- crates/ui-egui/src/panels/pathfinder.rs | 148 +++++++++++++++++++++--- docs/mcp.md | 2 +- docs/target-app-parity.md | 2 +- docs/ui-parity.md | 5 +- 10 files changed, 148 insertions(+), 29 deletions(-) diff --git a/crates/ui-egui/src/app/commands.rs b/crates/ui-egui/src/app/commands.rs index 0f136e65e..9273f9683 100644 --- a/crates/ui-egui/src/app/commands.rs +++ b/crates/ui-egui/src/app/commands.rs @@ -88,6 +88,12 @@ impl VectorcraftApp { { crate::ime::note_application_edit(self, &ctx, ime_view, ime_target, ime_before); } + if r.is_ok() + && let Some(op) = id.strip_prefix("object.pathfinder.") + && panels::pathfinder::operation_label(op).is_some() + { + self.last_pathfinder = Some(op.to_string()); + } if r.is_ok() && matches!(id, "edit.copy" | "edit.cut") { self.publish_clipboard(); } diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 1a1ce4c03..08e8c524d 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -5105,6 +5105,7 @@ @msg no document no hi ha document @msg no document open no hi ha cap document obert @msg no effect applied yet encara no s’ha aplicat cap efecte +@msg no Pathfinder operation applied yet encara no s’ha aplicat cap operació del Cercador de traçats @msg no effect at index {k} no hi ha cap efecte a l’índex {k} @msg no empty artboards to delete no hi ha taulers buits per suprimir @msg no end record no hi ha registre final diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 051726501..eb2a277f0 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -3931,6 +3931,7 @@ axis Both Her İkisi @msg no document belge yok @msg no document open hiçbir belge açık değil @msg no effect applied yet henüz hiçbir efekt uygulanmadı +@msg no Pathfinder operation applied yet henüz hiçbir Yol Bulucu işlemi uygulanmadı @msg no effect at index {k} {k} endeksinde etkisi yok @msg no end record son kayıt yok @msg no file manager here burada dosya yöneticisi yok diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index a888114dd..a15351576 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -134,9 +134,8 @@ pub struct VectorcraftApp { pub titlebar_inset: f32, /// Last applied effect (Effect → Apply Last Effect). pub last_effect: Option<(String, serde_json::Value)>, - /// The operation a Pathfinder panel button ran last (`unite`, `divide`, …) and the button's - /// label, which Repeat Pathfinder runs again ([`panels::pathfinder::repeat`]). - pub last_pathfinder: Option<(String, String)>, + /// Last successful destructive Pathfinder operation, repeated on the current selection. + pub last_pathfinder: Option, /// Last confirmed Warp Options (bend, distortion, orientation) for the next new warp dialog. pub last_warp: Option, /// The Character panel field that takes the keyboard focus when the panel next draws it diff --git a/crates/ui-egui/src/menus/state.rs b/crates/ui-egui/src/menus/state.rs index d66e3e76c..55ee84b80 100644 --- a/crates/ui-egui/src/menus/state.rs +++ b/crates/ui-egui/src/menus/state.rs @@ -217,7 +217,10 @@ pub fn enabled(app: &VectorcraftApp, id: &str) -> bool { "effect.dialog" | "ui.recolorDialog" | "ui.effectGalleryDialog" => app.session.active().is_some_and(|d| !d.selection.is_empty()), "ui.cropImage" => selected_image(app, |_| true), "effect.applyLast" | "effect.last" => app.last_effect.is_some() && app.session.active().is_some_and(|d| !d.selection.is_empty()), - "pathfinder.repeat" => app.last_pathfinder.as_ref().is_some_and(|(op, _)| enabled(app, &format!("object.pathfinder.{op}"))), + "pathfinder.repeat" => app.last_pathfinder.as_ref().is_some_and(|op| { + let min = crate::panels::pathfinder::minimum_operands(op); + crate::panels::pathfinder::operands(app) >= min + }), "file.export.pdf" | "ui.savePdfDialog" | "ui.fileInfoDialog" | "ui.rasterEffectsSettingsDialog" => app.session.active().is_some(), "ui.missingFontsDialog" => !cfg!(target_arch = "wasm32") && app.session.active().is_some(), "ui.findFontsInFolder" => crate::picks::can(app, &crate::picks::PickRequest::Folder) && app.session.active().is_some(), diff --git a/crates/ui-egui/src/menus/ui_commands.rs b/crates/ui-egui/src/menus/ui_commands.rs index 6eca8e9df..8c62a3f11 100644 --- a/crates/ui-egui/src/menus/ui_commands.rs +++ b/crates/ui-egui/src/menus/ui_commands.rs @@ -579,7 +579,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "pathfinder.repeat", "Repeat Pathfinder", "Cmd+4", - "{} repeat the last Pathfinder operation run from the Pathfinder panel on the selection; an error until a button has run", + "{} repeat the last successful Shape Mode or Pathfinder operation on the selection with the current Pathfinder Options; an error until one has run", ), ( "file.export.pdf", diff --git a/crates/ui-egui/src/panels/pathfinder.rs b/crates/ui-egui/src/panels/pathfinder.rs index a888ebbba..953393847 100644 --- a/crates/ui-egui/src/panels/pathfinder.rs +++ b/crates/ui-egui/src/panels/pathfinder.rs @@ -26,24 +26,38 @@ pub const PATHFINDERS: [(&str, &str, &str); 6] = [ ("dc-pf-minus-back", "Minus Back", "minusBack"), ]; -fn run(app: &mut VectorcraftApp, _ui: &Ui, op: &str, label: &str) { - if app.run(&format!("object.pathfinder.{op}"), json!({})).is_ok() { - app.last_pathfinder = Some((op.to_string(), label.to_string())); - } +/// Returns the minimum number of operands required by a Pathfinder operation. +pub(crate) fn minimum_operands(op: &str) -> usize { + if matches!(op, "outline" | "divide") { 1 } else { 2 } } -/// Repeat Pathfinder (`pathfinder.repeat`, Cmd/Ctrl+4, the panel menu's Repeat item): the -/// operation a button of the panel ran last, on the selection. +/// The panel name for one of the ten repeatable Shape Mode or Pathfinder operations. +pub fn operation_label(op: &str) -> Option<&'static str> { + SHAPE_MODES.into_iter().chain(PATHFINDERS).find(|(_, _, id)| *id == op).map(|(_, label, _)| label) +} + +fn run(app: &mut VectorcraftApp, op: &str) { + // The command path reports a failed operation in the status bar. + let _ = app.run(&format!("object.pathfinder.{op}"), json!({})); +} + +/// Repeat the last successful Shape Mode or Pathfinder operation on the current selection. pub fn repeat(app: &mut VectorcraftApp) -> Result { match app.last_pathfinder.clone() { - Some((op, _)) => app.run(&format!("object.pathfinder.{op}"), json!({})), + Some(op) => { + let min = minimum_operands(&op); + if operands(app) < min { + return Err("Select two or more objects, or a group".into()); + } + app.run(&format!("object.pathfinder.{op}"), json!({})) + } None => Err("no Pathfinder operation applied yet".into()), } } /// How many objects the selection gives an operation: a group selected alone counts as the shapes /// the operation takes from it, nested groups included (#1450, #1635). -fn operands(app: &VectorcraftApp) -> usize { +pub(crate) fn operands(app: &VectorcraftApp) -> usize { let alone = app.session.active().filter(|d| d.selection.len() == 1).and_then(|d| d.doc.node(*d.selection.objects.first()?)); match alone.filter(|n| matches!(n.kind, vectorcraft_doc::NodeKind::Group { .. })) { Some(group) => vectorcraft_engine::cmd::shape_count(group), @@ -64,13 +78,13 @@ fn button(ui: &mut Ui, icon: &str, tip: &str, selected: bool, n: usize, min: usi /// A Shape Mode button clicked: Alt makes a compound shape of the selection in `mode`; with /// members of compound shapes selected they take `mode`; otherwise operation `op` runs. -fn shape_mode(app: &mut VectorcraftApp, ui: &Ui, op: &str, label: &str, mode: ShapeMode, members: bool) { +fn shape_mode(app: &mut VectorcraftApp, ui: &Ui, op: &str, mode: ShapeMode, members: bool) { if ui.input(|i| i.modifiers.alt) { crate::menus::invoke(app, "object.compoundShape.make", json!({"mode": mode.name()})); } else if members { crate::menus::invoke(app, "object.compoundShape.setMode", json!({"mode": mode.name()})); } else { - run(app, ui, op, label); + run(app, op); } } @@ -88,7 +102,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { let count = if modes.is_empty() { n } else { n.max(2) }; for ((icon, tip, op), mode) in SHAPE_MODES.into_iter().zip(ShapeMode::ALL) { if button(ui, icon, tip, shared == Some(mode), count, 2) { - shape_mode(app, ui, op, tip, mode, !modes.is_empty()); + shape_mode(app, ui, op, mode, !modes.is_empty()); } } let resp = ui.add_enabled_ui(expand, |ui| widgets::flat_button_fit(ui, "Expand", 90.0)).inner; @@ -100,9 +114,9 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { ui.horizontal(|ui| { ui.spacing_mut().item_spacing.x = 5.0; for (icon, tip, op) in PATHFINDERS { - let min = if op == "outline" || op == "divide" { 1 } else { 2 }; + let min = minimum_operands(op); if button(ui, icon, tip, false, n, min) { - run(app, ui, op, tip); + run(app, op); } } }); @@ -122,7 +136,8 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { let label = app .last_pathfinder .as_ref() - .map(|(_, l)| crate::i18n::fmt(tl!("Repeat {name}"), &[("name", tl!(l))])) + .and_then(|op| operation_label(op)) + .map(|label| crate::i18n::fmt(tl!("Repeat {name}"), &[("name", tl!(label))])) .unwrap_or_else(|| tl!("Repeat Pathfinder").into()); if menu_item(ui, &label, crate::menus::enabled(app, "pathfinder.repeat"), false) { crate::menus::invoke(app, "pathfinder.repeat", json!({})); @@ -325,6 +340,11 @@ mod tests { /// A click on the panel button that screen readers name `label`. egui finds what the pointer /// is over from the frame before, so the move gets a frame of its own. fn click_button(app: &mut VectorcraftApp, label: &str) { + click_button_with_modifiers(app, label, egui::Modifiers::NONE); + } + + /// Click a panel button with a modifier held across the input frames. + fn click_button_with_modifiers(app: &mut VectorcraftApp, label: &str, modifiers: egui::Modifiers) { crate::i18n::set_current(crate::i18n::Lang::EN); let ctx = egui::Context::default(); ctx.enable_accesskit(); @@ -333,6 +353,8 @@ mod tests { let mut frame = |app: &mut VectorcraftApp, events: Vec| { time += 0.1; let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(300.0, 200.0)); + let mut events = events; + events.insert(0, egui::Event::ModifiersChanged(modifiers)); let input = egui::RawInput { events, time: Some(time), screen_rect: Some(screen), ..Default::default() }; let mut out = ctx.run_ui(input, |ui| show(app, ui)); out.textures_delta.clear(); @@ -344,8 +366,7 @@ mod tests { let node = nodes.iter().find(|(_, n)| n.label() == Some(label) && n.role() == egui::accesskit::Role::Button); let b = node.unwrap_or_else(|| panic!("no button named {label:?}")).1.bounds().expect("bounds"); let at = egui::pos2((b.x0 + b.x1) as f32 * 0.5, (b.y0 + b.y1) as f32 * 0.5); - let button = - |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE }; + let button = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers }; frame(app, vec![egui::Event::PointerMoved(at)]); frame(app, vec![button(true)]); frame(app, vec![button(false)]); @@ -375,6 +396,7 @@ mod tests { fn cmd_4_is_repeat_pathfinder() { use crate::shortcut_editor::{command_for_key, default_command_shortcut}; assert_eq!(default_command_shortcut("pathfinder.repeat"), Some("Cmd+4")); + assert_eq!(default_command_shortcut("effect.applyLast"), Some("Cmd+Shift+E")); assert_eq!(command_for_key("Cmd+4"), Some("pathfinder.repeat")); let chord = crate::shortcuts::parse("Cmd+4").expect("parses"); assert_eq!((chord.modifiers, chord.logical_key), (egui::Modifiers::COMMAND, egui::Key::Num4)); @@ -384,6 +406,8 @@ mod tests { let rows: Vec<_> = listed.as_array().expect("rows").iter().map(|r| (r["label"].clone(), r["default"].clone())).collect(); assert_eq!(rows, [(json!("Repeat Pathfinder"), json!("Cmd+4"))], "Keyboard Shortcuts lists it"); assert_eq!(app.run("shortcuts.conflicts", json!({})).unwrap(), json!([]), "no other default has the key"); + assert_eq!(vectorcraft_engine::command_specs().iter().filter(|c| c.shortcut == Some("Cmd+4")).count(), 0); + assert_eq!(crate::menus::UI_COMMANDS.iter().filter(|c| c.2 == "Cmd+4").map(|c| c.0).collect::>(), ["pathfinder.repeat"]); } /// After Unite from the panel, Cmd/Ctrl+4 unites the two squares selected next, and the panel @@ -404,11 +428,11 @@ mod tests { assert_eq!(objects(&app), [first, second, ("path", [210.0, 10.0, 290.0, 90.0])], "the menu item merged the third pair"); } - /// Before a button of the panel has run, Repeat Pathfinder is disabled and Cmd/Ctrl+4 leaves - /// the selected squares as they are, with a message in the status bar. After a button has + /// Before a repeatable operation has run, Repeat Pathfinder is disabled and Cmd/Ctrl+4 leaves + /// the selected squares as they are, with a message in the status bar. After an operation has /// run, the command is disabled again while nothing is selected. #[test] - fn cmd_4_does_nothing_before_a_panel_button_has_run() { + fn cmd_4_does_nothing_before_an_operation_has_run() { let mut app = two_squares(); let before = objects(&app); assert_eq!(before.len(), 2); @@ -419,5 +443,91 @@ mod tests { click_button(&mut app, "Unite"); app.run("select.none", json!({})).unwrap(); assert!(!crate::menus::enabled(&app, "pathfinder.repeat"), "nothing selected"); + assert!(app.run("pathfinder.repeat", json!({})).is_err()); + } + + /// One selected path cannot supply the operands for Unite; the shortcut reports the failure. + #[test] + fn repeat_with_one_path_keeps_art_and_history() { + let mut app = two_squares(); + click_button(&mut app, "Unite"); + let before = objects(&app); + assert_eq!(before.len(), 1); + assert!(!crate::menus::enabled(&app, "pathfinder.repeat")); + press_cmd_4(&mut app); + assert_eq!(objects(&app), before); + assert_eq!(app.ui.status, "Select two or more objects, or a group"); + assert_eq!(app.last_pathfinder.as_deref(), Some("unite")); + } + + /// A direct command replaces the panel's previous operation and supplies the menu name. + #[test] + fn direct_operation_becomes_the_one_repeated() { + let mut app = two_squares(); + click_button(&mut app, "Unite"); + select_two_squares(&mut app, 110.0); + app.run("object.pathfinder.intersect", json!({})).unwrap(); + assert_eq!(app.last_pathfinder.as_deref(), Some("intersect")); + select_two_squares(&mut app, 210.0); + click_menu_item(&mut app, "Repeat Intersect"); + assert_eq!(objects(&app).last(), Some(&("path", [240.0, 40.0, 260.0, 60.0]))); + assert_eq!(app.last_pathfinder.as_deref(), Some("intersect")); + } + + /// A rejected operation leaves the prior successful operation available to repeat. + #[test] + fn failed_operation_keeps_history() { + let mut app = two_squares(); + click_button(&mut app, "Unite"); + assert!(app.run("object.pathfinder.intersect", json!({"precision": -1.0})).is_err()); + assert_eq!(app.last_pathfinder.as_deref(), Some("unite")); + select_two_squares(&mut app, 110.0); + click_menu_item(&mut app, "Repeat Unite"); + assert_eq!(objects(&app).last(), Some(&("path", [110.0, 10.0, 190.0, 90.0]))); + } + + /// A successful Trap keeps the last repeatable operation in history. + #[test] + fn successful_trap_keeps_history() { + let mut app = two_squares(); + click_button(&mut app, "Unite"); + let mut rect = |x: f64, color: &str| { + let id = app.run("shape.rectangle", json!({"x": x, "y": 10, "width": 50, "height": 50})).unwrap()["id"].clone(); + app.run("paint.setFill", json!({"color": color})).unwrap(); + id + }; + let (a, b) = (rect(110.0, "#ffff00"), rect(160.0, "#0000ff")); + app.run("select.set", json!({"ids": [a, b]})).unwrap(); + app.run("object.pathfinder.trap", json!({"thickness": 2, "tintReduction": 30})).unwrap(); + assert_eq!(app.last_pathfinder.as_deref(), Some("unite")); + } + + /// Repeat reads the current options instead of replaying the earlier command's settings. + #[test] + fn repeat_uses_current_pathfinder_options() { + let mut app = two_squares(); + app.run("object.pathfinder.divide", json!({})).unwrap(); + assert_eq!(app.last_pathfinder.as_deref(), Some("divide")); + app.run("object.pathfinder.options", json!({"precision": 1.0})).unwrap(); + let a = app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 100, "height": 100})).unwrap()["id"].clone(); + let b = app.run("shape.rectangle", json!({"x": 0, "y": 99.5, "width": 100, "height": 100})).unwrap()["id"].clone(); + app.run("select.set", json!({"ids": [a, b]})).unwrap(); + let id = app.run("pathfinder.repeat", json!({})).unwrap()["ids"][0].as_u64().unwrap(); + let group = app.session.active().unwrap().doc.node(NodeId(id)).unwrap(); + assert_eq!(group.children().unwrap().len(), 2); + assert_eq!(app.last_pathfinder.as_deref(), Some("divide")); + } + + /// Compound-shape creation and member edits leave destructive Pathfinder history alone. + #[test] + fn compound_shape_commands_do_not_set_history() { + let mut app = two_squares(); + let a = app.session.active().unwrap().selection.objects[0]; + click_button_with_modifiers(&mut app, "Minus Front", egui::Modifiers::ALT); + assert_eq!(app.last_pathfinder, None); + app.run("select.set", json!({"ids": [a.0]})).unwrap(); + click_button(&mut app, "Unite"); + assert_eq!(app.last_pathfinder, None); + assert!(!crate::menus::enabled(&app, "pathfinder.repeat")); } } diff --git a/docs/mcp.md b/docs/mcp.md index f12412322..558154f70 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -431,7 +431,7 @@ objects' fills or strokes differ (`fillMixed` / `strokeMixed`, drawn as a "?" pr | `export` | `{path?, format?, scale?, artboard?, range?, selection?, outlineText?, options?}` | `svg`, `svgz`, `pdf`, `ai` (the PDF-compatible `.ai` that Save writes, which carries the document when every artboard is written in order), `eps`, `dxf`, `emf`, `wmf`, `png`, `jpg`, `webp`, `gif`, `png8` (an indexed `.png`), `tiff`, `bmp`, `tga`, `psd` (layered), `txt` (the document's text), `vectorcraft` or `template` (a native template). The tool's `format` enum and `document.formats` list them, generated from the engine's format table. When `format` is omitted, it comes from the path's extension. PDF and `.ai` write one page per artboard: all of them, or `artboard` (0-based) / `range` (`"1-3, 5"`, 1-based). SVG and SVGZ write one file per chosen artboard. EPS writes one file of the visible art; with `artboard`, `range`, `options.artboards` or `options: {"useArtboards": true}` it writes one file per chosen artboard, after a central file of the visible art when there are two or more, and `useArtboards: false` keeps the one file. Rasters, DXF, EMF and WMF write one artboard, or one file per chosen artboard with `options: {"useArtboards": true}`. Rasters also take `options: {"rect": [x, y, width, height]}` (page points, y down): that area of the page at the given `ppi`/`scale`, without an artboard and without changing the document; it is refused with `artboard`, `artboards`, `range` or `useArtboards`, with a non-positive or non-finite size, and for non-raster formats. `options` carries more format options (e.g. `{"quality": 80}` for JPEG). `selection: true` exports the selected objects cropped to their bounds (the reply adds their `bounds`, and reports `format` and the encoder's `warnings` as a whole-document export does); `outlineText: true` writes SVG text as paths. Template layers are left out, live effects are kept, and exporting `vectorcraft` never changes the document's path. Without `path` the bytes come back as `dataBase64`. Both backends run the same `document.export` call. | | `add_text` | `{text, x?, y?, width?, height?, fit?, fitMinPercent?, path?, mode?, pathEffect?, size?, font?, color?, justify?, vertical?}` | Point type at (x, y) (`justify`: `left`, `center` or `right` starts, centres or ends each line on x); area type with `width`/`height` (`fit`: `none`, `autoHeight`, `shrinkText`, as `text.areaOptions`); or `path` + `mode` (`area`/`onPath`) to flow text in or along a path, with `pathEffect` (`rainbow`, `skew`, `3dRibbon`, `stairStep`, `gravity`); `justify` (also `auto`, `justifyAll`) aligns the paragraphs of every kind, as `text.setStyle` does later. `vertical: true` uses the existing vertical writing mode for every kind (default `false`, horizontal). Returns `{id}`, with `overflow: true` when the text doesn't fit its frame or path. | | `apply_effect` | `{effect?, params?, ids?}` | Appends a live effect. Without `effect`, returns the effect catalogue with parameters and defaults. | -| `pathfinder` | `{operation, ids?}` | `unite`, `minusFront`, `intersect`, `exclude`, `divide`, `trim`, `merge`, `crop`, `outline`, `minusBack`. For a live version, apply the `pathfinder.*` effect to a group (`effect.apply` groups several loose selected objects first). In the desktop app, the UI command `pathfinder.repeat` (Repeat Pathfinder, Cmd/Ctrl+4) takes no params and runs the operation a Pathfinder panel button ran last on the selection; before a button has run it returns the error "no Pathfinder operation applied yet". | +| `pathfinder` | `{operation, ids?}` | `unite`, `minusFront`, `intersect`, `exclude`, `divide`, `trim`, `merge`, `crop`, `outline`, `minusBack`. For a live version, apply the `pathfinder.*` effect to a group (`effect.apply` groups several loose selected objects first). In remote desktop mode, `run_command` and `invoke_menu` can both call `pathfinder.repeat` with `{}`. Every successful Shape Mode or Pathfinder operation, including direct `object.pathfinder.*` commands, sets its history. Repeat applies it to the current selection using current Pathfinder Options. Trap, compound-shape edits and live effects do not set history. With no history it returns "no Pathfinder operation applied yet". | | `transform` | `{ids?, dx?, dy?, rotate?, scale?, scaleX?, scaleY?, reflect?, shear?, origin?, copy?}` | Runs move, rotate, scale, reflect, shear in that order. With `copy`, the first step duplicates. | | `create_graph` | `{type?, x, y, width, height, csv? \| series?, categories?, rows?}` | The nine Illustrator graph types: column, stacked column, bar, stacked bar, line, area, scatter, pie and radar. An empty CSV cell or a `null` in `rows` is a blank value; a number in straight quotes is a label. Edit later with `graph.setData` / `graph.setType` via `run_command`. `graph.column {design, columnType: vertical\|uniform\|repeating\|sliding, each, fractions: chop\|scale}` draws the columns or bars of the series picked with Group Selection (or `seriesIndexes`, or all of them) with a graph design saved by `graph.design`; with no `design` it returns the series' current design and options. Type in the design that holds a `%` followed by two digits shows each column's value, with at least the first digit's places before the decimal point and the second digit's places after it. | | `text_wrap` | `{ids?, offset?, invert?, release?}` | Area type below the objects (same layer) flows around them. | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index a5c1c349c..c4653b53d 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -615,7 +615,6 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - While the Type tool edits text, a character typed with AltGr is typed and runs no command. Firefox on Windows reports AltGr as Ctrl and Alt held, so in the web build AltGr+L (ł on a Polish layout) does not also open Layer Options for a new layer, and AltGr+Q (@ on a German layout) does not also run Reset Tracking and Kerning. A Ctrl+Alt chord that types no character runs its command. - With the Artboard tool, Cmd/Ctrl+A after a click on a row of the Artboards panel selects every artboard, as the macOS menu bar's Select All does then; the active artboard stays active, and the art's selection does not change. After a press outside the panel's list, Cmd/Ctrl+A selects every object again. It used to select every object after a click in the panel too. Public reference: [Select artboards](https://helpx.adobe.com/illustrator/desktop/create-manage-artboards/add-edit-artboards/select-artboards.html). - Cmd/Ctrl+/ (`appearance.addFill`) and Cmd/Ctrl+Alt+/ (`appearance.addStroke`) add a new fill and a new stroke to the selected objects, as the Appearance panel's Add New Fill and Add New Stroke buttons do, on the keys the reference app's [Default keyboard shortcuts](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html) page lists for adding a new fill and a new stroke. Keyboard Shortcuts lists both under Window › Appearance. - - Cmd/Ctrl+4 (`pathfinder.repeat`, Repeat Pathfinder) runs the operation a Pathfinder panel button ran last on the selected objects, as the panel menu's Repeat item does. Until a button has run, the command is disabled and the key leaves the art as it is, with "no Pathfinder operation applied yet" in the status bar. - Cmd/Ctrl+Alt+Shift+. and , (`type.size.increaseByFive`, `type.size.decreaseByFive`) step the font size of the selected type, or of the text the Type tool has selected, by five times the Size/Leading increment (Preferences › Type); Cmd/Ctrl+Shift+. and , still step it by one increment. - Cmd/Ctrl+Alt+Q (`type.resetTracking`) sets the tracking of the selected type, or of the text the Type tool has selected, to 0 and its kerning to Auto (0 in the Character panel), in one undo step; at a caret it resets the kerning of the character before it. - Cmd/Ctrl+Alt+K (`type.highlightKerning`) and Cmd/Ctrl+Alt+Shift+F (`type.highlightFontName`) show the Character panel and give its Kerning field the keyboard focus with the value selected, or open its font family menu with the search field focused, on the keys the reference app's [Default keyboard shortcuts](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html) page lists for highlighting kerning and the font name field. The page lists the same key for selecting the tracking value; `type.highlightTracking` focuses the Tracking field the same way and has no default key in Keyboard Shortcuts. @@ -795,6 +794,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Extrude & Bevel, Inflate and Rotate (Effect › 3D and Materials): live effects in the `three-d` crate with depth, twist, taper, solid or hollow caps, five original bevel shapes (width, height, repeat, spacing, inside, both sides), Inflate's volume, both sides, twist, taper and cap, Rotate's turned fills and strokes, Position presets, the rotation and light gizmos, SVG/PDF vector output and Expand Appearance; one 3D effect per object; covered by geometry, effect, canvas-versus-export, command and dialog tests ([three-d.md](three-d.md)). - Initial live Revolve (#605, #846): a portable geometry and shading crate (`three-d`), editable path and compound profiles, compact options with artboard rotation and light gizmos, SVG/PDF vector output and Expand Appearance that can trim covered faces. Open ends have continuous caps by default, shading is smooth with one to ten colored lights, and intersecting surfaces' visibility is approximate ([revolve.md](revolve.md)). - Live effects with previewing dialogs: Distort & Transform, Path, Convert to Shape, 15 Warp styles, Round Corners, Scribble, Effect → Pathfinder (all 10 operations, live on groups; several loose objects are grouped first, in one undo step), Color Adjustments (Brightness/Contrast, Curves, Levels, Hue/Saturation, Shift to Color, Temperature/Tint, on vectors, live type and embedded images), and raster drop shadow, glows and feather. SVG and PDF export keep live effects (geometry baked; SVG raster effects as filters). Zig Zag spaces its ridges evenly along each segment, so lines and curves get a regular wave in Corner and Smooth (#1558). Warp › Arc (the effect and Envelope › Make with Warp) bends in the object's real proportions: a circular arc with concentric edges and no fold-over at 100%, so Arc on art that isn't square draws differently from before (#1581, fixes #1578). `effect.apply` and `effect.setParams` give an error for a misspelt or wrongly typed effect parameter instead of running the effect with its default (#1623, #1592). Roughen with Smooth keeps turn-backs smooth instead of making cusps that draw as spikes (#1646, #1643). Scribble draws one zig-zag per region, with no lines across holes and notches; convex shapes are unchanged (#1651, #1648). On the canvas, in Expand Appearance and in exports, Effect → Pathfinder uses the glyph outlines of type in the group, and results painted like the type keep its opacity and blend mode. + - Cmd/Ctrl+4 (`pathfinder.repeat`) repeats the last successful Shape Mode or Pathfinder operation, including direct `object.pathfinder.*` commands, on the current selection; the panel menu names it. Trap, compound-shape edits and live effects do not set history. Public references: [Default keyboard shortcuts](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html), [Pathfinder panel overview](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/pathfinder-panel-overview.html). - Pathfinder Options ([Pathfinder panel overview](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/pathfinder-panel-overview.html)): the panel menu's Pathfinder Options… sets Precision (0.001–10 pt: results refit to within it, thinner pieces dropped), Remove Redundant Points and Divide and Outline Will Remove Unpainted Artwork (`object.pathfinder.options`, kept in the preferences) for every Shape Mode and Pathfinder operation; the defaults keep the earlier output (`pathops` `precision_drops_pieces_thinner_than_it`, engine `tests_pathops` Pathfinder Options tests, `panels::pathfinder` dialog test). - Pathfinder › Trap ([trapping](https://helpx.adobe.com/illustrator/using/trapping.html), [Pathfinder panel](https://helpx.adobe.com/illustrator/using/pathfinder-panel-overview.html)): the Pathfinder panel menu's Trap… (`object.pathfinder.trap`) and Effect › Pathfinder › Trap… (`pathfinder.trap`, live on groups) add, where two objects' visible areas of different solid colours meet, a strip of the darker one's area along the edge, filled with the lighter colour at the Tint Reduction percentage and set to overprint (Reverse Traps: the darker colour spreads instead, but not a rich black); Thickness 0.01–5000 pt across vertical edges, Height/Width scales it across horizontal ones, Traps with Process Color paints a swatch colour's trap with its CMYK equivalent; the effect also has Precision and Remove Redundant Points. The panel's Trap is one undo step and groups the traps above the topmost selected object, selected; with no adjoining edges of different colours it says so and changes nothing. Tests: `crates/pathops/src/trap.rs`, `crates/effects/src/trap.rs`, `group::tests::trap_effect_adds_an_overprinting_strip_above_the_members`, `crates/engine/src/tests/trap.rs`, `panels::pathfinder::tests::trap_opens_its_options_and_ok_traps_the_selection`. - Compound shapes ([Create compound shapes with Pathfinder](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/create-compound-shapes-with-pathfinder.html), [Pathfinder panel overview](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/pathfinder-panel-overview.html)): the Pathfinder panel menu's Make Compound Shape (every member in Add) and Alt-clicking a Shape Mode (every member in that mode) make the two or more selected paths, compound paths, groups, compound shapes or type a live compound shape (`object.compoundShape.make`): a group whose members each keep a shape mode and stay editable, drawn, printed and exported (SVG, PDF and the rest through the baked document) as the area the modes make from the backmost member up, open paths closed, painted with its own appearance, taken from the topmost member not subtracting. With members selected (Direct Selection, Layers panel, isolation mode) a Shape Mode button sets theirs (`object.compoundShape.setMode`) and is lit for the mode they share; paint given to a member goes to the compound shape. Release Compound Shape gives the members back as they were (`object.compoundShape.release`); Expand Compound Shape and the panel's Expand button turn it into the path or compound path of its area (`object.compoundShape.expand`). Shape Modes and Pathfinders take a compound shape as its area. Saved files keep it (`compound_shape`, `shape_mode`; an unknown mode reads as Add). Tests: `crates/engine/src/tests/compound_shape.rs`, `group::tests::compound_shapes_draw_the_area_their_shape_modes_make`, `group::tests::nested_compound_shapes_take_part_as_their_area`, `panels::pathfinder::tests::compound_shape_items_follow_the_selection`, the `CompoundShape` op of the property-test strategies. Not yet: blends, envelopes and warps as members, the question marks on mixed member modes, hit-testing by the area (a click hits the members). diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 6b8c0b1c3..8b79849c1 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -244,9 +244,7 @@ tools, never checked side by side) isn't captured row by row. ## Keyboard shortcuts Cmd/Ctrl+/ (`appearance.addFill`) and Cmd/Ctrl+Alt+/ (`appearance.addStroke`) add a new fill and a new stroke to the -selected objects, as the Appearance panel's Add New Fill and Add New Stroke buttons do. Cmd/Ctrl+4 -(`pathfinder.repeat`, Repeat Pathfinder) runs the operation a Pathfinder panel button ran last on the selected -objects, as the panel menu's Repeat item does. +selected objects, as the Appearance panel's Add New Fill and Add New Stroke buttons do. ~110 distinct modified shortcuts on menu commands plus single-key tool shortcuts for the default toolbar (`crates/tools/src/catalog.rs`), a shortcut editor with chords, and saved sets. Cmd/Ctrl+Alt+L (`ui.newLayer`) opens Layer Options for a new layer, as an Alt-click on the Layers panel's New Layer button does, and Cmd/Ctrl+L still adds a layer without the dialog. F8 (`ui.symbolOptions`) opens Symbol Options for a new symbol made from the selection, as the Symbols panel's New Symbol button does. Not yet diffed item by item @@ -308,6 +306,7 @@ next unit in the order a right-click on a ruler lists them, from Feet back to Po | Pathfinder panel on one group selected alone; a greyed-out button says what it needs | partial | #1450; the group's members are combined; one plain path selected alone stays disabled (the public documentation doesn't say what the operations do to a single path) | | Pathfinder Divide: an open cutting line splits a filled path | done | #1307; straight, crossed, bent and curved cutters, holes and frontmost appearance regression-tested; native automation: independent piece movement, one Undo/Redo, saved-document round trip and window capture; existing bounded-map fallback remains | | Pathfinder panel › Pathfinder Options…: Precision, Remove Redundant Points, Divide and Outline Will Remove Unpainted Artwork | done | #3426; `object.pathfinder.options`, kept in the preferences; defaults (0.01 pt, both off) keep the earlier results; [spec](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/pathfinder-panel-overview.html) | +| Cmd/Ctrl+4 repeats the last successful Shape Mode or Pathfinder operation on the selection | done | Direct `object.pathfinder.*` commands set history too; Trap, compound-shape edits and live effects do not. Repeat uses current Pathfinder Options; [key spec](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html), [menu spec](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/pathfinder-panel-overview.html) | | Pathfinder panel: compound shapes (Make, Release and Expand Compound Shape in the panel menu, Alt-click a Shape Mode, a Shape Mode for selected members, the Expand button) | partial | #4522; `object.compoundShape.make`/`setMode`/`release`/`expand`; members stay editable, the area draws, exports and saves; the button of the members' shared mode is lit; [spec](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/create-compound-shapes-with-pathfinder.html); missing: blends, envelopes and warps as members, question marks for mixed modes, hit-testing by the area | | Canvas context menu | done | Layers rows, panels and type have none (gap) | | SVG Interactivity panel: JavaScript for each object's events and the document's linked JavaScript files, written to SVG and read back as text | done | `svgInteractivity.*`, `ui.javascriptFilesDialog` | From b4899e665d1e1450e3cd3a1be4506b3e0ef5fcb2 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 30 Oct 2026 09:06:33 -0700 Subject: [PATCH 3/3] Pathfinder: batch steps set the operation Cmd+4 repeats A successful command.batch now records its last Unite, Intersect or other Pathfinder step as the operation Repeat Pathfinder runs, so playing an action from the Actions panel updates it. A batch that fails and rolls back leaves the previous operation in place. action.batch, which runs an action over other files or data sets, does not set it. --- crates/ui-egui/src/app/commands.rs | 13 +++++- crates/ui-egui/src/menus/ui_commands.rs | 2 +- crates/ui-egui/src/panels/pathfinder.rs | 60 +++++++++++++++++++++++++ docs/mcp.md | 2 +- docs/target-app-parity.md | 2 +- docs/ui-parity.md | 2 +- 6 files changed, 75 insertions(+), 6 deletions(-) diff --git a/crates/ui-egui/src/app/commands.rs b/crates/ui-egui/src/app/commands.rs index 9273f9683..2cbbf3eb1 100644 --- a/crates/ui-egui/src/app/commands.rs +++ b/crates/ui-egui/src/app/commands.rs @@ -1,6 +1,16 @@ use super::fonts::MAX_RECENT_FONTS; use super::{Value, VectorcraftApp, dialogs, fileio, io, menus, panels, picks, toolbar}; +/// The last repeatable Pathfinder operation completed by a command or atomic batch. +fn completed_pathfinder_operation<'a>(id: &'a str, params: &'a Value) -> Option<&'a str> { + let tracked = |id: &'a str| id.strip_prefix("object.pathfinder.").filter(|op| panels::pathfinder::operation_label(op).is_some()); + if id == "command.batch" { + params.get("commands")?.as_array()?.iter().rev().find_map(|step| step.get("command").and_then(Value::as_str).and_then(tracked)) + } else { + tracked(id) + } +} + impl VectorcraftApp { /// Run a UI or engine command by id. The single entry point for every frontend path. pub fn run(&mut self, id: &str, params: Value) -> Result { @@ -89,8 +99,7 @@ impl VectorcraftApp { crate::ime::note_application_edit(self, &ctx, ime_view, ime_target, ime_before); } if r.is_ok() - && let Some(op) = id.strip_prefix("object.pathfinder.") - && panels::pathfinder::operation_label(op).is_some() + && let Some(op) = completed_pathfinder_operation(id, ¶ms) { self.last_pathfinder = Some(op.to_string()); } diff --git a/crates/ui-egui/src/menus/ui_commands.rs b/crates/ui-egui/src/menus/ui_commands.rs index 8c62a3f11..5a99c4ca6 100644 --- a/crates/ui-egui/src/menus/ui_commands.rs +++ b/crates/ui-egui/src/menus/ui_commands.rs @@ -579,7 +579,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "pathfinder.repeat", "Repeat Pathfinder", "Cmd+4", - "{} repeat the last successful Shape Mode or Pathfinder operation on the selection with the current Pathfinder Options; an error until one has run", + "{} repeat the last successful Pathfinder panel operation, direct object.pathfinder.* command or command.batch step on the selection with current Pathfinder Options; an error until one has run", ), ( "file.export.pdf", diff --git a/crates/ui-egui/src/panels/pathfinder.rs b/crates/ui-egui/src/panels/pathfinder.rs index 953393847..34f2b58f3 100644 --- a/crates/ui-egui/src/panels/pathfinder.rs +++ b/crates/ui-egui/src/panels/pathfinder.rs @@ -474,6 +474,66 @@ mod tests { assert_eq!(app.last_pathfinder.as_deref(), Some("intersect")); } + /// A successful batch records its last repeatable Pathfinder step. + #[test] + fn batch_pathfinder_step_becomes_the_one_repeated() { + let mut app = two_squares(); + click_button(&mut app, "Intersect"); + select_two_squares(&mut app, 110.0); + app.run( + "command.batch", + json!({"commands": [ + {"command": "object.pathfinder.unite", "params": {}}, + {"command": "select.none", "params": {}} + ]}), + ) + .unwrap(); + assert_eq!(app.last_pathfinder.as_deref(), Some("unite")); + select_two_squares(&mut app, 210.0); + press_cmd_4(&mut app); + assert_eq!(objects(&app).last(), Some(&("path", [210.0, 10.0, 290.0, 90.0]))); + } + + /// A batch rollback leaves the previous Pathfinder operation available. + #[test] + fn failed_batch_pathfinder_step_keeps_history() { + let mut app = two_squares(); + click_button(&mut app, "Unite"); + select_two_squares(&mut app, 110.0); + let before = objects(&app); + assert!( + app.run( + "command.batch", + json!({"commands": [ + {"command": "object.pathfinder.intersect", "params": {}}, + {"command": "unknown.command", "params": {}} + ]}) + ) + .is_err() + ); + assert_eq!(app.last_pathfinder.as_deref(), Some("unite")); + assert_eq!(objects(&app), before); + press_cmd_4(&mut app); + assert_eq!(objects(&app).last(), Some(&("path", [110.0, 10.0, 190.0, 90.0]))); + } + + /// Playing an action records its Pathfinder step for the repeat shortcut. + #[test] + fn actions_panel_play_records_pathfinder_step() { + let mut app = two_squares(); + click_button(&mut app, "Intersect"); + app.ui.action_sets[0] + .actions + .push(crate::panels::actions::Action { name: "Unite".into(), steps: vec![("object.pathfinder.unite".into(), json!({}))] }); + let idx = app.ui.action_sets[0].actions.len() - 1; + select_two_squares(&mut app, 110.0); + crate::panels::actions::play(&mut app, 0, idx).unwrap(); + assert_eq!(app.last_pathfinder.as_deref(), Some("unite")); + select_two_squares(&mut app, 210.0); + press_cmd_4(&mut app); + assert_eq!(objects(&app).last(), Some(&("path", [210.0, 10.0, 290.0, 90.0]))); + } + /// A rejected operation leaves the prior successful operation available to repeat. #[test] fn failed_operation_keeps_history() { diff --git a/docs/mcp.md b/docs/mcp.md index 558154f70..17627ddea 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -431,7 +431,7 @@ objects' fills or strokes differ (`fillMixed` / `strokeMixed`, drawn as a "?" pr | `export` | `{path?, format?, scale?, artboard?, range?, selection?, outlineText?, options?}` | `svg`, `svgz`, `pdf`, `ai` (the PDF-compatible `.ai` that Save writes, which carries the document when every artboard is written in order), `eps`, `dxf`, `emf`, `wmf`, `png`, `jpg`, `webp`, `gif`, `png8` (an indexed `.png`), `tiff`, `bmp`, `tga`, `psd` (layered), `txt` (the document's text), `vectorcraft` or `template` (a native template). The tool's `format` enum and `document.formats` list them, generated from the engine's format table. When `format` is omitted, it comes from the path's extension. PDF and `.ai` write one page per artboard: all of them, or `artboard` (0-based) / `range` (`"1-3, 5"`, 1-based). SVG and SVGZ write one file per chosen artboard. EPS writes one file of the visible art; with `artboard`, `range`, `options.artboards` or `options: {"useArtboards": true}` it writes one file per chosen artboard, after a central file of the visible art when there are two or more, and `useArtboards: false` keeps the one file. Rasters, DXF, EMF and WMF write one artboard, or one file per chosen artboard with `options: {"useArtboards": true}`. Rasters also take `options: {"rect": [x, y, width, height]}` (page points, y down): that area of the page at the given `ppi`/`scale`, without an artboard and without changing the document; it is refused with `artboard`, `artboards`, `range` or `useArtboards`, with a non-positive or non-finite size, and for non-raster formats. `options` carries more format options (e.g. `{"quality": 80}` for JPEG). `selection: true` exports the selected objects cropped to their bounds (the reply adds their `bounds`, and reports `format` and the encoder's `warnings` as a whole-document export does); `outlineText: true` writes SVG text as paths. Template layers are left out, live effects are kept, and exporting `vectorcraft` never changes the document's path. Without `path` the bytes come back as `dataBase64`. Both backends run the same `document.export` call. | | `add_text` | `{text, x?, y?, width?, height?, fit?, fitMinPercent?, path?, mode?, pathEffect?, size?, font?, color?, justify?, vertical?}` | Point type at (x, y) (`justify`: `left`, `center` or `right` starts, centres or ends each line on x); area type with `width`/`height` (`fit`: `none`, `autoHeight`, `shrinkText`, as `text.areaOptions`); or `path` + `mode` (`area`/`onPath`) to flow text in or along a path, with `pathEffect` (`rainbow`, `skew`, `3dRibbon`, `stairStep`, `gravity`); `justify` (also `auto`, `justifyAll`) aligns the paragraphs of every kind, as `text.setStyle` does later. `vertical: true` uses the existing vertical writing mode for every kind (default `false`, horizontal). Returns `{id}`, with `overflow: true` when the text doesn't fit its frame or path. | | `apply_effect` | `{effect?, params?, ids?}` | Appends a live effect. Without `effect`, returns the effect catalogue with parameters and defaults. | -| `pathfinder` | `{operation, ids?}` | `unite`, `minusFront`, `intersect`, `exclude`, `divide`, `trim`, `merge`, `crop`, `outline`, `minusBack`. For a live version, apply the `pathfinder.*` effect to a group (`effect.apply` groups several loose selected objects first). In remote desktop mode, `run_command` and `invoke_menu` can both call `pathfinder.repeat` with `{}`. Every successful Shape Mode or Pathfinder operation, including direct `object.pathfinder.*` commands, sets its history. Repeat applies it to the current selection using current Pathfinder Options. Trap, compound-shape edits and live effects do not set history. With no history it returns "no Pathfinder operation applied yet". | +| `pathfinder` | `{operation, ids?}` | `unite`, `minusFront`, `intersect`, `exclude`, `divide`, `trim`, `merge`, `crop`, `outline`, `minusBack`. For a live version, apply the `pathfinder.*` effect to a group (`effect.apply` groups several loose selected objects first). In remote desktop mode, `run_command` and `invoke_menu` can both call `pathfinder.repeat` with `{}`. Successful panel operations, direct `object.pathfinder.*` commands, and `command.batch` steps (including Actions panel playback) set its history. Repeat applies it to the current selection using current Pathfinder Options. Trap, compound-shape edits, live effects and `action.batch` do not set history. With no history it returns "no Pathfinder operation applied yet". | | `transform` | `{ids?, dx?, dy?, rotate?, scale?, scaleX?, scaleY?, reflect?, shear?, origin?, copy?}` | Runs move, rotate, scale, reflect, shear in that order. With `copy`, the first step duplicates. | | `create_graph` | `{type?, x, y, width, height, csv? \| series?, categories?, rows?}` | The nine Illustrator graph types: column, stacked column, bar, stacked bar, line, area, scatter, pie and radar. An empty CSV cell or a `null` in `rows` is a blank value; a number in straight quotes is a label. Edit later with `graph.setData` / `graph.setType` via `run_command`. `graph.column {design, columnType: vertical\|uniform\|repeating\|sliding, each, fractions: chop\|scale}` draws the columns or bars of the series picked with Group Selection (or `seriesIndexes`, or all of them) with a graph design saved by `graph.design`; with no `design` it returns the series' current design and options. Type in the design that holds a `%` followed by two digits shows each column's value, with at least the first digit's places before the decimal point and the second digit's places after it. | | `text_wrap` | `{ids?, offset?, invert?, release?}` | Area type below the objects (same layer) flows around them. | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index c4653b53d..ca235f91a 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -794,7 +794,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Extrude & Bevel, Inflate and Rotate (Effect › 3D and Materials): live effects in the `three-d` crate with depth, twist, taper, solid or hollow caps, five original bevel shapes (width, height, repeat, spacing, inside, both sides), Inflate's volume, both sides, twist, taper and cap, Rotate's turned fills and strokes, Position presets, the rotation and light gizmos, SVG/PDF vector output and Expand Appearance; one 3D effect per object; covered by geometry, effect, canvas-versus-export, command and dialog tests ([three-d.md](three-d.md)). - Initial live Revolve (#605, #846): a portable geometry and shading crate (`three-d`), editable path and compound profiles, compact options with artboard rotation and light gizmos, SVG/PDF vector output and Expand Appearance that can trim covered faces. Open ends have continuous caps by default, shading is smooth with one to ten colored lights, and intersecting surfaces' visibility is approximate ([revolve.md](revolve.md)). - Live effects with previewing dialogs: Distort & Transform, Path, Convert to Shape, 15 Warp styles, Round Corners, Scribble, Effect → Pathfinder (all 10 operations, live on groups; several loose objects are grouped first, in one undo step), Color Adjustments (Brightness/Contrast, Curves, Levels, Hue/Saturation, Shift to Color, Temperature/Tint, on vectors, live type and embedded images), and raster drop shadow, glows and feather. SVG and PDF export keep live effects (geometry baked; SVG raster effects as filters). Zig Zag spaces its ridges evenly along each segment, so lines and curves get a regular wave in Corner and Smooth (#1558). Warp › Arc (the effect and Envelope › Make with Warp) bends in the object's real proportions: a circular arc with concentric edges and no fold-over at 100%, so Arc on art that isn't square draws differently from before (#1581, fixes #1578). `effect.apply` and `effect.setParams` give an error for a misspelt or wrongly typed effect parameter instead of running the effect with its default (#1623, #1592). Roughen with Smooth keeps turn-backs smooth instead of making cusps that draw as spikes (#1646, #1643). Scribble draws one zig-zag per region, with no lines across holes and notches; convex shapes are unchanged (#1651, #1648). On the canvas, in Expand Appearance and in exports, Effect → Pathfinder uses the glyph outlines of type in the group, and results painted like the type keep its opacity and blend mode. - - Cmd/Ctrl+4 (`pathfinder.repeat`) repeats the last successful Shape Mode or Pathfinder operation, including direct `object.pathfinder.*` commands, on the current selection; the panel menu names it. Trap, compound-shape edits and live effects do not set history. Public references: [Default keyboard shortcuts](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html), [Pathfinder panel overview](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/pathfinder-panel-overview.html). + - Cmd/Ctrl+4 (`pathfinder.repeat`) repeats the last successful Pathfinder panel operation, direct `object.pathfinder.*` command or `command.batch` step (including Actions playback) on the current selection; the panel menu names it. `action.batch`, Trap, compound-shape edits and live effects do not set history. Public references: [Default keyboard shortcuts](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html), [Pathfinder panel overview](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/pathfinder-panel-overview.html). - Pathfinder Options ([Pathfinder panel overview](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/pathfinder-panel-overview.html)): the panel menu's Pathfinder Options… sets Precision (0.001–10 pt: results refit to within it, thinner pieces dropped), Remove Redundant Points and Divide and Outline Will Remove Unpainted Artwork (`object.pathfinder.options`, kept in the preferences) for every Shape Mode and Pathfinder operation; the defaults keep the earlier output (`pathops` `precision_drops_pieces_thinner_than_it`, engine `tests_pathops` Pathfinder Options tests, `panels::pathfinder` dialog test). - Pathfinder › Trap ([trapping](https://helpx.adobe.com/illustrator/using/trapping.html), [Pathfinder panel](https://helpx.adobe.com/illustrator/using/pathfinder-panel-overview.html)): the Pathfinder panel menu's Trap… (`object.pathfinder.trap`) and Effect › Pathfinder › Trap… (`pathfinder.trap`, live on groups) add, where two objects' visible areas of different solid colours meet, a strip of the darker one's area along the edge, filled with the lighter colour at the Tint Reduction percentage and set to overprint (Reverse Traps: the darker colour spreads instead, but not a rich black); Thickness 0.01–5000 pt across vertical edges, Height/Width scales it across horizontal ones, Traps with Process Color paints a swatch colour's trap with its CMYK equivalent; the effect also has Precision and Remove Redundant Points. The panel's Trap is one undo step and groups the traps above the topmost selected object, selected; with no adjoining edges of different colours it says so and changes nothing. Tests: `crates/pathops/src/trap.rs`, `crates/effects/src/trap.rs`, `group::tests::trap_effect_adds_an_overprinting_strip_above_the_members`, `crates/engine/src/tests/trap.rs`, `panels::pathfinder::tests::trap_opens_its_options_and_ok_traps_the_selection`. - Compound shapes ([Create compound shapes with Pathfinder](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/create-compound-shapes-with-pathfinder.html), [Pathfinder panel overview](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/pathfinder-panel-overview.html)): the Pathfinder panel menu's Make Compound Shape (every member in Add) and Alt-clicking a Shape Mode (every member in that mode) make the two or more selected paths, compound paths, groups, compound shapes or type a live compound shape (`object.compoundShape.make`): a group whose members each keep a shape mode and stay editable, drawn, printed and exported (SVG, PDF and the rest through the baked document) as the area the modes make from the backmost member up, open paths closed, painted with its own appearance, taken from the topmost member not subtracting. With members selected (Direct Selection, Layers panel, isolation mode) a Shape Mode button sets theirs (`object.compoundShape.setMode`) and is lit for the mode they share; paint given to a member goes to the compound shape. Release Compound Shape gives the members back as they were (`object.compoundShape.release`); Expand Compound Shape and the panel's Expand button turn it into the path or compound path of its area (`object.compoundShape.expand`). Shape Modes and Pathfinders take a compound shape as its area. Saved files keep it (`compound_shape`, `shape_mode`; an unknown mode reads as Add). Tests: `crates/engine/src/tests/compound_shape.rs`, `group::tests::compound_shapes_draw_the_area_their_shape_modes_make`, `group::tests::nested_compound_shapes_take_part_as_their_area`, `panels::pathfinder::tests::compound_shape_items_follow_the_selection`, the `CompoundShape` op of the property-test strategies. Not yet: blends, envelopes and warps as members, the question marks on mixed member modes, hit-testing by the area (a click hits the members). diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 8b79849c1..4f3ac445e 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -306,7 +306,7 @@ next unit in the order a right-click on a ruler lists them, from Feet back to Po | Pathfinder panel on one group selected alone; a greyed-out button says what it needs | partial | #1450; the group's members are combined; one plain path selected alone stays disabled (the public documentation doesn't say what the operations do to a single path) | | Pathfinder Divide: an open cutting line splits a filled path | done | #1307; straight, crossed, bent and curved cutters, holes and frontmost appearance regression-tested; native automation: independent piece movement, one Undo/Redo, saved-document round trip and window capture; existing bounded-map fallback remains | | Pathfinder panel › Pathfinder Options…: Precision, Remove Redundant Points, Divide and Outline Will Remove Unpainted Artwork | done | #3426; `object.pathfinder.options`, kept in the preferences; defaults (0.01 pt, both off) keep the earlier results; [spec](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/pathfinder-panel-overview.html) | -| Cmd/Ctrl+4 repeats the last successful Shape Mode or Pathfinder operation on the selection | done | Direct `object.pathfinder.*` commands set history too; Trap, compound-shape edits and live effects do not. Repeat uses current Pathfinder Options; [key spec](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html), [menu spec](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/pathfinder-panel-overview.html) | +| Cmd/Ctrl+4 repeats the last successful Shape Mode or Pathfinder operation on the selection | done | Panel operations, direct `object.pathfinder.*` commands and `command.batch` steps (Actions playback) set history; `action.batch`, Trap, compound-shape edits and live effects do not. Repeat uses current Pathfinder Options; [key spec](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html), [menu spec](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/pathfinder-panel-overview.html) | | Pathfinder panel: compound shapes (Make, Release and Expand Compound Shape in the panel menu, Alt-click a Shape Mode, a Shape Mode for selected members, the Expand button) | partial | #4522; `object.compoundShape.make`/`setMode`/`release`/`expand`; members stay editable, the area draws, exports and saves; the button of the members' shared mode is lit; [spec](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/create-compound-shapes-with-pathfinder.html); missing: blends, envelopes and warps as members, question marks for mixed modes, hit-testing by the area | | Canvas context menu | done | Layers rows, panels and type have none (gap) | | SVG Interactivity panel: JavaScript for each object's events and the document's linked JavaScript files, written to SVG and read back as text | done | `svgInteractivity.*`, `ui.javascriptFilesDialog` |