Merge pull request #4730 from dotCooCoo/feat/pathfinder-repeat-shortcut

Pathfinder: Cmd+4 repeats the last Pathfinder operation
This commit is contained in:
BFlat
2026-10-31 00:50:33 +07:00
committed by GitHub
35 changed files with 384 additions and 19 deletions
+1
View File
@@ -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,
+15
View File
@@ -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<Value, String> {
@@ -88,6 +98,11 @@ impl VectorcraftApp {
{
crate::ime::note_application_edit(self, &ctx, ime_view, ime_target, ime_before);
}
if r.is_ok()
&& let Some(op) = completed_pathfinder_operation(id, &params)
{
self.last_pathfinder = Some(op.to_string());
}
if r.is_ok() && matches!(id, "edit.copy" | "edit.cut") {
self.publish_clipboard();
}
+1
View File
@@ -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 няма файлов мениджър тук
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -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ů
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -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 δεν υπάρχει διαχειριστής αρχείων εδώ
Can't render this file because it contains an unexpected character in line 164 and column 5.
+1
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -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ő
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2169 and column 5.
+1
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -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 指定された最近使ったフォントが見つかりません
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -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 აქ ფაილების მენეჯერი არ არის
Can't render this file because it is too large.
+1
View File
@@ -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 해당 최근 글꼴이 없습니다
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -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
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
View File
@@ -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 здесь нет файлового менеджера
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -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
Can't render this file because it contains an unexpected character in line 5 and column 25.
+1
View File
@@ -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
Can't render this file because it contains an unexpected character in line 5 and column 25.
+1
View File
@@ -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 тут немає файлового менеджера
Can't render this file because it contains an unexpected character in line 178 and column 5.
+1
View File
@@ -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
Can't render this file because it contains an unexpected character in line 200 and column 5.
+1
View File
@@ -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 没有此最近使用的字体
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -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 沒有這個最近使用的字體
Can't render this file because it contains an unexpected character in line 6 and column 47.
+2
View File
@@ -134,6 +134,8 @@ pub struct VectorcraftApp {
pub titlebar_inset: f32,
/// Last applied effect (Effect → Apply Last Effect).
pub last_effect: Option<(String, serde_json::Value)>,
/// Last successful destructive Pathfinder operation, repeated on the current selection.
pub last_pathfinder: Option<String>,
/// Last confirmed Warp Options (bend, distortion, orientation) for the next new warp dialog.
pub last_warp: Option<serde_json::Value>,
/// The Character panel field that takes the keyboard focus when the panel next draws it
+1
View File
@@ -544,6 +544,7 @@ fn run_ui_part6(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<Result<
Some((e, params)) => 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,
+4
View File
@@ -217,6 +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| {
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(),
+6
View File
@@ -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 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",
"Save as PDF…",
+327 -18
View File
@@ -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,15 +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() {
set_pstate(ui.ctx(), "pf-last", 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 }
}
/// 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<Value, String> {
match app.last_pathfinder.clone() {
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),
@@ -55,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);
}
}
@@ -79,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;
@@ -91,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);
}
}
});
@@ -110,13 +133,14 @@ 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()
.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!({}));
}
if menu_item(ui, tl!("Pathfinder Options…"), true, false) {
crate::menus::invoke(app, "object.pathfinder.options", json!({}));
@@ -281,4 +305,289 @@ 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) {
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();
crate::theme::install_fonts(&ctx);
let mut time = 0.0;
let mut frame = |app: &mut VectorcraftApp, events: Vec<egui::Event>| {
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();
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 };
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<egui::Event>| {
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::<Vec<_>>())
};
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!(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));
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");
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::<Vec<_>>(), ["pathfinder.repeat"]);
}
/// 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 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_an_operation_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");
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 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() {
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"));
}
}
+1 -1
View File
@@ -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 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. |
+1
View File
@@ -794,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 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).
+1
View File
@@ -306,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 | 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` |