From f78b943e9ed9f6e49388cb3d6d3399b652d18c30 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 10:32:27 -0800 Subject: [PATCH 01/11] Actions: Select Object selects objects by their Attributes note during playback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Actions panel menu gets Select Object…, which opens Set Selection with a Note field and Case Sensitive and Whole Word. The step it inserts runs the new select.byNote command, which replaces the selection with every object that can be selected whose note matches. When none matches, the selection, its Puppet Warp pins and the active Appearance item stay as they were, and playback stops with an error. Action set files that hold the step are saved as version 4. --- crates/engine/src/cmd/plugin_call/reviewed.rs | 1 + crates/engine/src/cmd/select.rs | 77 ++++++ crates/engine/src/tests/attributes.rs | 117 ++++++++- crates/mcp/src/ui_catalog.rs | 6 + crates/ui-egui/src/dialogs.rs | 2 + .../src/dialogs/action_select_object.rs | 62 +++++ crates/ui-egui/src/i18n/bg.tsv | 10 + crates/ui-egui/src/i18n/bs.tsv | 10 + crates/ui-egui/src/i18n/ca.tsv | 10 + crates/ui-egui/src/i18n/cs.tsv | 10 + crates/ui-egui/src/i18n/da.tsv | 10 + crates/ui-egui/src/i18n/de.tsv | 10 + crates/ui-egui/src/i18n/el.tsv | 10 + crates/ui-egui/src/i18n/es.tsv | 10 + crates/ui-egui/src/i18n/fi.tsv | 10 + crates/ui-egui/src/i18n/fr.tsv | 10 + crates/ui-egui/src/i18n/hi.tsv | 10 + crates/ui-egui/src/i18n/hu.tsv | 10 + crates/ui-egui/src/i18n/id.tsv | 10 + crates/ui-egui/src/i18n/it.tsv | 10 + crates/ui-egui/src/i18n/ja.tsv | 10 + crates/ui-egui/src/i18n/ka.tsv | 10 + crates/ui-egui/src/i18n/ko.tsv | 10 + crates/ui-egui/src/i18n/nl.tsv | 10 + crates/ui-egui/src/i18n/pl.tsv | 10 + crates/ui-egui/src/i18n/pt-br.tsv | 10 + crates/ui-egui/src/i18n/ru.tsv | 10 + crates/ui-egui/src/i18n/sv.tsv | 10 + crates/ui-egui/src/i18n/tr.tsv | 10 + crates/ui-egui/src/i18n/uk.tsv | 10 + crates/ui-egui/src/i18n/vi.tsv | 10 + crates/ui-egui/src/i18n/zh-hans.tsv | 10 + crates/ui-egui/src/i18n/zh-hant.tsv | 10 + crates/ui-egui/src/menus/run.rs | 1 + crates/ui-egui/src/menus/ui_commands.rs | 6 + crates/ui-egui/src/panels/actions.rs | 242 +++++++++++++++++- crates/ui-egui/src/panels/actions/files.rs | 44 +++- docs/mcp.md | 35 ++- docs/target-app-parity.md | 1 + 39 files changed, 836 insertions(+), 28 deletions(-) create mode 100644 crates/ui-egui/src/dialogs/action_select_object.rs diff --git a/crates/engine/src/cmd/plugin_call/reviewed.rs b/crates/engine/src/cmd/plugin_call/reviewed.rs index 94f865e224..b1805e9ea6 100644 --- a/crates/engine/src/cmd/plugin_call/reviewed.rs +++ b/crates/engine/src/cmd/plugin_call/reviewed.rs @@ -475,6 +475,7 @@ pub(super) const ALLOWED: &[&str] = &[ "select.allOnArtboard", "select.anchors", "select.anchorsMany", + "select.byNote", "select.color", "select.font", "select.globalEdit", diff --git a/crates/engine/src/cmd/select.rs b/crates/engine/src/cmd/select.rs index a2c7b3a8d4..5f4a0b4d50 100644 --- a/crates/engine/src/cmd/select.rs +++ b/crates/engine/src/cmd/select.rs @@ -11,6 +11,7 @@ use super::object::can_align; use super::wand::{fill_of, stroke_of}; use super::{ CommandSpec, Result, Session, bad, bool_or, checked_id, checked_ids_param, cmd, color_value, has_doc, has_selection, index, ok, str_param, + switch_or, text_param, }; use crate::DocState; @@ -103,6 +104,15 @@ pub fn specs() -> Vec { has_doc, select_color ), + cmd!( + "select.byNote", + "Select by Note", + [], + None, + "{note, caseSensitive?: bool, wholeWord?: bool} select the objects whose Attributes note (attributes.set) holds `note`, without its leading and trailing spaces (1 to 1,024 bytes): anywhere in the note and in any letter case by default; caseSensitive (default false) asks for the same letter case, and wholeWord (default false) for no letter, digit or _ right before or after it. It looks at the objects Select All takes and the objects inside them, leaving out hidden and locked objects and art on a locked artboard; an object whose note matches is selected without the objects inside it. The objects found replace the selection, in paint order. No object found is an error that leaves the selection as it was. Not an undo step; Select > Reselect repeats it. The Actions panel's Select Object step (ui.action.insertSelectObject) runs this → {count, ids} (resulting selection)", + has_doc, + by_note + ), cmd!( "select.same.fillColor", "Fill Color", @@ -730,6 +740,73 @@ fn select_color(s: &mut Session, p: &Value) -> Result { select_where(s, C, p, |d, n| paint_reported(d, n) && reads_as(fill_of(n), fill.as_deref()) && reads_as(stroke_of(n).0, stroke.as_deref())) } +/// The longest note, in bytes, that Select by Note searches for. +const MAX_NOTE_QUERY: usize = 1024; + +/// Select by Note: the objects whose Attributes note holds `note` ([`note_matches`]). An object +/// whose note matches is selected without the objects inside it. With none found, the selection +/// stays as it was. +fn by_note(s: &mut Session, p: &Value) -> Result { + const C: &str = "select.byNote"; + let query = text_param(p, "note", C)?.map(str::trim).unwrap_or_default(); + if query.is_empty() { + return Err(bad(C, "the note is empty")); + } + if query.len() > MAX_NOTE_QUERY { + return Err(bad(C, "the note is longer than 1,024 bytes")); + } + let case_sensitive = switch_or(p, "caseSensitive", false, C)?; + let whole_word = switch_or(p, "wholeWord", false, C)?; + let not_found = || bad(C, format!("no object that can be selected has a note matching “{query}”")); + let st = s.doc()?; + let locks = st.doc.locked_artboards(); + let mut ids = vec![]; + walk_selectable(&st.doc, st.isolation, |n| { + let note = n.extras().attrs.as_deref().map_or("", |a| a.note.as_str()); + if note_matches(note, query, case_sensitive, whole_word) && is_selectable(&st.doc, st.isolation, &locks, n.id) { + ids.push(n.id); + return false; + } + true + }); + // The selection leaves out guides under Lock Guides and hidden slices, whatever selects them. + let mut found = st.selection.clone(); + found.set(ids.iter().copied()); + crate::deselect_hidden(&s.menu, &st.doc, &mut found); + if found.objects.is_empty() { + return Err(not_found()); + } + let before = st.selection.objects.clone(); + s.select(|_, sel| sel.set(ids.iter().copied()))?; + let st = s.doc_mut()?; + st.last_selection_cmd = Some((C.to_string(), p.clone(), before)); + selection_result(s) +} + +/// Whether `note` holds `query`: anywhere, and in any letter case unless `case_sensitive`. With +/// `whole_word`, the text right before and right after it is not a letter, a digit or `_`. +fn note_matches(note: &str, query: &str, case_sensitive: bool, whole_word: bool) -> bool { + if note.is_empty() || query.is_empty() { + return false; + } + let (note, query) = if case_sensitive { (note.to_string(), query.to_string()) } else { (note.to_lowercase(), query.to_lowercase()) }; + if !whole_word { + return note.contains(&query); + } + let word = |c: char| c.is_alphanumeric() || c == '_'; + let mut from = 0; + while let Some(at) = note.get(from..).and_then(|rest| rest.find(&query)).map(|i| from + i) { + let before = note[..at].chars().next_back(); + let after = note[at + query.len()..].chars().next(); + if !before.is_some_and(word) && !after.is_some_and(word) { + return true; + } + // Matches may overlap: look again from the next character. + from = at + note[at..].chars().next().map_or(1, char::len_utf8); + } + false +} + /// Does `document.inspect` report a fill and stroke for `n`? A compound path's are its own, though /// it holds its members, and its members report none. fn paint_reported(d: &Document, n: &Node) -> bool { diff --git a/crates/engine/src/tests/attributes.rs b/crates/engine/src/tests/attributes.rs index 4dea459409..606200481e 100644 --- a/crates/engine/src/tests/attributes.rs +++ b/crates/engine/src/tests/attributes.rs @@ -1,4 +1,5 @@ -//! Window → Attributes (`attributes.set`, `attributes.info`, `path.setFillRule`, `path.reverse`), +//! Window → Attributes (`attributes.set`, `attributes.info`, `path.setFillRule`, `path.reverse`, +//! and `select.byNote`, which selects objects by their note), //! the links SVG export writes for objects with a URL, and what objects keep for other software //! (their `data-*` attributes and their attributes in other applications' namespaces). @@ -216,6 +217,120 @@ fn object_data_is_set_saved_and_written_to_svg() { assert_eq!(back.node(a).unwrap().extras().attrs, s.doc().unwrap().doc.node(a).unwrap().extras().attrs); } +/// The selected objects after `select.byNote` with `p`, or its error. +fn by_note(s: &mut Session, p: Value) -> std::result::Result, String> { + s.execute("select.byNote", &p).map_err(|e| e.to_string())?; + Ok(s.doc().unwrap().selection.objects.iter().map(|id| id.0).collect()) +} + +fn note(s: &mut Session, id: NodeId, note: &str) { + run(s, "attributes.set", json!({"ids": [id.0], "note": note})); +} + +/// Select by Note finds a note anywhere and in any letter case by default, replaces the selection +/// with every object it finds, in paint order, and is not an undo step. Case Sensitive and Whole +/// Word narrow it; Reselect repeats it. +#[test] +fn select_by_note_matches_part_of_a_note_in_any_case_unless_asked() { + let mut s = session(); + let [a, b, c, d] = [0.0, 20.0, 40.0, 60.0].map(|x| rect(&mut s, x)); + note(&mut s, a, "Logo front"); + note(&mut s, b, "logo"); + note(&mut s, c, "Logotype"); + note(&mut s, d, "back"); + run(&mut s, "select.set", json!({"ids": [d.0]})); + let undo = s.doc().unwrap().history.undo.len(); + assert_eq!(by_note(&mut s, json!({"note": " LOGO "})), Ok(vec![a.0, b.0, c.0]), "outer spaces are left out"); + assert_eq!(s.doc().unwrap().history.undo.len(), undo, "no undo step"); + assert_eq!(by_note(&mut s, json!({"note": "logo", "caseSensitive": true})), Ok(vec![b.0])); + assert_eq!(by_note(&mut s, json!({"note": "logo", "wholeWord": true})), Ok(vec![a.0, b.0])); + assert_eq!(by_note(&mut s, json!({"note": "Logo", "caseSensitive": true, "wholeWord": true})), Ok(vec![a.0])); + assert_eq!(by_note(&mut s, json!({"note": "o f"})), Ok(vec![a.0]), "spaces inside the note count"); + run(&mut s, "select.none", json!({})); + run(&mut s, "select.reselect", json!({})); + assert_eq!(s.doc().unwrap().selection.objects, [a]); +} + +/// Whole Word looks at every place the text occurs, also places that overlap. +#[test] +fn select_by_note_whole_word_matches_overlapping_places_and_other_scripts() { + let mut s = session(); + let a = rect(&mut s, 0.0); + let matches = |s: &mut Session, text: &str, p: Value| { + note(s, a, text); + by_note(s, p).is_ok() + }; + assert!(matches(&mut s, "a aa a a", json!({"note": "a a", "wholeWord": true}))); + assert!(!matches(&mut s, "aa_a", json!({"note": "a", "wholeWord": true}))); + assert!(matches(&mut s, "x-ray, x", json!({"note": "x", "wholeWord": true}))); + assert!(matches(&mut s, "ÉTIQUETTE", json!({"note": "étiquette"}))); + assert!(!matches(&mut s, "ÉTIQUETTE", json!({"note": "étiquette", "caseSensitive": true}))); + assert!(matches(&mut s, "标签 一", json!({"note": "一", "wholeWord": true}))); + assert!(!matches(&mut s, "", json!({"note": "a"}))); +} + +/// Select by Note leaves out hidden and locked objects, selects a group whose note matches without +/// its members and a member whose note matches on its own, keeps to the isolated group, and leaves +/// the selection as it was when it finds nothing. Bad params are errors that change nothing. +#[test] +fn select_by_note_takes_only_objects_that_can_be_selected() { + let mut s = session(); + let [a, b, c, d, e] = [0.0, 10.0, 20.0, 30.0, 40.0].map(|x| rect(&mut s, x)); + for id in [a, b, c, d, e] { + note(&mut s, id, "tag"); + } + run(&mut s, "select.set", json!({"ids": [a.0]})); + run(&mut s, "object.hide", json!({})); + run(&mut s, "select.set", json!({"ids": [b.0]})); + run(&mut s, "object.lock", json!({})); + run(&mut s, "select.set", json!({"ids": [c.0, d.0]})); + let g = NodeId(run(&mut s, "object.group", json!({}))["id"].as_u64().unwrap()); + note(&mut s, g, "tag group"); + assert_eq!(by_note(&mut s, json!({"note": "tag"})), Ok(vec![g.0, e.0]), "the group, not its members"); + note(&mut s, g, "box"); + assert_eq!(by_note(&mut s, json!({"note": "tag"})), Ok(vec![c.0, d.0, e.0])); + run(&mut s, "object.isolate", json!({"id": g.0})); + assert_eq!(by_note(&mut s, json!({"note": "tag"})), Ok(vec![c.0, d.0]), "inside the isolated group only"); + run(&mut s, "select.set", json!({"ids": [c.0]})); + let missing = by_note(&mut s, json!({"note": "nothing"})).unwrap_err(); + assert!(missing.contains("no object that can be selected has a note matching “nothing”"), "{missing}"); + assert_eq!(s.doc().unwrap().selection.objects, [c], "the selection stays"); + for bad in [ + json!({}), + json!({"note": " "}), + json!({"note": 5}), + json!({"note": "x".repeat(1025)}), + json!({"note": "tag", "caseSensitive": "yes"}), + json!({"note": "tag", "wholeWord": 1}), + ] { + assert!(s.execute("select.byNote", &bad).is_err(), "{bad}"); + assert_eq!(s.doc().unwrap().selection.objects, [c], "{bad}"); + } + assert!(by_note(&mut s, json!({"note": "x".repeat(1024)})).is_err_and(|e| e.contains("no object")), "1,024 bytes is allowed"); +} + +/// Under Lock Guides a guide whose note matches is not a match, and a search that finds only such +/// guides leaves the selection and the Puppet Warp pins on it as they were. +#[test] +fn select_by_note_leaves_locked_guides_out_before_changing_the_selection() { + let mut s = session(); + let a = rect(&mut s, 10.0); + let g = rect(&mut s, 50.0); + note(&mut s, g, "edge"); + run(&mut s, "select.set", json!({"ids": [g.0]})); + run(&mut s, "view.guides.make", json!({})); + run(&mut s, "view.guides.lock", json!({"locked": true})); + run(&mut s, "select.set", json!({"ids": [a.0]})); + run(&mut s, "object.puppetWarp", json!({"id": a.0, "rest": true, "pins": [[25, 25]], "moved": [[25, 25]]})); + run(&mut s, "select.set", json!({"ids": [a.0]})); + assert!(s.doc().unwrap().doc.puppet.is_some(), "a pin on the rectangle"); + let missing = by_note(&mut s, json!({"note": "edge"})).unwrap_err(); + assert!(missing.contains("no object that can be selected"), "{missing}"); + let st = s.doc().unwrap(); + assert_eq!(st.selection.objects, [a], "the selection stays"); + assert!(st.doc.puppet.is_some(), "the pins stay"); +} + /// An object's attributes in other applications' XML namespaces are its own, as its `data-*` /// attributes are: a pasted copy and a duplicate keep them, deleting the object takes them with /// it, and the native file and SVG export keep them. diff --git a/crates/mcp/src/ui_catalog.rs b/crates/mcp/src/ui_catalog.rs index 758fc67716..e233033c7d 100644 --- a/crates/mcp/src/ui_catalog.rs +++ b/crates/mcp/src/ui_catalog.rs @@ -1427,6 +1427,12 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "", "{set?: index, action?: index, step?: index, command, params?: object} insert the command of a menu item as a step, without running it; the step plays as choosing the item runs it. command and params (default {}) must be those of a menu item that can be a step: one that runs an engine command, whose label does not end with \"…\", outside the VectorCraft, File, Window and Help menus, and that is not Undo, Redo, a Paste, a batch (command.batch), a preference (prefs.*) or action (action.*) command, an Object › Align item, Image Trace's Make or Make and Expand, Pattern's Make or Edit Pattern, a saved selection, saved view or recent font item, or a plug-in's item. The place is as ui.action.insertStop takes it: with set and action, after step, else after the action's last step; without them, the recording in progress. The new step becomes the panel's selected step, and its action and set become the selected ones. A command and params that no such menu item has, a param of another type, params that are not an object, no action and no recording, or a step that would give the action more than 10,000 steps or its set's file more than 8 MiB, counting the steps that the recording in progress adds to the set, is an error and changes nothing. The panel menu's Insert Menu Item… (dialog `actionInsertMenuItem` with find, command, params, set, action and step) runs this on OK → {set, action, step}, or {recording: true}", ), + ( + "ui.action.insertSelectObject", + "Select Object", + "", + "{set?: index, action?: index, step?: index, note, caseSensitive?: bool, wholeWord?: bool} insert a Select Object step, without running it: playback runs select.byNote {note, caseSensitive, wholeWord}, which selects the objects whose Attributes note matches note, and a playback that finds no such object stops there with an error. note loses its leading and trailing spaces and must then be 1 to 1,024 bytes; caseSensitive and wholeWord default to false. The place is as ui.action.insertStop takes it: with set and action, after step, else after the action's last step; without them, the recording in progress. The new step becomes the panel's selected step, and its action and set become the selected ones. A missing or empty note, a param of another type, params that are not an object, no action and no recording, or a step that would give the action more than 10,000 steps or its set's file more than 8 MiB, counting the steps that the recording in progress adds to the set, is an error and changes nothing. The panel menu's Select Object… (dialog `actionSelectObject`, Set Selection, with note, caseSensitive, wholeWord, set, action and step) runs this on OK → {set, action, step}, or {recording: true}", + ), ( "ui.action.continue", "Continue Action", diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index c25fa2f7ed..d7e1c2e1aa 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -11,6 +11,7 @@ pub mod action_batch; pub mod action_insert_menu_item; pub mod action_new; pub mod action_playback; +pub mod action_select_object; pub mod action_set_options; pub mod action_stop; pub mod artboard_options; @@ -396,6 +397,7 @@ registry! { ActionStopRecord: [action_stop::RECORD_KIND] => action_stop::RECORD_SPEC, ActionStopMessage: [action_stop::MESSAGE_KIND] => action_stop::MESSAGE_SPEC, ActionInsertMenuItem: [action_insert_menu_item::KIND] => action_insert_menu_item::SPEC, + ActionSelectObject: [action_select_object::KIND] => action_select_object::SPEC, SliceOptions: [slices::OPTIONS] => slices::OPTIONS_SPEC, DivideSlices: [slices::DIVIDE] => slices::DIVIDE_SPEC, EpsOptions: [eps_options::KIND] => eps_options::SPEC, diff --git a/crates/ui-egui/src/dialogs/action_select_object.rs b/crates/ui-egui/src/dialogs/action_select_object.rs new file mode 100644 index 0000000000..80a72652c9 --- /dev/null +++ b/crates/ui-egui/src/dialogs/action_select_object.rs @@ -0,0 +1,62 @@ +//! Actions panel menu › Select Object… (`ui.action.insertSelectObject`): Set Selection, which adds a +//! step that selects the objects whose Attributes note matches the note typed. +//! +//! Its fields: `note`, `caseSensitive`, `wholeWord`, and `set`, `action` and `step`, the place of the +//! new step (no `set` and `action`: the recording in progress). OK is off while the note is empty. +//! An invalid note or place is an error, and the dialog stays open. + +use serde_json::{Value, json}; + +use super::DialogSpec; +use crate::state::Dialog; +use crate::{VectorcraftApp, widgets}; + +/// The dialog kind of Set Selection. +pub const KIND: &str = "actionSelectObject"; + +/// The width of the Note label column. +const LABEL_W: f32 = 70.0; + +pub(super) const SPEC: DialogSpec = DialogSpec { + heading: |_| tl!("Set Selection").into(), + body, + confirm, + ok_enabled: Some(|_, d| !d.str("note").trim().is_empty()), + min_width: 340.0, + ..DialogSpec::FORM +}; + +/// Opens Set Selection for action `target` as (set, action), after `step`, else for the recording +/// in progress. +pub fn open(app: &mut VectorcraftApp, target: Option<(usize, usize)>, step: Option) { + let mut fields = json!({"note": "", "caseSensitive": false, "wholeWord": false}); + if let Some((set, action)) = target { + fields["set"] = json!(set); + fields["action"] = json!(action); + if let Some(step) = step { + fields["step"] = json!(step); + } + } + app.ui.dialog = Some(Dialog::new(KIND, fields)); +} + +fn body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { + widgets::label_row(ui, "Note:", LABEL_W, |ui| { + super::form::text(ui, d, "note", 240.0); + }); + super::form::check(ui, d, "caseSensitive", "Case Sensitive"); + super::form::check(ui, d, "wholeWord", "Whole Word"); + false +} + +fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { + let mut params = json!({"note": d.str("note"), "caseSensitive": d.bool("caseSensitive"), "wholeWord": d.bool("wholeWord")}); + for key in ["set", "action", "step"] { + if let Some(value) = d.fields.get(key) { + params[key] = value.clone(); + } + } + let r = app.run(crate::panels::actions::INSERT_SELECT_OBJECT, params)?; + app.ui.dialog = None; + Ok(r) +} diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 4ca1e345f9..9d9183d904 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -5969,6 +5969,16 @@ axis Y Axis Y ос @msg Batch cannot play an action with a stop Пакет не може да изпълни действие със спиране Insert Menu Item… Вмъкване на елемент от меню… Insert Menu Item Вмъкване на елемент от меню + Select Object… Избиране на обект… + Select Object Избиране на обект + Set Selection Задаване на селекция + Case Sensitive Различаване на главни и малки букви + Whole Word Цяла дума + Select Object: {note} Избиране на обект: {note} + Select by Note Избиране по забележка +@msg the note is empty забележката е празна +@msg the note is longer than 1,024 bytes забележката е по-дълга от 1024 байта +@msg no object that can be selected has a note matching “{query}” няма обект, който може да бъде избран, със забележка, съответстваща на „{query}“ Menu Item: Елемент от меню: No menu item matches. Няма съвпадащ елемент от меню. Several menu items match. Choose one from the list. Съвпадат няколко елемента от меню. Изберете един от списъка. diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index 972414922b..8d3d162612 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -6011,6 +6011,16 @@ axis Y Axis Y osa @msg Batch cannot play an action with a stop Paketna obrada ne može reprodukovati akciju sa zaustavljanjem Insert Menu Item… Umetni stavku menija… Insert Menu Item Umetni stavku menija + Select Object… Odaberi objekt… + Select Object Odaberi objekt + Set Selection Postavi odabir + Case Sensitive Razlikuj velika i mala slova + Whole Word Cijela riječ + Select Object: {note} Odaberi objekt: {note} + Select by Note Odaberi po napomeni +@msg the note is empty napomena je prazna +@msg the note is longer than 1,024 bytes napomena je duža od 1.024 bajta +@msg no object that can be selected has a note matching “{query}” nijedan objekt koji se može odabrati nema napomenu koja odgovara „{query}” Menu Item: Stavka menija: No menu item matches. Nijedna stavka menija ne odgovara. Several menu items match. Choose one from the list. Odgovara više stavki menija. Izaberite jednu sa liste. diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index ebfbcd26db..4571e3701b 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -4020,6 +4020,16 @@ digits Malayalam Malaiàlam @msg Batch cannot play an action with a stop Lot no pot reproduir una acció amb una aturada Insert Menu Item… Insereix un element de menú… Insert Menu Item Insereix un element de menú + Select Object… Selecciona un objecte… + Select Object Selecciona un objecte + Set Selection Defineix la selecció + Case Sensitive Distingeix majúscules i minúscules + Whole Word Paraula sencera + Select Object: {note} Selecciona un objecte: {note} + Select by Note Selecciona per nota +@msg the note is empty la nota és buida +@msg the note is longer than 1,024 bytes la nota té més de 1.024 bytes +@msg no object that can be selected has a note matching “{query}” cap objecte que es pugui seleccionar té una nota que coincideixi amb “{query}” Menu Item: Element de menú: No menu item matches. Cap element de menú no coincideix. Several menu items match. Choose one from the list. Coincideixen diversos elements de menú. Trieu-ne un de la llista. diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index f5ea30ac2b..f8d0084008 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -6226,6 +6226,16 @@ axis Y Axis osa Y @msg Batch cannot play an action with a stop Dávka nemůže přehrát akci se zastavením Insert Menu Item… Vložit položku nabídky… Insert Menu Item Vložit položku nabídky + Select Object… Vybrat objekt… + Select Object Vybrat objekt + Set Selection Nastavit výběr + Case Sensitive Rozlišovat velká a malá písmena + Whole Word Celé slovo + Select Object: {note} Vybrat objekt: {note} + Select by Note Vybrat podle poznámky +@msg the note is empty poznámka je prázdná +@msg the note is longer than 1,024 bytes poznámka je delší než 1 024 bajtů +@msg no object that can be selected has a note matching “{query}” žádný objekt, který lze vybrat, nemá poznámku odpovídající „{query}“ Menu Item: Položka nabídky: No menu item matches. Žádná položka nabídky neodpovídá. Several menu items match. Choose one from the list. Odpovídá několik položek nabídky. Vyberte jednu ze seznamu. diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index fbc690cf7f..33e613cec4 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -5986,6 +5986,16 @@ axis Y Axis Y-akse @msg Batch cannot play an action with a stop Batch kan ikke afspille en handling med et stop Insert Menu Item… Indsæt menupunkt… Insert Menu Item Indsæt menupunkt + Select Object… Markér objekt… + Select Object Markér objekt + Set Selection Angiv markering + Case Sensitive Forskel på store og små bogstaver + Whole Word Helt ord + Select Object: {note} Markér objekt: {note} + Select by Note Markér efter note +@msg the note is empty noten er tom +@msg the note is longer than 1,024 bytes noten er længere end 1.024 byte +@msg no object that can be selected has a note matching “{query}” intet objekt, der kan markeres, har en note, der matcher »{query}« Menu Item: Menupunkt: No menu item matches. Intet menupunkt passer. Several menu items match. Choose one from the list. Flere menupunkter passer. Vælg et fra listen. diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index e776bc1264..63eba0d8ba 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -5817,6 +5817,16 @@ axis Y Axis Y-Achse @msg Batch cannot play an action with a stop Die Stapelverarbeitung kann keine Aktion mit einem Stopp abspielen Insert Menu Item… Menübefehl einfügen… Insert Menu Item Menübefehl einfügen + Select Object… Objekt auswählen… + Select Object Objekt auswählen + Set Selection Auswahl festlegen + Case Sensitive Groß-/Kleinschreibung beachten + Whole Word Ganzes Wort + Select Object: {note} Objekt auswählen: {note} + Select by Note Nach Notiz auswählen +@msg the note is empty die Notiz ist leer +@msg the note is longer than 1,024 bytes die Notiz ist länger als 1.024 Byte +@msg no object that can be selected has a note matching “{query}” kein auswählbares Objekt hat eine Notiz, die „{query}“ entspricht Menu Item: Menübefehl: No menu item matches. Kein Menübefehl passt. Several menu items match. Choose one from the list. Mehrere Menübefehle passen. Wählen Sie einen aus der Liste. diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 711bdeb099..077e30f902 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -6177,6 +6177,16 @@ axis Y Axis Άξονας Y @msg Batch cannot play an action with a stop Η μαζική επεξεργασία δεν μπορεί να αναπαράγει ενέργεια με διακοπή Insert Menu Item… Εισαγωγή στοιχείου μενού… Insert Menu Item Εισαγωγή στοιχείου μενού + Select Object… Επιλογή αντικειμένου… + Select Object Επιλογή αντικειμένου + Set Selection Ορισμός επιλογής + Case Sensitive Διάκριση πεζών-κεφαλαίων + Whole Word Ολόκληρη λέξη + Select Object: {note} Επιλογή αντικειμένου: {note} + Select by Note Επιλογή βάσει σημείωσης +@msg the note is empty η σημείωση είναι κενή +@msg the note is longer than 1,024 bytes η σημείωση υπερβαίνει τα 1.024 byte +@msg no object that can be selected has a note matching “{query}” κανένα αντικείμενο που μπορεί να επιλεγεί δεν έχει σημείωση που να ταιριάζει με «{query}» Menu Item: Στοιχείο μενού: No menu item matches. Κανένα στοιχείο μενού δεν ταιριάζει. Several menu items match. Choose one from the list. Ταιριάζουν πολλά στοιχεία μενού. Επιλέξτε ένα από τη λίστα. diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 2ab24e227f..a1676e8851 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -6168,6 +6168,16 @@ axis Y Axis Eje Y @msg Batch cannot play an action with a stop Lote no puede reproducir una acción con una parada Insert Menu Item… Insertar elemento de menú… Insert Menu Item Insertar elemento de menú + Select Object… Seleccionar objeto… + Select Object Seleccionar objeto + Set Selection Definir selección + Case Sensitive Distinguir mayúsculas y minúsculas + Whole Word Palabra completa + Select Object: {note} Seleccionar objeto: {note} + Select by Note Seleccionar por nota +@msg the note is empty la nota está vacía +@msg the note is longer than 1,024 bytes la nota ocupa más de 1024 bytes +@msg no object that can be selected has a note matching “{query}” ningún objeto que se pueda seleccionar tiene una nota que coincida con “{query}” Menu Item: Elemento de menú: No menu item matches. Ningún elemento de menú coincide. Several menu items match. Choose one from the list. Coinciden varios elementos de menú. Elija uno de la lista. diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index 650898ae92..00dfe3fae1 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -6256,6 +6256,16 @@ axis Y Axis Y-akseli @msg Batch cannot play an action with a stop Erä ei voi toistaa toimintoa, jossa on pysäytys Insert Menu Item… Lisää valikkokohta… Insert Menu Item Lisää valikkokohta + Select Object… Valitse objekti… + Select Object Valitse objekti + Set Selection Määritä valinta + Case Sensitive Kirjainkoko merkitsevä + Whole Word Koko sana + Select Object: {note} Valitse objekti: {note} + Select by Note Valitse huomautuksen mukaan +@msg the note is empty huomautus on tyhjä +@msg the note is longer than 1,024 bytes huomautus on pidempi kuin 1 024 tavua +@msg no object that can be selected has a note matching “{query}” millään valittavissa olevalla objektilla ei ole huomautusta, joka vastaa hakua ”{query}” Menu Item: Valikkokohta: No menu item matches. Mikään valikkokohta ei vastaa hakua. Several menu items match. Choose one from the list. Useampi valikkokohta vastaa hakua. Valitse yksi luettelosta. diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 95e9c9e4c2..49fe1d30c4 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -6221,6 +6221,16 @@ axis Y Axis Axe Y @msg Batch cannot play an action with a stop Le traitement par lots ne peut pas exécuter un script comportant un arrêt Insert Menu Item… Insérer une commande de menu… Insert Menu Item Insérer une commande de menu + Select Object… Sélectionner un objet… + Select Object Sélectionner un objet + Set Selection Définir la sélection + Case Sensitive Respecter la casse + Whole Word Mot entier + Select Object: {note} Sélectionner un objet : {note} + Select by Note Sélectionner par remarque +@msg the note is empty la remarque est vide +@msg the note is longer than 1,024 bytes la remarque dépasse 1 024 octets +@msg no object that can be selected has a note matching “{query}” aucun objet sélectionnable n’a de remarque correspondant à « {query} » Menu Item: Commande de menu : No menu item matches. Aucune commande de menu ne correspond. Several menu items match. Choose one from the list. Plusieurs commandes de menu correspondent. Choisissez-en une dans la liste. diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 3d9850d4f5..8d603a6e7c 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -3459,6 +3459,16 @@ antiAlias Sharp शार्प @msg Batch cannot play an action with a stop बैच स्टॉप वाला एक्शन नहीं चला सकता Insert Menu Item… मेनू आइटम जोड़ें… Insert Menu Item मेनू आइटम जोड़ें + Select Object… ऑब्जेक्ट सिलेक्ट करें… + Select Object ऑब्जेक्ट सिलेक्ट करें + Set Selection सिलेक्शन सेट करें + Case Sensitive केस सेंसिटिव + Whole Word पूरा शब्द + Select Object: {note} ऑब्जेक्ट सिलेक्ट करें: {note} + Select by Note नोट द्वारा चुनें +@msg the note is empty नोट खाली है +@msg the note is longer than 1,024 bytes नोट 1,024 बाइट से लंबा है +@msg no object that can be selected has a note matching “{query}” सिलेक्ट किए जा सकने वाले किसी भी ऑब्जेक्ट का नोट “{query}” से मेल नहीं खाता Menu Item: मेनू आइटम: No menu item matches. कोई मेनू आइटम मेल नहीं खाता। Several menu items match. Choose one from the list. कई मेनू आइटम मेल खाते हैं। सूची से एक चुनें। diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 948931ac13..f9bf74999d 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -6209,6 +6209,16 @@ axis Y Axis Y tengely @msg Batch cannot play an action with a stop A kötegelt feldolgozás nem játszhat le megállást tartalmazó műveletet Insert Menu Item… Menüelem beszúrása… Insert Menu Item Menüelem beszúrása + Select Object… Objektum kijelölése… + Select Object Objektum kijelölése + Set Selection Kijelölés beállítása + Case Sensitive Kis- és nagybetűk megkülönböztetése + Whole Word Teljes szó + Select Object: {note} Objektum kijelölése: {note} + Select by Note Kijelölés megjegyzés szerint +@msg the note is empty a megjegyzés üres +@msg the note is longer than 1,024 bytes a megjegyzés hosszabb 1024 bájtnál +@msg no object that can be selected has a note matching “{query}” egyetlen kijelölhető objektum megjegyzése sem egyezik ezzel: „{query}” Menu Item: Menüelem: No menu item matches. Egy menüelem sem egyezik. Several menu items match. Choose one from the list. Több menüelem is egyezik. Válasszon egyet a listából. diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 55e2e73bf1..633147f4cc 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -5986,6 +5986,16 @@ axis Y Axis Sumbu Y @msg Batch cannot play an action with a stop Batch tidak dapat memutar tindakan dengan titik henti Insert Menu Item… Sisipkan item menu… Insert Menu Item Sisipkan item menu + Select Object… Pilih objek… + Select Object Pilih objek + Set Selection Atur seleksi + Case Sensitive Peka huruf besar/kecil + Whole Word Seluruh kata + Select Object: {note} Pilih objek: {note} + Select by Note Pilih berdasarkan catatan +@msg the note is empty catatan kosong +@msg the note is longer than 1,024 bytes catatan lebih panjang dari 1.024 byte +@msg no object that can be selected has a note matching “{query}” tidak ada objek yang dapat dipilih dengan catatan yang cocok dengan “{query}” Menu Item: Item menu: No menu item matches. Tidak ada item menu yang cocok. Several menu items match. Choose one from the list. Beberapa item menu cocok. Pilih salah satu dari daftar. diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 81dffc495f..e6727b24b2 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -5851,6 +5851,16 @@ axis Y Axis Asse Y @msg Batch cannot play an action with a stop L'elaborazione batch non può eseguire un'azione con un'interruzione Insert Menu Item… Inserisci voce di menu… Insert Menu Item Inserisci voce di menu + Select Object… Seleziona oggetto… + Select Object Seleziona oggetto + Set Selection Imposta selezione + Case Sensitive Maiuscole/minuscole + Whole Word Parola intera + Select Object: {note} Seleziona oggetto: {note} + Select by Note Seleziona per nota +@msg the note is empty la nota è vuota +@msg the note is longer than 1,024 bytes la nota supera 1.024 byte +@msg no object that can be selected has a note matching “{query}” nessun oggetto selezionabile ha una nota corrispondente a “{query}” Menu Item: Voce di menu: No menu item matches. Nessuna voce di menu corrisponde. Several menu items match. Choose one from the list. Corrispondono più voci di menu. Sceglierne una dall’elenco. diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 9d8ec83078..ceb7b4b2b1 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -5916,6 +5916,16 @@ axis Y Axis Y 軸 @msg Batch cannot play an action with a stop バッチでは停止を含むアクションを再生できません Insert Menu Item… メニュー項目を挿入… Insert Menu Item メニュー項目を挿入 + Select Object… オブジェクトを選択… + Select Object オブジェクトを選択 + Set Selection 選択範囲を設定 + Case Sensitive 大文字と小文字を区別 + Whole Word 単語単位 + Select Object: {note} オブジェクトを選択: {note} + Select by Note メモで選択 +@msg the note is empty メモが空です +@msg the note is longer than 1,024 bytes メモが 1,024 バイトを超えています +@msg no object that can be selected has a note matching “{query}” 「{query}」に一致するメモを持つ選択可能なオブジェクトはありません Menu Item: メニュー項目: No menu item matches. 一致するメニュー項目がありません。 Several menu items match. Choose one from the list. 複数のメニュー項目が一致します。リストから 1 つ選択してください。 diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 2630b64269..3668505433 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -6177,6 +6177,16 @@ axis Y Axis Y ღერძი @msg Batch cannot play an action with a stop პაკეტურ დამუშავებას არ შეუძლია გაჩერების მქონე მოქმედების შესრულება Insert Menu Item… მენიუს ელემენტის ჩასმა… Insert Menu Item მენიუს ელემენტის ჩასმა + Select Object… ობიექტის მონიშვნა… + Select Object ობიექტის მონიშვნა + Set Selection მონიშვნის დაყენება + Case Sensitive რეგისტრის გათვალისწინება + Whole Word მთელი სიტყვა + Select Object: {note} ობიექტის მონიშვნა: {note} + Select by Note მონიშვნა შენიშვნის მიხედვით +@msg the note is empty შენიშვნა ცარიელია +@msg the note is longer than 1,024 bytes შენიშვნა 1024 ბაიტზე გრძელია +@msg no object that can be selected has a note matching “{query}” არცერთ მოსანიშნ ობიექტს არ აქვს შენიშვნა, რომელიც ემთხვევა „{query}“-ს Menu Item: მენიუს ელემენტი: No menu item matches. მენიუს არცერთი ელემენტი არ ემთხვევა. Several menu items match. Choose one from the list. ემთხვევა მენიუს რამდენიმე ელემენტი. აირჩიეთ ერთი სიიდან. diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 30ac7dfb76..584682c039 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -5812,6 +5812,16 @@ axis Y Axis Y축 @msg Batch cannot play an action with a stop 일괄 처리에서는 중지가 있는 액션을 재생할 수 없습니다 Insert Menu Item… 메뉴 항목 삽입… Insert Menu Item 메뉴 항목 삽입 + Select Object… 개체 선택… + Select Object 개체 선택 + Set Selection 선택 영역 설정 + Case Sensitive 대/소문자 구분 + Whole Word 전체 단어 + Select Object: {note} 개체 선택: {note} + Select by Note 참고로 선택 +@msg the note is empty 참고가 비어 있습니다 +@msg the note is longer than 1,024 bytes 참고가 1,024바이트보다 깁니다 +@msg no object that can be selected has a note matching “{query}” 선택할 수 있는 개체 중 참고가 “{query}”와(과) 일치하는 개체가 없습니다 Menu Item: 메뉴 항목: No menu item matches. 일치하는 메뉴 항목이 없습니다. Several menu items match. Choose one from the list. 여러 메뉴 항목이 일치합니다. 목록에서 하나를 선택하세요. diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 57ca80dc20..b5ab903e3b 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -6037,6 +6037,16 @@ axis Y Axis Y-as @msg Batch cannot play an action with a stop Batch kan geen actie met een stop afspelen Insert Menu Item… Menu-item invoegen… Insert Menu Item Menu-item invoegen + Select Object… Object selecteren… + Select Object Object selecteren + Set Selection Selectie instellen + Case Sensitive Hoofdlettergevoelig + Whole Word Heel woord + Select Object: {note} Object selecteren: {note} + Select by Note Selecteren op notitie +@msg the note is empty de notitie is leeg +@msg the note is longer than 1,024 bytes de notitie is langer dan 1024 bytes +@msg no object that can be selected has a note matching “{query}” geen selecteerbaar object heeft een notitie die overeenkomt met „{query}” Menu Item: Menu-item: No menu item matches. Geen menu-item komt overeen. Several menu items match. Choose one from the list. Meerdere menu-items komen overeen. Kies er een uit de lijst. diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 4094d29879..32cd6f132e 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -6080,6 +6080,16 @@ axis Y Axis Oś Y @msg Batch cannot play an action with a stop Przetwarzanie wsadowe nie może odtworzyć operacji z zatrzymaniem Insert Menu Item… Wstaw polecenie menu… Insert Menu Item Wstaw polecenie menu + Select Object… Zaznacz obiekt… + Select Object Zaznacz obiekt + Set Selection Ustaw zaznaczenie + Case Sensitive Uwzględniaj wielkość liter + Whole Word Całe słowo + Select Object: {note} Zaznacz obiekt: {note} + Select by Note Zaznacz według notatki +@msg the note is empty notatka jest pusta +@msg the note is longer than 1,024 bytes notatka jest dłuższa niż 1024 bajty +@msg no object that can be selected has a note matching “{query}” żaden obiekt, który można zaznaczyć, nie ma notatki pasującej do „{query}” Menu Item: Polecenie menu: No menu item matches. Żadne polecenie menu nie pasuje. Several menu items match. Choose one from the list. Pasuje kilka poleceń menu. Wybierz jedno z listy. diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index a367880be2..8d21519b38 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -6141,6 +6141,16 @@ axis Y Axis Eixo Y @msg Batch cannot play an action with a stop Lote não pode reproduzir uma ação com uma parada Insert Menu Item… Inserir Item de Menu… Insert Menu Item Inserir Item de Menu + Select Object… Selecionar Objeto… + Select Object Selecionar Objeto + Set Selection Definir Seleção + Case Sensitive Diferenciar Maiúsculas e Minúsculas + Whole Word Palavra Inteira + Select Object: {note} Selecionar Objeto: {note} + Select by Note Selecionar por Nota +@msg the note is empty a nota está vazia +@msg the note is longer than 1,024 bytes a nota tem mais de 1.024 bytes +@msg no object that can be selected has a note matching “{query}” nenhum objeto que pode ser selecionado tem uma nota correspondente a “{query}” Menu Item: Item de menu: No menu item matches. Nenhum item de menu corresponde. Several menu items match. Choose one from the list. Vários itens de menu correspondem. Escolha um da lista. diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index e03cc555b1..9b1de85541 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -5837,6 +5837,16 @@ axis Y Axis Ось Y @msg Batch cannot play an action with a stop Пакет не может воспроизвести действие с остановкой Insert Menu Item… Вставить пункт меню… Insert Menu Item Вставить пункт меню + Select Object… Выделить объект… + Select Object Выделить объект + Set Selection Задать выделение + Case Sensitive С учетом регистра + Whole Word Слово целиком + Select Object: {note} Выделить объект: {note} + Select by Note Выделить по примечанию +@msg the note is empty примечание пустое +@msg the note is longer than 1,024 bytes примечание длиннее 1024 байт +@msg no object that can be selected has a note matching “{query}” ни у одного объекта, который можно выделить, нет примечания, совпадающего с «{query}» Menu Item: Пункт меню: No menu item matches. Нет подходящих пунктов меню. Several menu items match. Choose one from the list. Подходит несколько пунктов меню. Выберите один из списка. diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 83a29c8531..679a1d097a 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -6243,6 +6243,16 @@ axis Y Axis Y-axel @msg Batch cannot play an action with a stop Batch kan inte spela upp en åtgärd med ett stopp Insert Menu Item… Infoga menyalternativ… Insert Menu Item Infoga menyalternativ + Select Object… Markera objekt… + Select Object Markera objekt + Set Selection Ange markering + Case Sensitive Skiftlägeskänslig + Whole Word Helt ord + Select Object: {note} Markera objekt: {note} + Select by Note Markera efter anteckning +@msg the note is empty anteckningen är tom +@msg the note is longer than 1,024 bytes anteckningen är längre än 1 024 byte +@msg no object that can be selected has a note matching “{query}” inget objekt som kan markeras har en anteckning som matchar ”{query}” Menu Item: Menyalternativ: No menu item matches. Inget menyalternativ matchar. Several menu items match. Choose one from the list. Flera menyalternativ matchar. Välj ett i listan. diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index c781b80ebb..add3fcf25b 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -6182,6 +6182,16 @@ axis Y Axis Y ekseni @msg Batch cannot play an action with a stop Toplu işlem durak içeren bir eylemi oynatamaz Insert Menu Item… Menü Öğesi Ekle… Insert Menu Item Menü Öğesi Ekle + Select Object… Nesne Seç… + Select Object Nesne Seç + Set Selection Seçimi Ayarla + Case Sensitive Büyük/Küçük Harfe Duyarlı + Whole Word Tam Sözcük + Select Object: {note} Nesne Seç: {note} + Select by Note Nota Göre Seç +@msg the note is empty not boş +@msg the note is longer than 1,024 bytes not 1.024 bayttan uzun +@msg no object that can be selected has a note matching “{query}” seçilebilen hiçbir nesnenin notu “{query}” ile eşleşmiyor Menu Item: Menü öğesi: No menu item matches. Eşleşen menü öğesi yok. Several menu items match. Choose one from the list. Birden çok menü öğesi eşleşiyor. Listeden birini seçin. diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 75e062627c..cb5dc0aa9f 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -5831,6 +5831,16 @@ axis Y Axis Вісь Y @msg Batch cannot play an action with a stop Пакет не може відтворити дію із зупинкою Insert Menu Item… Вставити пункт меню… Insert Menu Item Вставити пункт меню + Select Object… Виділити об’єкт… + Select Object Виділити об’єкт + Set Selection Задати виділення + Case Sensitive З урахуванням регістру + Whole Word Ціле слово + Select Object: {note} Виділити об’єкт: {note} + Select by Note Виділити за приміткою +@msg the note is empty примітка порожня +@msg the note is longer than 1,024 bytes примітка довша за 1024 байти +@msg no object that can be selected has a note matching “{query}” жоден об’єкт, який можна виділити, не має примітки, що збігається з «{query}» Menu Item: Пункт меню: No menu item matches. Немає відповідних пунктів меню. Several menu items match. Choose one from the list. Відповідає кілька пунктів меню. Виберіть один зі списку. diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 7df0386f9e..f27b9c58f0 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -5832,6 +5832,16 @@ axis Y Axis Trục Y @msg Batch cannot play an action with a stop Hàng loạt không thể phát hành động có điểm dừng Insert Menu Item… Chèn mục menu… Insert Menu Item Chèn mục menu + Select Object… Chọn đối tượng… + Select Object Chọn đối tượng + Set Selection Đặt vùng chọn + Case Sensitive Phân biệt chữ hoa chữ thường + Whole Word Toàn bộ từ + Select Object: {note} Chọn đối tượng: {note} + Select by Note Chọn theo ghi chú +@msg the note is empty ghi chú trống +@msg the note is longer than 1,024 bytes ghi chú dài hơn 1.024 byte +@msg no object that can be selected has a note matching “{query}” không có đối tượng nào có thể chọn có ghi chú khớp với “{query}” Menu Item: Mục menu: No menu item matches. Không có mục menu nào khớp. Several menu items match. Choose one from the list. Có nhiều mục menu khớp. Hãy chọn một mục trong danh sách. diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 9b593188b9..81db19e797 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -6064,6 +6064,16 @@ axis Y Axis Y 轴 @msg Batch cannot play an action with a stop 批处理无法播放含有停止的动作 Insert Menu Item… 插入菜单项… Insert Menu Item 插入菜单项 + Select Object… 选择对象… + Select Object 选择对象 + Set Selection 设置选区 + Case Sensitive 区分大小写 + Whole Word 全字匹配 + Select Object: {note} 选择对象:{note} + Select by Note 按注释选择 +@msg the note is empty 注释为空 +@msg the note is longer than 1,024 bytes 注释超过 1,024 字节 +@msg no object that can be selected has a note matching “{query}” 没有可选对象的注释与“{query}”匹配 Menu Item: 菜单项: No menu item matches. 没有匹配的菜单项。 Several menu items match. Choose one from the list. 有多个菜单项匹配。请从列表中选择一个。 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index d2245176dd..6268b6e074 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -5869,6 +5869,16 @@ axis Y Axis Y 軸 @msg Batch cannot play an action with a stop 批次無法播放含有停止的動作 Insert Menu Item… 插入選單項目… Insert Menu Item 插入選單項目 + Select Object… 選取物件… + Select Object 選取物件 + Set Selection 設定選取範圍 + Case Sensitive 區分大小寫 + Whole Word 全字拼寫須相符 + Select Object: {note} 選取物件:{note} + Select by Note 依註解選取 +@msg the note is empty 註解是空的 +@msg the note is longer than 1,024 bytes 註解超過 1,024 位元組 +@msg no object that can be selected has a note matching “{query}” 沒有可選取物件的註解符合「{query}」 Menu Item: 選單項目: No menu item matches. 沒有相符的選單項目。 Several menu items match. Choose one from the list. 有多個選單項目相符。請從清單中選擇一個。 diff --git a/crates/ui-egui/src/menus/run.rs b/crates/ui-egui/src/menus/run.rs index 72e6c25acb..17158e0479 100644 --- a/crates/ui-egui/src/menus/run.rs +++ b/crates/ui-egui/src/menus/run.rs @@ -978,6 +978,7 @@ fn run_ui_part11(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option crate::panels::actions::stop(app, p), crate::panels::actions::INSERT_STOP => crate::panels::actions::insert_stop(app, p), crate::panels::actions::INSERT_MENU_ITEM => crate::panels::actions::insert_menu_item(app, p), + crate::panels::actions::INSERT_SELECT_OBJECT => crate::panels::actions::insert_select_object(app, p), crate::panels::actions::CONTINUE_PLAYBACK => crate::panels::actions::continue_playback(app, p), crate::panels::actions::DELETE_STEP => crate::panels::actions::delete_step(app, p), "ui.batchDialog" => { diff --git a/crates/ui-egui/src/menus/ui_commands.rs b/crates/ui-egui/src/menus/ui_commands.rs index 390f168659..426a707965 100644 --- a/crates/ui-egui/src/menus/ui_commands.rs +++ b/crates/ui-egui/src/menus/ui_commands.rs @@ -1428,6 +1428,12 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "", "{set?: index, action?: index, step?: index, command, params?: object} insert the command of a menu item as a step, without running it; the step plays as choosing the item runs it. command and params (default {}) must be those of a menu item that can be a step: one that runs an engine command, whose label does not end with \"…\", outside the VectorCraft, File, Window and Help menus, and that is not Undo, Redo, a Paste, a batch (command.batch), a preference (prefs.*) or action (action.*) command, an Object › Align item, Image Trace's Make or Make and Expand, Pattern's Make or Edit Pattern, a saved selection, saved view or recent font item, or a plug-in's item. The place is as ui.action.insertStop takes it: with set and action, after step, else after the action's last step; without them, the recording in progress. The new step becomes the panel's selected step, and its action and set become the selected ones. A command and params that no such menu item has, a param of another type, params that are not an object, no action and no recording, or a step that would give the action more than 10,000 steps or its set's file more than 8 MiB, counting the steps that the recording in progress adds to the set, is an error and changes nothing. The panel menu's Insert Menu Item… (dialog `actionInsertMenuItem` with find, command, params, set, action and step) runs this on OK → {set, action, step}, or {recording: true}", ), + ( + crate::panels::actions::INSERT_SELECT_OBJECT, + "Select Object", + "", + "{set?: index, action?: index, step?: index, note, caseSensitive?: bool, wholeWord?: bool} insert a Select Object step, without running it: playback runs select.byNote {note, caseSensitive, wholeWord}, which selects the objects whose Attributes note matches note, and a playback that finds no such object stops there with an error. note loses its leading and trailing spaces and must then be 1 to 1,024 bytes; caseSensitive and wholeWord default to false. The place is as ui.action.insertStop takes it: with set and action, after step, else after the action's last step; without them, the recording in progress. The new step becomes the panel's selected step, and its action and set become the selected ones. A missing or empty note, a param of another type, params that are not an object, no action and no recording, or a step that would give the action more than 10,000 steps or its set's file more than 8 MiB, counting the steps that the recording in progress adds to the set, is an error and changes nothing. The panel menu's Select Object… (dialog `actionSelectObject`, Set Selection, with note, caseSensitive, wholeWord, set, action and step) runs this on OK → {set, action, step}, or {recording: true}", + ), ( crate::panels::actions::CONTINUE_PLAYBACK, "Continue Action", diff --git a/crates/ui-egui/src/panels/actions.rs b/crates/ui-egui/src/panels/actions.rs index f2426f33ab..3fa7ba5824 100644 --- a/crates/ui-egui/src/panels/actions.rs +++ b/crates/ui-egui/src/panels/actions.rs @@ -948,6 +948,55 @@ pub fn insert_menu_item(app: &mut VectorcraftApp, p: &Value) -> Result Result<(String, bool, bool), String> { + if !p.is_object() { + return Err("`params` must be an object".into()); + } + let key = "note"; + let note = match p.get(key) { + None | Some(Value::Null) => "", + Some(v) => v.as_str().ok_or_else(|| format!("`{key}` must be a string"))?, + } + .trim(); + if note.is_empty() { + return Err("the note is empty".into()); + } + if note.len() > MAX_SELECT_NOTE { + return Err("the note is longer than 1,024 bytes".into()); + } + let flag = |key: &str| match p.get(key) { + None | Some(Value::Null) => Ok(false), + Some(v) => v.as_bool().ok_or_else(|| format!("`{key}` must be true or false")), + }; + Ok((note.to_string(), flag("caseSensitive")?, flag("wholeWord")?)) +} + +/// The command that inserts a Select Object step ([`insert_select_object`]). +pub const INSERT_SELECT_OBJECT: &str = "ui.action.insertSelectObject"; + +/// `ui.action.insertSelectObject {set?, action?, step?, note, caseSensitive?, wholeWord?}`: inserts +/// a Select Object step, without running it. Playback selects the objects whose Attributes note +/// matches `note` ([`SELECT_OBJECT_STEP`]); a playback that finds none stops there with an error. +/// The note and options are as [`select_object_params`] reads them. The place is as [`insert_stop`] +/// takes it, and the step is selected as a stop is. Bad params, or a place [`insert_stop`] +/// refuses, is an error, and then nothing changes. Set Selection's OK runs this. → {set, action, +/// step}, or {recording: true} +pub fn insert_select_object(app: &mut VectorcraftApp, p: &Value) -> Result { + let (note, case_sensitive, whole_word) = select_object_params(p)?; + insert(app, p, (SELECT_OBJECT_STEP.to_string(), json!({"note": note, "caseSensitive": case_sensitive, "wholeWord": whole_word}))) +} + /// The journal entry the recording in progress records from. fn recording_start(app: &VectorcraftApp) -> Option { let (_, _, start) = app.ui.recording.as_ref()?; @@ -1256,7 +1305,7 @@ pub const DELETE_STEP: &str = "ui.action.deleteStep"; /// `ui.action.deleteStep {set, action, step}`: deletes step `step`, a stop or a command, of the /// action at index `action` of set `set`. The steps after it keep whether they are excluded, and -/// no step of that action stays selected. A Record Stop or Insert Menu Item dialog open on that step +/// no step of that action stays selected. A Record Stop, Insert Menu Item or Set Selection dialog open on that step /// closes, and one open on a later step of the action follows it up. A missing or invalid index is an error, and /// then nothing changes. Undo does not bring the step back. Delete Selection runs this for the /// selected step. → {set, action, step} @@ -1567,10 +1616,12 @@ fn holds_set(d: &crate::state::Dialog) -> bool { || (d.kind == crate::dialogs::action_set_options::KIND && d.fields.contains_key("set")) } -/// Whether dialog `d` is a Record Stop or Insert Menu Item dialog, which holds the place of a new -/// step in its `set`, `action` and `step` fields. +/// Whether dialog `d` is a Record Stop, Insert Menu Item or Set Selection dialog, which holds the +/// place of a new step in its `set`, `action` and `step` fields. fn holds_step(d: &crate::state::Dialog) -> bool { - d.kind == crate::dialogs::action_stop::RECORD_KIND || d.kind == crate::dialogs::action_insert_menu_item::KIND + d.kind == crate::dialogs::action_stop::RECORD_KIND + || d.kind == crate::dialogs::action_insert_menu_item::KIND + || d.kind == crate::dialogs::action_select_object::KIND } /// The command that duplicates an action or an action set ([`duplicate`]). @@ -1624,8 +1675,8 @@ pub fn duplicate(app: &mut VectorcraftApp, p: &Value) -> Result { edited.actions.insert(a + 1, copy); edit_keeps_set_loadable(app, Some(set), &edited)?; app.ui.action_sets[set] = edited; - // A Batch, Record Stop or Insert Menu Item dialog open on a later action of the set follows it - // down. + // A Batch, Record Stop, Insert Menu Item or Set Selection dialog open on a later action of the + // set follows it down. if let Some(d) = app.ui.dialog.as_mut().filter(|d| d.kind == crate::dialogs::action_batch::KIND || holds_step(d)) && d.fields.get("set").and_then(index) == Some(set) && let Some(da) = d.fields.get("action").and_then(index).filter(|&da| da > a) @@ -1706,6 +1757,10 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut egui::Ui) { let step = selected_step(app, ui.ctx(), selected); crate::dialogs::action_insert_menu_item::open(app, if recording { None } else { selected }, step); } + if widgets::menu_item(ui, tl!("Select Object…"), recording || has, false) { + let step = selected_step(app, ui.ctx(), selected); + crate::dialogs::action_select_object::open(app, if recording { None } else { selected }, step); + } if widgets::menu_item(ui, tl!("Action Options…"), has, false) && let Some((s, a)) = selected { @@ -1892,10 +1947,14 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut egui::Ui) { current = Some(si); sets_selected = vec![si]; } - // A stop's row shows its message; other steps show their command. + // A stop's row shows its message, and a Select Object step's its note; other + // steps show their command. let shown = if command == STOP_STEP { let message = params.get("message").and_then(Value::as_str).and_then(|m| m.lines().next()).unwrap_or_default(); crate::i18n::fmt(tl!("Stop: {message}"), &[("message", message)]) + } else if command == SELECT_OBJECT_STEP { + let note = params.get("note").and_then(Value::as_str).and_then(|m| m.lines().next()).unwrap_or_default(); + crate::i18n::fmt(tl!("Select Object: {note}"), &[("note", note)]) } else { command.clone() }; @@ -3087,7 +3146,7 @@ mod tests { (json!(long.to_string()), "an action in the action set file has more than 10,000 steps"), (json!(" ".repeat(files::MAX_SET_FILE + 1)), "the action set file is larger than 8 MiB"), ( - json!("{\"format\": \"vcactions\", \"version\": 4, \"name\": \"S\", \"actions\": []}"), + json!("{\"format\": \"vcactions\", \"version\": 5, \"name\": \"S\", \"actions\": []}"), "the action set file needs a later version of VectorCraft", ), ] { @@ -5520,7 +5579,7 @@ mod tests { json!({"format": "vcactions", "version": version, "name": "Old", "actions": [{"name": "A", "steps": [{"command": "object.move"}]}]}); assert_eq!(files::read_set(&old.to_string(), "Test").unwrap().actions[0].steps.len(), 1, "version {version}"); } - let later = json!({"format": "vcactions", "version": 4, "name": "New", "actions": []}); + let later = json!({"format": "vcactions", "version": 5, "name": "New", "actions": []}); assert_eq!(files::read_set(&later.to_string(), "Test").unwrap_err(), "the action set file needs a later version of VectorCraft"); for params in [json!({"message": 5}), json!({"allowContinue": "yes"}), json!(42)] { let stop = json!({"command": "action.stop", "params": params}); @@ -5664,4 +5723,169 @@ mod tests { app.run(DELETE_SETS, json!({"sets": [1]})).unwrap(); assert_eq!(open_kind(&app), None, "deleting its set closes it"); } + + /// [`app_with_three_moves`] with a second rectangle whose Attributes note is "Badge front" → + /// (the app, the first rectangle's x offset, the second rectangle's x offset). + #[allow(clippy::type_complexity)] + fn app_with_a_noted_rect() -> (VectorcraftApp, Box f64>, Box f64>) { + let (mut app, x) = app_with_three_moves(); + let first = app.session.active().unwrap().selection.objects.clone(); + let id = vectorcraft_doc::NodeId( + app.run("shape.rectangle", json!({"x": 100, "y": 10, "width": 20, "height": 20})).unwrap()["id"].as_u64().unwrap(), + ); + app.run("attributes.set", json!({"ids": [id.0], "note": "Badge front"})).unwrap(); + app.run("select.set", json!({"ids": first.iter().map(|i| i.0).collect::>()})).unwrap(); + let noted = move |app: &VectorcraftApp| app.session.active().unwrap().doc.node(id).unwrap().geometric_bounds().unwrap().x0 - 100.0; + (app, Box::new(x), Box::new(noted)) + } + + /// A Select Object step goes after the given step, or at the action's end, runs nothing when it + /// goes in, and plays as `select.byNote`: the steps after it act on the objects it selects. + #[test] + fn insert_select_object_goes_after_the_given_step_and_plays_as_a_selection_by_note() { + let (mut app, x, noted) = app_with_a_noted_rect(); + let selection = app.session.active().unwrap().selection.clone(); + let r = app.run(INSERT_SELECT_OBJECT, json!({"set": 0, "action": 0, "step": 0, "note": " badge "})).unwrap(); + assert_eq!(r, json!({"set": 0, "action": 0, "step": 1})); + assert_eq!(app.session.active().unwrap().selection, selection, "inserting runs nothing"); + let action = &app.ui.action_sets[0].actions[0]; + assert_eq!(action.steps[1], (SELECT_OBJECT_STEP.to_string(), json!({"note": "badge", "caseSensitive": false, "wholeWord": false}))); + app.run(INSERT_SELECT_OBJECT, json!({"set": 0, "action": 0, "note": "Badge", "caseSensitive": true, "wholeWord": true})).unwrap(); + assert_eq!(app.ui.action_sets[0].actions[0].steps[4].1, json!({"note": "Badge", "caseSensitive": true, "wholeWord": true})); + play(&mut app, 0, 0).unwrap(); + assert_eq!((x(&app), noted(&app)), (1.0, 6.0), "the moves after the step move the object with the note"); + let ids = &app.session.active().unwrap().selection.objects; + assert_eq!(ids.len(), 1); + } + + /// A playback whose Select Object step finds no object stops there with the step's error, at + /// every speed, and runs none of the steps after it. + #[test] + fn a_select_object_step_that_finds_nothing_stops_the_playback() { + for speed in ["accelerated", "step"] { + let (mut app, x, noted) = app_with_a_noted_rect(); + app.run(PLAYBACK_OPTIONS, json!({"speed": speed})).unwrap(); + app.ui.action_sets[0].actions[0].insert_step(0, (SELECT_OBJECT_STEP.into(), json!({"note": "missing"}))); + let selection = app.session.active().unwrap().selection.clone(); + let e = play(&mut app, 0, 0).unwrap_err(); + assert!(e.contains("no object that can be selected has a note matching “missing”"), "{speed}: {e}"); + play_out(&mut app); + assert_eq!((x(&app), noted(&app)), (0.0, 0.0), "{speed}: nothing after it runs"); + assert_eq!(app.session.active().unwrap().selection, selection, "{speed}: the selection stays"); + assert!(app.ui.action_playing.is_none(), "{speed}"); + } + } + + #[test] + fn insert_select_object_refuses_bad_params_and_changes_nothing() { + let (mut app, _, _) = app_with_a_noted_rect(); + let place = |extra: Value| { + let mut p = json!({"set": 0, "action": 0}); + if let (Some(p), Some(extra)) = (p.as_object_mut(), extra.as_object()) { + p.extend(extra.clone()); + } + p + }; + for (params, want) in [ + (place(json!({})), "the note is empty"), + (place(json!({"note": " \t "})), "the note is empty"), + (place(json!({"note": 5})), "`note` must be a string"), + (place(json!({"note": "x".repeat(1025)})), "the note is longer than 1,024 bytes"), + (place(json!({"note": "x", "caseSensitive": "yes"})), "`caseSensitive` must be true or false"), + (place(json!({"note": "x", "wholeWord": 0})), "`wholeWord` must be true or false"), + (place(json!({"note": "x", "action": 5})), "no such action"), + (place(json!({"note": "x", "step": 9})), "no such action step"), + (json!({"note": "x"}), "no such action"), + (json!(["note"]), "`params` must be an object"), + ] { + assert_eq!(app.run(INSERT_SELECT_OBJECT, params.clone()).unwrap_err(), want, "{params}"); + assert_eq!(app.ui.action_sets[0].actions[0].steps.len(), 3, "{params}"); + } + assert!(app.run(INSERT_SELECT_OBJECT, place(json!({"note": "x".repeat(1024)}))).is_ok(), "1,024 bytes goes in"); + } + + /// A saved Select Object step loads back as it was, in a version 4 file that older builds refuse; + /// a file whose Select Object step has bad params is refused, also inside a recorded batch. + #[test] + fn a_saved_select_object_step_survives_and_bad_ones_are_refused() { + let (mut app, _, _) = app_with_a_noted_rect(); + app.run(INSERT_SELECT_OBJECT, json!({"set": 0, "action": 0, "note": "Badge", "wholeWord": true})).unwrap(); + let saved = files::write_set(&app.ui.action_sets[0]); + let file: Value = serde_json::from_str(&saved).unwrap(); + assert_eq!(file["version"], json!(4), "older builds must refuse a Select Object step"); + assert_eq!( + file["actions"][0]["steps"][3], + json!({"command": "select.byNote", "params": {"note": "Badge", "caseSensitive": false, "wholeWord": true}}) + ); + let loaded = files::read_set(&saved, "Test").unwrap(); + assert_eq!(loaded.actions[0].steps, app.ui.action_sets[0].actions[0].steps); + app.ui.action_sets[0].actions[0].steps.push(("action.stop".into(), json!({"message": ""}))); + let file: Value = serde_json::from_str(&files::write_set(&app.ui.action_sets[0])).unwrap(); + assert_eq!(file["version"], json!(4), "with a stop too"); + for params in [json!({}), json!({"note": " "}), json!({"note": 5}), json!({"note": "a", "caseSensitive": 1}), json!(null), json!(42)] { + let step = json!({"command": "select.byNote", "params": params}); + for step in [step.clone(), json!({"command": "command.batch", "params": {"commands": [step]}})] { + let bad = json!({"format": "vcactions", "version": 4, "name": "Bad", "actions": [{"name": "A", "steps": [step]}]}); + assert_eq!(files::read_set(&bad.to_string(), "Test").unwrap_err(), "not an action set file", "{step}"); + } + } + let nested = json!({"commands": [{"command": "select.byNote", "params": {"note": "Inner"}}]}); + let set = ActionSet::new("Nested", vec![Action::new("A", vec![("command.batch".into(), nested)])]); + assert_eq!(serde_json::from_str::(&files::write_set(&set)).unwrap()["version"], json!(4), "a step in a recorded batch counts"); + } + + /// The panel menu's Select Object… opens Set Selection on the selected step's place, with an + /// empty note and both options off; Cancel inserts nothing, and OK inserts the step after the + /// selected step, whose row shows the note. While recording, the step goes into the recording. + /// With nothing selected and nothing recording, the item is off. + #[test] + fn select_object_in_the_menu_opens_set_selection_and_inserts_on_ok() { + use super::super::tests_appearance::{click, frame_events}; + let (mut app, _, _) = app_with_a_noted_rect(); + app.ui.action_set = Some(0); + let ctx = egui::Context::default(); + app.ui_ctx = Some(ctx.clone()); + let open = |ctx: &egui::Context, app: &mut VectorcraftApp| { + let texts = frame_events(ctx, app, vec![], menu); + let item = texts.iter().find(|(s, _)| s.trim() == "Select Object…").map(|(_, r)| *r).expect("Select Object… in the menu"); + click(ctx, app, item.center(), menu); + }; + open(&ctx, &mut app); + assert_eq!(open_kind(&app), None, "nothing selected and nothing recording: the item is off"); + + click_first_step(&ctx, &mut app); + open(&ctx, &mut app); + let d = app.ui.dialog.as_ref().expect("Select Object… opens Set Selection"); + assert_eq!(d.kind, crate::dialogs::action_select_object::KIND); + assert_eq!((&d.fields["set"], &d.fields["action"], &d.fields["step"]), (&json!(0), &json!(0), &json!(0))); + assert_eq!((d.str("note"), d.bool("caseSensitive"), d.bool("wholeWord")), (String::new(), false, false)); + crate::dialogs::cancel(&mut app); + assert_eq!(app.ui.action_sets[0].actions[0].steps.len(), 3, "Cancel inserts nothing"); + + open(&ctx, &mut app); + if let Some(d) = app.ui.dialog.as_mut() { + d.fields.insert("note".into(), json!("Badge")); + d.fields.insert("caseSensitive".into(), json!(true)); + } + crate::dialogs::confirm(&mut app).unwrap(); + assert_eq!(open_kind(&app), None, "OK closes Set Selection"); + let action = &app.ui.action_sets[0].actions[0]; + assert_eq!(action.steps[1], (SELECT_OBJECT_STEP.to_string(), json!({"note": "Badge", "caseSensitive": true, "wholeWord": false}))); + let texts = frame_events(&ctx, &mut app, vec![], show); + assert!(texts.iter().any(|(s, _)| s == "Select Object: Badge"), "the step's row shows the note: {texts:?}"); + + app.run(START_RECORDING, json!({})).unwrap(); + open(&ctx, &mut app); + assert!(!app.ui.dialog.as_ref().is_some_and(|d| d.fields.contains_key("set")), "while recording it goes into the recording"); + if let Some(d) = app.ui.dialog.as_mut() { + d.fields.insert("note".into(), json!("While recording")); + } + crate::dialogs::confirm(&mut app).unwrap(); + app.run(STOP_RECORDING, json!({})).unwrap(); + let recorded = app.ui.action_sets[0].actions.last().unwrap(); + assert_eq!( + recorded.steps, + [(SELECT_OBJECT_STEP.to_string(), json!({"note": "While recording", "caseSensitive": false, "wholeWord": false}))] + ); + } } diff --git a/crates/ui-egui/src/panels/actions/files.rs b/crates/ui-egui/src/panels/actions/files.rs index fd8b40fbd7..ee6304690b 100644 --- a/crates/ui-egui/src/panels/actions/files.rs +++ b/crates/ui-egui/src/panels/actions/files.rs @@ -1,14 +1,16 @@ //! Action set files: the Actions panel menu's Save Actions… and Load Actions…. //! -//! An action set file (`.vcactions`) is JSON: `{"format": "vcactions", "version": 1, 2 or 3, "name", +//! An action set file (`.vcactions`) is JSON: `{"format": "vcactions", "version": 1 to 4, "name", //! "actions": [{"name", "steps": [{"command", "params", "excluded"?}], "key"?, "color"?}]}`. Each step is a //! command id and its params, as `command.batch` takes them, or a stop: the command `action.stop` -//! with `{"message", "allowContinue"}`. `key` is the action's function key +//! with `{"message", "allowContinue"}`. A Select Object step is the command `select.byNote` with +//! `{"note", "caseSensitive", "wholeWord"}`. `key` is the action's function key //! (`{"key": 1 to 15, "cmd", "shift"}`) and `color` its Button Mode color; an action without them, //! or with a value that is not one, has none. `excluded: true` keeps a step but skips it during //! playback. Sets with exclusions use version 2 so older builds refuse them rather than replay -//! excluded steps, and sets with stops use version 3 so older builds refuse them rather than fail -//! at a stop. Other sets use version 1, and earlier version 1 and 2 files still work. +//! excluded steps, sets with stops use version 3 so older builds refuse them rather than fail at a +//! stop, and sets with Select Object steps use version 4 so older builds refuse them rather than +//! fail at that step. Other sets use version 1, and earlier version 1 to 3 files still work. //! Load Actions… also reads the action sets another app saves as text (`.aia`, through //! `actionSet.read`). @@ -122,14 +124,25 @@ pub(super) fn step_bytes(command: &str, params: &Value) -> usize { /// The params of the stops in a step: a stop's own, or those of the stops a `command.batch` step /// holds, which playback runs as its own steps. fn stops_in<'a>(command: &str, params: &'a Value) -> Vec<&'a Value> { - if command == super::STOP_STEP { + steps_in(super::STOP_STEP, command, params) +} + +/// The params of the Select Object steps in a step, as [`stops_in`] finds stops. +fn select_objects_in<'a>(command: &str, params: &'a Value) -> Vec<&'a Value> { + steps_in(super::SELECT_OBJECT_STEP, command, params) +} + +/// The params of the steps that run command `kind` in a step: the step's own, or those of the +/// steps a `command.batch` step holds, which playback runs as its own steps. +fn steps_in<'a>(kind: &str, command: &str, params: &'a Value) -> Vec<&'a Value> { + if command == kind { return vec![params]; } if command != "command.batch" { return vec![]; } let steps = params.get("commands").and_then(Value::as_array).into_iter().flatten(); - steps.filter(|step| super::is_stop(step)).map(|step| step.get("params").unwrap_or(&Value::Null)).collect() + steps.filter(|step| step.get("command").and_then(Value::as_str) == Some(kind)).map(|step| step.get("params").unwrap_or(&Value::Null)).collect() } /// `set` as the text of an action set file. @@ -149,11 +162,14 @@ pub fn write_set(set: &ActionSet) -> String { color: a.color.clone(), }) .collect(); - let has_stop = set.actions.iter().flat_map(|a| &a.steps).any(|(command, params)| !stops_in(command, params).is_empty()); - let version = match (has_stop, set.actions.iter().any(|a| !a.excluded_steps.is_empty())) { - (true, _) => 3, - (false, true) => 2, - (false, false) => 1, + let steps = || set.actions.iter().flat_map(|a| &a.steps); + let has_select_object = steps().any(|(command, params)| !select_objects_in(command, params).is_empty()); + let has_stop = steps().any(|(command, params)| !stops_in(command, params).is_empty()); + let version = match (has_select_object, has_stop, set.actions.iter().any(|a| !a.excluded_steps.is_empty())) { + (true, ..) => 4, + (false, true, _) => 3, + (false, false, true) => 2, + (false, false, false) => 1, }; let file = SetFile { format: ACTIONS_EXT.into(), version, name: set.name.clone(), actions }; serde_json::to_string_pretty(&file).unwrap_or_default() @@ -168,7 +184,7 @@ pub fn read_set(text: &str, name: &str) -> Result { if f.format != ACTIONS_EXT { return Err("not an action set file".into()); } - if !matches!(f.version, 1..=3) { + if !matches!(f.version, 1..=4) { return Err("the action set file needs a later version of VectorCraft".into()); } if f.actions.len() > MAX_SET_ACTIONS { @@ -181,6 +197,10 @@ pub fn read_set(text: &str, name: &str) -> Result { if stops.any(|p| super::stop_params(p).is_err()) { return Err("not an action set file".into()); } + let mut select_objects = f.actions.iter().flat_map(|a| &a.steps).flat_map(|s| select_objects_in(&s.command, &s.params)); + if select_objects.any(|p| super::select_object_params(p).is_err()) { + return Err("not an action set file".into()); + } let actions = f .actions .into_iter() diff --git a/docs/mcp.md b/docs/mcp.md index 62a4212472..61ff6686b5 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -2864,13 +2864,14 @@ already walks Image Trace. Short agent entry points: the file at `path`, else a picked one, or the file's text `data`, adds its set after the other sets and returns `{set, name, actions}`. A set named like one already in the panel gets the next free number (`Print 2`), and the other sets and the current set stay as they are. An action set file (`.vcactions`) is JSON: - `{"format": "vcactions", "version": 1, 2 or 3, "name", "actions": [{"name", "steps": [{"command", "params", "excluded"?}]}]}`, each step + `{"format": "vcactions", "version": 1 to 4, "name", "actions": [{"name", "steps": [{"command", "params", "excluded"?}]}]}`, each step a command id and its params as `command.batch` takes them, or a stop: the command `action.stop` with - `{message, allowContinue}`. `excluded: true` preserves a step but omits it from playback; - an absent or false flag includes the step. Files with stops use version 3, and files with excluded steps and no stops - use version 2, so older builds refuse them - instead of playing the excluded steps or failing at a stop; other files use version 1. A file with another `format` or a `version` other than - 1, 2 or 3, a stop whose message is not text or whose `allowContinue` is not true or false, a file larger than 8 MiB, one with more than 1,000 actions and one with an action of more than 10,000 steps are + `{message, allowContinue}`. A Select Object step is the command `select.byNote` with `{note, caseSensitive, + wholeWord}`. `excluded: true` preserves a step but omits it from playback; + an absent or false flag includes the step. Files with Select Object steps use version 4, files with stops and no + Select Object step use version 3, and files with excluded steps and neither use version 2, so older builds refuse them + instead of playing the excluded steps or failing at a stop or a Select Object step; other files use version 1. A file with another `format` or a `version` other than + 1 to 4, a stop whose message is not text or whose `allowContinue` is not true or false, a Select Object step whose params `ui.action.insertSelectObject` would refuse, a file larger than 8 MiB, one with more than 1,000 actions and one with an action of more than 10,000 steps are refused, and no set is added. So is a file whose set `ui.actionSet.save` would write larger than 8 MiB (a file with less white space, a set name that gets a number, or an `.aia` set), with the error `the action set would be larger than 8 MiB`. The Actions panel menu's Save Actions… and Load Actions… run these; Load Actions… is @@ -2963,6 +2964,28 @@ already walks Image Trace. Short agent entry points: `actionInsertMenuItem` dialog (`find`, `command`, `params`, `set`, `action`, `step`): typing in Find and pressing Find picks the one item whose menu path, shown name or command id holds every word typed, or lists the matches, and OK runs this. +- Actions: `ui.action.insertSelectObject {set?, action?, step?, note, caseSensitive?, wholeWord?}` inserts a Select + Object step without running it. The step is `select.byNote {note, caseSensitive, wholeWord}`: during playback it + selects the objects whose Attributes note matches `note`, and the steps after it act on them. `note` loses its + leading and trailing spaces and must then be 1 to 1,024 bytes; `caseSensitive` and `wholeWord` default to false. The + place, the selected step and the result are as `ui.action.insertStop` has them. A missing or empty note, a param of + another type, params that are not an object, an index past the last set, action or step, no action and no + recording, or a step that would give the action more than 10,000 steps or its set's file more than 8 MiB is an + error, and then nothing changes. When the step finds no object, the playback stops there with that error: at + Accelerated the whole batch is undone as for any failing step, and at Step by Step and Pause For the steps already + played stay. The panel's row for the step reads `Select Object: `. The panel menu's Select Object… opens the + `actionSelectObject` dialog, Set Selection (`note`, `caseSensitive`, `wholeWord`, `set`, `action`, `step`), whose OK + runs this; OK is off while the note is empty. The two options, the 1,024-byte limit and stopping when nothing is + found are VectorCraft's own rules. +- Select: `select.byNote {note, caseSensitive?, wholeWord?}` selects the objects whose Attributes note + (`attributes.set {note}`) holds `note` without its leading and trailing spaces, and returns `{count, ids}`. By default + the text may be anywhere in the note and in any letter case. `caseSensitive: true` asks for the same letter case, and + `wholeWord: true` for no letter, digit or `_` right before or after the text. It looks at the objects Select All + takes and the objects inside them, and leaves out hidden and locked objects and art on a locked artboard; in + isolation mode it looks inside the isolated group only. An object whose note matches is selected without the objects + inside it, and a member whose note matches is selected on its own. The objects found replace the selection, in paint + order. The command makes no undo step, and Select › Reselect repeats it. An empty note, a note longer than 1,024 + bytes, a param of another type, or no object found is an error, and then the selection stays as it was. - Actions: `ui.action.continue {}` plays on from the stop the playing action waits at, on the document active now, and closes the stop's message box (`actionStopMessage`). The next step runs at once, or at Accelerated the steps up to the next stop as one undo step. It returns `{results, pending, atStop?}`. With no action waiting at a stop it is an diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 5ec97e34c1..05ecdd216f 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -731,6 +731,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Actions panel: the panel menu's Playback Options… sets how actions play: Accelerated (the default) plays every step at once, Step by Step plays one step at a time with the canvas drawn after each, and Pause For waits the given number of seconds between steps ([Specify playback speed](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/specify-playback-speed.html); `ui.action.playbackOptions`). VectorCraft's own rules, which the page does not cover: the pause is a whole number of seconds from 1 to 60, the setting is kept with the UI preferences, a paced playback ends at Stop Playing/Recording or when a step comes due while another document is active, each step of a paced playback is its own undo step, and Batch… plays at full speed. - Actions panel: the panel menu's Insert Stop… opens Record Stop, with a Message box and Allow Continue, and adds a stop to the recording in progress, after the selected command, or at the end of the selected action ([Insert stops in actions](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/insert-stops-in-actions.html); `ui.action.insertStop`). A playing action waits at the stop and shows the message so the user can do a manual task, and Play Current Selection plays the rest ([Illustrator CS5 help](https://help.adobe.com/archive/en_US/illustrator/cs5/illustrator_cs5_help.pdf#page=459); `ui.action.continue`). With Allow Continue, the message box also has Continue. VectorCraft's own rules, which the pages do not cover: an empty message still waits, Stop Playing/Recording ends the playback and keeps the steps already played, the rest plays on the document active then, a stop waits at every playback speed, at Accelerated the steps between two stops are one undo step, Batch… refuses an action with an included stop, and Delete Selection on a selected step deletes that step, a stop or a command, without asking (`ui.action.deleteStep`). - Actions panel: the panel menu's Insert Menu Item… adds a menu command to the recording in progress, after the selected step, or at the end of the selected action, without running it, and playback runs it as the menu does ([Insert non-recordable tasks into actions](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/insert-non-recordable-tasks-into-actions.html); `ui.action.insertMenuItem`). The dialog's Find box picks the one item that matches the words typed, and a click on a listed item picks it. VectorCraft's own rules, which the page does not cover: items that open a dialog, the Object › Align items, Image Trace's Make and Make and Expand, Pattern's Make and Edit Pattern, and the File, Window and Help menu items are not listed, and an action holds at most 10,000 steps and its set file at most 8 MiB, for stops too. + - Actions panel: the panel menu's Select Object… opens Set Selection, where the note typed names the objects to select, and adds a step to the recording in progress, after the selected step, or at the end of the selected action; playback selects the objects whose Attributes note matches it, and the steps after it act on them ([Insert non-recordable tasks into actions](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/insert-non-recordable-tasks-into-actions.html); `ui.action.insertSelectObject`, `select.byNote`). VectorCraft's own rules, which the page does not cover: the note matches anywhere and in any letter case unless Case Sensitive or Whole Word is checked, hidden and locked objects are left out, the objects found replace the selection, a step that finds none stops the playback with an error, and the note is at most 1,024 bytes. Action sets with the step save as version 4. - Actions panel: Delete Selection deletes the selected action set with its actions and keeps the other sets ([Actions panel overview](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/actions-panel-overview.html); `ui.actionSet.delete`). VectorCraft's own rules, which the overview does not cover: deleting a set asks first, Shift-click on a set's name selects a range of sets to delete together, the panel menu's Delete does the same, and Default Actions and the last set can be deleted. Undo does not bring a deleted set back. - Actions panel: the panel menu's Duplicate copies the selected action, else the current action set with all its actions ([Actions panel overview](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/actions-panel-overview.html); `ui.actionSet.duplicate`). VectorCraft's own rules, which the overview does not cover: the copy goes right after the original, is named after it with " copy" added and is selected. - Batch… over data sets names each file after the document and the data set (`action.batch`). It writes `/`, `\`, `:`, `<`, `>`, `"`, `|`, `?`, `*` and control characters in the data set's name as `-`, and adds `-2`, `-3`… to a name an earlier data set used. From 499a74cedb52b476ef80499abd2b79b614b12398 Mon Sep 17 00:00:00 2001 From: Sven Lafebre Date: Thu, 12 Nov 2026 23:04:36 +0100 Subject: [PATCH 02/11] Fix #6969: Rasterize to Grayscale or Bitmap makes a grayscale image --- crates/engine/src/cmd/menucmds.rs | 24 ++++++++--- crates/engine/src/tests/rastersettings.rs | 47 ++++++++++++++++++++ crates/render/src/encode/png.rs | 15 +++++++ crates/render/src/encode/tests.rs | 35 +++++++++++++++ crates/render/src/options.rs | 52 +++++++++++++++++++++++ docs/target-app-parity.md | 3 +- 6 files changed, 168 insertions(+), 8 deletions(-) diff --git a/crates/engine/src/cmd/menucmds.rs b/crates/engine/src/cmd/menucmds.rs index 1dd1548363..ef6b5654f3 100644 --- a/crates/engine/src/cmd/menucmds.rs +++ b/crates/engine/src/cmd/menucmds.rs @@ -115,7 +115,7 @@ pub fn specs() -> Vec { "Rasterize…", ["Object"], None, - "{ppi?, background?: transparent|white|black|\"#rrggbb\" (#rrggbbaa and #rgba are refused), antiAlias?: none|art|type (text snapped to pixels)|bool, padding?|addAround?: pt (0–1000), colorModel?: \"rgb\"|\"cmyk\"|\"grayscale\"|\"bitmap\", clippingMask?: bool (a clip group with the art's outline)} replace the selection with an embedded image (PNG; in a CMYK document with its own colour model, a CMYK image of the art's inks, its transparency as its opacity mask); each defaults to document.rasterEffectsSettings; an image larger than 64 Mpx or 32,768 px a side is refused; a frame of threaded text that is rasterized leaves its thread with the text it shows → {id (the image, or its clip group), width, height, warning?: \"an image couldn't be decoded and is missing from the picture\" (as raster exports say)}", + "{ppi?, background?: transparent|white|black|\"#rrggbb\" (#rrggbbaa and #rgba are refused), antiAlias?: none|art|type (text snapped to pixels)|bool, padding?|addAround?: pt (0–1000), colorModel?: \"rgb\"|\"cmyk\"|\"grayscale\"|\"bitmap\", clippingMask?: bool (a clip group with the art's outline)} replace the selection with an embedded image (PNG; grayscale makes a gray PNG, and bitmap a 1-bit TIFF or, where it has transparency, a black-and-white gray PNG with alpha; in a CMYK document with its own colour model, a CMYK image of the art's inks, its transparency as its opacity mask); each defaults to document.rasterEffectsSettings; an image larger than 64 Mpx or 32,768 px a side is refused; a frame of threaded text that is rasterized leaves its thread with the text it shows → {id (the image, or its clip group), width, height, warning?: \"an image couldn't be decoded and is missing from the picture\" (as raster exports say)}", has_selection, rasterize ), @@ -729,7 +729,9 @@ fn cmyk_raster( /// The image Object › Rasterize makes of some art, before it goes in a document /// ([`rasterize_art`]). pub(crate) struct Rasterized { - /// A PNG, or a CMYK image of the art's inks ([`cmyk_raster`]). + /// A PNG (greyscale for the Grayscale colour model), a 1-bit TIFF or greyscale PNG for Bitmap + /// ([`vectorcraft_render::Rendered::to_bilevel`]), or a CMYK image of the art's inks + /// ([`cmyk_raster`]). blob: vectorcraft_doc::ImageBlob, /// A CMYK image's transparency, a greyscale PNG: it becomes the image's opacity mask. alpha: Option>, @@ -769,7 +771,8 @@ impl Rasterized { /// Object › Rasterize's image of `nodes` (drawn also where hidden) with the params `p` of command /// `cmd`, each defaulting to `doc`'s raster effects settings: over the bounds of what the art draws /// ([`vectorcraft_render::drawn_bounds`]) and the room around them, in whole pixels. With `inks`, -/// a CMYK document's colour model makes a CMYK image ([`cmyk_raster`]); otherwise images are PNGs. +/// a CMYK document's colour model makes a CMYK image ([`cmyk_raster`]); otherwise images are PNGs, +/// greyscale for the Grayscale and Bitmap colour models (an opaque Bitmap image is a 1-bit TIFF). pub(crate) fn rasterize_art(doc: &vectorcraft_doc::Document, nodes: Vec, p: &Value, cmd: &str, inks: bool) -> Result { check_numbers(p, &["ppi"], cmd)?; let ppi = f64_or(p, "ppi", doc.raster_effects_ppi); @@ -836,10 +839,17 @@ pub(crate) fn rasterize_art(doc: &vectorcraft_doc::Document, nodes: Vec, p let tmp = isolated_doc(doc, nodes); let opts = raster.render_options(RasterFormat::Png); let (img, warning) = render_pixels(&tmp, region, scale, &opts, look.color_model)?; - let (blob, alpha) = if inks && doc.color_mode == vectorcraft_doc::ColorMode::Cmyk && look.color_model == RasterColorModel::Document { - cmyk_raster(&tmp, region, scale, &opts, &img)? - } else { - (vectorcraft_doc::ImageBlob::new("image/png", img.to_png().map_err(EngineError::Other)?), None) + let png = + |bytes: std::result::Result, String>| bytes.map(|b| vectorcraft_doc::ImageBlob::new("image/png", b)).map_err(EngineError::Other); + let (blob, alpha) = match look.color_model { + RasterColorModel::Document if inks && doc.color_mode == vectorcraft_doc::ColorMode::Cmyk => cmyk_raster(&tmp, region, scale, &opts, &img)?, + RasterColorModel::Document => (png(img.to_png())?, None), + // Grayscale images, as the colour models say (#6969). + RasterColorModel::Grayscale => (png(img.to_gray_png())?, None), + RasterColorModel::Bitmap => { + let (mime, bytes) = img.to_bilevel(ppi).map_err(EngineError::Other)?; + (vectorcraft_doc::ImageBlob::new(mime, bytes), None) + } }; let xf = Affine::translate(region.origin().to_vec2()) * Affine::scale(1.0 / scale); Ok(Rasterized { blob, alpha, width: img.width, height: img.height, xf, clip, warning }) diff --git a/crates/engine/src/tests/rastersettings.rs b/crates/engine/src/tests/rastersettings.rs index 2110e611ea..75659faf32 100644 --- a/crates/engine/src/tests/rastersettings.rs +++ b/crates/engine/src/tests/rastersettings.rs @@ -455,3 +455,50 @@ fn rasterize_to_cmyk_makes_a_cmyk_image() { assert_eq!((alpha.get_pixel(1, 1).0, alpha.get_pixel(25, 25).0), ([0], [255])); assert!(mask.clip); } + +/// Rasterize with the Grayscale or Bitmap colour model makes a grayscale image (#6969): the +/// image says Grayscale, its file holds one gray channel (one bit a pixel for an opaque Bitmap, +/// gray and alpha over a transparent background), in a CMYK document too, and a fill color +/// colorizes it. +#[test] +fn rasterize_to_grayscale_or_bitmap_makes_a_grayscale_image() { + for mode in ["rgb", "cmyk"] { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 100, "height": 100, "colorMode": mode})).unwrap(); + let r = id(&s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 40, "height": 40})).unwrap()); + s.execute("paint.setFill", &json!({"color": "#ff0000", "ids": [r.0]})).unwrap(); + s.execute("paint.setStroke", &json!({"none": true, "ids": [r.0]})).unwrap(); + let mut rasterize = |model: &str, background: &str| { + s.execute("select.set", &json!({"ids": [r.0]})).unwrap(); + let p = json!({"ppi": 72, "colorModel": model, "background": background, "addAround": 5}); + let img = id(&s.execute("object.rasterize", &p).unwrap()); + let info = s.execute("image.info", &json!({"id": img.0})).unwrap(); + let d = &s.doc().unwrap().doc; + let n = d.node(img).unwrap(); + assert!(n.extras().mask.is_none(), "{mode} {model} {background}: no opacity mask"); + let NodeKind::Image(im) = &n.kind else { panic!("an image") }; + let blob = d.images[&im.key].clone(); + let pixels = image::load_from_memory(&blob.bytes).unwrap().to_luma_alpha8(); + assert_eq!(pixels.dimensions(), (50, 50)); + let at = |x, y| pixels.get_pixel(x, y).0; + s.execute("edit.undo", &json!({})).unwrap(); + (info["colorMode"].clone(), blob.gray_source(), at(25, 25), at(1, 1)) + }; + let gray = |one_bit, alpha| Some(vectorcraft_doc::GraySource { one_bit, alpha }); + let (info, source, [level, alpha], corner) = rasterize("grayscale", "white"); + assert_eq!((info, source, alpha, corner), (json!("Grayscale"), gray(false, false), 255, [255, 255]), "{mode}"); + if mode == "rgb" { + assert_eq!(level, 54, "red's Rec. 709 luma"); + } else { + assert!((1..=254).contains(&level), "the CMYK red's screen color as a gray: {level}"); + } + assert_eq!(rasterize("grayscale", "transparent"), (json!("Grayscale"), gray(false, true), [level, 255], [255, 0]), "{mode}"); + assert_eq!(rasterize("bitmap", "white"), (json!("Grayscale"), gray(true, false), [0, 255], [255, 255]), "{mode}"); + assert_eq!(rasterize("bitmap", "transparent"), (json!("Grayscale"), gray(false, true), [0, 255], [255, 0]), "{mode}"); + // A fill color colorizes the image, as it does any grayscale image. + s.execute("select.set", &json!({"ids": [r.0]})).unwrap(); + let img = id(&s.execute("object.rasterize", &json!({"ppi": 72, "colorModel": "grayscale"})).unwrap()); + s.execute("paint.setFill", &json!({"color": "#0000ff", "ids": [img.0]})).unwrap(); + assert!(s.doc().unwrap().doc.node(img).unwrap().extras().colorize.is_some(), "{mode}: colorized"); + } +} diff --git a/crates/render/src/encode/png.rs b/crates/render/src/encode/png.rs index f55fd316db..6a62034e10 100644 --- a/crates/render/src/encode/png.rs +++ b/crates/render/src/encode/png.rs @@ -53,6 +53,21 @@ pub fn encode(rgba: &[u8], width: u32, height: u32, o: &PngOptions) -> Result Result, String> { + check_size(width, height)?; + if gray_alpha.len() as u64 != u64::from(width) * u64::from(height) * 2 { + return Err("PNG encoding failed: pixel buffer doesn't match the image size".into()); + } + let px = gray_alpha.as_chunks::<2>().0; + if px.iter().all(|p| p[1] == 255) { + let levels: Vec = px.iter().map(|p| p[0]).collect(); + return write(&levels, width, height, Layout { channels: 1, bits: 8, color_type: 0 }, &[], o); + } + write(gray_alpha, width, height, Layout { channels: 2, bits: 8, color_type: 4 }, &[], o) +} + /// Encode a palette image as an indexed PNG (colour type 3: `PLTE`, and `tRNS` for its /// transparent entry) at 1, 2, 4 or 8 bits per pixel, as few as the palette needs. pub fn encode_indexed(img: &Indexed, o: &PngOptions) -> Result, String> { diff --git a/crates/render/src/encode/tests.rs b/crates/render/src/encode/tests.rs index 0935ba3567..a910480641 100644 --- a/crates/render/src/encode/tests.rs +++ b/crates/render/src/encode/tests.rs @@ -67,6 +67,41 @@ fn png_refuses_a_buffer_of_the_wrong_size() { assert!(encode(&[], 0, 0, &PngOptions::default()).is_err()); } +/// Object › Rasterize's Grayscale and Bitmap images (#6969): a grey PNG of one channel when +/// opaque and grey and alpha otherwise; Bitmap's black and white as a 1-bit TIFF when opaque, +/// else as a grey PNG with alpha. Odd widths keep each row on a byte. +#[test] +fn gray_and_bilevel_images_store_gray() { + let header = |file: &[u8]| (file[24], file[25]); + let gray = |file: &[u8]| image::load_from_memory(file).unwrap().to_luma_alpha8().into_raw(); + // 3 × 2, premultiplied: black, red, light grey; white, mid grey, almost black. + let mut img = Rendered { + width: 3, + height: 2, + pixels: [[0, 0, 0], [255, 0, 0], [200; 3], [255; 3], [128; 3], [127; 3]].iter().flat_map(|&[r, g, b]| [r, g, b, 255]).collect(), + }; + let levels: Vec = [0u8, 54, 200, 255, 128, 127].iter().flat_map(|&g| [g, 255]).collect(); + let file = img.to_gray_png().unwrap(); + assert_eq!(header(&file), (8, 0), "8-bit grey"); + assert_eq!(gray(&file), levels); + let (mime, file) = img.to_bilevel(150.0).unwrap(); + assert_eq!(mime, "image/tiff"); + let mut d = ::tiff::decoder::Decoder::new(std::io::Cursor::new(&file)).unwrap(); + assert_eq!(d.colortype().unwrap(), ::tiff::ColorType::Gray(1)); + let bw: Vec = [0u8, 0, 255, 255, 255, 0].iter().flat_map(|&g| [g, 255]).collect(); + assert_eq!(gray(&file), bw, "from 128 up is white"); + // Half-covered white over the light grey; a clear pixel is white. + img.pixels[8..12].copy_from_slice(&[100, 100, 100, 100]); + img.pixels[20..24].copy_from_slice(&[0; 4]); + let file = img.to_gray_png().unwrap(); + assert_eq!(header(&file), (8, 4), "grey and alpha"); + assert_eq!(gray(&file), [0, 255, 54, 255, 255, 100, 255, 255, 128, 255, 255, 0]); + let (mime, file) = img.to_bilevel(150.0).unwrap(); + assert_eq!((mime, header(&file)), ("image/png", (8, 4)), "no 1-bit grey with alpha"); + assert_eq!(gray(&file), [0, 255, 0, 255, 255, 100, 255, 255, 255, 255, 255, 0], "still black and white"); + assert!(super::png::encode_gray(&levels[..10], 3, 2, &PngOptions::default()).is_err()); +} + /// A 40×30 pt document with a black circle off the pixel grid. fn circle_doc() -> Document { let mut d = Document::new(40.0, 30.0); diff --git a/crates/render/src/options.rs b/crates/render/src/options.rs index 8ca670eb3f..7a9a9b81f2 100644 --- a/crates/render/src/options.rs +++ b/crates/render/src/options.rs @@ -356,6 +356,58 @@ impl Rendered { img.write_to(&mut std::io::Cursor::new(&mut buf), image::ImageFormat::Png).map_err(|e| format!("PNG encoding failed: {e}"))?; Ok(buf) } + /// Each pixel's grey level and alpha: the Rec. 709 luma of its straight colour, with `bilevel` + /// black below 128 and white from there up. A clear pixel is white: no ink where a fill + /// colour colorizes the image. + fn gray_alpha(&self, bilevel: bool) -> Vec { + let straight = self.to_straight(); + let px = straight.as_chunks::<4>().0; + px.iter() + .flat_map(|&[r, g, b, a]| { + let y = (0.2126 * f32::from(r) + 0.7152 * f32::from(g) + 0.0722 * f32::from(b)).round().clamp(0.0, 255.0) as u8; + let y = if a == 0 || (bilevel && y >= 128) { + 255 + } else if bilevel { + 0 + } else { + y + }; + [y, a] + }) + .collect() + } + /// Encode as a greyscale PNG without metadata (Object › Rasterize's Grayscale colour model): + /// grey levels ([`Self::gray_alpha`]), with alpha only when some pixel isn't opaque. + pub fn to_gray_png(&self) -> Result, String> { + encode::png::encode_gray(&self.gray_alpha(false), self.width, self.height, &encode::png::PngOptions::default()) + } + /// Encode in black and white (Object › Rasterize's Bitmap colour model): a 1-bit TIFF at `ppi` + /// when every pixel is opaque, else a grey PNG with alpha of only black and white (one-bit grey + /// with alpha is no format readers take). Returns the media type and the file. + pub fn to_bilevel(&self, ppi: f64) -> Result<(&'static str, Vec), String> { + let gray_alpha = self.gray_alpha(true); + let px = gray_alpha.as_chunks::<2>().0; + if !px.iter().all(|p| p[1] == 255) { + return Ok(("image/png", encode::png::encode_gray(&gray_alpha, self.width, self.height, &encode::png::PngOptions::default())?)); + } + // WhiteIsZero: a set bit is black. Each row starts on a byte. + let mut bits = Vec::with_capacity(px.len() / 8 + self.height as usize); + for row in px.chunks(self.width.max(1) as usize) { + bits.extend(row.chunks(8).map(|byte| byte.iter().enumerate().fold(0u8, |b, (i, p)| b | u8::from(p[0] == 0) << (7 - i)))); + } + let image = encode::tiff::Image { + width: self.width, + height: self.height, + data: &bits, + photometric: encode::tiff::Photometric::WhiteIsZero, + samples: 1, + bits: 1, + alpha: false, + }; + let layout = + encode::tiff::Layout { byte_order: encode::tiff::ByteOrder::Little, compression: encode::tiff::Compression::PackBits, ppi, icc: None }; + Ok(("image/tiff", encode::tiff::write(&image, &layout)?)) + } /// Encode as JPEG (flattened on white) at `quality` 1..=100. pub fn to_jpeg(&self, quality: u8) -> Result, String> { encode::jpeg(self, quality, None) diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 8e501889db..e0147bfc66 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # Parity with Adobe Illustrator -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-12 · **Change:** minor (#6865: explicitly requested oversized editing artboard; macOS: the canvas cursor stays after the pointer moves, #6362; scores and estimates unchanged; previously minor: Type tool: kerning at a caret at the start of a line moves the character after it, #5111; scores and estimates unchanged; previously minor: Rulers: the zero point dragged out of the rulers' corner snaps as a drawn point does; previously minor: Live Paint Bucket fills faces crossed in one drag (#6373); previously: #6404: transform-aware Eyedropper character copying; scores and estimates unchanged; previously: Envelopes: with Distort Appearance, type's strokes paint along the bent glyph outlines at their own weight, as users' `.ai` files draw them, which changes how VectorCraft's own envelopes of stroked type draw (#6644; scores and estimates unchanged); previously minor: G5: Energy Beams brush library; bundled content ~44% → ~46%; previously minor: G5: Skill Icons symbol library; bundled content ~42% → ~44%; previously minor: `.ai` and EPS import: an envelope saved in Edit Contents whose mesh lies away from its drawn art by a move opens live on that art (#6570; scores and estimates unchanged); previously minor: ruler double-click adds a guide at the pointer, #5239; G5: Status Effects graphic style library; bundled content ~40% → ~42%; MCP symbol libraries list UI Frames and Map Markers; previously minor: G5: Inventory Icons built-in symbol library; bundled content ~38% → ~40%; previously minor: `.ai` and EPS import: stroked type in an envelope maps from its glyphs grown by half its stroke (#3179; scores and estimates unchanged); previously minor: `.ai` and EPS import: warps of type open as mesh envelopes on their saved grid where VectorCraft's warp doesn't draw as their drawn art (#3179; scores and estimates unchanged); G5: Game Trails brush library; bundled content ~36% → ~38%; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: `.ai` and EPS import: envelopes of type open live where they draw as their drawn art and the layers with them draw as the page (#3179; scores and estimates unchanged); G5: Game HUD graphic style library; bundled content ~34% → ~36%; previously minor: Line Arrow arrowhead (#5421; scores and estimates unchanged); G5: Map Markers built-in symbol library; bundled content ~32% → ~34%; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: Canvas: a 3D effect changed on type or a group redraws where its solid was; Fit to Artwork Bounds and per-artboard SVG export build each solid once for all artboards; previously minor: Effect Gallery: a thumbnail for each filter and a preview area inside the dialog, on an original drawing; Raster effects 52% → 55%, hours 1–2 → none; headline numbers unchanged; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; G5: UI Frames built-in symbol library with default 9-slice; bundled content ~32%; previously minor: G5: dynamic symbol part on-canvas opacity/blend/gradient/pattern; strokes remaining no longer lists Overlap/auto corners; symbols ~88%; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; previously minor: harakat paint fallback clarified; previously minor: current Guides feature-area rows consolidated using existing ruler evidence; scores and estimates unchanged; previously minor: Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; previously minor: G5 docs: Art brush Overlap and Pattern brush auto corners removed from strokes remaining; symbols remaining lists dynamic on-canvas beyond solid and guides outside the art; previously minor: custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; `.ai`/EPS type keeps its tab stops, and right-aligned lines with tabs lay out as the file's page, #6147; `.ai`/EPS type from the text document lays its lines out closer to the file's page, within limits, #6137; `.ai` and EPS import: older Art brush objects read; numeric fields step on wheel hover without taking keyboard focus, and a panel wheel turn cannot seed a canvas zoom glide, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Tools stays beside the canvas with left-side panels; new gradients follow document color mode; Selection QoL: marquee modes and visible feedback, #5313; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; the canvas keeps selection outlines and anchors between frames; Indian-language hyphenation review and license allowlist, #4947; Stroke gradients: an arrowhead aligned to the tip is painted to the corners of its base, #5964; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Hindi catalog and contributor loanword preferences; full wording review in progress; Properties in Edit Artboards: a Transform section with the reference point above the Artboards section, #5219; the Control bar's Stroke: and Opacity: links scrub their fields, #4088; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window, #4155; Control bar Paragraph section with type selected, #5372; translating unchanged type reuses its glyph and character-stroke outlines; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026; Units › East Asian Type acts, with Q and Ha units; scores unchanged) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-12 · **Change:** minor (Object › Rasterize with the Grayscale or Bitmap colour model makes a grayscale image, #6969; scores and estimates unchanged; previously minor: #6865: explicitly requested oversized editing artboard; macOS: the canvas cursor stays after the pointer moves, #6362; scores and estimates unchanged; previously minor: Type tool: kerning at a caret at the start of a line moves the character after it, #5111; scores and estimates unchanged; previously minor: Rulers: the zero point dragged out of the rulers' corner snaps as a drawn point does; previously minor: Live Paint Bucket fills faces crossed in one drag (#6373); previously: #6404: transform-aware Eyedropper character copying; scores and estimates unchanged; previously: Envelopes: with Distort Appearance, type's strokes paint along the bent glyph outlines at their own weight, as users' `.ai` files draw them, which changes how VectorCraft's own envelopes of stroked type draw (#6644; scores and estimates unchanged); previously minor: G5: Energy Beams brush library; bundled content ~44% → ~46%; previously minor: G5: Skill Icons symbol library; bundled content ~42% → ~44%; previously minor: `.ai` and EPS import: an envelope saved in Edit Contents whose mesh lies away from its drawn art by a move opens live on that art (#6570; scores and estimates unchanged); previously minor: ruler double-click adds a guide at the pointer, #5239; G5: Status Effects graphic style library; bundled content ~40% → ~42%; MCP symbol libraries list UI Frames and Map Markers; previously minor: G5: Inventory Icons built-in symbol library; bundled content ~38% → ~40%; previously minor: `.ai` and EPS import: stroked type in an envelope maps from its glyphs grown by half its stroke (#3179; scores and estimates unchanged); previously minor: `.ai` and EPS import: warps of type open as mesh envelopes on their saved grid where VectorCraft's warp doesn't draw as their drawn art (#3179; scores and estimates unchanged); G5: Game Trails brush library; bundled content ~36% → ~38%; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: `.ai` and EPS import: envelopes of type open live where they draw as their drawn art and the layers with them draw as the page (#3179; scores and estimates unchanged); G5: Game HUD graphic style library; bundled content ~34% → ~36%; previously minor: Line Arrow arrowhead (#5421; scores and estimates unchanged); G5: Map Markers built-in symbol library; bundled content ~32% → ~34%; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: Canvas: a 3D effect changed on type or a group redraws where its solid was; Fit to Artwork Bounds and per-artboard SVG export build each solid once for all artboards; previously minor: Effect Gallery: a thumbnail for each filter and a preview area inside the dialog, on an original drawing; Raster effects 52% → 55%, hours 1–2 → none; headline numbers unchanged; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; G5: UI Frames built-in symbol library with default 9-slice; bundled content ~32%; previously minor: G5: dynamic symbol part on-canvas opacity/blend/gradient/pattern; strokes remaining no longer lists Overlap/auto corners; symbols ~88%; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; previously minor: harakat paint fallback clarified; previously minor: current Guides feature-area rows consolidated using existing ruler evidence; scores and estimates unchanged; previously minor: Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; previously minor: G5 docs: Art brush Overlap and Pattern brush auto corners removed from strokes remaining; symbols remaining lists dynamic on-canvas beyond solid and guides outside the art; previously minor: custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; `.ai`/EPS type keeps its tab stops, and right-aligned lines with tabs lay out as the file's page, #6147; `.ai`/EPS type from the text document lays its lines out closer to the file's page, within limits, #6137; `.ai` and EPS import: older Art brush objects read; numeric fields step on wheel hover without taking keyboard focus, and a panel wheel turn cannot seed a canvas zoom glide, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Tools stays beside the canvas with left-side panels; new gradients follow document color mode; Selection QoL: marquee modes and visible feedback, #5313; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; the canvas keeps selection outlines and anchors between frames; Indian-language hyphenation review and license allowlist, #4947; Stroke gradients: an arrowhead aligned to the tip is painted to the corners of its base, #5964; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Hindi catalog and contributor loanword preferences; full wording review in progress; Properties in Edit Artboards: a Transform section with the reference point above the Artboards section, #5219; the Control bar's Stroke: and Opacity: links scrub their fields, #4088; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window, #4155; Control bar Paragraph section with type selected, #5372; translating unchanged type reuses its glyph and character-stroke outlines; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026; Units › East Asian Type acts, with Q and Ha units; scores unchanged) > > Oversized artboard import update (2026-11-12): Explicit editing-data import retains a single artboard beyond a capped 14,400 pt PDF page (#6865). > @@ -2116,6 +2116,7 @@ Edit › Copy and Cut put the selected ruler guides on the clipboard, with the s | Date | Change | Summary | |---|---|---| +| 2026-11-12 | minor | Object › Rasterize with the Grayscale colour model makes a grayscale image (one gray channel, with alpha over a transparent background), so the Properties panel says Grayscale and a fill color colorizes it; Bitmap makes a 1-bit image (a TIFF), or a black-and-white gray image with alpha over a transparent background, since 1-bit gray with alpha is no format readers take. Both were RGB images of gray pixels (#6969). Scores and estimates unchanged. | | 2026-11-12 | minor | Explicit editing-data import keeps a single artboard beyond a capped 14,400 pt PDF page (#6865), with a warning; automatic mode stays unchanged. Scores and estimates unchanged. | | 2026-11-12 | minor | Glyphs review (#6084): Entire Font uses the selection's version and variable axes for previews and alternate applicability. The default cell can reset an unlisted glyph choice when GSUB lists no alternates. Encoded Latin and other ordinary characters insert unchanged even if their glyph also has Arabic positional provenance; Arabic presentation forms retain base-letter insertion. Remove a redundant charmap scan from unlisted-glyph checks. Scores and estimates unchanged. | | 2026-11-09 | minor | Glyphs review (#6084): positional alternates with no supported feature are accepted as glyph choices in both panel views; Entire Font's unlisted-glyph and name-ownership behavior is retained. OpenType style options keep only a valid last on-value when enabling a set. Contradictory old glyph-choice evidence corrected; scores and estimates unchanged. | From c4473a41c9d7eee29dfe72a8cf5d33b22d4e981f Mon Sep 17 00:00:00 2001 From: Sven Lafebre Date: Thu, 12 Nov 2026 23:19:33 +0100 Subject: [PATCH 03/11] Fix #6969: parity evidence for Rasterize's Grayscale and Bitmap images --- docs/target-app-parity.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index e0147bfc66..bd53019ff7 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1681,6 +1681,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Tests and robustness:** ~2,840 automated tests: model-based property tests, a junk-parameter sweep over every command, import fuzzing (SVG, PDF, libraries), golden renders, and MCP end-to-end tests over stdio. Shipped code never panics (workspace lints and a rollback safety net; see `docs/development.md`). Scatter graph data near the largest number is refused with a message instead of hanging the app (#1780). One very long Liquify stroke segment can no longer create an unbounded number of dabs (#1779). A drag whose last preview failed is no longer recorded as a committed command in the journal that actions and scripts read (#1775). Tests: text import fuzzing no longer searches installed fonts (#1785). Tests: render tests that add fonts no longer race the frame cache tests (#1795). - **Raster-effect resolution in PDF:** when the 64 million pixel cap on an effect image renders an object's raster effects below the document's raster effects resolution, PDF export warns once per object with the requested and the lowest achieved ppi (`crates/engine/src/tests/pdfraster.rs`). - **Rasterize effect:** Effect › Rasterize… (`rasterize`) draws an object as an image of square pixels at its own resolution or the document's raster effects resolution, with Object › Rasterize's color model, background, anti-aliasing, clipping mask and room around the object, while its paths stay editable. Expand Appearance and PDF and SVG export of an object whose only raster effect is Rasterize give the image Object › Rasterize gives (`crates/engine/src/tests/rastersettings.rs`, `crates/render/src/tests/rasterize.rs`). Behavior reference: [How to save artwork in Illustrator](https://helpx.adobe.com/illustrator/using/saving-artwork.html), [Work with effects](https://helpx.adobe.com/illustrator/using/effects.html). +- **Rasterize colour models:** Object › Rasterize with the Grayscale colour model makes a grayscale image (a gray PNG, with alpha over a transparent background), and with Bitmap a 1-bit image (a TIFF), or a black-and-white gray PNG with alpha over a transparent background; `image.info` and the Control bar say Grayscale, and a fill color colorizes the image, in RGB and CMYK documents (#6969). Expand Appearance and PDF export of a Rasterize effect make the same images. Regression coverage: `rasterize_to_grayscale_or_bitmap_makes_a_grayscale_image` (`crates/engine/src/tests/rastersettings.rs`) and `gray_and_bilevel_images_store_gray` (`crates/render/src/encode/tests.rs`). Scores and estimates unchanged. - **SVG filters:** Effect › SVG Filters lists the document's own SVG filters below Apply SVG Filter… and Import SVG Filter…, and choosing one applies it to the selection as a live effect in one undo step (`svgFilter.apply`). Apply SVG Filter applies the selected filter, New and Edit open its code, and Delete and Import… remove filters or add those of an SVG file (`svgFilter.define`, `svgFilter.delete`, `svgFilter.import`). VectorCraft ships no default filters. The canvas, raster export and PDF draw the filter at the Document Raster Effects resolution; SVG export writes it as a `` element when it is the object's last effect and otherwise draws the object as an image; SVG import keeps filters the other live effects can't express as this effect instead of leaving them out; the native format keeps the filters (`crates/engine/src/tests/svgfilter.rs`, `crates/effects/src/svg_filter/tests.rs`). Behavior reference: [Apply SVG filter effects](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-filter-effects/apply-svg-filter-effects.html), [CS5 help, Apply SVG effects](https://help.adobe.com/archive/en_US/illustrator/cs5/illustrator_cs5_help.pdf#page=402), [SVG 1.1 Filter Effects](https://www.w3.org/TR/SVG11/filters.html). - **Pattern tiles:** the renderer keeps a tile of each pattern for each scale it rasterizes the pattern at, until the tiles fill the tile cache's budget, and then drops the least recently used tiles first. Objects that show one pattern at different scales, such as a pattern scaled to 25% with Transform Pattern Tiles next to the same pattern at 100%, draw from tiles kept between frames. From 800715b42fcd0de5bdf860f3b1be176b67bb56bf Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 16:03:57 -0800 Subject: [PATCH 04/11] Actions: Select Object matches letter case per character and rejects mistyped dialog fields MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit select.byNote compares letter case one character at a time, so a final sigma matches σ, and Whole Word checks its boundaries against the note's own characters, so İ neither ends a word early nor matches half of itself. The search runs in time proportional to the note's length. Set Selection passes its fields to ui.action.insertSelectObject as they are, so a note or option of another type set through ui.dialog.set is an error and the dialog stays open. --- crates/engine/src/cmd/select.rs | 75 ++++++++++++++++--- crates/engine/src/tests/attributes.rs | 20 +++++ .../src/dialogs/action_select_object.rs | 5 +- crates/ui-egui/src/panels/actions.rs | 13 ++++ 4 files changed, 99 insertions(+), 14 deletions(-) diff --git a/crates/engine/src/cmd/select.rs b/crates/engine/src/cmd/select.rs index 5f4a0b4d50..ea307c7575 100644 --- a/crates/engine/src/cmd/select.rs +++ b/crates/engine/src/cmd/select.rs @@ -784,29 +784,80 @@ fn by_note(s: &mut Session, p: &Value) -> Result { } /// Whether `note` holds `query`: anywhere, and in any letter case unless `case_sensitive`. With -/// `whole_word`, the text right before and right after it is not a letter, a digit or `_`. +/// `whole_word`, the match starts and ends on whole characters of `note`, and the characters of +/// `note` right before and right after it are not a letter, a digit or `_`. +/// +/// Letter case is compared one character at a time ([`fold_case`]), so a final Σ still matches σ, +/// and each folded character keeps the byte offset of the character of `note` it comes from. fn note_matches(note: &str, query: &str, case_sensitive: bool, whole_word: bool) -> bool { if note.is_empty() || query.is_empty() { return false; } - let (note, query) = if case_sensitive { (note.to_string(), query.to_string()) } else { (note.to_lowercase(), query.to_lowercase()) }; - if !whole_word { - return note.contains(&query); - } + let fold = |text: &str| -> Vec<(char, usize)> { + let mut out = Vec::with_capacity(text.len()); + for (at, c) in text.char_indices() { + if case_sensitive { + out.push((c, at)); + } else { + out.extend(fold_case(c).map(|f| (f, at))); + } + } + out + }; + let folded = fold(note); + let query: Vec = fold(query).into_iter().map(|(c, _)| c).collect(); let word = |c: char| c.is_alphanumeric() || c == '_'; - let mut from = 0; - while let Some(at) = note.get(from..).and_then(|rest| rest.find(&query)).map(|i| from + i) { - let before = note[..at].chars().next_back(); - let after = note[at + query.len()..].chars().next(); - if !before.is_some_and(word) && !after.is_some_and(word) { + // Whether the match of `query` that starts at `folded[i]` counts. + let accept = |i: usize| { + if !whole_word { return true; } - // Matches may overlap: look again from the next character. - from = at + note[at..].chars().next().map_or(1, char::len_utf8); + let (start, last) = (folded[i].1, folded[i + query.len() - 1].1); + let starts_whole = i == 0 || folded[i - 1].1 != start; + let ends_whole = folded.get(i + query.len()).is_none_or(|(_, at)| *at != last); + if !starts_whole || !ends_whole { + return false; + } + let end = last + note[last..].chars().next().map_or(0, char::len_utf8); + !note[..start].chars().next_back().is_some_and(word) && !note[end..].chars().next().is_some_and(word) + }; + // Knuth-Morris-Pratt, so a long note takes time in proportion to its length, and every place + // the query occurs is tried, also places that overlap. `fail[k]` is the length of the longest + // proper prefix of `query[..=k]` that is also its suffix. + let mut fail = vec![0; query.len()]; + let mut k = 0; + for i in 1..query.len() { + while k > 0 && query[i] != query[k] { + k = fail[k - 1]; + } + if query[i] == query[k] { + k += 1; + } + fail[i] = k; + } + let mut k = 0; + for (j, (c, _)) in folded.iter().enumerate() { + while k > 0 && *c != query[k] { + k = fail[k - 1]; + } + if *c == query[k] { + k += 1; + } + if k == query.len() { + if accept(j + 1 - k) { + return true; + } + k = fail[k - 1]; + } } false } +/// `c` in lowercase for a comparison that ignores letter case, with the final sigma ς read as σ. +fn fold_case(c: char) -> impl Iterator { + c.to_lowercase().map(|l| if l == 'ς' { 'σ' } else { l }) +} + /// Does `document.inspect` report a fill and stroke for `n`? A compound path's are its own, though /// it holds its members, and its members report none. fn paint_reported(d: &Document, n: &Node) -> bool { diff --git a/crates/engine/src/tests/attributes.rs b/crates/engine/src/tests/attributes.rs index 606200481e..d3edf2615b 100644 --- a/crates/engine/src/tests/attributes.rs +++ b/crates/engine/src/tests/attributes.rs @@ -269,6 +269,26 @@ fn select_by_note_whole_word_matches_overlapping_places_and_other_scripts() { assert!(!matches(&mut s, "", json!({"note": "a"}))); } +/// Letter case is compared one character at a time: a final Σ, whose lowercase is ς, matches Σ +/// and σ. Whole Word looks at the characters of the note itself, so a letter whose lowercase +/// takes two characters (İ) neither ends a word early nor matches half of itself. +#[test] +fn select_by_note_compares_letter_case_per_character() { + let mut s = session(); + let a = rect(&mut s, 0.0); + let matches = |s: &mut Session, text: &str, p: Value| { + note(s, a, text); + by_note(s, p).is_ok() + }; + assert!(matches(&mut s, "ΟΣ", json!({"note": "Σ"}))); + assert!(matches(&mut s, "ΟΣ", json!({"note": "σ"}))); + assert!(matches(&mut s, "ΟΣ", json!({"note": "ος", "wholeWord": true}))); + assert!(!matches(&mut s, "ΟΣ", json!({"note": "σ", "caseSensitive": true}))); + assert!(!matches(&mut s, "İx", json!({"note": "i", "wholeWord": true}))); + assert!(matches(&mut s, "İ x", json!({"note": "İ", "wholeWord": true}))); + assert!(!matches(&mut s, "İ x", json!({"note": "i", "wholeWord": true}))); +} + /// Select by Note leaves out hidden and locked objects, selects a group whose note matches without /// its members and a member whose note matches on its own, keeps to the isolated group, and leaves /// the selection as it was when it finds nothing. Bad params are errors that change nothing. diff --git a/crates/ui-egui/src/dialogs/action_select_object.rs b/crates/ui-egui/src/dialogs/action_select_object.rs index 80a72652c9..455ae61cac 100644 --- a/crates/ui-egui/src/dialogs/action_select_object.rs +++ b/crates/ui-egui/src/dialogs/action_select_object.rs @@ -50,8 +50,9 @@ fn body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { } fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { - let mut params = json!({"note": d.str("note"), "caseSensitive": d.bool("caseSensitive"), "wholeWord": d.bool("wholeWord")}); - for key in ["set", "action", "step"] { + // The fields go as they are, so a field of another type is an error rather than a default. + let mut params = json!({}); + for key in ["note", "caseSensitive", "wholeWord", "set", "action", "step"] { if let Some(value) = d.fields.get(key) { params[key] = value.clone(); } diff --git a/crates/ui-egui/src/panels/actions.rs b/crates/ui-egui/src/panels/actions.rs index 3fa7ba5824..fc70693ce8 100644 --- a/crates/ui-egui/src/panels/actions.rs +++ b/crates/ui-egui/src/panels/actions.rs @@ -5863,9 +5863,22 @@ mod tests { assert_eq!(app.ui.action_sets[0].actions[0].steps.len(), 3, "Cancel inserts nothing"); open(&ctx, &mut app); + // A field of another type (set through ui.dialog.set) is an error, and the dialog stays. + for (key, value) in [("note", json!(5)), ("caseSensitive", json!("yes")), ("wholeWord", json!(1))] { + if let Some(d) = app.ui.dialog.as_mut() { + d.fields.insert("note".into(), json!("Badge")); + d.fields.insert("caseSensitive".into(), json!(false)); + d.fields.insert("wholeWord".into(), json!(false)); + d.fields.insert(key.into(), value.clone()); + } + assert!(crate::dialogs::confirm(&mut app).is_err(), "`{key}`: {value} is an error"); + assert_eq!(open_kind(&app), Some(crate::dialogs::action_select_object::KIND), "`{key}`: the dialog stays open"); + assert_eq!(app.ui.action_sets[0].actions[0].steps.len(), 3, "`{key}`: nothing is inserted"); + } if let Some(d) = app.ui.dialog.as_mut() { d.fields.insert("note".into(), json!("Badge")); d.fields.insert("caseSensitive".into(), json!(true)); + d.fields.insert("wholeWord".into(), json!(false)); } crate::dialogs::confirm(&mut app).unwrap(); assert_eq!(open_kind(&app), None, "OK closes Set Selection"); From 06db8adae94689cae2e159474c77345e0fc67e38 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 16:45:18 -0800 Subject: [PATCH 05/11] MCP: a parameter nested too deeply is refused as bad parameters A value that nests past a command schema's 16-level limit was reported as an internal error ("please report this bug"). It is now a parameter error that names the value's path, like the schema's other refusals; a schema that is wrong in itself is still an internal error. The schema check now also refuses a list of ids or a point inside 16 lists, since every value of those types is a list and the value check refuses a list at that depth. A color there still takes a string. Docs: docs/mcp.md, docs/target-app-parity.md. --- crates/apischema/src/lib.rs | 9 +++++++-- crates/apischema/src/tests.rs | 30 +++++++++++++++++++++++++++++ crates/engine/src/schemas.rs | 26 ++++++++++++++++++++++++- crates/ui-egui/src/i18n/ca.tsv | 1 + crates/ui-egui/src/i18n/cs.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/hi.tsv | 1 + crates/ui-egui/src/i18n/hu.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/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/tr.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 + docs/mcp.md | 5 +++++ docs/target-app-parity.md | 2 +- 27 files changed, 90 insertions(+), 4 deletions(-) diff --git a/crates/apischema/src/lib.rs b/crates/apischema/src/lib.rs index 5db24230e1..cff749e84f 100644 --- a/crates/apischema/src/lib.rs +++ b/crates/apischema/src/lib.rs @@ -250,7 +250,8 @@ impl ParamSchema { /// What is wrong with the schema itself, or `None`: a parameter name that is empty, repeated /// or holds `.`, `[` or `]`; a required parameter with a default; a default that isn't JSON or /// doesn't pass its parameter's type; an enum with no names or a repeated name; a range with - /// an end that isn't finite or that no value can be in; nesting past [`MAX_DEPTH`]. + /// an end that isn't finite or that no value can be in; nesting past [`MAX_DEPTH`], or a + /// [`Type::Ids`] or [`Type::Point`], whose values are all lists, at the nesting limit. pub fn definition_problem(&self) -> Option { object_problem("", self.params, 0) } @@ -319,7 +320,9 @@ pub enum Problem { Missing, /// The value is not of the parameter's type: `expected` says what it must be. WrongType { expected: String, got: String }, - /// The schema nests deeper than [`MAX_DEPTH`] here ([`ParamSchema::definition_problem`]). + /// The value nests deeper than [`MAX_DEPTH`] here: a list given for a [`Type::Color`] at the + /// nesting limit, or any value under a schema that nests too deeply itself + /// ([`ParamSchema::definition_problem`]). TooDeep, } @@ -779,6 +782,8 @@ fn type_problem(at: &str, ty: &Type, depth: usize) -> Option { Type::Object(params) => object_problem(at, params, depth + 1), Type::Array(_) if depth >= MAX_DEPTH => Some(format!("`{at}` nests deeper than {MAX_DEPTH} levels")), Type::Array(item) => type_problem(&format!("{at}[]"), item, depth + 1), + // Every value of these is a list, and a list at the nesting limit is refused. + Type::Ids | Type::Point if depth >= MAX_DEPTH => Some(format!("`{at}` nests deeper than {MAX_DEPTH} levels")), _ => None, } } diff --git a/crates/apischema/src/tests.rs b/crates/apischema/src/tests.rs index 30b939879a..4b8d51d8b0 100644 --- a/crates/apischema/src/tests.rs +++ b/crates/apischema/src/tests.rs @@ -156,6 +156,36 @@ fn id_lists_points_and_color_lists_count_toward_the_nesting_limit() { assert!(check(MAX_DEPTH, &Type::Color, &json!("#ff0000")).is_ok()); } +/// `ty` inside `levels` lists. +fn in_lists(levels: usize, ty: Type) -> Type { + (0..levels).fold(ty, |inner, _| Type::Array(Box::leak(Box::new(inner)))) +} + +/// `v` inside `levels` lists. +fn wrapped(levels: usize, v: Value) -> Value { + (0..levels).fold(v, |inner, _| Value::Array(vec![inner])) +} + +/// The schema check and the value check agree on the fixed-shape lists at the nesting limit: an id +/// list or a point there could take no value, so the schema is wrong in itself, and a color there +/// takes a string, while a color list is refused as nested too deeply, at its path. +#[test] +fn fixed_shape_lists_at_the_nesting_limit_follow_the_schema_check() { + for ty in [Type::Ids, Type::Point, Type::Color] { + assert_eq!(one(in_lists(MAX_DEPTH - 1, ty)).definition_problem(), None, "{ty:?}"); + } + for ty in [Type::Ids, Type::Point] { + let problem = one(in_lists(MAX_DEPTH, ty)).definition_problem(); + assert!(problem.as_deref().is_some_and(|p| p.contains("nests deeper")), "{ty:?}: {problem:?}"); + } + let color = one(in_lists(MAX_DEPTH, Type::Color)); + assert_eq!(color.definition_problem(), None); + assert_eq!(color.validate(&json!({"x": wrapped(MAX_DEPTH, json!("#ff0000"))})).unwrap(), None); + let e = color.validate(&json!({"x": wrapped(MAX_DEPTH, json!([1, 0, 0]))})).unwrap_err(); + assert_eq!(e.problem, Problem::TooDeep); + assert_eq!(e.path, format!("x{}", "[0]".repeat(MAX_DEPTH))); +} + #[test] fn a_saved_retired_key_past_the_nesting_limit_is_refused() { let params = holding("older", nested_lists(500_000)); diff --git a/crates/engine/src/schemas.rs b/crates/engine/src/schemas.rs index fb08bb649c..08bc0f719d 100644 --- a/crates/engine/src/schemas.rs +++ b/crates/engine/src/schemas.rs @@ -69,7 +69,7 @@ fn param_error(id: &str, e: &ParamError) -> EngineError { Problem::Unknown { keys } => bad(id, format!("unknown parameter `{path}`; it takes: {keys}", keys = keys.join(", "))), Problem::Missing => bad(id, format!("missing `{path}`")), Problem::WrongType { expected, got } => bad(id, format!("`{path}` must be {expected}, not {got}")), - Problem::TooDeep => EngineError::Internal { cmd: id.into(), msg: e.to_string() }, + Problem::TooDeep => bad(id, format!("`{path}` is nested too deeply")), } } @@ -246,6 +246,30 @@ mod tests { assert!(s.doc().unwrap().history.undo.is_empty()); } + /// A value nested past the limit of a sound schema is a parameter error that names it, not an + /// internal error: a color list at the nesting limit, where a color string still passes. + #[test] + fn a_value_nested_too_deeply_is_a_parameter_error() { + let levels = vectorcraft_apischema::MAX_DEPTH; + let ty = (0..levels).fold(Type::Color, |inner, _| Type::Array(Box::leak(Box::new(inner)))); + let params: &'static [Param] = Box::leak(Box::new([Param::new("fills", ty)])); + let schema = ParamSchema::new(params, ""); + assert_eq!(schema.definition_problem(), None); + let wrapped = |v: Value| (0..levels).fold(v, |inner, _| Value::Array(vec![inner])); + assert!(schema.validate(&json!({"fills": wrapped(json!("#ff0000"))})).is_ok()); + let e = schema.validate(&json!({"fills": wrapped(json!([1, 0, 0]))})).unwrap_err(); + let path = format!("fills{}", "[0]".repeat(levels)); + match param_error("view.synthetic", &e) { + EngineError::BadParams { cmd, msg } => { + assert_eq!(cmd, "view.synthetic"); + assert_eq!(msg, format!("`{path}` is nested too deeply")); + } + other => panic!("not a parameter error: {other}"), + } + let shown = param_error("view.synthetic", &e).to_string(); + assert!(shown.starts_with("invalid parameters for `view.synthetic`") && !shown.contains("report this bug"), "{shown}"); + } + /// Each command's JSON schema follows the 2020-12 rules, and a validator of those rules, which /// shares no code with the writer, gives the same answer as the schema check. None of these /// schemas has a lenient parameter. diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 95b2615ec3..a10fe06229 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -6103,6 +6103,7 @@ digits Malayalam Malaiàlam @msg unknown parameter `{path}`; it takes: {keys} paràmetre desconegut `{path}`; admet: {keys} @msg missing `{path}` falta `{path}` @msg `{path}` must be {expected}, not {got} `{path}` ha de ser {expected}, no {got} +@msg `{path}` is nested too deeply `{path}` està imbricat massa profundament @msg unknown preference `{key}` preferència desconeguda `{key}` @msg Preferences imported. S’han importat les preferències. @msg not a preferences file: {e} no és un fitxer de preferències: {e} diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index f27fad5417..2716bb37e3 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -5794,6 +5794,7 @@ mark-style Japanese Japonské @msg unknown parameter `{path}`; it takes: {keys} neznámý parametr `{path}`; přijímá: {keys} @msg missing `{path}` chybí `{path}` @msg `{path}` must be {expected}, not {got} `{path}` musí být {expected}, nikoli {got} +@msg `{path}` is nested too deeply `{path}` je vnořený příliš hluboko Load EPS into Clipboard Načíst EPS do schránky @msg an image of {w} × {h} pixels is too large to draw obraz o velikosti {w} × {h} obrazových bodů je příliš velký na vykreslení @msg {path}: not a PNG, JPEG, WebP, TIFF or BMP image {path}: není obraz PNG, JPEG, WebP, TIFF ani BMP diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index d9434741c0..bb33689a8a 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -5369,6 +5369,7 @@ lighting Light Licht @msg unknown parameter `{path}`; it takes: {keys} unbekannter Parameter `{path}`; akzeptiert werden: {keys} @msg missing `{path}` `{path}` fehlt @msg `{path}` must be {expected}, not {got} `{path}` muss {expected} sein, nicht {got} +@msg `{path}` is nested too deeply `{path}` ist zu tief verschachtelt Load EPS into Clipboard EPS in die Zwischenablage laden @msg an image of {w} × {h} pixels is too large to draw ein Bild mit {w} × {h} Pixeln ist zu groß, um gezeichnet zu werden @msg {path}: not a PNG, JPEG, WebP, TIFF or BMP image {path}: kein PNG-, JPEG-, WebP-, TIFF- oder BMP-Bild diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 1ceb93c669..104183afc8 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -5153,6 +5153,7 @@ digits Malayalam Μαλαγιάλαμ @msg unknown parameter `{path}`; it takes: {keys} άγνωστη παράμετρος `{path}`· δέχεται: {keys} @msg missing `{path}` λείπει το `{path}` @msg `{path}` must be {expected}, not {got} Το `{path}` πρέπει να είναι {expected}, όχι {got} +@msg `{path}` is nested too deeply Το `{path}` έχει υπερβολικά βαθιά ένθεση 1 Layer 1 επίπεδο 1 bit 1 bit 16 Colors 16 χρώματα diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 7708e930bb..865eddb2a4 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -5735,6 +5735,7 @@ mark-style Japanese Japonesas @msg unknown parameter `{path}`; it takes: {keys} parámetro desconocido `{path}`; acepta: {keys} @msg missing `{path}` falta `{path}` @msg `{path}` must be {expected}, not {got} `{path}` debe ser {expected}, no {got} +@msg `{path}` is nested too deeply `{path}` está anidado a demasiada profundidad Load EPS into Clipboard Cargar EPS en el portapapeles @msg an image of {w} × {h} pixels is too large to draw una imagen de {w} × {h} píxeles es demasiado grande para dibujarse @msg {path}: not a PNG, JPEG, WebP, TIFF or BMP image {path}: no es una imagen PNG, JPEG, WebP, TIFF ni BMP diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index 765400c5a6..b43864d60e 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -5676,6 +5676,7 @@ mark-style Japanese Japanilainen @msg unknown parameter `{path}`; it takes: {keys} tuntematon parametri `{path}`; parametrit: {keys} @msg missing `{path}` `{path}` puuttuu @msg `{path}` must be {expected}, not {got} `{path}` on oltava {expected}, ei {got} +@msg `{path}` is nested too deeply `{path}` on liian syvällä sisäkkäin Load EPS into Clipboard Lataa EPS leikepöydälle @msg an image of {w} × {h} pixels is too large to draw kuva, jonka koko on {w} × {h} pikseliä, on liian suuri piirrettäväksi @msg {path}: not a PNG, JPEG, WebP, TIFF or BMP image {path}: ei ole PNG-, JPEG-, WebP-, TIFF- tai BMP-kuva diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index f7eb341e39..7902d37ceb 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -5364,6 +5364,7 @@ lighting Light Lumière @msg unknown parameter `{path}`; it takes: {keys} paramètre inconnu `{path}` ; il accepte : {keys} @msg missing `{path}` `{path}` manquant @msg `{path}` must be {expected}, not {got} `{path}` doit être {expected}, pas {got} +@msg `{path}` is nested too deeply `{path}` est imbriqué trop profondément 1 Layer 1 calque 1 bit 1 bit 16 Colors 16 couleurs diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 978b6811fd..546862d379 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -8144,6 +8144,7 @@ antiAlias Sharp शार्प @msg unknown parameter `{path}`; it takes: {keys} अज्ञात पैरामीटर `{path}`; यह लेता है: {keys} @msg missing `{path}` लापता `{path}` @msg `{path}` must be {expected}, not {got} `{path}` {expected} होना चाहिए, {got} नहीं +@msg `{path}` is nested too deeply `{path}` बहुत गहराई तक नेस्ट किया गया है @msg unknown PDF preset `{name}` (presets: {_1}) अज्ञात PDF प्रीसेट `{name}` (presets: {_1}) @msg unknown pieLegend `{v}` (none, standard or wedges) अज्ञात pieLegend `{v}` (one, मानक या wedges) @msg unknown piePosition `{v}` (even, ratio or stacked) अज्ञात piePosition `{v}` (यहां तक कि अनुपात या स्टैक्ड) diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 7bfe5b5682..4d52dc2bbb 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -5777,6 +5777,7 @@ imageTrace Outline Körvonal @msg unknown parameter `{path}`; it takes: {keys} ismeretlen paraméter: `{path}`; ezeket fogadja el: {keys} @msg missing `{path}` hiányzik: `{path}` @msg `{path}` must be {expected}, not {got} `{path}`: {expected} kell, nem ez: {got} +@msg `{path}` is nested too deeply `{path}` túl mélyen van egymásba ágyazva Load EPS into Clipboard EPS betöltése a vágólapra @msg an image of {w} × {h} pixels is too large to draw egy {w} × {h} pixeles kép túl nagy a kirajzoláshoz @msg {path}: not a PNG, JPEG, WebP, TIFF or BMP image {path}: nem PNG, JPEG, WebP, TIFF vagy BMP kép diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index d9aa6ea3c8..25a7eba5c0 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -5411,6 +5411,7 @@ lighting Light Luce @msg unknown parameter `{path}`; it takes: {keys} parametro sconosciuto `{path}`; accetta: {keys} @msg missing `{path}` manca `{path}` @msg `{path}` must be {expected}, not {got} `{path}` deve essere {expected}, non {got} +@msg `{path}` is nested too deeply `{path}` è annidato troppo in profondità Load EPS into Clipboard Carica EPS negli appunti @msg an image of {w} × {h} pixels is too large to draw un'immagine di {w} × {h} pixel è troppo grande per essere disegnata @msg {path}: not a PNG, JPEG, WebP, TIFF or BMP image {path}: non è un'immagine PNG, JPEG, WebP, TIFF o BMP diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 5e337a9bc4..6ead68811f 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -5453,6 +5453,7 @@ lighting Light ライト @msg unknown parameter `{path}`; it takes: {keys} `{path}` は不明なパラメーターです。使えるパラメーター: {keys} @msg missing `{path}` `{path}` が指定されていません @msg `{path}` must be {expected}, not {got} `{path}` は {expected} にしてください ({got} は使えません) +@msg `{path}` is nested too deeply `{path}` の入れ子が深すぎます Load EPS into Clipboard EPS をクリップボードに読み込む @msg an image of {w} × {h} pixels is too large to draw {w} × {h} ピクセルの画像は大きすぎて描画できません @msg {path}: not a PNG, JPEG, WebP, TIFF or BMP image {path}: PNG、JPEG、WebP、TIFF、BMP のいずれの画像でもありません diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index e5bf998d5c..37355334b1 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -5161,6 +5161,7 @@ font-technology Variable ცვლადი @msg unknown parameter `{path}`; it takes: {keys} უცნობი პარამეტრი `{path}`; იღებს: {keys} @msg missing `{path}` აკლია `{path}` @msg `{path}` must be {expected}, not {got} `{path}` უნდა იყოს {expected} და არა {got} +@msg `{path}` is nested too deeply `{path}` ზედმეტად ღრმადაა ჩადგმული file-filter All readable files ყველა წაკითხვადი ფაილი file-filter All placeable files ყველა განთავსებადი ფაილი file-filter Swatch libraries ფერის ნიმუშების ბიბლიოთეკები diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index b10632963f..5cda96323c 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -5372,6 +5372,7 @@ lighting Light 광원 @msg unknown parameter `{path}`; it takes: {keys} 알 수 없는 매개변수 `{path}`: 받는 매개변수: {keys} @msg missing `{path}` `{path}`이(가) 없습니다 @msg `{path}` must be {expected}, not {got} `{path}`은(는) {expected}이어야 합니다({got} 사용 불가) +@msg `{path}` is nested too deeply `{path}`이(가) 너무 깊게 중첩되어 있습니다 Load EPS into Clipboard EPS를 클립보드로 불러오기 @msg an image of {w} × {h} pixels is too large to draw {w} × {h}픽셀 이미지는 너무 커서 그릴 수 없습니다 @msg {path}: not a PNG, JPEG, WebP, TIFF or BMP image {path}: PNG, JPEG, WebP, TIFF 또는 BMP 이미지가 아닙니다 diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 2eca92f56b..c0c8940764 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -5633,6 +5633,7 @@ mark-style Japanese Japońskie @msg unknown parameter `{path}`; it takes: {keys} nieznany parametr `{path}`; przyjmuje: {keys} @msg missing `{path}` brak `{path}` @msg `{path}` must be {expected}, not {got} `{path}` musi być {expected}, a nie {got} +@msg `{path}` is nested too deeply `{path}` jest zagnieżdżone zbyt głęboko Load EPS into Clipboard Wczytaj EPS do schowka @msg an image of {w} × {h} pixels is too large to draw obraz o wymiarach {w} × {h} pikseli jest zbyt duży, aby go narysować @msg {path}: not a PNG, JPEG, WebP, TIFF or BMP image {path}: to nie jest obraz PNG, JPEG, WebP, TIFF ani BMP diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 24a3db3fe0..004b195c27 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -4667,6 +4667,7 @@ axis Both Ambos @msg unknown parameter `{path}`; it takes: {keys} parâmetro desconhecido `{path}`; aceita: {keys} @msg missing `{path}` falta `{path}` @msg `{path}` must be {expected}, not {got} `{path}` deve ser {expected}, não {got} +@msg `{path}` is nested too deeply `{path}` está aninhado com profundidade excessiva Load EPS into Clipboard Carregar EPS na Área de Transferência Drawer menu Menu da gaveta Toolbar: Show Controls Barra de ferramentas: Mostrar controles diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 0c0c0bb1f0..913029426d 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -5397,6 +5397,7 @@ lighting Light Свет @msg unknown parameter `{path}`; it takes: {keys} неизвестный параметр `{path}`; принимает: {keys} @msg missing `{path}` отсутствует `{path}` @msg `{path}` must be {expected}, not {got} `{path}` должно быть {expected}, а не {got} +@msg `{path}` is nested too deeply `{path}` вложено слишком глубоко Load EPS into Clipboard Загрузить EPS в буфер обмена @msg an image of {w} × {h} pixels is too large to draw изображение размером {w} × {h} пикселей слишком велико для отрисовки @msg {path}: not a PNG, JPEG, WebP, TIFF or BMP image {path}: это не изображение PNG, JPEG, WebP, TIFF или BMP diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 911f406a57..6652cf531e 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -5265,6 +5265,7 @@ lighting Light Ljus @msg unknown parameter `{path}`; it takes: {keys} okänd parameter `{path}`; den tar: {keys} @msg missing `{path}` `{path}` saknas @msg `{path}` must be {expected}, not {got} `{path}` måste vara {expected}, inte {got} +@msg `{path}` is nested too deeply `{path}` är för djupt kapslat 1 Layer 1 lager 1 bit 1 bit 16 Colors 16 färger diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index b85b65184b..77add76ff9 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -5060,6 +5060,7 @@ lighting Light Işık @msg unknown parameter `{path}`; it takes: {keys} bilinmeyen parametre `{path}`; şunları alır: {keys} @msg missing `{path}` `{path}` eksik @msg `{path}` must be {expected}, not {got} `{path}` {expected} olmalıdır, {got} değil +@msg `{path}` is nested too deeply `{path}` çok derin iç içe geçmiş 1 Layer 1 Katman 1 bit 1 bit 16 Colors 16 Renk diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 8e05bb2fc9..f8de1bea4e 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -5391,6 +5391,7 @@ lighting Light Світло @msg unknown parameter `{path}`; it takes: {keys} невідомий параметр `{path}`; приймає: {keys} @msg missing `{path}` відсутнє `{path}` @msg `{path}` must be {expected}, not {got} `{path}` має бути {expected}, а не {got} +@msg `{path}` is nested too deeply `{path}` вкладено надто глибоко Load EPS into Clipboard Завантажити EPS у буфер обміну @msg an image of {w} × {h} pixels is too large to draw зображення розміром {w} × {h} пікселів завелике для відображення @msg {path}: not a PNG, JPEG, WebP, TIFF or BMP image {path}: це не зображення PNG, JPEG, WebP, TIFF чи BMP diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index f10ab118e4..4578022349 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -5305,6 +5305,7 @@ theme Appearance Giao diện @msg unknown parameter `{path}`; it takes: {keys} tham số không xác định `{path}`; nhận: {keys} @msg missing `{path}` thiếu `{path}` @msg `{path}` must be {expected}, not {got} `{path}` phải là {expected}, không phải {got} +@msg `{path}` is nested too deeply `{path}` lồng nhau quá sâu @msg unknown preference `{key}` tùy chọn không xác định `{key}` @msg Preferences imported. Đã nhập tùy chọn. @msg not a preferences file: {e} không phải tệp tùy chọn: {e} diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index b699270a8b..1a6a153032 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -5667,6 +5667,7 @@ lighting Light 光源 @msg unknown parameter `{path}`; it takes: {keys} 未知参数 `{path}`;它接受:{keys} @msg missing `{path}` 缺少 `{path}` @msg `{path}` must be {expected}, not {got} `{path}` 必须是 {expected},而不是 {got} +@msg `{path}` is nested too deeply `{path}` 嵌套过深 Load EPS into Clipboard 将 EPS 载入剪贴板 @msg an image of {w} × {h} pixels is too large to draw {w} × {h} 像素的图像过大,无法绘制 @msg the JPEG XL file is cut short JPEG XL 文件不完整 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 83e4eb4a60..cf5b76f7ec 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -5499,6 +5499,7 @@ lighting Light 光源 @msg unknown parameter `{path}`; it takes: {keys} 未知的參數 `{path}`;它接受:{keys} @msg missing `{path}` 未提供 `{path}` @msg `{path}` must be {expected}, not {got} `{path}` 必須是 {expected},而不是 {got} +@msg `{path}` is nested too deeply `{path}` 巢狀層級過深 Load EPS into Clipboard 將 EPS 載入剪貼簿 @msg an image of {w} × {h} pixels is too large to draw {w} × {h} 像素的影像過大,無法繪製 @msg {path}: not a PNG, JPEG, WebP, TIFF or BMP image {path}:不是 PNG、JPEG、WebP、TIFF 或 BMP 影像 diff --git a/docs/mcp.md b/docs/mcp.md index 0dd9f958b3..5de1ed4539 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1784,6 +1784,11 @@ path to a value inside a list or an object, and the document and its undo histor → invalid parameters for `view.saved.edit`: `edits[0].delete` must be true or false, not 1 ``` +A schema nests at most 16 lists and objects. A value that would go past that depth is refused with the same kind of +parameter error, ``invalid parameters for ``: `` is nested too deeply``, where `` is the path to the +value. A schema that is wrong in itself, such as one that nests past 16 levels or puts a list of ids or a point +inside 16 lists, is an internal error in the command, and the command does not run. + An omitted value with a `default` takes it (`rotation` 0 in `view.saved.new`); the journal keeps the params as they were given. Saved steps played from a journal or an action keep a key the schema does not name, unless its value nests more than 16 levels deep, and the values of the parameters it names are checked as in a new call. The schemas of built-in commands belong to API level 3 diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 8938bfce54..390efb691b 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -719,7 +719,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Linux file associations:** the desktop entry lists the MIME type of every format File › Open reads but PSD, PSB, `.ps` PostScript, Targa, PCX, Pixar, JPEG 2000, HEIC/HEIF, JPEG XL and HP-GL, `.ai`, `.ait` and EPS included, and the MIME package declares the types shared-mime-info lacks (VectorCraft documents and templates, `.ait`, Affinity), so file managers list VectorCraft under Open With for them, as Finder does on macOS. `the_linux_desktop_entry_opens_every_readable_format` in `apps/vectorcraft/src/tests.rs` checks both files against `OPEN_EXTS`. On Debian 13, Ubuntu 24.04 and Fedora 42, `desktop-file-validate` accepts the desktop entry, `update-mime-database` accepts the MIME package, and `gio` registers the app for every one of these types. - **Architecture:** 19+ crates with enforced layering (`cargo xtask layers`). Every action is a command (~400 engine + ~50 UI). Undo is unlimited via structural sharing. `command.batch` runs several commands as one transaction. -- **Parameter schemas:** a command can carry a machine-readable parameter schema (the new `vectorcraft-apischema` crate): each parameter's type, range, unit, whether it is required, its default, whether null counts as left out, and the API levels it was added and deprecated at. `engine.commands` lists the schema in the shape of a JSON Schema 2020-12 object schema, a test keeps the command's `params` text equal to the text the schema writes, and a new call has the params checked against it before the command runs (batch steps before the first step), with an error naming the parameter and no change to the document; saved steps keep a key the schema does not name, and their values are checked. The View commands `view.capsLock`, `view.saved.new`, `view.saved.edit`, `view.saved.list`, `view.transparencyGrid` and `view.snapToGlyph.options` have schemas. The schemas belong to API level 1 (`vectorcraft_engine::schemas::API_VERSION`), and a test fails when a schema changes without a new level. Tests: `crates/apischema/src/tests.rs` (with `conformance.rs`, a separate check of the JSON form against the JSON Schema 2020-12 rules), `schemas::tests` and `cmd::views::tests::each_view_command_refuses_a_bad_value_naming_the_parameter` in `vectorcraft-engine`. +- **Parameter schemas:** a command can carry a machine-readable parameter schema (the new `vectorcraft-apischema` crate): each parameter's type, range, unit, whether it is required, its default, whether null counts as left out, and the API levels it was added and deprecated at. `engine.commands` lists the schema in the shape of a JSON Schema 2020-12 object schema, a test keeps the command's `params` text equal to the text the schema writes, and a new call has the params checked against it before the command runs (batch steps before the first step), with an error naming the parameter and no change to the document (a value nested past the schema's 16 levels is such a parameter error, and only a schema that is wrong in itself is an internal error); saved steps keep a key the schema does not name, and their values are checked. The View commands `view.capsLock`, `view.saved.new`, `view.saved.edit`, `view.saved.list`, `view.transparencyGrid` and `view.snapToGlyph.options` have schemas. The schemas belong to API level 1 (`vectorcraft_engine::schemas::API_VERSION`), and a test fails when a schema changes without a new level. Tests: `crates/apischema/src/tests.rs` (with `conformance.rs`, a separate check of the JSON form against the JSON Schema 2020-12 rules), `schemas::tests` and `cmd::views::tests::each_view_command_refuses_a_bad_value_naming_the_parameter` in `vectorcraft-engine`. - **Input schemas in the command listings:** `engine.commands` and the MCP tools `command_list` and `list_commands` give each command with a parameter schema an `inputSchema` next to `schema`: the same schema with the root `type` `"object"`, the form an MCP tool's input schema takes ([MCP schema reference](https://modelcontextprotocol.io/specification/2026-07-28/schema)). A command without a schema lists neither, and `brief` leaves out both schemas with the `params` text. Tests: `schemas::tests::engine_commands_list_an_input_schema_with_an_object_root` and `schemas::tests::an_input_schema_keeps_deprecation` in `vectorcraft-engine`, and `tests::conventions::command_listings_carry_input_schemas` in `vectorcraft-mcp`, which check every listed input schema against the JSON Schema 2020-12 rules. - **Command API reference:** `engine.apiReference` returns a Markdown reference written from the parameter schemas when it is asked for, and MCP serves the same text as the resource `vectorcraft://commands/reference` (`text/markdown; charset=utf-8`). Each command that has a schema gets a section in registry order with its label, menu path, API levels, what it writes, summary and return, and a table of its parameters (type, range with its ends marked included or not, unit, default, required, levels and description; members of objects as dotted rows). Commands without a schema, plug-in commands included, are left out. Schema text is escaped so it stays plain text in its table cell, the same build always returns the same bytes, and a reference past 1 MiB is an error rather than a cut-short text. The command's own schema raised the API level to 2. This is a VectorCraft documentation feature; no score changes. Tests: `cmd::apiref::tests` and `schemas::tests` in `vectorcraft-engine`, `resources::tests` in `vectorcraft-mcp`. - **Automation:** From 1ddcfb38a7f44b9adec63407f235546510516f4a Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 16:47:27 -0800 Subject: [PATCH 06/11] Artboards: a right-click on a panel row opens a menu with Rename A right-click on a row of the Artboards panel opens a menu with Rename, which opens the Rename dialog (artboard.rename) for the selected artboards: OK names them in one undo step, numbered in panel order when there are several, and Cancel renames nothing. A right-click on a row that is not selected first makes its artboard the active one and the only selected one; on a selected row the selection stays. A drag with the right button no longer moves a row: only the primary button starts a row drag. --- .../ui-egui/src/dialogs/rename_artboards.rs | 3 +- crates/ui-egui/src/panels/artboards.rs | 22 ++- crates/ui-egui/src/panels/artboards/tests.rs | 126 ++++++++++++++++++ docs/target-app-parity.md | 1 + 4 files changed, 149 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/dialogs/rename_artboards.rs b/crates/ui-egui/src/dialogs/rename_artboards.rs index 82761e9284..bd621ebdbe 100644 --- a/crates/ui-egui/src/dialogs/rename_artboards.rs +++ b/crates/ui-egui/src/dialogs/rename_artboards.rs @@ -1,4 +1,5 @@ -//! The Artboard tool's canvas menu › Rename: a name for the selected artboards. OK runs +//! The Artboard tool's canvas menu › Rename, and the Artboards panel's row menu › Rename: a name +//! for the selected artboards. OK runs //! `artboard.rename` as one undo step; with several artboards the name is numbered. //! //! Fields: `name` (the artboard's name with one artboard, empty with several) and `indices` (the diff --git a/crates/ui-egui/src/panels/artboards.rs b/crates/ui-egui/src/panels/artboards.rs index 0b661c8bcf..774002a72d 100644 --- a/crates/ui-egui/src/panels/artboards.rs +++ b/crates/ui-egui/src/panels/artboards.rs @@ -1,5 +1,6 @@ //! Artboards panel: numbered list with inline rename (double-click the name, or the panel menu's -//! Rename; while several artboards are selected, the name typed goes to all of them, numbered), +//! Rename; while several artboards are selected, the name typed goes to all of them, numbered), a +//! row menu (right-click) whose Rename opens the Rename dialog for the selected artboards, //! move up / down (or drag a row above or below another), new //! (Alt-click: Artboard Options for the new artboard first), delete; a row dragged onto New //! Artboard duplicates its artboard (as Duplicate Artboards does, with @@ -541,7 +542,8 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { let scrolled = scroll.show(ui, |ui| { for (i, name) in abs.iter().enumerate() { let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 24.0), Sense::click_and_drag()); - if resp.drag_started() { + // Only the primary button drags a row; a right-button drag reorders nothing. + if resp.drag_started_by(egui::PointerButton::Primary) { egui::DragAndDrop::set_payload(ui.ctx(), RowDrag(i)); } if all.contains(&i) { @@ -648,6 +650,14 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { } else if resp.double_clicked() { start_rename(ui.ctx(), app, i); } + // A right-click on a row that isn't selected makes its artboard the active one and + // the only selected one ([`select`] sets the Artboard tool's `active` option, which + // clears the rest of its selection); on a selected row the selection stays. The row + // menu then acts on the selection. + if resp.secondary_clicked() && !picked(app, abs.len()).contains(&i) { + select(app, i); + } + resp.context_menu(|ui| row_menu(app, ui)); // Another row dragged over this one lands above or below it by the pointer's half, // a line showing where. if let Some(from) = resp.dnd_hover_payload::().map(|d| d.0).filter(|from| *from != i) { @@ -707,6 +717,14 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { }); } +/// A row's menu: Rename opens the Rename dialog for the selected artboards ([`picked`]), whose OK +/// runs `artboard.rename` in one undo step, numbering the name when there are several. +fn row_menu(app: &mut VectorcraftApp, ui: &mut Ui) { + if menu_item(ui, tl!("Rename"), true, false) { + crate::menus::invoke(app, "artboard.rename", json!({})); + } +} + /// Open Rearrange All Artboards (#681). pub(crate) fn open_rearrange(app: &mut VectorcraftApp) { app.run("ui.menuDialog", json!({ "command": "artboard.rearrange" })).ok(); diff --git a/crates/ui-egui/src/panels/artboards/tests.rs b/crates/ui-egui/src/panels/artboards/tests.rs index f2be587642..eae59fd7dc 100644 --- a/crates/ui-egui/src/panels/artboards/tests.rs +++ b/crates/ui-egui/src/panels/artboards/tests.rs @@ -1122,6 +1122,132 @@ fn dragging_a_row_onto_another_row_reorders_the_artboards() { assert_eq!(names(&app), ["Artboard 1", "Artboard 2", "Artboard 3"]); } +/// The panel drawn in a 240 × 400 pt window, for the row menu's tests. +struct RowMenuWindow { + ctx: egui::Context, + time: f64, +} + +impl RowMenuWindow { + fn new() -> Self { + RowMenuWindow { ctx: egui::Context::default(), time: 0.0 } + } + + /// One frame of the panel with `events`; the texts drawn and where. + fn frame(&mut self, app: &mut VectorcraftApp, events: Vec) -> Vec<(String, egui::Rect)> { + self.time += 0.5; + let screen = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, vec2(240.0, 400.0))); + let raw = egui::RawInput { events, time: Some(self.time), screen_rect: screen, ..Default::default() }; + let mut out = self.ctx.run_ui(raw, |ui| show(app, ui)); + out.textures_delta.clear(); + out.shapes + .iter() + .filter_map(|c| match &c.shape { + egui::Shape::Text(t) => Some((t.galley.text().trim_start().to_string(), t.visual_bounding_rect())), + _ => None, + }) + .collect() + } + + /// Where the text `label` is drawn now, if it is. + fn find(&mut self, app: &mut VectorcraftApp, label: &str) -> Option { + self.frame(app, vec![]).into_iter().find(|(t, _)| t == label).map(|(_, r)| r.center()) + } + + /// A click with `button` at `at`. + fn click(&mut self, app: &mut VectorcraftApp, at: egui::Pos2, button: egui::PointerButton) { + let b = |pressed| egui::Event::PointerButton { pos: at, button, pressed, modifiers: Default::default() }; + self.frame(app, vec![egui::Event::PointerMoved(at), b(true), b(false)]); + self.frame(app, vec![]); + } + + /// A right-click on the row named `row`, then a click on its menu's Rename. + fn rename_from_row_menu(&mut self, app: &mut VectorcraftApp, row: &str) { + let at = self.find(app, row).expect("the row"); + self.click(app, at, egui::PointerButton::Secondary); + let item = self.find(app, "Rename").expect("the row menu's Rename"); + self.click(app, item, egui::PointerButton::Primary); + } +} + +/// A right-click on an Artboards panel row opens a menu with Rename, which opens the Rename dialog +/// and renames nothing until OK: on a row of the selection, the dialog takes every selected +/// artboard and OK numbers the name in one undo step; on a row that isn't selected, that row's +/// artboard becomes the only selected one and the dialog takes it alone, and Cancel keeps its name. +#[test] +fn the_row_menus_rename_opens_the_rename_dialog_for_the_selected_artboards() { + let mut app = menu_app(4); + select(&mut app, 2); + app.session.set_tool_option("selected", &json!([2, 1])); + let mut window = RowMenuWindow::new(); + let steps = undo_steps(&app); + window.rename_from_row_menu(&mut app, "Artboard 2"); + let d = app.ui.dialog.as_ref().expect("the Rename dialog"); + assert_eq!(d.kind, crate::dialogs::rename_artboards::KIND); + assert_eq!((d.str("name").as_str(), &d.fields["indices"]), ("", &json!([1, 2])), "the dialog takes both selected artboards"); + assert_eq!(picked(&app, 4), [1, 2], "the selection stays"); + assert_eq!(board_names(&app), ["Artboard 1", "Artboard 2", "Artboard 3", "Artboard 4"], "nothing is renamed before OK"); + assert_eq!(undo_steps(&app), steps); + app.ui.dialog.as_mut().unwrap().fields.insert("name".into(), json!("Page")); + crate::dialogs::confirm(&mut app).unwrap(); + assert_eq!(board_names(&app), ["Artboard 1", "Page-1", "Page-2", "Artboard 4"]); + assert_eq!(undo_steps(&app), steps + 1, "one undo step"); + + window.rename_from_row_menu(&mut app, "Artboard 4"); + let d = app.ui.dialog.as_ref().expect("the Rename dialog"); + assert_eq!((d.str("name").as_str(), &d.fields["indices"]), ("Artboard 4", &json!([3])), "the dialog takes the row's artboard alone"); + assert_eq!((picked(&app, 4), selected(&app, 4)), (vec![3], 3), "the row's artboard is the only one selected"); + crate::dialogs::cancel(&mut app); + assert!(app.ui.dialog.is_none()); + assert_eq!(board_names(&app), ["Artboard 1", "Page-1", "Page-2", "Artboard 4"]); + assert_eq!(undo_steps(&app), steps + 1, "Cancel records no undo step"); +} + +/// Without the Artboard tool the row menu's Rename takes the right-clicked row's artboard, which +/// becomes the active one; a locked artboard can be renamed too. A menu closed without a choice +/// opens no dialog. +#[test] +fn the_row_menu_renames_the_right_clicked_artboard_with_any_tool() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 100, "height": 50, "units": "Points", "artboards": 3})).unwrap(); + app.session.execute("artboard.setProps", &json!({"index": 2, "locked": true})).unwrap(); + select(&mut app, 0); + let mut window = RowMenuWindow::new(); + let at = window.find(&mut app, "Artboard 2").expect("the row"); + window.click(&mut app, at, egui::PointerButton::Secondary); + assert!(window.find(&mut app, "Rename").is_some(), "the row menu is open"); + let escape = egui::Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() }; + window.frame(&mut app, vec![escape]); + window.frame(&mut app, vec![]); + assert!(window.find(&mut app, "Rename").is_none(), "Escape closes the menu"); + assert!(app.ui.dialog.is_none(), "and opens no dialog"); + assert_eq!(selected(&app, 3), 1, "the right-clicked artboard is active"); + + window.rename_from_row_menu(&mut app, "Artboard 3"); + let d = app.ui.dialog.as_ref().expect("the Rename dialog"); + assert_eq!((d.str("name").as_str(), &d.fields["indices"]), ("Artboard 3", &json!([2]))); + app.ui.dialog.as_mut().unwrap().fields.insert("name".into(), json!("Back")); + crate::dialogs::confirm(&mut app).unwrap(); + assert_eq!(board_names(&app), ["Artboard 1", "Artboard 2", "Back"], "the locked artboard is renamed"); +} + +/// A row dragged with the right button reorders nothing; the primary button still drags rows. +#[test] +fn a_right_button_drag_does_not_move_a_row() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 100, "height": 100, "artboards": 3})).unwrap(); + let mut window = RowMenuWindow::new(); + let (first, last) = (window.find(&mut app, "Artboard 1").unwrap(), window.find(&mut app, "Artboard 3").unwrap()); + let below = last + vec2(0.0, 8.0); + let button = |at, pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Secondary, pressed, modifiers: Default::default() }; + window.frame(&mut app, vec![egui::Event::PointerMoved(first), button(first, true)]); + window.frame(&mut app, vec![egui::Event::PointerMoved(first + vec2(0.0, 12.0))]); + window.frame(&mut app, vec![egui::Event::PointerMoved(below)]); + window.frame(&mut app, vec![button(below, false)]); + window.frame(&mut app, vec![]); + assert_eq!(board_names(&app), ["Artboard 1", "Artboard 2", "Artboard 3"]); +} + /// A click in a row's lock column locks its artboard and shows the lock next to the name, a /// second click unlocks it, and each click is one undo step. Delete Artboard leaves a locked /// artboard. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 8938bfce54..e03ff5e167 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1120,6 +1120,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur With the Artboard tool in use, a Shift-click on a row of the Artboards panel selects the artboards from the last row clicked without Shift to the clicked row, and the clicked row's artboard becomes the active one. When the artboard of the last row clicked without Shift is no longer selected, the range starts from the active artboard, and later Shift-clicks start from there too. A Ctrl/Cmd-click still adds or removes one artboard. A Shift-click used to add or remove one artboard as well. The public [Select artboards](https://helpx.adobe.com/illustrator/desktop/create-manage-artboards/add-edit-artboards/select-artboards.html) page lists Shift-click in the Artboards panel for selecting several artboards. In the Artboards panel, a click on the active artboard's options icon, or a double-click on another row's icon, opens Artboard Options for that row's artboard, and the panel menu's Artboard Options… opens it for the active artboard (part of #1876). Both used to choose the Artboard tool instead. Opened while another tool is in use, the dialog moves the art with a new X or Y when the Artboard tool's Move Artwork with Artboard option is on. With several artboards selected with the Artboard tool, the Artboards panel menu's Rename opens an empty name field on the active artboard's row (on the first selected row when the active artboard is not selected), and the name typed there goes to every selected artboard, numbered in panel order (abc-1, abc-2, abc-3), in one undo step (`artboard.setProps {artboards}`). A double-click on a name with Shift or Ctrl/Cmd held that leaves several artboards selected opens the same field; a Ctrl/Cmd double-click on an unselected row's name adds its artboard to the selection first. Both used to rename the active artboard only. With one artboard selected, Rename still gives it the name as typed. The public [Rename artboards](https://helpx.adobe.com/illustrator/desktop/create-manage-artboards/add-edit-artboards/rename-artboards.html) page numbers the name the same way for several selected artboards. + A right-click on a row of the Artboards panel opens a menu with Rename, as the [help page](https://helpx.adobe.com/illustrator/desktop/create-manage-artboards/add-edit-artboards/rename-artboards.html) describes, with any tool. Rename opens the Rename dialog for the selected artboards, and OK names them in one undo step, numbered in panel order when there are several (`artboard.rename`); Cancel or a menu closed without a choice renames nothing, and a locked artboard can be renamed. A right-click on a row that is not selected first makes its artboard the only selected one and the active one, and on a selected row the selection stays; this is VectorCraft's own rule. A drag with the right button no longer moves a row. Artboard Options has X and Y fields beside a reference point locator (#1876), so an artboard's position can be typed as well as its size. X and Y are where the reference point lies, counted from the ruler origin (#1212), as the Transform panel and the Control bar show an artboard's position, and the locator is the one they and Properties share. A typed X or Y moves the artboard so that its reference point lands there, and a new Width or Height keeps the reference point in place; OK applies everything in one undo step. The shape dialogs still keep their X and Y to themselves (the click position). Pointer and key tests in `dialogs/tests.rs` cover both. Public references: [Resize artboards](https://helpx.adobe.com/illustrator/desktop/create-manage-artboards/add-edit-artboards/resize-artboards.html) and [Create and add new artboards](https://helpx.adobe.com/illustrator/desktop/create-manage-artboards/add-edit-artboards/add-new-artboards.html) (a reference point in a nine-point grid, then X and Y). Alt-clicking New Artboard in the Artboards panel opens Artboard Options for the new artboard, and OK adds it and makes it the active artboard (`ui.newArtboard`; behavior reference: [Default keyboard shortcuts](https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html), Use panels). A plain click still adds the artboard at once. With Align to Artboard chosen, the Align panel's Distribute Objects and Distribute Spacing buttons use the active artboard (`object.distribute` and `object.distributeSpacing` with `to: "artboard"` and `artboard`). The first object's left or top edge goes to the artboard's left or top edge, and the last object's right or bottom edge goes to the artboard's right or bottom edge. Distribute Objects spaces the other objects evenly by its button's line, and two objects are enough. Distribute Spacing makes the gaps between the objects equal. With anchor points selected, the buttons space the points among themselves and do not use the artboard. When an action plays, its Distribute Objects and Distribute Spacing steps with Align to Artboard use the artboard that is active then. From c5f406ffabfdffa850f68f1cb199697a249e77a4 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 17:07:13 -0800 Subject: [PATCH 07/11] 3D: an image inside a clip's box but outside its shape is left out A 3D effect left an image in a clipping group out only when the image's box missed the clipping path's box. An image in the corner of a round clip's box, a turned image whose box reaches a clip but whose frame does not, an image in an even-odd hole, or one inside each of two nested clips but outside the area they show together was refused with the Image Trace message, although it draws nothing. The image's frame, the pixel rectangle as its transform places it, is now cut to every clipping path around it in turn, each with its own fill rule, and the image is left out when nothing is left. An image the clips show any part of, a strip narrower than a hundredth of a point included, is still refused. One effect's art cuts image frames at most 1,024 times; past that, or when a cut fails, the art is refused. --- crates/effects/src/solids/source.rs | 89 ++++++++++++++++++---- crates/effects/src/solids/tests_sources.rs | 80 +++++++++++++++++++ docs/target-app-parity.md | 1 + docs/three-d.md | 10 ++- 4 files changed, 164 insertions(+), 16 deletions(-) diff --git a/crates/effects/src/solids/source.rs b/crates/effects/src/solids/source.rs index 585f23bd6b..d71b2993af 100644 --- a/crates/effects/src/solids/source.rs +++ b/crates/effects/src/solids/source.rs @@ -8,7 +8,7 @@ use std::borrow::Cow; use std::sync::Arc; use vectorcraft_doc::{Appearance, AppearanceItem, Document, FillLayer, Node, NodeKind}; -use vectorcraft_geom::{FillRule, PathData, Rect}; +use vectorcraft_geom::{FillRule, PathData, Point, Rect, SubPath}; use vectorcraft_pathops as po; use super::{MAX_MEMBERS, Scope, geometry, is_container, members, profile, region, reshape_members, solid_paints}; @@ -24,6 +24,19 @@ const MAX_DEPTH: usize = 64; /// many steps. pub(super) const MAX_WORK: usize = 1 << 18; +/// The most times resolving one 3D effect's art may cut an image's frame to a clipping path to +/// learn whether the clip shows any of it ([`Resolver::hidden`]). +pub(super) const MAX_IMAGE_CUTS: usize = 1024; + +/// The area, in square points, under which what a clip shows of an image's frame counts as nothing: +/// the sliver left where the frame only touches the clipping path. +const SHOWN_AREA: f64 = 1e-9; + +/// The precision an image's frame is cut to a clipping path with: the cut drops pieces narrower +/// than this, so it is far below [`po::DEFAULT_PRECISION`], and a strip of the image much +/// narrower than a hundredth of a point still counts as shown. +const CUT_PRECISION: f64 = 1e-6; + /// The error for an image a 3D effect is asked to build from. pub(crate) fn image_refusal(label: &str) -> String { format!("{label} can't build a solid from an image: trace it first with Object › Image Trace › Make.") @@ -66,7 +79,7 @@ pub(crate) fn resolve_for<'a>(doc: Option<&Document>, n: &'a Node, label: &str, if !needs(n) { return Ok(Cow::Borrowed(n)); } - let mut r = Resolver { doc, label, every_fill, leaves: 0, work: 0, shown: None }; + let mut r = Resolver { doc, label, every_fill, leaves: 0, work: 0, cuts: 0, shown: None }; let children = r.content(n, 0); #[cfg(test)] WORK.with(|w| w.set(r.work)); @@ -90,8 +103,18 @@ struct Resolver<'a> { leaves: usize, /// The objects visited or made so far, counted against [`MAX_WORK`]. work: usize, - /// Inside clipping groups, the box of what their clipping paths show together. - shown: Option, + /// The image frames cut to clipping paths so far, counted against [`MAX_IMAGE_CUTS`]. + cuts: usize, + /// Inside clipping groups, what their clipping paths show together. + shown: Option, +} + +/// The clipping paths around the art being listed, outermost first, with the box of what they +/// show together. +#[derive(Clone)] +struct Shown { + bounds: Rect, + clips: Vec>, } impl Resolver<'_> { @@ -111,15 +134,52 @@ impl Resolver<'_> { /// `f` run on the art of `c`. The effects of an object or group can move its art into a clip, /// so inside one with effects no image is left out for lying outside the clip ([`Self::shown`]). fn inside(&mut self, c: &Node, f: impl FnOnce(&mut Self) -> Result) -> Result { - let outer = self.shown; - if has_effects(c) { - self.shown = None; + if !has_effects(c) { + return f(self); } + let outer = self.shown.take(); let out = f(self); self.shown = outer; out } + /// Does the plain image `c` lie wholly outside what the clips around it show together, each + /// clipping path with its own fill rule? Its frame, the pixel rectangle as its transform places + /// it, is cut to each clip in turn until nothing is left. An error when a cut fails or the cuts + /// pass [`MAX_IMAGE_CUTS`]: the image may show, so it can't be left out. + fn hidden(&mut self, c: &Node) -> Result { + let Some(shown) = &self.shown else { return Ok(false) }; + let NodeKind::Image(im) = &c.kind else { return Ok(false) }; + let Some(b) = c.visual_bounds() else { return Ok(true) }; + if b.intersect(shown.bounds).area() <= 0.0 { + return Ok(true); + } + let (w, h) = (f64::from(im.width), f64::from(im.height)); + let corners = [(0.0, 0.0), (w, 0.0), (w, h), (0.0, h)].map(|(x, y)| im.xf * Point::new(x, y)); + if corners.iter().any(|p| !p.x.is_finite() || !p.y.is_finite()) { + return Err(image_refusal(self.label)); + } + let clips = shown.clips.clone(); + let mut left = PathData::single(SubPath::polyline(&corners, true)); + for clip in clips { + let Some(here) = clip.0.bounds() else { return Ok(true) }; + if left.bounds().is_none_or(|l| l.intersect(here).area() <= 0.0) { + return Ok(true); + } + self.cuts += 1; + if self.cuts > MAX_IMAGE_CUTS { + return Err(self.too_much_work()); + } + self.spend(1)?; + left = + po::try_boolean(&left, FillRule::NonZero, &clip.0, clip.1, po::BoolOp::Intersect, CUT_PRECISION).map_err(|_| self.too_complex())?; + if left.subpaths.iter().all(|s| s.area().abs() <= SHOWN_AREA) { + return Ok(true); + } + } + Ok(false) + } + /// The art `n`, a symbol instance, a blend, a clipping group or layer, or a group holding one, /// shows, as the members of a plain group. fn content(&mut self, n: &Node, depth: usize) -> Result>, String> { @@ -162,11 +222,15 @@ impl Resolver<'_> { if clip_nested_past(first, MAX_DEPTH.saturating_sub(depth)) { return Err(self.too_deep()); } - let clip = clip_area(first); - // An image wholly outside every clip around it shows nothing and is left out. - let outer = self.shown; + let clip = Arc::new(clip_area(first)); + // An image wholly outside what the clips around it show together draws nothing and + // is left out. let here = clip.0.bounds().unwrap_or(Rect::ZERO); - self.shown = Some(outer.map_or(here, |o| o.intersect(here))); + let outer = self.shown.take(); + let mut inner_shown = outer.clone().unwrap_or(Shown { bounds: here, clips: vec![] }); + inner_shown.bounds = inner_shown.bounds.intersect(here); + inner_shown.clips.push(clip.clone()); + self.shown = Some(inner_shown); let listed = self.list(rest, depth + 1); self.shown = outer; let (inner, _) = listed?; @@ -198,8 +262,7 @@ impl Resolver<'_> { // Only an image with no effects or fills and strokes of its own is measured by its // frame; any other one may draw inside the clip. let plain = !has_effects(c) && c.appearance.items.iter().all(|i| !i.visible()); - let hidden = plain && self.shown.is_some_and(|k| c.visual_bounds().is_none_or(|b| b.intersect(k).area() <= 0.0)); - if hidden { + if plain && self.hidden(c)? { changed = true; continue; } diff --git a/crates/effects/src/solids/tests_sources.rs b/crates/effects/src/solids/tests_sources.rs index b2f6daf1c6..b7a9429fd9 100644 --- a/crates/effects/src/solids/tests_sources.rs +++ b/crates/effects/src/solids/tests_sources.rs @@ -317,6 +317,86 @@ fn images_are_refused_alone_and_in_a_group_with_image_trace_named() { } } +/// A clipping group: `art` inside a clipping path of `path` filled under `rule`. +fn shaped_clip_group(id: u64, path: PathData, rule: FillRule, art: Vec) -> Node { + let mut clipping = Node::path(NodeId(id + 1), path, Appearance::default()); + if let NodeKind::Path { clipping: c, rule: r, .. } = &mut clipping.kind { + *c = true; + *r = rule; + } + let children = std::iter::once(clipping).chain(art).map(Arc::new).collect(); + Node::new(NodeId(id), NodeKind::Group { children, clip: true }) +} + +/// An image `width` by `height` pixels placed by `xf`. +fn placed(id: u64, width: u32, height: u32, xf: Affine) -> Node { + let mut img = image(id); + if let NodeKind::Image(i) = &mut img.kind { + i.width = width; + i.height = height; + i.xf = xf; + } + img +} + +#[test] +fn an_image_inside_a_clips_box_but_outside_its_shape_is_left_out() { + let d = document(); + let circle = || shapes::ellipse(Rect::new(100.0, 100.0, 200.0, 200.0)); + let square = || shapes::rectangle(Rect::new(100.0, 100.0, 150.0, 150.0)); + let at = |x: f64, y: f64| Affine::translate((x, y)); + // Two circles inside each other's clips: they show together only the lens between them. + let lens = |art: Vec| { + let inner = shaped_clip_group(70, shapes::ellipse(Rect::new(120.0, 100.0, 220.0, 200.0)), FillRule::NonZero, art); + shaped_clip_group(80, shapes::ellipse(Rect::new(80.0, 100.0, 180.0, 200.0)), FillRule::NonZero, vec![inner]) + }; + let mut ring = shapes::rectangle(Rect::new(100.0, 100.0, 200.0, 200.0)); + ring.subpaths.extend(shapes::rectangle(Rect::new(130.0, 130.0, 170.0, 170.0)).subpaths); + // Turned 45° about its center at (156, 156): its box reaches into the square's corner, and its + // frame does not. + let turned = at(156.0, 156.0) * Affine::rotate(std::f64::consts::FRAC_PI_4) * at(-5.0, -5.0); + // A shown rectangle and an image `w` by `h` at `xf` inside the clipping path `path`. + let clipped = |path: PathData, rule: FillRule, shown: Rect, (w, h, xf): (u32, u32, Affine)| { + shaped_clip_group(60, path, rule, vec![rect(62, shown.x0, shown.y0, shown.x1, shown.y1, 0.0), placed(63, w, h, xf)]) + }; + let middle = Rect::new(140.0, 140.0, 160.0, 160.0); + let band = Rect::new(100.0, 100.0, 110.0, 200.0); + let left_out = [ + // In the corner of the circle's box, outside the circle. + ("a circle's corner", clipped(circle(), FillRule::NonZero, middle, (10, 10, at(100.0, 100.0)))), + ("a turned frame", clipped(square(), FillRule::NonZero, Rect::new(110.0, 110.0, 140.0, 140.0), (10, 10, turned))), + // Above the lens: inside each circle, outside both at once. + ("two clips' shared area", lens(vec![rect(62, 145.0, 140.0, 155.0, 160.0, 0.0), placed(63, 8, 3, at(146.0, 100.0))])), + ("an even-odd hole", clipped(ring.clone(), FillRule::EvenOdd, band, (10, 10, at(140.0, 140.0)))), + ]; + let shown = [ + ("part of the circle", clipped(circle(), FillRule::NonZero, middle, (10, 10, at(145.0, 145.0)))), + ("a non-zero hole", clipped(ring, FillRule::NonZero, band, (10, 10, at(140.0, 140.0)))), + // A strip 0.005 pt wide inside the square still shows. + ("a narrow strip", clipped(square(), FillRule::NonZero, Rect::new(110.0, 110.0, 140.0, 140.0), (10, 10, at(149.995, 120.0)))), + ("the lens", lens(vec![rect(62, 145.0, 140.0, 155.0, 160.0, 0.0), placed(63, 8, 3, at(146.0, 108.0))])), + ]; + for id in TYPES { + for (what, g) in &left_out { + let n = with(g.clone(), id, json!({})); + assert_eq!(validate_three_d_in(Some(&d), &n, id, &json!({})), Ok(()), "{id}: {what}"); + assert!(art(&d, &n, id).is_some(), "{id}: {what} draws"); + assert!(three_d_meshes_in(Some(&d), &n).is_ok_and(|m| !m.is_empty()), "{id}: {what} exports"); + } + for (what, g) in &shown { + let n = with(g.clone(), id, json!({})); + assert!(validate_three_d_in(Some(&d), &n, id, &json!({})).is_err_and(|e| e.contains("Image Trace")), "{id}: {what}"); + assert!(art(&d, &n, id).is_none(), "{id}: {what}"); + } + } + // Each frame cut to a clip counts: past the limit the art is refused, never left out unchecked. + let many: Vec = std::iter::once(rect(62, 140.0, 140.0, 160.0, 160.0, 0.0)) + .chain((0..=source::MAX_IMAGE_CUTS as u64).map(|i| placed(100 + i, 10, 10, at(100.0, 100.0)))) + .collect(); + let n = with(shaped_clip_group(60, circle(), FillRule::NonZero, many), ROTATE, front()); + assert!(validate_three_d_in(Some(&d), &n, ROTATE, &json!({})).is_err_and(|e| e.contains("too many objects"))); +} + /// Symbols `L0` to `L{levels}`: `L0` holds `leaf`, each later one `fan` instances of the one /// before it. fn nested_symbols(levels: usize, fan: usize, leaf: Node) -> Document { diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 11dc8cb895..97b0e7c3c0 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -572,6 +572,7 @@ since 2026-10-01. - Window › 3D and Materials panel: `crates/ui-egui/src/panels/three_d.rs` applies a 3D effect by object type (Plane, Extrude, Revolve, Inflate), changes an applied one in place (`effect.setParams` `threeDType`) and edits it with the options dialog's Object, Rotation and Lighting controls, one undo step per change; `crates/engine/src/cmd/effectcmd/three_d_type_tests.rs` and `crates/ui-egui/src/panels/tests_three_d.rs` cover it. No Materials tab. - 3D and Materials › Quick Actions › Expand as Wireframes (#6354): `effect.expandAsWireframes` replaces each selected 3D object with a group of 0.5 pt black stroked paths, the edges of the faces its effect draws, each edge once and joined end to end, curves kept, in one undo step ([three-d.md](three-d.md#3d-and-materials-panel)); `crates/effects/src/solids/wireframe/tests.rs`, `crates/engine/src/cmd/wireframe/tests.rs` and `crates/ui-egui/src/panels/tests_three_d.rs` cover it. Scores and hours unchanged. - 3D effects on clipping groups, blends and symbol instances (#6140, #5725): every 3D type builds from the art the object shows (the art inside the clipping path, the blend's keys and steps, the symbol's art where the instance places it), alone or in a group, on the canvas, in exports, Object › Rasterize and `assets.add3D`; an image is refused with a pointer to Object › Image Trace › Make. `crates/effects/src/solids/tests_sources.rs`, `crates/engine/src/cmd/effectcmd/three_d_source_tests.rs` and `fx::tests::a_symbol_instance_a_blend_and_a_clipping_group_draw_their_solids` cover it. Scores and estimates unchanged. +- 3D effects, images a clip hides: an image in a clipping group is left out of the solid when its transformed frame lies wholly outside the area every clipping path around it fills together, holes included, instead of when only the boxes miss; an image the clips show part of is still refused ([the help page](https://helpx.adobe.com/illustrator/using/clipping-masks.html) on clipping paths hiding everything outside their shape; the omission rule is VectorCraft's own). `solids::tests_sources::an_image_inside_a_clips_box_but_outside_its_shape_is_left_out` covers it. Scores and estimates unchanged. - **Status-bar pointer readout (2026-10-18):** uses the same effective ruler origin as the position fields, then formats the relative X/Y in General units. The absolute pointer position, document, selection, Undo/Redo and saved state are preserved. Three `chrome::tests::status_coordinates_*` painted-text regressions cover zero/positive/negative origins, unit conversion, global/artboard mode, active artboard, reset and missing pointer/document. Original headless status-bar captures were reviewed before and after the change. Scores and hours are unchanged. diff --git a/docs/three-d.md b/docs/three-d.md index 2eb3e6c7fe..e4cd4fa76f 100644 --- a/docs/three-d.md +++ b/docs/three-d.md @@ -177,9 +177,13 @@ solids are ([Groups](#groups)), turned about one shared axis. - The objects stay live: editing the clipping path, the blend's keys or the symbol reshapes the solid. - An image is refused, alone or in a group: trace it first with Object › Image Trace › Make. The help page routes images to 3D through Image Trace ([Create 3D vector artwork](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/create-3d-graphics/create-3d-vector-artwork.html)). - A hidden image in a group is left out like any hidden member, and so is an image in a clipping group whose bounds - lie wholly outside the clipping path's bounds, when the image has no visible fills or strokes of its own and - neither the image nor a group holding it inside the clipping group has effects. + A hidden image in a group is left out like any hidden member. An image in a clipping group is left out when its + frame, as its transform places it, lies wholly outside the area that every clipping path around it fills together + (holes of an even-odd or compound clipping path included), when the image has no visible fills or strokes of its + own and neither the image nor a group holding it inside the clipping group has effects. A clipping path hides + everything outside its shape ([the help page](https://helpx.adobe.com/illustrator/using/clipping-masks.html)). An + image that the clips show any part of is refused. Resolving one effect's art cuts image frames to clipping paths at + most 1,024 times (VectorCraft's own limit); past that, the art is refused. ## Rotate From e51685dc2338fa4f3efab5b3bc23e16a78a37f28 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 17:15:04 -0800 Subject: [PATCH 08/11] Effects: a symbol instance's fill or stroke effects reshape its art and expand A geometry effect on a symbol instance's topmost fill now reshapes the filled shapes of the symbol's art, and one on its topmost stroke reshapes the stroked shapes, keeping their colors, strokes and other detail. Only a visible topmost fill or stroke with an opacity above 0 counts. Clipping paths, guides and opacity-mask art keep their shapes; a group's own fills and strokes in the art take the effects; a nested instance passes them on to its own art, except one whose symbol holds the outer symbol. The canvas, Use Preview Bounds and the drawn bounds, SVG and PDF export and Object > Expand Appearance use the same art. Expand Appearance is enabled for such an instance and turns it into an editable group in one undo step. Bounds measure the art with a dynamic symbol's overrides, as the canvas draws it. Existing documents with a geometry effect on a symbol instance's fill or stroke draw differently: before, the effect drew nothing. --- crates/effects/src/bake.rs | 40 +++- crates/effects/src/reshape.rs | 142 +++++++++++++- crates/engine/src/cmd/effectcmd.rs | 43 ++++- crates/engine/src/tests/expand.rs | 296 +++++++++++++++++++++++++++++ crates/render/src/fx/bounds.rs | 64 ++++++- docs/mcp.md | 5 +- docs/target-app-parity.md | 1 + 7 files changed, 574 insertions(+), 17 deletions(-) diff --git a/crates/effects/src/bake.rs b/crates/effects/src/bake.rs index 55a904d02c..2b4953d151 100644 --- a/crates/effects/src/bake.rs +++ b/crates/effects/src/bake.rs @@ -104,6 +104,15 @@ fn path_kind(d: &mut Document, path: PathData, rule: FillRule) -> NodeKind { } } +thread_local! { + /// How many symbol instances deep [`bake_node`] is baking, through the art of instances whose + /// effects reshape it. + static INSTANCE_DEPTH: std::cell::Cell = const { std::cell::Cell::new(0) }; + /// The objects of symbol art that [`bake_document`] has baked for instances inside the art of + /// other instances, counted against [`crate::SYMBOL_ART_MAX_NODES`]. + static NESTED_NODES: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + /// `n` with its geometry effects evaluated (`None` = unchanged). fn bake_node(d: &mut Document, n: &Node) -> Option { // A cancelled export leaves the rest of the document as it is: the caller drops the result. @@ -143,8 +152,27 @@ fn bake_node(d: &mut Document, n: &Node) -> Option { } _ => None, }; + // Instances baked inside the art of others stop at the depth limit, or once their art + // passes the objects one export bakes that way: an instance of a symbol whose art holds + // instances of itself is kept as it is there. + let instance = matches!(n.kind, NodeKind::SymbolInstance { .. }); + if instance && INSTANCE_DEPTH.with(|c| c.get()) > 0 { + let count = symbol.as_ref().map_or(0, |a| a.count() as u64); + let spent = NESTED_NODES.with(|c| c.get()).saturating_add(count); + if INSTANCE_DEPTH.with(|c| c.get()) >= vectorcraft_doc::depth::MAX_DEPTH || spent > crate::SYMBOL_ART_MAX_NODES { + return None; + } + NESTED_NODES.with(|c| c.set(spent)); + } let m = reshape(Some(d), n, symbol.as_ref())?; - return Some(bake_node(d, &m).unwrap_or(m)); + if instance { + INSTANCE_DEPTH.with(|c| c.set(c.get() + 1)); + } + let baked = bake_node(d, &m); + if instance { + INSTANCE_DEPTH.with(|c| c.set(c.get() - 1)); + } + return Some(baked.unwrap_or(m)); } // A path whose Transform effect copies it with a gradient, or scales its strokes or moves its // patterns: each copy an object of its own, so its gradient fits it rather than spanning @@ -531,7 +559,9 @@ fn hoist_transparency(mut p: Node) -> Node { } /// [`expand_leaf`] every path and compound path in `n` (art made for Expand Appearance, such as a -/// group's own fills and strokes or outlined type), in place. +/// group's own fills and strokes or outlined type), in place. A group or layer in it with an +/// appearance of its own ([`crate::has_container_appearance`]) is first made into the art that +/// appearance paints ([`crate::evaluate_container`]). pub fn expand_art(d: &mut Document, n: &mut Node, stroke_art: StrokeArt) { if matches!(n.kind, NodeKind::Path { clipping: false, guide: false, .. } | NodeKind::Compound { .. }) { if let Some(m) = expand_leaf(d, n, stroke_art) { @@ -539,6 +569,11 @@ pub fn expand_art(d: &mut Document, n: &mut Node, stroke_art: StrokeArt) { } return; } + if crate::has_container_appearance(n) + && let Some(m) = crate::evaluate_container(Some(&*d), n) + { + *n = m; + } for c in n.children_mut().into_iter().flatten() { expand_art(d, Arc::make_mut(c), stroke_art); } @@ -661,6 +696,7 @@ pub fn bake_document(doc: &Document) -> Option { }; } let mut d = placed.into_owned(); + NESTED_NODES.with(|c| c.set(0)); let layers = d.layers.clone(); d.layers = layers.iter().map(|l| bake_node(&mut d, l).map(Arc::new).unwrap_or_else(|| l.clone())).collect(); Some(d) diff --git a/crates/effects/src/reshape.rs b/crates/effects/src/reshape.rs index 16c271dca0..c7da7beafa 100644 --- a/crates/effects/src/reshape.rs +++ b/crates/effects/src/reshape.rs @@ -130,19 +130,142 @@ pub fn has_painted_effects(n: &Node) -> bool { } /// Is `n` type without inline graphics that has a fill or stroke of its own that paints and has a -/// visible geometry effect, or an image with a fill or stroke of its own that paints a shape -/// ([`image_rows`])? On type, such an effect reshapes the whole outline that its fill or stroke -/// paints ([`outline_text`], which leaves inline graphics out). +/// visible geometry effect, an image with a fill or stroke of its own that paints a shape +/// ([`image_rows`]), or a symbol instance whose active fill or stroke has a visible geometry effect +/// ([`active_fill`], [`active_stroke`])? On type, such an effect reshapes the whole outline that its +/// fill or stroke paints ([`outline_text`], which leaves inline graphics out). On a symbol +/// instance, it reshapes the fills or strokes of the art it places ([`add_instance_row_effects`]). pub fn has_item_geometry(n: &Node) -> bool { match &n.kind { NodeKind::Text(t) => { t.runs.iter().all(|r| r.inline.is_none()) && n.appearance.items.iter().any(|i| crate::paints(i) && crate::has_geometry(i.effects())) } NodeKind::Image(_) => n.appearance.items.iter().any(shapes_image), + NodeKind::SymbolInstance { .. } => { + active_fill(n).is_some_and(|f| crate::has_geometry(&f.effects)) || active_stroke(n).is_some_and(|s| crate::has_geometry(&s.effects)) + } _ => false, } } +/// The fill of symbol instance `n` whose effects its art takes: its topmost fill, the one that +/// stains the art, when it is visible and its opacity is above 0. A lower fill's effects do not +/// reach the art. +fn active_fill(n: &Node) -> Option<&vectorcraft_doc::FillLayer> { + n.appearance.fill().filter(|f| f.visible && f.opacity > 0.0) +} + +/// The stroke of symbol instance `n` whose effects its art takes: its topmost stroke, when it is +/// visible and its opacity is above 0. +fn active_stroke(n: &Node) -> Option<&vectorcraft_doc::StrokeLayer> { + n.appearance.stroke().filter(|s| s.visible && s.opacity > 0.0) +} + +/// The visible geometry effects in `fx`, in order. +fn geometry_of(fx: &[Effect]) -> Vec { + fx.iter().filter(|e| e.visible && is_geometry(&e.id)).cloned().collect() +} + +/// `art`, the art symbol instance `n` places, with the visible geometry effects of `n`'s active +/// fill added after the effects of every fill in it that paints, and those of its active stroke +/// after the effects of every stroke in it that paints ([`active_fill`], [`active_stroke`]). Each +/// fill or stroke keeps its paint, and the others keep their shapes. Live objects in it that are +/// reached are evaluated first (with the symbols of `doc`), so the effects reshape what they draw. +/// Clip groups' clipping paths, guides and the art of opacity masks keep their shapes. A symbol +/// instance in the art takes the effects on its own active fill or stroke, or on a new fill or +/// stroke without paint when it has none, and passes them on to its own art in turn; an instance +/// of a symbol whose art (in `doc`) holds an instance of `n`'s symbol, at any depth, keeps its +/// appearance, so the effects never pass round a symbol that holds itself. Without `doc`, no +/// instance in the art takes them. +fn add_instance_row_effects(doc: Option<&Document>, n: &Node, art: &mut Node) { + let NodeKind::SymbolInstance { symbol, .. } = &n.kind else { return }; + let fill = active_fill(n).map_or(vec![], |f| geometry_of(&f.effects)); + let stroke = active_stroke(n).map_or(vec![], |s| geometry_of(&s.effects)); + if !fill.is_empty() || !stroke.is_empty() { + add_row_effects(doc, symbol, art, &fill, &stroke); + } +} + +/// Does the art of the symbol named `from` in `doc` hold an instance of the symbol named `to`, at +/// any depth of the symbols placed in it? `seen` holds the symbols already looked into. +fn places_symbol(doc: &Document, from: &str, to: &str, seen: &mut HashSet) -> bool { + if !seen.insert(from.to_string()) { + return false; + } + let Some(sym) = doc.symbols.iter().find(|s| s.name == from) else { return false }; + let mut names = vec![]; + sym.art.walk(&mut |c| { + if let NodeKind::SymbolInstance { symbol, .. } = &c.kind { + names.push(symbol.clone()); + } + }); + names.iter().any(|name| name == to || places_symbol(doc, name, to, seen)) +} + +/// [`add_instance_row_effects`] of `fill` and `stroke` to `n` and the art in it, the art of an +/// instance of the symbol named `outer`. +fn add_row_effects(doc: Option<&Document>, outer: &str, n: &mut Node, fill: &[Effect], stroke: &[Effect]) { + match &n.kind { + NodeKind::Path { clipping: true, .. } | NodeKind::Path { guide: true, .. } | NodeKind::RulerGuide { .. } => return, + NodeKind::SymbolInstance { symbol, .. } => { + if let Some(d) = doc + && !places_symbol(d, symbol, outer, &mut HashSet::new()) + { + add_to_instance(n, fill, stroke); + } + return; + } + _ if vectorcraft_doc::live::is_live(n) => *n = evaluated(doc, n), + _ => {} + } + let takes = |i: &AppearanceItem| { + crate::paints(i) && !matches!(i, AppearanceItem::Fill(_) if fill.is_empty()) && !matches!(i, AppearanceItem::Stroke(_) if stroke.is_empty()) + }; + if n.appearance.items.iter().any(takes) { + for item in n.appearance.items.iter_mut().filter(|i| takes(i)) { + let fx = if matches!(item, AppearanceItem::Fill(_)) { fill } else { stroke }; + item.effects_mut().extend_from_slice(fx); + } + } + // A compound path paints its members with its own fills and strokes. + if matches!(n.kind, NodeKind::Compound { .. }) { + return; + } + // A clip group's first member is its clipping path, whatever its kind. + let clips = n.clips(); + for (i, c) in n.children_mut().into_iter().flatten().enumerate() { + if !(clips && i == 0) { + add_row_effects(doc, outer, Arc::make_mut(c), fill, stroke); + } + } +} + +/// Add `fill` to the effects of symbol instance `n`'s active fill and `stroke` to those of its +/// active stroke ([`active_fill`], [`active_stroke`]); without one, to a new topmost fill or stroke +/// without paint, which neither stains nor strokes its art. +fn add_to_instance(n: &mut Node, fill: &[Effect], stroke: &[Effect]) { + if !fill.is_empty() { + match n.appearance.fill_mut().filter(|f| f.visible && f.opacity > 0.0) { + Some(f) => f.effects.extend_from_slice(fill), + None => { + let mut f = vectorcraft_doc::FillLayer::new(vectorcraft_doc::color::Paint::None); + f.effects = fill.to_vec(); + n.appearance.items.push(AppearanceItem::Fill(f)); + } + } + } + if !stroke.is_empty() { + match n.appearance.stroke_mut().filter(|s| s.visible && s.opacity > 0.0) { + Some(s) => s.effects.extend_from_slice(stroke), + None => { + let mut s = vectorcraft_doc::StrokeLayer::new(vectorcraft_doc::color::Paint::None, 0.0); + s.effects = stroke.to_vec(); + n.appearance.items.push(AppearanceItem::Stroke(s)); + } + } + } +} + /// Does effect `e` give an image's fill or stroke a shape to paint (a visible Convert to Shape or /// Outline Object)? fn gives_shape(e: &Effect) -> bool { @@ -302,7 +425,9 @@ fn with_id(art: &Node, id: NodeId) -> Node { /// `n` as plain art that geometry effects can reshape, without its transparency and object /// effects: type outlined ([`outline_text`]), live objects evaluated (type inside them outlined), /// a symbol instance as `symbol_art` (the symbol's art, stained as the caller draws it) placed by -/// the instance's transform (through the symbol's 9-slice guides in `doc`, when it has them). +/// the instance's transform (through the symbol's 9-slice guides in `doc`, when it has them), its +/// fills and strokes carrying the geometry effects of the instance's active fill and stroke +/// ([`add_instance_row_effects`]). /// `None` for other kinds (images, paths, containers), and for an instance whose art placed /// through the guides could hold more than [`vectorcraft_doc::MAX_SYMBOL_NODES`] objects. A Repeat counts the Repeats in the art of /// `doc`'s symbols that its instances show toward its instance budget. @@ -351,6 +476,9 @@ fn outline_art_with( _ => return None, }; outline_nested_text(paint_doc, &mut art, false); + if matches!(n.kind, NodeKind::SymbolInstance { .. }) { + add_instance_row_effects(symbols, n, &mut art); + } art.opacity = 1.0; art.blend = Default::default(); art.isolate = false; @@ -497,8 +625,10 @@ pub fn reshape(doc: Option<&Document>, n: &Node, symbol_art: Option<&Node>) -> O /// `n` (see [`needs_outline`], or a path with [`has_transform_art`]) as plain art for Expand /// Appearance: [`reshape`]d when it has -/// geometry effects, else type with fills or strokes of its own outlined, or an image with fills -/// or strokes of its own that paint a shape, in the same form. `None` when it has none of these. +/// geometry effects, else type with fills or strokes of its own outlined, an image with fills or +/// strokes of its own that paint a shape, or a symbol instance's art reshaped by the geometry +/// effects of its active fill or stroke ([`has_item_geometry`]), in the same form. `None` when it +/// has none of these. pub fn expand_outlined(doc: Option<&Document>, n: &Node, symbol_art: Option<&Node>) -> Option { // A path whose Transform effect copies it, or scales its strokes or moves its patterns: each // copy an object of its own with the path's fills and strokes, the copies of each Transform diff --git a/crates/engine/src/cmd/effectcmd.rs b/crates/engine/src/cmd/effectcmd.rs index 8040b0016c..72a72e6411 100644 --- a/crates/engine/src/cmd/effectcmd.rs +++ b/crates/engine/src/cmd/effectcmd.rs @@ -94,7 +94,7 @@ fn effect_specs() -> Vec { "Expand Appearance", ["Object"], None, - "{ids?|id?: [..], target?} turn each object's appearance into objects: every fill and stroke becomes an object of its own (a stroke stays a stroke, with its weight, dashes, arrowheads and width profile, on its own copy of the path, which object.expand or object.path.outlineStroke outline; brushed strokes become their brush art) grouped in paint order under the object's id and transparency, geometry effects are baked, raster effects become an embedded image (shadows and outer glows below the art; blur, feather and inner glow replace it; an object whose only raster effect is its own Rasterize becomes the image object.rasterize makes with the effect's options), type with effects or fills of its own is outlined; Crop Marks become a group of the object and its marks; Vector Halftone (addon.color.halftone) becomes a group of its dot paths, one per ink; a group's or layer's own fills and strokes become objects among its members, which are expanded too. An outlined frame of threaded text leaves its thread with the text it shows, and a frame kept as type in a group stays in its thread. A dimension whose art it changes becomes a plain group of its art, except one selected with art it measures, directly or in groups without fills, strokes or effects of their own: its lines and label stay as they are, and if the new art no longer fits its link it stays where it was, no longer linked. Hidden fills, strokes and effects are dropped. In Safe Mode, an object with a visible plug-in effect (in it, or in a symbol it places) is an error and nothing changes. Enabled when a selected or targeted object's appearance isn't basic (`ids` then pick which objects) → {ids, warning? (an image in the art couldn't be decoded, as object.rasterize says)}", + "{ids?|id?: [..], target?} turn each object's appearance into objects: every fill and stroke becomes an object of its own (a stroke stays a stroke, with its weight, dashes, arrowheads and width profile, on its own copy of the path, which object.expand or object.path.outlineStroke outline; brushed strokes become their brush art) grouped in paint order under the object's id and transparency, geometry effects are baked, raster effects become an embedded image (shadows and outer glows below the art; blur, feather and inner glow replace it; an object whose only raster effect is its own Rasterize becomes the image object.rasterize makes with the effect's options), type with effects or fills of its own is outlined; Crop Marks become a group of the object and its marks; Vector Halftone (addon.color.halftone) becomes a group of its dot paths, one per ink; a group's or layer's own fills and strokes become objects among its members, which are expanded too; a symbol instance whose topmost fill or stroke is visible, has an opacity above 0 and has a geometry effect becomes a group of its art, that fill's effects reshaping the art's filled shapes and that stroke's effects reshaping its stroked shapes, as the canvas draws them. An outlined frame of threaded text leaves its thread with the text it shows, and a frame kept as type in a group stays in its thread. A dimension whose art it changes becomes a plain group of its art, except one selected with art it measures, directly or in groups without fills, strokes or effects of their own: its lines and label stay as they are, and if the new art no longer fits its link it stays where it was, no longer linked. Hidden fills, strokes and effects are dropped. In Safe Mode, an object with a visible plug-in effect (in it, or in a symbol it places) is an error and nothing changes. Enabled when a selected or targeted object's appearance isn't basic (`ids` then pick which objects) → {ids, warning? (an image in the art couldn't be decoded, as object.rasterize says)}", can_expand, expand_appearance ), @@ -848,8 +848,9 @@ fn load_texture(params: &mut Value, cmd: &str) -> Result<()> { /// Has `n` an appearance Expand Appearance turns into objects: a path that isn't basic (several /// fills or strokes, effects, transparency of a fill or stroke, a brush), a group or layer with -/// fills, strokes or effects of its own or such members, other objects with effects, or type with -/// fills or strokes of its own? +/// fills, strokes or effects of its own or such members, other objects with effects, type with +/// fills or strokes of its own, or a symbol instance whose active fill or stroke has a geometry +/// effect ([`effects::has_item_geometry`])? pub(crate) fn expandable(n: &Node) -> bool { expandable_except(n, &[]) } @@ -866,7 +867,7 @@ pub(crate) fn expandable_except(n: &Node, skip: &[NodeId]) -> bool { !n.appearance.items.is_empty() || !n.appearance.effects.is_empty() || children.iter().any(|c| expandable_except(c, skip)) } NodeKind::Text(_) => !n.appearance.items.is_empty() || !n.appearance.effects.is_empty(), - NodeKind::Image(_) => effects::has_item_geometry(n) || !n.appearance.effects.is_empty(), + NodeKind::Image(_) | NodeKind::SymbolInstance { .. } => effects::has_item_geometry(n) || !n.appearance.effects.is_empty(), _ => !n.appearance.effects.is_empty(), } } @@ -939,6 +940,38 @@ fn expand_pieces( expanded } +/// Replace each symbol instance in `m`, art an instance placed (`depth` instances deep), whose +/// active fill or stroke has a geometry effect ([`effects::has_item_geometry`]) by its art as +/// [`effects::expand_outlined`] makes it, and the instances in that art in turn, so +/// [`expand_pieces`] bakes their effects with the rest. An instance stays as it is when it is more +/// than [`vectorcraft_doc::depth::MAX_DEPTH`] instances deep, its symbol is missing, or its art +/// would take the objects placed so far past `budget`. +fn expand_nested_instances(d: &Document, m: &mut Node, depth: usize, budget: &mut u64) { + for c in m.children_mut().into_iter().flatten() { + if !matches!(c.kind, NodeKind::SymbolInstance { .. }) { + if c.children().is_some() { + expand_nested_instances(d, Arc::make_mut(c), depth, budget); + } + continue; + } + let NodeKind::SymbolInstance { symbol, .. } = &c.kind else { continue }; + if depth >= vectorcraft_doc::depth::MAX_DEPTH || !effects::has_item_geometry(c) { + continue; + } + let Some(sym) = d.symbols.iter().find(|s| s.name == *symbol) else { continue }; + let count = sym.art.count() as u64; + if count > *budget { + continue; + } + *budget -= count; + let art = vectorcraft_render::instance_art(&sym.art, c); + if let Some(mut a) = effects::expand_outlined(Some(d), c, Some(&art)) { + expand_nested_instances(d, &mut a, depth + 1, budget); + *c = Arc::new(a); + } + } +} + /// Does `n` hold an id of `seen`, or one id twice: does [`effects::fresh_ids`] give it new ids? fn repeats_an_id(n: &Node, seen: &std::collections::HashSet) -> bool { let mut ids = std::collections::HashSet::new(); @@ -1094,6 +1127,8 @@ fn expanded_node(d: &mut Document, mut n: Node, brushes: &[vectorcraft_brush::Br _ => None, }; effects::expand_outlined(Some(&*d), &v, symbol.as_ref()).map(|mut m| { + let mut budget = vectorcraft_doc::MAX_SYMBOL_NODES; + expand_nested_instances(d, &mut m, 1, &mut budget); expand_pieces(d, &mut m, true, brushes, &mut stroke_art); m }) diff --git a/crates/engine/src/tests/expand.rs b/crates/engine/src/tests/expand.rs index 038b8844bb..3fd187016f 100644 --- a/crates/engine/src/tests/expand.rs +++ b/crates/engine/src/tests/expand.rs @@ -126,6 +126,302 @@ fn a_symbol_instance_fill_with_no_opacity_keeps_swatch_links_when_expanded() { assert!(linked > 0, "the expanded art keeps the Brand swatch link"); } +/// The square of [`square`] made a symbol, its instance selected, with a new fill (`fill`) or +/// stroke on top that has no paint: it neither stains nor strokes the art. Returns the instance and +/// the new row's index. +fn instance_with_row(s: &mut Session, name: &str, fill: bool) -> (NodeId, usize) { + square(s); + let id = NodeId(run(s, "symbol.new", json!({"name": name}))["id"].as_u64().unwrap()); + run(s, if fill { "appearance.addFill" } else { "appearance.addStroke" }, json!({"ids": [id.0]})); + let row = node(s, id).appearance.items.len() - 1; + run(s, "appearance.setItem", json!({"ids": [id.0], "index": row, "none": true})); + (id, row) +} + +fn offset_row(s: &mut Session, id: NodeId, row: usize, offset: f64) { + run(s, "effect.apply", json!({"ids": [id.0], "item": row, "effect": "path.offsetPath", "params": {"offset": offset}})); +} + +fn near(p: [u8; 4], q: [u8; 4]) -> bool { + p.iter().zip(q.iter()).all(|(x, y)| x.abs_diff(*y) <= 24) +} + +const WHITE: [u8; 4] = [255, 255, 255, 255]; +const RED: [u8; 4] = [255, 0, 0, 255]; +const BLUE: [u8; 4] = [0, 0, 255, 255]; + +/// No object in `n` keeps an effect of its own or on a fill or stroke. +fn no_effects_left(n: &Node) { + n.walk(&mut |part| { + assert!(part.appearance.effects.is_empty(), "{:?} keeps object effects", part.id); + assert!(part.appearance.items.iter().all(|i| i.effects().is_empty()), "{:?} keeps fill or stroke effects", part.id); + }); +} + +/// An effect on a symbol instance's fill reshapes the filled shapes of its art, keeping their +/// colors, and leaves their strokes where they were; Expand Appearance bakes the same look into +/// editable art in one undo step. +#[test] +fn a_symbol_instance_fill_effect_reshapes_its_filled_art_and_expands() { + let mut s = session(); + let (id, row) = instance_with_row(&mut s, "Square", true); + assert!(!expandable(&s)); + let plain = render(&s); + assert!(near(plain.pixel(44, 100), WHITE)); + offset_row(&mut s, id, row, 10.0); + assert!(expandable(&s), "the fill's effect can be expanded"); + let before = render(&s); + assert!(near(before.pixel(44, 100), RED), "the art's red fill grows by the offset: {:?}", before.pixel(44, 100)); + assert!(near(before.pixel(50, 100), BLUE), "the blue stroke stays on the square: {:?}", before.pixel(50, 100)); + + run(&mut s, "effect.expandAppearance", json!({})); + let n = node(&s, id); + assert!(!matches!(n.kind, NodeKind::SymbolInstance { .. })); + no_effects_left(&n); + let mut paints = (0, 0); + n.walk(&mut |part| { + if part.appearance.fill().is_some_and(|f| f.paint.color().is_some_and(|c| c.to_hex() == "#ff0000")) { + paints.0 += 1; + } + if part.appearance.stroke().is_some_and(|st| st.paint.color().is_some_and(|c| c.to_hex() == "#0000ff")) { + paints.1 += 1; + } + }); + assert!(paints.0 > 0 && paints.1 > 0, "the art keeps its red fill and blue stroke: {paints:?}"); + let after = render(&s); + assert!(differing(&before, &after, 8) < 200, "expanding changed {} pixels", differing(&before, &after, 8)); + + run(&mut s, "edit.undo", json!({})); + let n = node(&s, id); + assert!(matches!(n.kind, NodeKind::SymbolInstance { .. }), "one undo gives the instance back"); + assert!(n.appearance.items[row].effects().iter().any(|e| e.id == "path.offsetPath")); +} + +/// Export bakes a symbol instance's fill effect into its art as the canvas draws it. +#[test] +fn export_bakes_a_symbol_instance_fill_effect() { + let mut s = session(); + let (id, row) = instance_with_row(&mut s, "Square", true); + offset_row(&mut s, id, row, 10.0); + let doc = s.doc().unwrap().doc.clone(); + let baked = vectorcraft_render::effects::bake_document(&doc).expect("the instance is baked"); + let n = baked.node(id).expect("the baked art keeps the instance's id").clone(); + assert!(!matches!(n.kind, NodeKind::SymbolInstance { .. })); + no_effects_left(&n); + let b = n.geometric_bounds().expect("the baked art has bounds"); + assert!((b.x0 - 40.0).abs() < 1.0 && (b.x1 - 160.0).abs() < 1.0, "the fill grows by the offset: {b:?}"); +} + +/// An effect on a symbol instance's stroke reshapes the stroked shapes of its art and leaves +/// their fills where they were. +#[test] +fn a_symbol_instance_stroke_effect_reshapes_its_stroked_art_and_expands() { + let mut s = session(); + let (id, row) = instance_with_row(&mut s, "Square", false); + assert!(near(render(&s).pixel(40, 100), WHITE)); + offset_row(&mut s, id, row, 10.0); + assert!(expandable(&s)); + let before = render(&s); + assert!(near(before.pixel(40, 100), BLUE), "the stroke moves out with the offset: {:?}", before.pixel(40, 100)); + assert!(near(before.pixel(45, 100), WHITE), "the fill keeps its shape: {:?}", before.pixel(45, 100)); + assert!(near(before.pixel(100, 100), RED)); + + run(&mut s, "effect.expandAppearance", json!({"ids": [id.0]})); + let n = node(&s, id); + assert!(!matches!(n.kind, NodeKind::SymbolInstance { .. })); + no_effects_left(&n); + let after = render(&s); + assert!(differing(&before, &after, 8) < 200, "expanding changed {} pixels", differing(&before, &after, 8)); +} + +/// A dynamic symbol's instance that paints a stroke of its own on art without one: the instance's +/// stroke effect reshapes that stroke, and its bounds take in where the stroke is drawn. +#[test] +fn a_stroke_effect_reshapes_a_stroke_a_dynamic_instance_adds() { + let mut s = session(); + square(&mut s); + run(&mut s, "paint.setStroke", json!({"none": true})); + let id = NodeId(run(&mut s, "symbol.new", json!({"name": "Art", "type": "dynamic"}))["id"].as_u64().unwrap()); + run(&mut s, "appearance.addStroke", json!({"ids": [id.0]})); + let row = node(&s, id).appearance.items.len() - 1; + run(&mut s, "appearance.setItem", json!({"ids": [id.0], "index": row, "none": true})); + let blue = vectorcraft_color::Paint::solid(vectorcraft_color::Color::rgb(0.0, 0.0, 1.0)); + s.edit("paint", |d, _| { + let o = vectorcraft_doc::symbol_overrides::SymbolOverride { path: vec![], stroke: Some(blue), ..Default::default() }; + d.node_mut(id).ok_or(EngineError::NoNode(id))?.extras_mut().symbol_overrides.push(o); + Ok(()) + }) + .unwrap(); + // The 1 pt stroke's center moves to x = 30.5, so it covers the pixel column from 30 to 31. + offset_row(&mut s, id, row, 19.5); + assert!(near(render(&s).pixel(30, 100), BLUE), "the added stroke moves out with the offset: {:?}", render(&s).pixel(30, 100)); + let doc = s.doc().unwrap().doc.clone(); + let b = vectorcraft_render::painted_bounds(&doc, doc.node(id).unwrap()).expect("the instance has bounds"); + assert!(b.x0 <= 31.0 && b.x1 >= 169.0, "the bounds take in the offset stroke: {b:?}"); + let drawn = vectorcraft_render::drawn_bounds(&doc, doc.node(id).unwrap()).expect("the instance is drawn"); + assert!(drawn.x0 <= 31.0 && drawn.x1 >= 169.0, "the drawn bounds take in the offset stroke: {drawn:?}"); +} + +/// A group in the symbol's art with a fill of its own: the instance's fill effect reshapes what +/// that fill paints, and the instance's bounds take it in. +#[test] +fn a_fill_effect_reshapes_the_fill_of_a_group_in_the_art() { + let mut s = session(); + square(&mut s); + run(&mut s, "paint.setFill", json!({"none": true})); + run(&mut s, "paint.setStroke", json!({"none": true})); + let g = NodeId(run(&mut s, "object.group", json!({}))["id"].as_u64().unwrap()); + run(&mut s, "appearance.addFill", json!({"ids": [g.0]})); + run(&mut s, "paint.setFill", json!({"ids": [g.0], "item": 0, "color": "#ff0000"})); + let id = NodeId(run(&mut s, "symbol.new", json!({"name": "Group"}))["id"].as_u64().unwrap()); + run(&mut s, "appearance.addFill", json!({"ids": [id.0]})); + let row = node(&s, id).appearance.items.len() - 1; + run(&mut s, "appearance.setItem", json!({"ids": [id.0], "index": row, "none": true})); + offset_row(&mut s, id, row, 20.0); + assert!(near(render(&s).pixel(35, 100), RED), "the group's fill grows by the offset: {:?}", render(&s).pixel(35, 100)); + let doc = s.doc().unwrap().doc.clone(); + let b = vectorcraft_render::painted_bounds(&doc, doc.node(id).unwrap()).expect("the instance has bounds"); + assert!(b.x0 <= 31.0 && b.x1 >= 169.0, "the bounds take in the group's grown fill: {b:?}"); + let drawn = vectorcraft_render::drawn_bounds(&doc, doc.node(id).unwrap()).expect("the instance is drawn"); + assert!(drawn.x0 <= 31.0 && drawn.x1 >= 169.0, "the drawn bounds take in the group's grown fill: {drawn:?}"); + + let before = render(&s); + run(&mut s, "effect.expandAppearance", json!({})); + no_effects_left(&node(&s, id)); + let after = render(&s); + assert!(differing(&before, &after, 8) < 200, "expanding changed {} pixels", differing(&before, &after, 8)); +} + +/// Of several fills, only the topmost one's effects reach the art, and only while it is visible. +#[test] +fn only_the_topmost_visible_instance_fill_reshapes_the_art() { + let mut s = session(); + let (id, lower) = instance_with_row(&mut s, "Square", true); + offset_row(&mut s, id, lower, 30.0); + run(&mut s, "appearance.addFill", json!({"ids": [id.0]})); + let top = node(&s, id).appearance.items.len() - 1; + run(&mut s, "appearance.setItem", json!({"ids": [id.0], "index": top, "none": true})); + assert!(!expandable(&s), "a lower fill's effect does not reach the art"); + offset_row(&mut s, id, top, 10.0); + let both = render(&s); + assert!(near(both.pixel(44, 100), RED), "the top fill's offset shows"); + assert!(near(both.pixel(25, 100), WHITE), "the lower fill's offset does not: {:?}", both.pixel(25, 100)); + + run(&mut s, "appearance.setItem", json!({"ids": [id.0], "index": top, "visible": false})); + assert!(near(render(&s).pixel(44, 100), WHITE), "a hidden top fill reshapes nothing"); + assert!(!expandable(&s)); + + run(&mut s, "appearance.setItem", json!({"ids": [id.0], "index": top, "visible": true})); + run(&mut s, "effect.expandAppearance", json!({})); + let after = render(&s); + assert!(differing(&both, &after, 8) < 200, "expanding changed {} pixels", differing(&both, &after, 8)); + let n = node(&s, id); + no_effects_left(&n); +} + +/// An instance inside a symbol's art passes the outer instance's fill effect on to its own art. +#[test] +fn a_nested_symbol_instance_takes_the_outer_fill_effect() { + let mut s = session(); + square(&mut s); + run(&mut s, "symbol.new", json!({"name": "Inner"})); + let id = NodeId(run(&mut s, "symbol.new", json!({"name": "Outer"}))["id"].as_u64().unwrap()); + run(&mut s, "appearance.addFill", json!({"ids": [id.0]})); + let row = node(&s, id).appearance.items.len() - 1; + run(&mut s, "appearance.setItem", json!({"ids": [id.0], "index": row, "none": true})); + offset_row(&mut s, id, row, 40.0); + let before = render(&s); + assert!(near(before.pixel(20, 100), RED), "the inner art grows by the offset: {:?}", before.pixel(20, 100)); + assert!(expandable(&s)); + // A view that shows only the grown part, right of the square: the instance is still drawn. + let doc = s.doc().unwrap().doc.clone(); + let opts = vectorcraft_render::RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() }; + let right = vectorcraft_render::Renderer::new().render(&doc, 200, 200, vectorcraft_geom::Affine::translate((-155.0, 0.0)), &opts); + assert!(near(right.pixel(20, 100), RED), "the grown art past the square's box is drawn: {:?}", right.pixel(20, 100)); + let b = vectorcraft_render::painted_bounds(&doc, doc.node(id).unwrap()).expect("the instance has bounds"); + assert!(b.x0 <= 11.0 && b.x1 >= 189.0, "the bounds take in the inner art's offset: {b:?}"); + + run(&mut s, "effect.expandAppearance", json!({})); + let n = node(&s, id); + assert!(!matches!(n.kind, NodeKind::SymbolInstance { .. })); + n.walk(&mut |part| assert!(!matches!(part.kind, NodeKind::SymbolInstance { .. }), "the inner instance is expanded too")); + no_effects_left(&n); + let after = render(&s); + assert!(differing(&before, &after, 8) < 200, "expanding changed {} pixels", differing(&before, &after, 8)); +} + +/// A symbol whose art holds an instance of itself draws, exports and expands without running away. +/// The engine keeps no such document after an edit, so Expand Appearance refuses it and the +/// instance stays as it was. +#[test] +fn a_fill_effect_on_an_instance_of_a_symbol_that_holds_itself_ends() { + let mut s = session(); + let (id, row) = instance_with_row(&mut s, "Square", true); + offset_row(&mut s, id, row, 4.0); + { + let d = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc); + let inst = d.node(id).unwrap().clone(); + let sym = d.symbols.iter_mut().find(|sy| sy.name == "Square").unwrap(); + sym.art = std::sync::Arc::new(Node::group(NodeId(90_000), vec![sym.art.clone(), std::sync::Arc::new(inst)])); + } + let doc = s.doc().unwrap().doc.clone(); + assert!(vectorcraft_render::effects::bake_document(&doc).is_some(), "export bakes the instance"); + assert!(vectorcraft_render::painted_bounds(&doc, doc.node(id).unwrap()).is_some(), "the instance is measured"); + render(&s); + assert!(expandable(&s)); + let e = s.execute("effect.expandAppearance", &json!({})).expect_err("the symbol nests without end"); + assert!(e.to_string().contains(crate::TOO_DEEP), "{e}"); + assert!(matches!(node(&s, id).kind, NodeKind::SymbolInstance { .. }), "the instance stays as it was"); +} + +/// A clip group's clipping path in the art keeps its shape, a compound one included, while the art +/// it clips takes the instance's fill effect. +#[test] +fn a_compound_clipping_path_in_the_art_keeps_its_shape() { + let mut s = session(); + let (id, row) = instance_with_row(&mut s, "Square", true); + offset_row(&mut s, id, row, 10.0); + let red = || { + let mut ap = vectorcraft_doc::Appearance::default(); + ap.items.push(vectorcraft_doc::AppearanceItem::Fill(vectorcraft_doc::FillLayer::new(vectorcraft_color::Paint::solid( + vectorcraft_color::Color::rgb(1.0, 0.0, 0.0), + )))); + ap + }; + let square = |id: u64, x: f64| { + let rect = vectorcraft_geom::Rect::new(x, 50.0, x + 100.0, 150.0); + std::sync::Arc::new(Node::path(NodeId(id), vectorcraft_geom::shapes::rectangle(rect), red())) + }; + let mut clip = Node::new(NodeId(90_001), NodeKind::Compound { children: vec![square(90_002, 40.0)], rule: Default::default() }); + clip.appearance = red().into(); + let group = Node::new(NodeId(90_003), NodeKind::Group { children: vec![std::sync::Arc::new(clip), square(90_004, 50.0)], clip: true }); + let doc = { + let d = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc); + let sym = d.symbols.iter_mut().find(|sy| sy.name == "Square").unwrap(); + sym.art = std::sync::Arc::new(group); + d.clone() + }; + let inst = doc.node(id).unwrap(); + let art = doc.symbols.iter().find(|sy| sy.name == "Square").unwrap().art.clone(); + let out = vectorcraft_render::effects::outline_art(Some(&doc), inst, Some(&art)).expect("the instance's art"); + let mut seen = (false, false); + out.walk(&mut |part| { + let offset = part.appearance.items.iter().any(|i| i.effects().iter().any(|e| e.id == "path.offsetPath")); + match part.id.0 { + 90_001 => { + seen.0 = true; + assert!(!offset, "the compound clipping path keeps its shape"); + } + 90_004 => { + seen.1 = true; + assert!(offset, "the clipped square takes the fill effect"); + } + _ => {} + } + }); + assert_eq!(seen, (true, true)); +} + #[test] fn two_fills_and_an_offset_stroke_become_three_paths_that_look_the_same() { let mut s = session(); diff --git a/crates/render/src/fx/bounds.rs b/crates/render/src/fx/bounds.rs index 58c8714306..6fbd9f37ac 100644 --- a/crates/render/src/fx/bounds.rs +++ b/crates/render/src/fx/bounds.rs @@ -4,6 +4,7 @@ use super::{ Affine, AppearanceItem, Arc, BezPath, Document, Effect, FillRule, GeomContext, Node, NodeKind, PathEl, Rect, Renderer, Shape, SymbolBoxes, effects, }; +use vectorcraft_doc::symbol_overrides::dynamic_art; fn node_bezpath(n: &Node) -> Option { match &n.kind { @@ -203,11 +204,14 @@ fn shape_bounds(symbols: &SymbolBoxes, n: &Node) -> Option { // effects in a symbol's art and a live object's content paint past it. _ if has_object_fx(n) => { let symbol = match &n.kind { - NodeKind::SymbolInstance { symbol, .. } => symbols.document().symbols.iter().find(|s| s.name == *symbol).map(|s| s.art.clone()), + NodeKind::SymbolInstance { symbol, .. } => { + symbols.document().symbols.iter().find(|s| s.name == *symbol).map(|s| dynamic_art(&s.art, n)) + } _ => None, }; - let reshaped = effects::reshape(Some(symbols.document()), n, symbol.as_deref()); + let reshaped = effects::reshape(Some(symbols.document()), n, symbol.as_ref()); let b = match &reshaped { + Some(r) if matches!(n.kind, NodeKind::SymbolInstance { .. }) && effects::has_item_geometry(n) => instance_piece_bounds(symbols, r), Some(r) if matches!(n.kind, NodeKind::Text(_)) || (matches!(n.kind, NodeKind::Image(_)) && !r.clips()) => { r.children().into_iter().flatten().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, painted_reach(symbols, c))) } @@ -230,6 +234,55 @@ fn shape_bounds(symbols: &SymbolBoxes, n: &Node) -> Option { } } +/// The bounds of `art`, the art [`effects::reshape`] made of a symbol instance whose active fill or +/// stroke has geometry effects ([`effects::has_item_geometry`]): its paths measured with the +/// geometry effects of their fills and strokes ([`visual_bounds`]), the symbol instances in it that +/// took those effects by their own reshaped art in turn, its groups and layers with their own fills +/// and strokes by the art those paint ([`effects::evaluate_container`]), its clip groups by their +/// clipping paths and other objects as [`Node::visual_bounds_with`] measures them. Hidden objects are left out. +/// An instance more than [`vectorcraft_doc::depth::MAX_DEPTH`] instances deep, or whose art would +/// take the objects measured so far past [`effects::SYMBOL_ART_MAX_NODES`], is measured as +/// [`Node::visual_bounds_with`] measures it. +fn instance_piece_bounds(symbols: &SymbolBoxes, art: &Node) -> Option { + let mut budget = effects::SYMBOL_ART_MAX_NODES; + pieces_within(symbols, art, 1, &mut budget) +} + +/// [`instance_piece_bounds`] of `art`, `depth` instances deep, with `budget` objects left to measure. +fn pieces_within(symbols: &SymbolBoxes, art: &Node, depth: usize, budget: &mut u64) -> Option { + if !art.visible { + return None; + } + match &art.kind { + NodeKind::Group { clip: false, .. } | NodeKind::Layer { clip: false, .. } => { + // A group's or layer's own fills and strokes as the art among its members that they paint. + let evaluated = effects::has_container_appearance(art).then(|| effects::evaluate_container(Some(symbols.document()), art)).flatten(); + let g = evaluated.as_ref().unwrap_or(art); + if g.clips() { + return g.visual_bounds_with(symbols); + } + g.children().into_iter().flatten().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, pieces_within(symbols, c, depth, budget))) + } + NodeKind::Path { .. } | NodeKind::Compound { .. } if has_fx(art) => visual_bounds(symbols, art), + NodeKind::SymbolInstance { symbol, .. } if depth < vectorcraft_doc::depth::MAX_DEPTH && effects::has_item_geometry(art) => { + let doc = symbols.document(); + let reshaped = doc.symbols.iter().find(|s| s.name == *symbol).and_then(|s| { + let count = s.art.count() as u64; + if count > *budget { + return None; + } + *budget -= count; + effects::reshape(Some(doc), art, Some(&dynamic_art(&s.art, art))) + }); + match reshaped { + Some(r) => pieces_within(symbols, &r, depth + 1, budget), + None => art.visual_bounds_with(symbols), + } + } + _ => art.visual_bounds_with(symbols), + } +} + /// [`drawn_bounds`] of `n` grown by how far its own raster effects paint past them. With Crop /// Marks, the [`drawn_bounds`] of its art: the marks and the object, which paints its own raster /// effects inside the art. @@ -406,11 +459,14 @@ fn cull_bounds_in(symbols: &SymbolBoxes, n: &Node, after_reshape: bool) -> Optio // symbol instance's art paint past that art. _ if has_object_fx(n) => { let symbol = match &n.kind { - NodeKind::SymbolInstance { symbol, .. } => symbols.document().symbols.iter().find(|s| s.name == *symbol).map(|s| s.art.clone()), + NodeKind::SymbolInstance { symbol, .. } => { + symbols.document().symbols.iter().find(|s| s.name == *symbol).map(|s| dynamic_art(&s.art, n)) + } _ => None, }; - let reshaped = effects::reshape(Some(symbols.document()), n, symbol.as_deref()); + let reshaped = effects::reshape(Some(symbols.document()), n, symbol.as_ref()); let b = match &reshaped { + Some(r) if matches!(n.kind, NodeKind::SymbolInstance { .. }) && effects::has_item_geometry(n) => instance_piece_bounds(symbols, r), Some(r) if matches!(n.kind, NodeKind::Text(_)) || (matches!(n.kind, NodeKind::Image(_)) && !r.clips()) => { r.children().into_iter().flatten().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, inner(c))) } diff --git a/docs/mcp.md b/docs/mcp.md index 3eadc40f4a..64926d164e 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -4269,7 +4269,10 @@ document's raster effects resolution (shadows and outer glows under the art; a b the object with the image). Type with a geometry effect (Outline Object included) or with fills and strokes of its own is outlined; type that has no fills or strokes of its own and whose only effects are shadows or outer glows stays type, with the image under it. A group's or layer's own fills and strokes become objects among its members, and its -members are expanded too. The result is `{ids}`; when an image in the art can't be decoded, the images made from +members are expanded too. A symbol instance whose topmost fill or topmost stroke is visible, has an opacity above 0 +and has a geometry effect becomes a group of its art: that fill's effects reshape the filled shapes of the art and that +stroke's effects reshape its stroked shapes, which keep their colors, as the canvas draws them (VectorCraft's own +rule); the instance's other fills and strokes are dropped. The result is `{ids}`; when an image in the art can't be decoded, the images made from raster effects do not show it, and the result also carries the `warning` that Flatten Transparency gives. A path's strokes stay strokes, as the [Expand objects](https://helpx.adobe.com/illustrator/desktop/manage-objects/arrange-objects/expand-objects.html) help diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 11dc8cb895..46f28cf621 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1421,6 +1421,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur The Add New Effect popup (Appearance panel, Properties), the Appearance panel's swatch picker, the Gradient panel's gradient swatches menu and the Image Trace button's list of presets stop at the bottom of a short window, and the mouse wheel or the scroll bar reaches their last rows. A list that fits does not scroll (`a_long_swatch_picker_scrolls_inside_a_short_window`). An image's own fills and strokes draw the shape that a Convert to Shape or Outline Object effect on them gives them (the image's box or its frame, reshaped by the effects after it), under or over the image as its Contents row says, on the canvas, in exports and through Expand Appearance; new fills and strokes on an image go under it. Without one of those effects they draw nothing, and Use Preview Bounds measures an image by its frame. A geometry effect on one of type's own fills or strokes reshapes the whole outline that the fill or stroke paints, on the canvas and in exports; PDF, SVG, EPS and the other vector exports write such type as paths. On type that holds inline graphics, those effects are drawn and exported only when the type has a geometry effect or perspective of its own. + A geometry effect on a symbol instance's fill reshapes the filled shapes of the symbol's art and leaves their strokes as they are; one on the instance's stroke reshapes the stroked shapes and leaves their fills as they are. The shapes keep their colors. Only the instance's topmost fill and topmost stroke count, and only while they are visible with an opacity above 0. The canvas and Object › Expand Appearance use the same art, SVG and PDF export bake the same shapes, and Expand Appearance turns the instance into an editable group in one undo step. A group's own fills and strokes in the art take the effects too. An instance inside the art passes the effects on to its own art, and a dynamic symbol's instance reshapes the fills and strokes it paints on the art. How the effects map onto the symbol's art is VectorCraft's own rule. The help page describes effects on one fill or stroke, and the help page describes Expand Appearance (engine `expand::a_symbol_instance_fill_effect_reshapes_its_filled_art_and_expands`, `a_symbol_instance_stroke_effect_reshapes_its_stroked_art_and_expands`, `a_stroke_effect_reshapes_a_stroke_a_dynamic_instance_adds`, `a_fill_effect_reshapes_the_fill_of_a_group_in_the_art`, `only_the_topmost_visible_instance_fill_reshapes_the_art`, `a_nested_symbol_instance_takes_the_outer_fill_effect`, `export_bakes_a_symbol_instance_fill_effect`, `a_fill_effect_on_an_instance_of_a_symbol_that_holds_itself_ends`). Existing documents with a geometry effect on a symbol instance's fill or stroke draw differently: before, the effect drew nothing. The Appearance panel's inline effect editor labels each parameter as the effect dialogs' generic fields do, without the colon ("Max. Radius", "Channel 1", "Create New Fields by"). Roughen's and Zig Zag's Points, Offset Path's Joins, Inner Glow's Source and the menus of Radial Blur, Smart Blur, Sprayed Strokes, Glass, Mezzotint, Halftone Pattern, Grain, Texturizer and De-Interlace are dropdowns with the dialog's labels that send the dialog's values, and a plug-in's choice is a dropdown of the plug-in's values. Revolve's edge, Extrude & Bevel's bevel, every Warp style's orientation, Rasterize's Color Model, Background and Anti-aliasing and the channel of Levels and Curves are dropdowns too, with the labels their own dialogs show. `effect.apply` and `effect.setParams` refuse a Levels or Curves `channel` other than `rgb`, `red`, `green` or `blue` (or `r`, `g` and `b`, in any letter case). Object › Expand Appearance keeps each stroke of a path a stroke (weight, caps, joins, dashes, arrowheads, alignment and width profile) on a copy of the path with the geometry effects baked in, and Object › Expand… is dimmed, with a tooltip, while the selection has appearance attributes, so a stroked line with Zig Zag becomes a stroked zig-zag line and only Expand then outlines it, the two steps the [Expand objects](https://helpx.adobe.com/illustrator/desktop/manage-objects/arrange-objects/expand-objects.html) help page describes (#3089). Brushed strokes still become their brush art; `object.expand` still bakes and outlines in one step for agents. From bc97a8f89767e91a265a56dfbe1741fe481fc009 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 17:22:28 -0800 Subject: [PATCH 09/11] New Document: profiles bring their color profiles and link base Follow-up to #6887. - file.new installs the color profiles a New Document profile's file carries when they are missing, as document.open does, so the new document's assigned profile draws and a save embeds it. A color profile that can't be used is listed in the result's `warnings` and shown in the status bar. - The new document's links find their files through relative paths from the profile's file (`links_from`), as edit.newDocumentFromState sets it. - When the read roots leave out the profiles folder, profileError's reason no longer names the folder's path. --- crates/engine/src/cmd/edit.rs | 2 +- crates/engine/src/cmd/newdoc.rs | 46 +++++++++++++----- crates/engine/src/tests/newdoc.rs | 56 ++++++++++++++++++++++ crates/ui-egui/src/dialogs/new_document.rs | 14 +++++- docs/mcp.md | 7 ++- 5 files changed, 110 insertions(+), 15 deletions(-) diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 985d54fa50..256578f9b2 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -23,7 +23,7 @@ pub fn specs() -> Vec { "New…", ["File"], None, - "{preset?: a name from file.newPresets (start from it; the other params change it), name?|title?: document name (default Untitled-N), width?: pt=612|\"210 mm\", height?: pt=792 (each more than 0 and at most 3,999,856 pt, as artboard.new takes them), units?: \"Pixels\"|\"Points\"|\"Picas\"|\"Inches\"|\"Millimeters\"|\"Centimeters\"|\"Feet\"|\"Yards\"|\"Meters\"|\"Feet & Inches\" (default: prefs unitsGeneral, as print presets; screen presets: Pixels), orientation?: \"portrait\"|\"landscape\" (swaps width and height to match), artboards?: n (1–1000), artboardLayout?: {layout?: \"gridByRow\"|\"gridByColumn\"|\"row\"|\"column\", columns?: n (default: all in one row), spacing?: pt=20, rightToLeft?: bool}, bleed?: pt|[top, bottom, left, right]|{top?, …} (0–72 pt), backgroundContents?: \"transparent\"|\"white\" (a white artboard background, not an object), colorMode?: \"rgb\"|\"cmyk\" (a CMYK document starts with CMYK swatches and stores the colours applied to it as CMYK), rasterEffectsPpi?: 72|150|300 (1–2400), previewMode?: \"default\"|\"pixel\"|\"overprint\" (overprint turns Overprint Preview on, pixel Pixel Preview in the app; default leaves both), alignNewObjectsToPixelGrid?: bool (Advanced Options › Align New Objects to Pixel Grid: paths drawn in the document are pixel-aligned; default on for the Mobile and Web presets, else off; document.setup changes it later), profile?: a category of file.newPresets|null (the New Document profile the document starts from, see file.newProfiles; default: a built-in preset's category, kept by the saved and Recent presets made from it; null or no preset: the defaults), created?: Unix seconds|null (File Info's created date; default now, recorded in the journal so a replay matches), size?: a preset's size label (a preset file.newPresets lists can be passed back; ignored)} → {index, previewMode, profile?: the profile applied, profileError?: {profile, reason} (its file could not be read, was larger than 64 MiB or is not a native document, or the request may not read the profiles folder: the document has the defaults)}; the size is remembered in file.newPresets' Recent", + "{preset?: a name from file.newPresets (start from it; the other params change it), name?|title?: document name (default Untitled-N), width?: pt=612|\"210 mm\", height?: pt=792 (each more than 0 and at most 3,999,856 pt, as artboard.new takes them), units?: \"Pixels\"|\"Points\"|\"Picas\"|\"Inches\"|\"Millimeters\"|\"Centimeters\"|\"Feet\"|\"Yards\"|\"Meters\"|\"Feet & Inches\" (default: prefs unitsGeneral, as print presets; screen presets: Pixels), orientation?: \"portrait\"|\"landscape\" (swaps width and height to match), artboards?: n (1–1000), artboardLayout?: {layout?: \"gridByRow\"|\"gridByColumn\"|\"row\"|\"column\", columns?: n (default: all in one row), spacing?: pt=20, rightToLeft?: bool}, bleed?: pt|[top, bottom, left, right]|{top?, …} (0–72 pt), backgroundContents?: \"transparent\"|\"white\" (a white artboard background, not an object), colorMode?: \"rgb\"|\"cmyk\" (a CMYK document starts with CMYK swatches and stores the colours applied to it as CMYK), rasterEffectsPpi?: 72|150|300 (1–2400), previewMode?: \"default\"|\"pixel\"|\"overprint\" (overprint turns Overprint Preview on, pixel Pixel Preview in the app; default leaves both), alignNewObjectsToPixelGrid?: bool (Advanced Options › Align New Objects to Pixel Grid: paths drawn in the document are pixel-aligned; default on for the Mobile and Web presets, else off; document.setup changes it later), profile?: a category of file.newPresets|null (the New Document profile the document starts from, see file.newProfiles; default: a built-in preset's category, kept by the saved and Recent presets made from it; null or no preset: the defaults), created?: Unix seconds|null (File Info's created date; default now, recorded in the journal so a replay matches), size?: a preset's size label (a preset file.newPresets lists can be passed back; ignored)} → {index, previewMode, profile?: the profile applied, warnings?: [text] (a color profile the profile's file carries that can't be used), profileError?: {profile, reason} (its file could not be read, was larger than 64 MiB or is not a native document, or the request may not read the profiles folder, a reason that leaves out the folder's path: the document has the defaults)}; the size is remembered in file.newPresets' Recent", always, super::newdoc::file_new ), diff --git a/crates/engine/src/cmd/newdoc.rs b/crates/engine/src/cmd/newdoc.rs index f4aaa841ca..c7c82c7625 100644 --- a/crates/engine/src/cmd/newdoc.rs +++ b/crates/engine/src/cmd/newdoc.rs @@ -798,9 +798,9 @@ pub(crate) fn file_new(s: &mut Session, p: &Value) -> Result { None => (None, None), }; let applied = profile.is_some(); - let mut d = match profile { - Some(p) => settings.document_from_profile(p), - None => settings.document(), + let (mut d, links_from, warnings) = match profile { + Some(p) => (settings.document_from_profile(p.doc), Some(p.file), p.warnings), + None => (settings.document(), None, vec![]), }; d.grid = s.prefs.grid(); d.title = match str_param(p, "name").or(str_param(p, "title")).map(str::trim).filter(|t| !t.is_empty()) { @@ -811,6 +811,9 @@ pub(crate) fn file_new(s: &mut Session, p: &Value) -> Result { }; d.metadata.created = clock_date(s, "created", date_param(p, "created", C)?); let i = s.add_document(d, None); + // The profile's links keep their relative paths, which name places from the profile's file, as + // a new document from a history state keeps its source's. + s.doc_mut()?.links_from = links_from; // Overprint Preview is the engine's (the UI turns Pixel Preview on from `previewMode`). Both // are views of the whole app, so Default leaves them as they are. if settings.preview_mode == PreviewMode::Overprint { @@ -821,6 +824,9 @@ pub(crate) fn file_new(s: &mut Session, p: &Value) -> Result { if applied { out["profile"] = json!(settings.profile); } + if !warnings.is_empty() { + out["warnings"] = json!(warnings); + } if let Some(e) = profile_error { out["profileError"] = json!({ "profile": settings.profile, "reason": e }); } @@ -883,6 +889,19 @@ fn delete(s: &mut Session, p: &Value) -> Result { /// larger one is reported and the document starts with the defaults. pub const MAX_PROFILE_BYTES: u64 = 64 << 20; +/// `file.new`'s reason for a profile when the request being served may not read the profiles +/// folder: the folder's path is left out. +const PROFILE_DENIED: &str = "the request may not read the New Document profiles folder"; + +/// A profile's file as [`Session::read_profile`] read it. +struct ReadProfile { + doc: Document, + /// The file's path, which its links' relative paths name places from. + file: String, + /// A warning for each color profile the file carries that can't be used. + warnings: Vec, +} + /// The New Document profiles' names: the built-in categories, in tab order. pub fn profile_names() -> impl Iterator { CATEGORIES.iter().map(|c| c.0) @@ -919,24 +938,29 @@ impl Session { self.profile_path(name).is_some_and(|p| p.is_file()) } - /// Profile `name`'s document: `None` when it has no file (the shipped profile, the defaults), + /// Profile `name`'s document, its file's path and a warning for each color profile the file + /// carries that can't be used: `None` when it has no file (the shipped profile, the defaults), /// the reason when the request being served may not read it, or its file can't be read, is /// larger than [`MAX_PROFILE_BYTES`] or isn't a native document VectorCraft reads. - fn read_profile(&self, name: &str) -> std::result::Result, String> { + fn read_profile(&self, name: &str) -> std::result::Result, String> { let Some(path) = self.profile_path(name) else { return Ok(None) }; + let file = path.to_string_lossy().to_string(); // Checked before the file is looked for, so a request that may not read the folder learns - // nothing of its files. - crate::file_access::check_read(&path.to_string_lossy())?; + // nothing of its files, nor where the folder is. + let denied = |_| PROFILE_DENIED.to_string(); + crate::file_access::check_read(&file).map_err(denied)?; if std::fs::symlink_metadata(&path).is_err_and(|e| e.kind() == std::io::ErrorKind::NotFound) { return Ok(None); } - crate::file_access::check_read(&path.to_string_lossy())?; + crate::file_access::check_read(&file).map_err(denied)?; let bytes = crate::store::read_capped(&path, MAX_PROFILE_BYTES)?; - let mut doc = vectorcraft_format::load(&bytes).map_err(|e| e.to_string())?; + // The color profiles the file carries are installed where missing, as opening it installs + // them, so the new document's assigned profiles draw and are saved with it. + let (mut doc, _, warnings) = super::fileio::native_file(&bytes).map_err(|e| e.to_string())?; // Linked images in its symbols, patterns and brushes are read from their files, as placing // a document reads them. - super::links::resolve(&mut doc, super::links::LinkBase::file(Some(&path.to_string_lossy())), false, false); - Ok(Some(doc)) + super::links::resolve(&mut doc, super::links::LinkBase::file(Some(&file)), false, false); + Ok(Some(ReadProfile { doc, file, warnings })) } } diff --git a/crates/engine/src/tests/newdoc.rs b/crates/engine/src/tests/newdoc.rs index 247a037e45..deaf60b64f 100644 --- a/crates/engine/src/tests/newdoc.rs +++ b/crates/engine/src/tests/newdoc.rs @@ -597,6 +597,10 @@ fn the_profile_follows_the_preset_access_and_reset() { let roots = crate::file_access::AutomationRoots::new(&[elsewhere.as_path()], &[dir.as_path()]).unwrap().unwrap(); let r = crate::file_access::confine(Some(&roots), || s.execute("file.new", &json!({"preset": "A4"}))).unwrap(); assert!(r.get("profile").is_none() && r["profileError"]["profile"] == "Print", "{r}"); + // The reason does not say where the folder is. + let reason = r["profileError"]["reason"].as_str().unwrap_or_default(); + let folder = dir.file_name().unwrap().to_string_lossy().to_string(); + assert!(!reason.is_empty() && !reason.contains(&folder), "{reason}"); assert!(!swatch_names(&s).contains(&"Brand Red".to_string())); // Nothing tells such a request which profiles have files. let r = crate::file_access::confine(Some(&roots), || s.execute("file.new", &json!({"preset": "Web 1920×1080"}))).unwrap(); @@ -627,6 +631,58 @@ fn the_profile_follows_the_preset_access_and_reset() { let _ = std::fs::remove_dir_all(&dir); } +/// The new document's links find their files from the profile's file, as a document opened from +/// that file does; a document without a profile has no such file. +#[test] +fn the_new_document_links_from_the_profile_file() { + let (mut s, dir) = with_profiles("links"); + edit_print_profile(&mut s, &dir); + assert_eq!(s.execute("file.new", &json!({"preset": "A4"})).unwrap()["profile"], "Print"); + let file = dir.join("Print.vectorcraft").to_string_lossy().to_string(); + assert_eq!(s.doc().unwrap().links_from.as_deref(), Some(file.as_str())); + s.execute("file.new", &json!({"preset": "Web 1920×1080"})).unwrap(); + assert_eq!(s.doc().unwrap().links_from, None); + let _ = std::fs::remove_dir_all(&dir); +} + +/// A color profile the profile's file carries is installed when this machine lacks it, as opening +/// the file installs it, so the new document keeps it and a save carries it; one that can't be used +/// is a warning. +#[test] +fn a_profile_brings_the_color_profile_its_file_carries() { + use vectorcraft_color::cms; + let (mut s, dir) = with_profiles("icc"); + let builtin = cms::profiles().into_iter().find(|p| p.kind == cms::ProfileKind::Rgb && p.builtin && p.name != cms::SRGB).unwrap(); + let icc = cms::icc_bytes(&builtin.name).unwrap().to_vec(); + let name = format!("Profile RGB {}", std::process::id()); + cms::register_icc(&icc, Some(name.clone())).unwrap(); + let bytes_of = |v: &Value| vectorcraft_format::base64_decode(v.as_str().expect("base64")).unwrap(); + s.execute("file.new", &json!({"preset": "Web 1920×1080", "profile": null})).unwrap(); + std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).color_profiles.rgb = Some(name.clone()); + let file = String::from_utf8(bytes_of(&s.execute("file.saveCopy", &json!({"compress": false})).unwrap()["dataBase64"])).unwrap(); + cms::unregister_icc(&name); + // A profile made on another machine names a color profile this one lacks. + let elsewhere = format!("Profile RGB elsewhere {}", std::process::id()); + assert!(cms::profile(&elsewhere).is_none()); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("Web.vectorcraft"), file.replace(&name, &elsewhere)).unwrap(); + let r = s.execute("file.new", &json!({"preset": "Web 1920×1080"})).unwrap(); + assert!(r["profile"] == "Web" && r.get("warnings").is_none(), "{r}"); + assert_eq!(cms::profile(&elsewhere).map(|p| p.kind), Some(cms::ProfileKind::Rgb)); + assert_eq!(s.doc().unwrap().doc.color_profiles.rgb.as_deref(), Some(elsewhere.as_str())); + let saved: Value = serde_json::from_slice(&bytes_of(&s.execute("file.saveCopy", &json!({"compress": false})).unwrap()["dataBase64"])).unwrap(); + assert_eq!(vectorcraft_format::base64_decode(saved["profiles"][&elsewhere]["data"].as_str().unwrap()).unwrap(), icc); + cms::unregister_icc(&elsewhere); + // Damaged profile data starts the document with a warning. + let broken = format!("Profile RGB broken {}", std::process::id()); + let mut v: Value = serde_json::from_str(&file.replace(&name, &broken)).unwrap(); + v["profiles"][&broken]["data"] = json!("AAAA"); + std::fs::write(dir.join("Web.vectorcraft"), serde_json::to_vec(&v).unwrap()).unwrap(); + let r = s.execute("file.new", &json!({"preset": "Web 1920×1080"})).unwrap(); + assert!(r["profile"] == "Web" && r["warnings"][0].as_str().unwrap_or_default().contains(&broken), "{r}"); + let _ = std::fs::remove_dir_all(&dir); +} + #[test] fn a_damaged_or_oversized_profile_leaves_the_defaults_and_is_reported() { let (mut s, dir) = with_profiles("damaged"); diff --git a/crates/ui-egui/src/dialogs/new_document.rs b/crates/ui-egui/src/dialogs/new_document.rs index 96e33684bc..cfd6c3b0d3 100644 --- a/crates/ui-egui/src/dialogs/new_document.rs +++ b/crates/ui-egui/src/dialogs/new_document.rs @@ -151,11 +151,14 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { r } -/// Say in the status bar when `file.new`'s result `r` reports a profile it could not read. +/// Say in the status bar when `file.new`'s result `r` reports a profile it could not read, or +/// color profiles its file carries that can't be used. fn report_profile(app: &mut VectorcraftApp, r: &Value) { if let Some(e) = r.get("profileError") { let (name, reason) = (e["profile"].as_str().unwrap_or_default(), e["reason"].as_str().unwrap_or_default()); app.status(format!("The {name} profile could not be read, so the new document has the default settings: {reason}")); + } else if let Some(w) = r["warnings"].as_array().filter(|w| !w.is_empty()) { + app.status(w.iter().filter_map(Value::as_str).collect::>().join("; ")); } } @@ -1592,6 +1595,15 @@ mod tests { } } + /// A color profile in the profile's file that can't be used, which `file.new` reports, shows in + /// the status bar. + #[test] + fn profile_warnings_show_in_the_status_bar() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + super::report_profile(&mut app, &json!({"index": 0, "warnings": ["the color profile X can't be used"]})); + assert_eq!(app.ui.status, "the color profile X can't be used"); + } + /// Manage Profiles lists a row per profile; Reset is off until a profile has been edited, Escape /// closes only the pop-up, and Edit opens the profile as a document and closes New Document. #[test] diff --git a/docs/mcp.md b/docs/mcp.md index 0dd9f958b3..dbb68acfda 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -5849,8 +5849,11 @@ The profile is read on every `file.new`, so a replayed journal or a recorded act runs, as `document.open` reads its file when it runs. A file larger than 64 MiB (VectorCraft's own limit) is not read. When the file cannot be read, is too large or is not a native document, or the request's read roots leave out the folder (whether or not the file exists), the document starts with the defaults and -`file.new`'s result carries `profileError: {profile, reason}`; the desktop app shows the reason in the status bar. In -the New Document dialog, Manage Profiles lists the profiles with Edit and Reset buttons. Edit is available only when +`file.new`'s result carries `profileError: {profile, reason}`; the desktop app shows the reason in the status bar. The +reason given when the read roots leave out the folder does not name the folder's path. Color profiles the profile's +file carries are installed when they are missing, as `document.open` installs them, and each one that can't be used +adds a line to `file.new`'s `warnings`. The new document's links find their files through relative paths from the +profile's file until the document is saved. In the New Document dialog, Manage Profiles lists the profiles with Edit and Reset buttons. Edit is available only when there is a profiles folder, and Reset only for a profile that has a file. An unknown profile name is an error that lists the names. From 057db09c1237570e5a6bc8ccc1a22bbfb776bc73 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 17:23:58 -0800 Subject: [PATCH 10/11] Curvature: Alt-click on an open end goes on from it, and starts a path with a corner An Alt-click on an end of a selected open path makes that end the current point without converting it, so the next click extends the path from it. This applies to the other end too while one end is current; a plain click there still closes the path. Alt-click on any other point still toggles it between smooth and corner, and a double-click toggles any point, ends included. An Alt-click that starts a new path places a corner point, as later Alt-clicks do. Changed behavior: an Alt-click on an end of a selected open path no longer converts it; double-click converts it. Help page: https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/draw-shapes/draw-shapes-with-the-curvature-tool.html --- crates/tools/src/cursors.toml | 2 +- crates/tools/src/draw2/curvature.rs | 40 ++++++++++------ crates/tools/src/draw2/curvature/tests.rs | 58 +++++++++++++++++++++-- docs/mcp.md | 5 +- docs/target-app-parity.md | 2 +- 5 files changed, 85 insertions(+), 22 deletions(-) diff --git a/crates/tools/src/cursors.toml b/crates/tools/src/cursors.toml index 13e98ec4ee..362262b488 100644 --- a/crates/tools/src/cursors.toml +++ b/crates/tools/src/cursors.toml @@ -232,7 +232,7 @@ id = "Curvature" group = "Pen" shown_when = [ "The Curvature tool over empty canvas, or anywhere while drawing a path away from its points.", - "Elsewhere the Curvature tool borrows Pen-family cursors: PenAdd over a segment, Move over an anchor, PenContinue at an open end, PenConvert with Alt, PenClose over the first point of the path being drawn.", + "Elsewhere the Curvature tool borrows Pen-family cursors: PenAdd over a segment, Move over an anchor, PenContinue at an open end of a selected path (Alt held or not), PenConvert with Alt over any other anchor or a point of the path being drawn, PenClose over the first point of the path being drawn.", ] hotspot = "The nib's tip." look = "The Pen nib with a curve badge." diff --git a/crates/tools/src/draw2/curvature.rs b/crates/tools/src/draw2/curvature.rs index b92f8adfba..c40607ee63 100644 --- a/crates/tools/src/draw2/curvature.rs +++ b/crates/tools/src/draw2/curvature.rs @@ -1,6 +1,7 @@ //! The Curvature tool (Shift+~). //! -//! Each click adds a point and the path curves smoothly through all points. Alt-click or +//! Each click adds a point and the path curves smoothly through all points; an Alt-click adds a +//! corner point, the first point included. Alt-click or //! double-click a point toggles it between smooth and corner; drag a point to move it; click the //! first point to close; Backspace/Delete removes the last touched point; Esc/Enter ends the path. //! A rubber band shows the curve to the cursor (Enable Rubber Band for Curvature Tool). Each point @@ -8,8 +9,10 @@ //! //! It edits any selected path too, whatever drew it, keeping its shape except where edited //! (#798). A click on one of its points makes that the current point (direct-selected); a drag -//! moves it, re-curving only the two segments at it ([`curvature_move`]); Alt-click or -//! double-click toggles it between smooth and corner and Backspace/Delete removes it, the curve +//! moves it, re-curving only the two segments at it ([`curvature_move`]); double-click, or +//! Alt-click on any point but an end of an open path, toggles it between smooth and corner. An +//! Alt-click on an end of an open path makes it the current point without changing it, so the +//! next click goes on from it. Backspace/Delete removes the current point, the curve //! where it was then following the remaining points ([`curvature_remove`]). A click on //! a segment adds a smooth point there without changing the shape ([`curvature_insert`]), and a //! drag moves the new point at once. While the current point is an end of an open path, clicks go @@ -429,12 +432,16 @@ fn delete_point((id, si, ai): (NodeId, usize, usize)) -> Action { fn toggle(cx: &ToolContext, (id, si, ai): (NodeId, usize, usize)) -> Option { let sp = subpath(cx, id, si)?; let a = sp.anchors.get(ai)?; - let open_end = !sp.closed && (ai == 0 || ai + 1 == sp.anchors.len()); - let smooth = if open_end { a.has_in() || a.has_out() } else { a.kind == AnchorKind::Smooth }; + let smooth = if open_end(sp, ai) { a.has_in() || a.has_out() } else { a.kind == AnchorKind::Smooth }; let to = if smooth { "corner" } else { "smooth" }; Some(Action::Exec("path.convertAnchor".into(), json!({"id": id.0, "subpath": si, "anchor": ai, "to": to}))) } +/// Whether anchor `ai` is an end of the open subpath `sp`. +fn open_end(sp: &SubPath, ai: usize) -> bool { + !sp.closed && (ai == 0 || ai + 1 == sp.anchors.len()) +} + /// Snap targets for dragging anchors `anchors` of subpath `si` of `id` (the rest of the path /// pulls). fn hold_targets(snap: &mut DrawSnap, cx: &ToolContext, id: NodeId, si: usize, anchors: &[usize]) { @@ -507,12 +514,13 @@ impl CurvatureTool { } /// A press on a selected path the tool isn't drawing: on the other end of the open path going - /// on (it closes), on a point, anywhere while an end is current (the path goes on), or on a + /// on (without Alt it closes), on a point, anywhere while an end is current (the path goes on), or on a /// segment. None elsewhere: a new path starts. fn press_selected(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Option> { let (p, tol) = (ev.pos, point_tol(cx)); let end = current_end(cx); - if let Some(e) = end.filter(|e| e.anchors >= 3 && p.distance(e.other) <= tol) { + // An Alt-click on the other end makes it current instead (below). + if let Some(e) = end.filter(|e| !ev.mods.alt && e.anchors >= 3 && p.distance(e.other) <= tol) { let how = self.how(&e); self.placed = None; let v = json!({"id": e.id.0, "subpath": e.si, "op": "close", "end": e.name(), "from": how.name()}); @@ -520,11 +528,14 @@ impl CurvatureTool { } if let Some(hit) = anchor_in(cx, editable_paths(cx), p, tol) { let mut out = vec![select_point(hit)]; + let (id, si, ai) = hit; if ev.mods.alt { - out.extend(toggle(cx, hit)); + // On an end of an open path the path goes on from it; any other point toggles. + if !subpath(cx, id, si).is_some_and(|sp| open_end(sp, ai)) { + out.extend(toggle(cx, hit)); + } return Some(out); } - let (id, si, ai) = hit; hold_targets(&mut self.snap, cx, id, si, &[ai]); self.drag = Some((Grab::Anchor(id, si, ai), p, false)); return Some(out); @@ -596,7 +607,7 @@ impl Tool for CurvatureTool { // Start a new path. self.reset(); let p = self.snap.press(cx, p, &[], None); - self.pts.push((p, false)); + self.pts.push((p, ev.mods.alt)); self.current = Some(0); vec![Action::Exec("path.curvature".into(), self.params()), Action::Notify("created".into())] } @@ -762,15 +773,14 @@ impl Tool for CurvatureTool { } let tol = point_tol(cx); let end = current_end(cx); - if end.is_some_and(|e| e.anchors >= 3 && p.distance(e.other) <= tol) { + if !m.alt && end.is_some_and(|e| e.anchors >= 3 && p.distance(e.other) <= tol) { return Cursor::PenClose; } if let Some((id, si, ai)) = anchor_in(cx, editable_paths(cx), p, tol) { - let open_end = subpath(cx, id, si).is_some_and(|sp| !sp.closed && (ai == 0 || ai + 1 == sp.anchors.len())); - return if m.alt { - Cursor::PenConvert - } else if open_end { + return if subpath(cx, id, si).is_some_and(|sp| open_end(sp, ai)) { Cursor::PenContinue + } else if m.alt { + Cursor::PenConvert } else { Cursor::Move }; diff --git a/crates/tools/src/draw2/curvature/tests.rs b/crates/tools/src/draw2/curvature/tests.rs index 05593a3dc3..f21deec016 100644 --- a/crates/tools/src/draw2/curvature/tests.rs +++ b/crates/tools/src/draw2/curvature/tests.rs @@ -300,17 +300,69 @@ fn curvature_remove_keeps_a_corner_neighbors_handle_direction() { assert_eq!((sp.anchors[1].h_in, sp.anchors[1].h_out), (corner.h_in, corner.p)); } -/// A curved end counts as smooth for the toggle: Alt-click turns it into a corner. +/// A curved end counts as smooth for the toggle: a double-click turns it into a corner. #[test] -fn alt_click_on_a_curved_end_makes_it_a_corner() { +fn double_click_on_a_curved_end_makes_it_a_corner() { let sp = curvature_spline(&[(pt(100.0, 300.0), false), (pt(200.0, 200.0), false), (pt(300.0, 300.0), false)], false); let (d, id) = doc_with(sp); let (s, p) = (selected(id, None), paint()); let cx = quiet(&d, &s, &p); - let a = press(&mut CurvatureTool::default(), &cx, 100.0, 300.0, true); + let a = CurvatureTool::default().pointer(&cx, &PointerEvent::new(PointerKind::DoubleClick, 100.0, 300.0)); assert!(a.iter().any(|a| matches!(a, Action::Exec(c, v) if c == "path.convertAnchor" && v["anchor"] == 0 && v["to"] == "corner")), "{a:?}"); } +/// An Alt-click on either end of a selected open path makes it the current point without +/// converting it, and the next click goes on from it. +#[test] +fn alt_click_on_an_open_end_goes_on_from_it() { + let (d, id) = doc_with(wave()); + let (s, p) = (selected(id, None), paint()); + let cx = quiet(&d, &s, &p); + let mut t = CurvatureTool::default(); + let alt = Mods { alt: true, ..Mods::default() }; + for (x, y, ai) in [(450.0, 60.0, 4), (0.0, 100.0, 0)] { + assert_eq!(t.cursor(&cx, pt(x, y), alt), Cursor::PenContinue); + let a = press(&mut t, &cx, x, y, true); + assert_eq!(a, vec![select_point((id, 0, ai))]); + assert!(!t.busy(), "an Alt-click does not grab the point"); + } + // As the engine does it: the Alt-clicked end is current, and the next click goes on from it. + let s = selected(id, Some(0)); + let a = press(&mut t, &quiet(&d, &s, &p), -50.0, 150.0, false); + assert!(matches!(edit_op(&a[0]), Some(("extend", v)) if v["end"] == "start" && v["from"] == "keep"), "{a:?}"); + // With the start current, an Alt-click on the other end makes that end current; a plain click + // there closes the path. + let cx = quiet(&d, &s, &p); + assert_eq!(t.cursor(&cx, pt(450.0, 60.0), alt), Cursor::PenContinue); + assert_eq!(press(&mut t, &cx, 450.0, 60.0, true), vec![select_point((id, 0, 4))]); + assert_eq!(t.cursor(&cx, pt(450.0, 60.0), Mods::default()), Cursor::PenClose); + assert!(matches!(edit_op(&press(&mut t, &cx, 450.0, 60.0, false)[0]), Some(("close", v)) if v["end"] == "start")); + // A closed path has no ends: an Alt-click on its first point converts it. + let mut closed = wave(); + closed.closed = true; + let (d, id) = doc_with(closed); + let s = selected(id, None); + let cx = quiet(&d, &s, &p); + assert_eq!(t.cursor(&cx, pt(0.0, 100.0), alt), Cursor::PenConvert); + let a = press(&mut t, &cx, 0.0, 100.0, true); + assert!(a.iter().any(|a| matches!(a, Action::Exec(c, v) if c == "path.convertAnchor" && v["anchor"] == 0)), "{a:?}"); +} + +/// An Alt-click that starts a path places a corner point, as later Alt-clicks do; a plain click +/// places a smooth one. +#[test] +fn alt_click_starts_a_path_with_a_corner() { + let d = Document::new(800.0, 600.0); + let (s, p) = (Selection::default(), paint()); + let cx = quiet(&d, &s, &p); + for alt in [true, false] { + let mut t = CurvatureTool::default(); + let a = press(&mut t, &cx, 100.0, 120.0, alt); + assert!(matches!(&a[0], Action::Exec(c, v) if c == "path.curvature" && v["points"][0]["corner"] == alt), "{a:?}"); + assert_eq!(t.pts, vec![(pt(100.0, 120.0), alt)]); + } +} + /// A document with one path, `sp`. fn doc_with(sp: SubPath) -> (Document, NodeId) { let mut d = Document::new(800.0, 600.0); diff --git a/docs/mcp.md b/docs/mcp.md index 3eadc40f4a..5bea538236 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -4793,8 +4793,9 @@ the curve at an end), and the neighbours' handles keep their directions (their l moved there: the tool's click-and-drag on a segment). `op: "extend"` with `x`, `y` goes on from the `end` (default) or the `start` of an open subpath (the new end curves into the path unless `corner` is true), and `op: "close"` closes it from that end; `from` says how the old end bends into the new segment: `"keep"` (default) keeps its curve, `"smooth"` curves it through its neighbours (a point the tool -placed), `"corner"` leaves it without handles. Alt-click or double-click toggles a point with `path.convertAnchor`, and -Backspace/Delete removes the current point with `path.removeAnchor`. +placed), `"corner"` leaves it without handles. Double-click, or Alt-click on any point but an end of an open subpath, +toggles a point with `path.convertAnchor`; an Alt-click on an end of an open subpath only makes it the current point +(`select.anchors`), so the next click extends from it. Backspace/Delete removes the current point with `path.removeAnchor`. ```json {"name":"run_command","arguments":{"command":"path.curvatureEdit","params":{"id":12,"op":"move","anchor":2,"x":260,"y":140}}} diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 11dc8cb895..0d934afcb5 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -993,7 +993,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Saved selections:** Select ▸ Save Selection… asks for a name and keeps the selection in the document (saved with the file, undoable, up to 25); each one is listed at the bottom of the Select menu and a click selects its objects again; Edit Selection… lists them to rename or delete, applied together on OK. A file's saved selections are capped and checked against its objects when it opens. Select ▸ Update Selection replaces the objects of the saved selection last recalled with the selected objects, in one undo step that marks the document changed (`select.update`). After Edit Selection… renames that saved selection, Update Selection replaces it under the new name; after Edit Selection… deletes it, Update Selection is disabled until another saved selection is recalled or the delete is undone. Undo and Redo do not change which saved selection is recalled. - **Drawing modes:** in Draw Inside, a double-click with a selection tool outside the object being drawn into sets the drawing mode to Draw Normal, as Shift+D does. Pastes follow the drawing mode ([drawing modes](https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/learn-drawing-basics/drawing-modes-overview.html)) (#3705): in Draw Inside, Paste, Paste in Place and Paste on All Artboards put the copies inside the object drawn into, in one undo step, Paste Remembers Layers aside; in Draw Behind, Paste in Place and Paste on All Artboards put them just below the selection; Paste in Front and Paste in Back ignore the mode. Shift+D cycles Draw Normal → Draw Behind → Draw Inside → Draw Normal, skipping Draw Inside unless one path, compound path or type is selected. With the object drawn into isolated (a double-click on it with the Selection tool), art drawn or pasted inside it takes isolation mode into its clip group, so it can be clicked and isn't dimmed, as art added in isolation mode belongs with what is isolated ([isolation mode](https://helpx.adobe.com/illustrator/desktop/manage-objects/select-objects/isolate-objects.html)) (#3705); undo or redo that removes the isolated object ends isolation mode. While Draw Inside lasts, a dotted open rectangle marks the corners of the object drawn into (#4319). - - **Drawing:** Pen, Curvature, anchor tools, Pencil, Paintbrush, Blob Brush, Smooth, Path Eraser, Join. Direction handles dragged with Direct Selection or the Anchor Point tool snap to smart guides, and Shift keeps them at 45° steps from their anchor. The Pen continues any open path from the end clicked, and while drawing, a click on an end of another open path joins the two into one (#776). The Curvature tool edits any selected path, whatever drew it, keeping its shape except where edited (#798): a click on a point makes it current and a drag moves it, re-curving only its two segments; Alt-click or double-click toggles it smooth or corner and Delete removes it; a click on a segment adds a point without changing the shape; with an end of an open path current, clicks go on from it and its other end closes it (`path.curvatureEdit`). Every point a drawing tool places snaps to Smart Guides through one snapper (#506): the Pen's and Curvature tool's anchors, the Pencil's ends, the shape tools' start and dragged corner or radius (Rectangle, Rounded Rectangle, Ellipse, Polygon, Star, Line Segment, Arc, Spiral, the grids), the Type tool's click and frame, a new artboard's corners. A filled Pencil path keeps a stroke of None (#1804, part of #1679). While a Pencil or Paintbrush stroke is drawn back to within Close Paths When Ends Are Within of its start, the cursor shows the closing ring and a release there closes the path, as [Draw straight lines with the Pencil tool](https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/draw-shapes/draw-straight-lines-with-the-pencil-tool.html) describes for the Pencil. The [ and ] keys reach the Blob Brush, the Eraser and the Symbolism tools in the app (#1734, part of #1661). + - **Drawing:** Pen, Curvature, anchor tools, Pencil, Paintbrush, Blob Brush, Smooth, Path Eraser, Join. Direction handles dragged with Direct Selection or the Anchor Point tool snap to smart guides, and Shift keeps them at 45° steps from their anchor. The Pen continues any open path from the end clicked, and while drawing, a click on an end of another open path joins the two into one (#776). The Curvature tool edits any selected path, whatever drew it, keeping its shape except where edited (#798): a click on a point makes it current and a drag moves it, re-curving only its two segments; double-click, or Alt-click on any point but an end of an open path, toggles it smooth or corner and Delete removes it; a click on a segment adds a point without changing the shape; with an end of an open path current, clicks go on from it and its other end closes it (`path.curvatureEdit`). An Alt-click on an end of a selected open path makes that end current without changing it, so the next click goes on from it, as [the help page](https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/draw-shapes/draw-shapes-with-the-curvature-tool.html) describes for continuing a path; an Alt-click that starts a new path places a corner point, as later Alt-clicks do. Every point a drawing tool places snaps to Smart Guides through one snapper (#506): the Pen's and Curvature tool's anchors, the Pencil's ends, the shape tools' start and dragged corner or radius (Rectangle, Rounded Rectangle, Ellipse, Polygon, Star, Line Segment, Arc, Spiral, the grids), the Type tool's click and frame, a new artboard's corners. A filled Pencil path keeps a stroke of None (#1804, part of #1679). While a Pencil or Paintbrush stroke is drawn back to within Close Paths When Ends Are Within of its start, the cursor shows the closing ring and a release there closes the path, as [Draw straight lines with the Pencil tool](https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/draw-shapes/draw-straight-lines-with-the-pencil-tool.html) describes for the Pencil. The [ and ] keys reach the Blob Brush, the Eraser and the Symbolism tools in the app (#1734, part of #1661). Blob Brush and Eraser Tool Options' Size is Fixed or Pressure with a Variation (#4590, #3961): with Pressure a pen stroke is Size − Variation wide where it presses lightest and Size + Variation where it presses hardest, and a mouse stroke keeps the Size. With the Blob Brush, Shift held during a drag keeps the stroke on the horizontal or vertical line through the press point, whichever is nearer to the pointer (90° steps from the Constrain Angle preference), and the points dragged since Shift went down move to the other line when the pointer crosses to it; the points dragged while Shift is up follow the pointer. With the Blob Brush, Alt held before the press, or pressed before the drag has moved, switches to the Smooth tool, as 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: the cursor shows the Smooth tool, the drag shows a thin line without the length label, Shift does not constrain it, and the release smooths the selected paths under the drag (`path.smoothRegion`) instead of painting; with nothing selected the release changes no path. A stroke that has moved keeps painting when Alt goes down. From d3bef19990239e017d4d99380110d6958e112e77 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 17:31:36 -0800 Subject: [PATCH 11/11] Docs: distance and angle labels while drawing are listed as shipped docs/ui-parity.md and docs/target-app-parity.md listed the distance and angle readouts while drawing as missing. With Smart Guides > Measurement Labels on, the Line Segment tool labels the segment being drawn with its distance, angle and X and Y offsets, and the Pen, Curvature and Smart Pen tools label their rubber band the same way (#2983). The Measurement labels row is marked done, the two dimension rows drop the gap, and the Drawing aids entry describes the labels. --- docs/target-app-parity.md | 6 +++--- docs/ui-parity.md | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index f86ba61ae3..bd3996cbfd 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -249,7 +249,7 @@ dominate; 3D, graphs and automation are specialist). They sum to 105. Feature de | File formats | 6 | 75% | `.ai` files open with a flattened or wrong layer structure in real cases (#951), a saved `.ai`'s editing copy without live effects on objects other than paths, compound paths and groups that qualify (at most a fill and a stroke, a non-zero fill rule when filled, no large canvas) and whose appearance and effects the art-style writer supports, and with live objects as their art (#868), FreeHand/CorelDRAW/DWG/HEIC import, 3D export; see [file-format-parity.md](file-format-parity.md); EPS legacy mesh/opacity-mask and raster comparison follow-up verified PDF marked-text batching covered by M4 evidence (#6309); cross-line/style ActualText layout (#6165) and styled-cluster limits remain.| 10–20 | estimated (lowered from 85%: open issues); DXF control-point SPLINE import closure regression-tested (#1299 follow-up after #1320); PDF writer metadata, reader streaming and immutable backend checks in `crates/pdf/src/tests.rs`, `crates/pdf/src/tests/reader_streaming.rs` and `xtask/src/pdf_deps.rs`; PDF Place and clipboard paste retain live text (synthetic regressions); SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; EPS ordinary Save retains the page frame and editable document (2026-10-26); `.ai`/EPS editing data checked against a user's files (2026-10-30): case setting (`ate::tests`, `tests_native::type_from_the_text_document_keeps_its_case_setting`), placed-file placements (`tests_native::a_small_placed_file_goes_where_it_draws_closer_to_the_page`), pages cropped to the art (`tests_native::type_on_the_copies_of_a_page_cropped_to_the_art_is_read_once`), mesh corner weights and state (`ai::mesh::tests::corners_with_handle_weights_of_zero_are_read`, `ai::tests::a_gradient_mesh_takes_the_transparency_written_after_it`), type on a path spacing (`tests_native::type_round_a_circle_is_spaced_as_the_file_lays_it_out`), blend state (`ai::tests::a_blends_transparency_follows_its_record`); scores and estimates unchanged. PDF ActualText preserved in editable import (#6165; bounded synthetic coverage below); score/hours unchanged. Save refusal continuation (#6146): `READ_ONLY_LOCATION`, Save As on ordinary UI Save only; regression execution pending; score/hours unchanged. | | View & navigation | 3 | 70% | multiple windows and the rest of Arrange (Cascade, Float in Window, Float All in Windows) | 9–14 | estimated; Window › Arrange › Tile and Consolidate All Windows done (score/hours unchanged); document-tab artwork hover/copy and lossless transfer tested (2026-10-22; score/hours unchanged); cached canvas reprojection preserves scale during resize, rotation and DPI changes (regression and contributor original-file evidence below; scores/hours unchanged) | | View & navigation | 3 | 70% | multiple windows and the rest of Arrange (Cascade, Float in Window, Float All in Windows) | 9–14 | estimated; Window › Arrange › Tile and Consolidate All Windows done (score/hours unchanged) | -| Guides, grids, smart guides, snapping, rulers | 3 | 80% | "intersect" smart guides for a moved selection's anchors (drawn points and dragged anchors snap to intersections, #1658), distance readouts while drawing | 4–7 | estimated (lowered from 85%: target moved); Control bar snapping quick access includes Snap to Glyph and Alignment Guides (#6164); new ruler-guide Alt orientation implemented; global/artboard ruler modes (`view.globalRulers`) and video rulers (View › Rulers › Show Video Rulers, #2849) done; status-bar X/Y uses the active ruler origin and General units; score unchanged | +| Guides, grids, smart guides, snapping, rulers | 3 | 80% | "intersect" smart guides for a moved selection's anchors (drawn points and dragged anchors snap to intersections, #1658) | 4–7 | estimated (lowered from 85%: target moved); Control bar snapping quick access includes Snap to Glyph and Alignment Guides (#6164); new ruler-guide Alt orientation implemented; global/artboard ruler modes (`view.globalRulers`) and video rulers (View › Rulers › Show Video Rulers, #2849) done; status-bar X/Y uses the active ruler origin and General units; score unchanged | | Export for Screens, Asset Export, slices, Save for Web | 3 | 85% | anti-aliasing ghost borders (#983); TIFF choices and settings are implemented | 2–4 | estimated | | Print, colour management, separations, flattener | 3 | 80% | print preview fidelity | 2–4 | estimated | | Automation (Actions, Variables, scripting, batch) | 3 | 60% | a scripting surface (Illustrator: JavaScript/ExtendScript), Batch, image and graph variables, dataset import/export | 8–14 | estimated; MCP `add_text` exposes existing vertical type; score and hours unchanged | @@ -508,7 +508,7 @@ about 1–1.5 agent-hours each, so the other rows (estimated on the older scale) | Image Trace, graphs, image tools | 3 | 72% | graph Column designs (Graph Design, Marker designs and series colours set with Group Selection are done), Create Object Mosaic, per-feature stroke/fill choice and varying widths in Image Trace's Create Strokes (Vector Halftone and the Image Trace View modes, Palette and Create Strokes are done) | 6–10 | | Layers, artboards, document setup | 5 | 82% | Layers panel: a confirmation before deleting layers that hold art, Collect for Export, row context menus, the Layer Options dialog for several rows with mixed values, Paste Remembers Layers for sublayers, drag-scrolling a long panel while dragging rows; artboard preset polish (artboard escape routing and document setup modal handoff done; Move Artwork for artboard X/Y edits, Rearrange All Artboards, Document Setup, New Document and the rest of the Layers panel are done) | 4–7 | | View & navigation | 3 | 70% | New View/Edit Views, multiple windows/arrange, Snap to Pixel/Glyph (print tiling is done) | 9–14 | -| Guides, grids, smart guides, snapping, rulers | 3 | 85% | global/video rulers, Spacing Guides' distance readouts and spacing for resized or drawn art, Smart Guides' angle and distance readouts while drawing, Alt swapping a ruler guide's orientation, ruler guides selected together with art (marquee, Shift-click), ruler guides as Layers rows with their own lock and hide (#451: Lock Guides locks them all; Make Guides paths are Layers rows already) (dragging existing guides is done, #414; new guides snapping to side midpoints and artboards, and artboard guides, #451; the drawing tools snapping, with Construction Guides, #506; Spacing Guides for moves, #394; moves snapping to the grid, #740; ruler guides pulling drawn points with Smart Guides on, and "center" and "anchor" labels while hovering with the Selection tool, #812) | 2–4; status-bar X/Y uses the active ruler origin and General units | +| Guides, grids, smart guides, snapping, rulers | 3 | 85% | global/video rulers, Spacing Guides' distance readouts and spacing for resized or drawn art, Alt swapping a ruler guide's orientation, ruler guides selected together with art (marquee, Shift-click), ruler guides as Layers rows with their own lock and hide (#451: Lock Guides locks them all; Make Guides paths are Layers rows already) (dragging existing guides is done, #414; new guides snapping to side midpoints and artboards, and artboard guides, #451; the drawing tools snapping, with Construction Guides, #506; Spacing Guides for moves, #394; moves snapping to the grid, #740; ruler guides pulling drawn points with Smart Guides on, and "center" and "anchor" labels while hovering with the Selection tool, #812; distance and angle labels while drawing, #2983) | 2–4; status-bar X/Y uses the active ruler origin and General units | | File formats | 6 | 85% | Explicit editing-data oversized single-artboard import fixed (#6865); scores and estimates unchanged. Affinity export, and the rest of Affinity import (adjustments, blur and bevel effects, brushes, live symbol instances, text threading), DWG (no open spec: DXF instead), PSD placement as layers (Photoshop Import Options; the merged image is done), EPS checked against more real files users send (Inkscape files open as vectors; other generators' constructs are tested with programs written from the PostScript Language Reference only; CID-keyed CJK type and `CCITTFaxDecode` images not read), legacy `.ai` files saved without the prolog that defines their operators (their own operator set isn't read yet), the rest of Illustrator's editing data (appearances and live effects, symbols, pattern fills, brushes, placed files: #637), the remaining fidelity polish; EPS, DXF, EMF/WMF, TIFF, BMP, Targa, PSD export, SVGZ, PDF security, PDF/X, PDF layers, presets and Affinity import are done | 6–10 (existing scope; Affinity export unestimated) | | Export for Screens, Asset Export, slices, Save for Web | 3 | 85% | polish only (all four are done) | 2–4 | | Print, colour management, separations, flattener | 3 | 80% | print preview fidelity (flattener presets in PDF and print, overprint in PDF and composite print, Print dialog, presets, PostScript, marks, separations, Print Tiling, print overprint/flattener/bitmap options and printer profile are done, as are the Japanese papers in its Media list: JIS B4/B5/B6, はがき, 往復はがき, 封筒 長形3号/長形4号/角形2号/角形3号 and 四六判, beside the ISO B4/B5 that settings saved before them keep) | 1–2 | @@ -1099,7 +1099,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur Layer Options for several highlighted rows shows the first row's settings, and OK changes only the settings set in the dialog: each row keeps its own color, Template, Lock, Show, Print, Preview and Dim Images otherwise (`crates/ui-egui/src/dialogs/layer_options.rs` tests). As VectorCraft's own rule, a layer color (`layer.setProps`, `layer.new`, `layer.newSublayer`) or a Live Paint Gap Preview Color (`livePaint.gapOptions`) given as a hex color with an alpha part (`#rrggbbaa` or `#rgba`) is an error and changes nothing, instead of being set without its alpha. Regression tests: `a_layer_color_with_an_alpha_part_is_refused` in `crates/engine/src/tests/layers.rs` and `a_gap_color_with_an_alpha_part_is_refused` in `crates/engine/src/tests/build.rs`. Panel Options › Locate Selected Objects (beyond Illustrator, off by default): when the last selected object changes on the canvas or by a command, the panel opens the layers and groups around it and scrolls to its row, as Locate Object does, without highlighting the row; when Show Layers Only, a search or the filter leaves the object without a row, the panel opens no rows for it and scrolls as with the option off. -- **Drawing aids:** Smart Guides and snapping (moving, with the Selection, Group Selection or Direct Selection tool, and resizing by the bounding-box handles with the Selection or Free Transform tool, Shift and Alt too: the dragged corner or side lands on the other objects' anchors and centres ("anchor", "center") or lines up with their edges and centres (a line and "align"); with Smart Guides › Spacing Guides, a moved selection that lines up with nothing along an axis lands as far from its neighbour as two other objects of its row or column are apart, or halfway between its two neighbours, the equal gaps marked; with View › Snap to Grid on, a moved selection lands on the grid instead, by whichever of its edges is nearest a gridline on each axis (the center does not snap), #740, #1816; the Rotate, Scale, Shear and Reflect tools drag from the anchor they grab and snap the pointer the same way, so a corner dragged onto another object's anchor scales exactly onto it, #482; anchors dragged with Direct Selection: the one pressed on lands on other anchors, its own path's that stay put among them, on path segments or in line with them, the others following, Shift keeps 45° steps, and with Smart Guides off Snap to Point pulls it onto anchors; the Artboard tool snaps artboards, and their bleed, to other artboards, bleeds and art, the grid or whole pixels while moving, copying and resizing them, and the Transform panel shows and edits the active artboard's X/Y/W/H; drawn points, #506: hovering shows where the press goes, the point lands on the other objects' anchors, centres and paths ("anchor", "center", "path") or lines up with their anchors, edges and centres ("align"), the Pen's on the path being drawn too, near a construction guide through the anchor before it on that guide (Preferences › Smart Guides › Construction Guides and Angles), and with Shift kept to its 45° step or a square's diagonal it slides along that way into line; the targets are gathered once per document state, from the art in the window, and the anchors, centres and lines near the pointer are found by bisection, so hovering never rescans the document), guides dragged out of the rulers (#451: they snap as moved guides do, Shift to the ruler's ticks; with the Artboard tool they are artboard guides, which run across their artboard only and move, copy and delete with it, `guide.add {artboard}`) that the Selection, Direct Selection and Group Selection tools then pick and drag (#414: the selected guides highlight and move together, Alt copies them, Shift snaps the dragged one to the ticks, otherwise it snaps to pixels, the grid or the edges, centres (so the sides' midpoints) and anchors of the art and the artboards, or with Smart Guides off Snap to Point pulls it onto anchors; Shift-clicked they join the selected art, and Shift-added art keeps them, so guides and art are selected together in either order, Delete then deleting both in one step; the arrow keys nudge them, Delete deletes them, dragging one back onto its ruler deletes it, Lock Guides and Hide Guides leave them alone; `guide.list`, `guide.select`, `guide.move` and `guide.remove` reach agents), a right-click on either ruler or the origin box to pick the document's units (the current one checked; `document.setUnits`, one undo step, #528), and Draw Normal / Behind / Inside modes. Smart Guides: a point drawn with the Pen or a shape tool, and an anchor dragged with Direct Selection, snaps to where paths and ruler guides cross, labelled "intersect" (#1658, part of #1602; a moved selection's own anchors don't). +- **Drawing aids:** Smart Guides and snapping (moving, with the Selection, Group Selection or Direct Selection tool, and resizing by the bounding-box handles with the Selection or Free Transform tool, Shift and Alt too: the dragged corner or side lands on the other objects' anchors and centres ("anchor", "center") or lines up with their edges and centres (a line and "align"); with Smart Guides › Spacing Guides, a moved selection that lines up with nothing along an axis lands as far from its neighbour as two other objects of its row or column are apart, or halfway between its two neighbours, the equal gaps marked; with View › Snap to Grid on, a moved selection lands on the grid instead, by whichever of its edges is nearest a gridline on each axis (the center does not snap), #740, #1816; the Rotate, Scale, Shear and Reflect tools drag from the anchor they grab and snap the pointer the same way, so a corner dragged onto another object's anchor scales exactly onto it, #482; anchors dragged with Direct Selection: the one pressed on lands on other anchors, its own path's that stay put among them, on path segments or in line with them, the others following, Shift keeps 45° steps, and with Smart Guides off Snap to Point pulls it onto anchors; the Artboard tool snaps artboards, and their bleed, to other artboards, bleeds and art, the grid or whole pixels while moving, copying and resizing them, and the Transform panel shows and edits the active artboard's X/Y/W/H; drawn points, #506: hovering shows where the press goes, the point lands on the other objects' anchors, centres and paths ("anchor", "center", "path") or lines up with their anchors, edges and centres ("align"), the Pen's on the path being drawn too, near a construction guide through the anchor before it on that guide (Preferences › Smart Guides › Construction Guides and Angles), and with Shift kept to its 45° step or a square's diagonal it slides along that way into line; the targets are gathered once per document state, from the art in the window, and the anchors, centres and lines near the pointer are found by bisection, so hovering never rescans the document), guides dragged out of the rulers (#451: they snap as moved guides do, Shift to the ruler's ticks; with the Artboard tool they are artboard guides, which run across their artboard only and move, copy and delete with it, `guide.add {artboard}`) that the Selection, Direct Selection and Group Selection tools then pick and drag (#414: the selected guides highlight and move together, Alt copies them, Shift snaps the dragged one to the ticks, otherwise it snaps to pixels, the grid or the edges, centres (so the sides' midpoints) and anchors of the art and the artboards, or with Smart Guides off Snap to Point pulls it onto anchors; Shift-clicked they join the selected art, and Shift-added art keeps them, so guides and art are selected together in either order, Delete then deleting both in one step; the arrow keys nudge them, Delete deletes them, dragging one back onto its ruler deletes it, Lock Guides and Hide Guides leave them alone; `guide.list`, `guide.select`, `guide.move` and `guide.remove` reach agents), a right-click on either ruler or the origin box to pick the document's units (the current one checked; `document.setUnits`, one undo step, #528), and Draw Normal / Behind / Inside modes. Smart Guides: a point drawn with the Pen or a shape tool, and an anchor dragged with Direct Selection, snaps to where paths and ruler guides cross, labelled "intersect" (#1658, part of #1602; a moved selection's own anchors don't). With Smart Guides › Measurement Labels on, the Line Segment tool labels the segment being drawn with its distance, angle and X and Y offsets (`D`, `Angle`, `dX`, `dY`), and the Pen, Curvature and Smart Pen tools label their rubber band the same way when Enable Rubber Band is on (#2983). A move that Shift keeps level, upright or diagonal stays on that line when it snaps, with the Selection, Group Selection or Direct Selection tool. It slides along the line until the selection lands on the grid or on whole pixels, lines up with the other objects or is spaced evenly with them, or until the line passes an anchor or crosses a ruler guide near the point the selection was grabbed by. A level or upright move snaps along its own axis only. Snap to Last Location: with Smart Guides on and Preferences › Smart Guides › Snap to Last Location on (off by default, VectorCraft's own choice), a selection moved by dragging with the Selection, Group Selection or Direct Selection tool, Shift too, goes back to where the move began while it is within Snapping Tolerance of it, labeled "last location"; an Alt copy is not pulled back onto its original, and with View › Snap to Grid or Snap to Pixel on the move snaps to the grid or to whole pixels instead (`prefs.set` key `snapToLastLocation`). The public [Smart Guides options](https://helpx.adobe.com/illustrator/desktop/measure-and-align/grids-and-guides/smart-guides-options.html) page lists the option as snapping objects back to their previous position; the distance, the label, the copy rule and the grid and pixel rule are VectorCraft's own. Tests: `select::tests::snap_to_last_location_puts_a_move_back_where_it_began`, `select::tests::snap_to_pixel_wins_over_snap_to_last_location`, `direct::tests::snap_to_last_location_puts_a_path_moved_by_its_anchor_back`, `direct::tests::snap_to_pixel_wins_over_snap_to_last_location_for_a_path_moved_by_its_anchor`, `tests_prefs::snap_to_last_location_puts_a_dragged_square_back`. Pixel Preview: with View › Pixel Preview on, the tools use no Smart Guides. Nothing snaps to Smart Guides or shows their guides, and Snap to Point acts as it does with Smart Guides off. View › Smart Guides keeps its setting and applies again when Pixel Preview is turned off. [Work with Smart Guides](https://helpx.adobe.com/illustrator/desktop/measure-and-align/grids-and-guides/work-with-smart-guides.html) says Smart Guides cannot be used while Pixel Preview is on. Tests: `tests::smartguides::pixel_preview_turns_smart_guides_off`, `canvas::tests::pixel_preview_stops_smart_guides_snapping_a_move`. diff --git a/docs/ui-parity.md b/docs/ui-parity.md index e5f9b1cf19..45dbfd76e6 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -231,7 +231,7 @@ tools, never checked side by side) isn't captured row by row. | "intersect" label and snapping to path intersections | partial | #1658 (part of #1602): a point drawn with the Pen, the shape tools or the other drawing tools and an anchor dragged with Direct Selection snap to where paths and ruler guides cross; a moved selection's own anchors and the Width tool don't | | Construction guides at preference angles while drawing | done | #506 | | Line extension: the dragged end of a straight segment snaps to the segment's own direction at any angle, past its other end or short of it, with the guide along it | done | #2120; Direct Selection and Curvature, one anchor dragged (with several, the move has no single direction); nearer the horizontal or vertical through the other end, the point lines up with it ("align"), so a nearly level segment can still be straightened | -| Measurement labels while drawing, moving and resizing | partial | sizes and offsets; angle and distance readouts while drawing missing | +| Measurement labels while drawing, moving and resizing | done | sizes and offsets; the segment being drawn is labeled with its distance, angle and offsets by the Line Segment tool, and by the Pen, Curvature and Smart Pen tools with Enable Rubber Band on (#2983) | | Spacing guides (equal distances) | partial | for moves and bounding-box resizes (#394); not while drawing | | Distance Guides: Alt/Option over another object, or an empty part of an artboard, shows the selection's distances to it (Smart Guides › Invoke Distance Guides) | done | Selection tool hover with Smart Guides on: the gap between facing edges, else the offsets of matching edges, to another object; the left, right, top and bottom distances to the artboard; labelled in the General unit. The selection is measured as a whole. Nothing while moving or nudging, which the public pages don't describe. [Use Distance Guides](https://helpx.adobe.com/illustrator/desktop/measure-and-align/grids-and-guides/use-distance-guides-for-accurate-placement.html), [Smart Guides options](https://helpx.adobe.com/illustrator/desktop/measure-and-align/grids-and-guides/smart-guides-options.html) | | Snapping tolerance preference | done | |