From 112fe53d88a05a448f632c92bfe7e8d1b64a208f Mon Sep 17 00:00:00 2001 From: jmverges Date: Wed, 11 Nov 2026 10:26:00 +0000 Subject: [PATCH 1/8] .ai import: an envelope in Edit Contents opens on its drawn art when its mesh lies away by a move (#6570) While an envelope's contents are edited, the editing data keeps its mesh in the envelope's dictionary. In some users' files that mesh lies away from the drawn art by a plain move, the contents sitting with the drawn art, as if the mesh hadn't followed the envelope when it moved. The envelope then drew its contents elsewhere, failed the drawn-art check and kept its drawn art. Nothing in the dictionary records the move, and no envelope saved outside Edit Contents was found displaced. A mesh or top object saved in Edit Contents (and a warp of type tried on its saved grid) whose mesh draws the contents as large as the drawn art, within half a point over width and height together, but further from it than that, is now tried again after every other way, with one more of the import's checks, its mesh moved by as far as the drawn art's box lies from the box of what it draws. The drawn-art check, the budget and the page comparison decide as before, so an envelope that opened live before opens the same way. Co-Authored-By: Claude Opus 5.5 --- crates/eps/src/import/ai/envelope.rs | 67 +++++++++++++++++++-- crates/eps/src/import/ai/read/containers.rs | 13 +++- crates/eps/src/import/ai/tests.rs | 57 ++++++++++++++++++ crates/eps/src/import/tests_native.rs | 61 +++++++++++++++++++ docs/file-format-parity.md | 3 +- docs/target-app-parity.md | 4 +- 6 files changed, 195 insertions(+), 10 deletions(-) diff --git a/crates/eps/src/import/ai/envelope.rs b/crates/eps/src/import/ai/envelope.rs index 25701650c..8b3fceecf 100644 --- a/crates/eps/src/import/ai/envelope.rs +++ b/crates/eps/src/import/ai/envelope.rs @@ -19,10 +19,12 @@ //! (`AIDeformArtWidthKey`, `AIDeformArtHeightKey`), the contents (`X=` … `X+`, then //! `(AIDeformArtKey)`) and the kind (`AIDeformStyle`: 1 a warp, 2 a mesh, 3 a top object). Then the //! distorted art on plain lines, and the plugin's `XP`. While the contents are edited, the group -//! holds the contents and the dictionary the mesh. A top object's outline is the edge of its -//! one-patch mesh. A warp's mesh is a grid of its shape: a warp of type whose own warp doesn't come -//! out as drawn opens as a mesh envelope on that grid where that does. An envelope whose parts don't -//! read, or that doesn't come out as drawn, keeps its drawn art. +//! holds the contents and the dictionary the mesh, which in some files lies away from the drawn art +//! by a move, as if it hadn't followed the envelope: such a mesh or top object whose grid draws its +//! contents as large as the drawn art but elsewhere is tried again moved onto that art. A top +//! object's outline is the edge of its one-patch mesh. A warp's mesh is a grid of its shape: a warp +//! of type whose own warp doesn't come out as drawn opens as a mesh envelope on that grid where that +//! does. An envelope whose parts don't read, or that doesn't come out as drawn, keeps its drawn art. use std::collections::{BTreeMap, BTreeSet, HashMap}; use std::sync::Arc; @@ -62,6 +64,10 @@ const MAX_GRID: u32 = 50; /// How far, in points over width and height together, a saved size may lie from the contents' /// bounds with their strokes and still count as that box. const SAVED_SIZE_SLACK: f64 = 0.5; +/// How far, in points over width and height together, what an envelope draws may differ in size +/// from its drawn art and still count as that art moved, and how far it must lie from it to count +/// as moved at all ([`moved_onto`]). +const MOVED_SLACK: f64 = 0.5; /// The bits of the options word that are read: Distort Appearance, Distort Pattern Fills, Distort /// Linear Gradients, Anti-Alias (set: off) and Preserve Shape Using (set: Clipping Mask). const KNOWN_BITS: i64 = 0b111 | 1 << 16 | 1 << 17; @@ -183,6 +189,40 @@ fn with_strokes(kind: &NodeKind) -> Option { Some(kind) } +/// The kind of `envelope`, a mesh or a top object in Edit Contents, with its grid moved onto +/// `drawn`, the drawn art the file saves for it, where what it draws is as large as that art (within +/// [`MOVED_SLACK`]) but lies elsewhere (further than that): moved by as far as the drawn art's box +/// lies from the box of what it draws. While the contents are edited, the grid is kept in the +/// envelope's dictionary, and in some files it lies away from the drawn art by a move, the contents +/// with the drawn art, as if the grid hadn't followed the envelope when it moved. `None` outside +/// Edit Contents, for a warp (it maps from its contents' box, which lies with them), and where +/// nothing is drawn. Symbol instances and pattern fills in the contents are measured as they are, +/// not drawn out: where that makes the sizes differ, no move is tried. +pub(super) fn moved_onto(envelope: &Node, drawn: &Node) -> Option { + let NodeKind::Envelope { kind, editing: true, .. } = &envelope.kind else { return None }; + if matches!(**kind, EnvelopeKind::Warp { .. }) { + return None; + } + // What it draws, measured as its drawn art is: the pieces in a group with its own appearance. + let mut drew = envelope.clone(); + drew.kind = NodeKind::Group { children: vectorcraft_render::expand_live(envelope).into_iter().map(Arc::new).collect(), clip: false }; + let (from, to) = (drew.visual_bounds()?, drawn.visual_bounds()?); + let by = to.center() - from.center(); + let resized = (from.width() - to.width()).abs() + (from.height() - to.height()).abs(); + if !(resized <= MOVED_SLACK && by.x.abs() + by.y.abs() > MOVED_SLACK && by.x.is_finite() && by.y.is_finite()) { + return None; + } + let mut moved = envelope.kind.clone(); + if let NodeKind::Envelope { kind, .. } = &mut moved { + match &mut **kind { + EnvelopeKind::Mesh { points, .. } => points.iter_mut().for_each(|p| *p += by), + EnvelopeKind::TopObject { path, .. } => path.transform(Affine::translate(by)), + EnvelopeKind::Warp { .. } => return None, + } + } + Some(moved) +} + /// A warp: its style, bend, distortions and orientation. fn warp(o: &Obj) -> Option { let style = whole(o, "DeformStyle")??; @@ -552,7 +592,8 @@ impl WarpedType { /// for it ([`live_on_saved_grid`]), which opens in its place where it does: its type stays /// editable in that shape, and the warp's style and bend are lost. With Distort Appearance, each /// is also checked mapping from its type's glyphs grown by half their strokes' weight - /// ([`with_strokes`]). Each + /// ([`with_strokes`]). In Edit Contents, a mesh or top object whose grid draws the type + /// elsewhere than its drawn art is checked last moved onto that art ([`moved_onto`]). Each /// check uses one of the import's checks and, for an envelope that shows, adds to what the page /// check allows ([`drawn_alike`]). Elsewhere, and where the type is set in a font that isn't /// available, the drawn art and the hidden type stay. @@ -634,7 +675,17 @@ impl WarpedType { (plain.visible, plain.opacity) = (true, 1.0); let page = self.page.filter(|_| shown.contains(&drawn)); let mut alike = None; - for (i, kind) in kinds.into_iter().enumerate() { + // In Edit Contents, a grid that draws the type elsewhere than its drawn art is tried again + // moved onto that art ([`moved_onto`]), after every other way. + let (mut tries, mut moved, mut again, mut i) = (kinds.into_iter(), vec![], false, 0); + loop { + let Some(kind) = tries.next() else { + if again || moved.is_empty() { + break; + } + (tries, again) = (std::mem::take(&mut moved).into_iter(), true); + continue; + }; // A second try is only made while more checks are left than there are envelopes of // type and warped text objects still to come (some of which may need none): one // envelope's tries don't use up the checks of the others. @@ -642,6 +693,7 @@ impl WarpedType { return Err(TypeKept::Budget); } self.checks -= 1; + i += 1; let mut envelope = art.clone(); (envelope.id, envelope.kind) = (id, kind); let shared = Shared { patterns: &doc.patterns, images: &doc.images }; @@ -651,6 +703,9 @@ impl WarpedType { alike = Some(envelope); break; } + if !again && let Some(kind) = moved_onto(&live_one, &plain) { + moved.push(kind); + } } let envelope = alike.ok_or(TypeKept::Differs)?; if doc.node(drawn).is_none() { diff --git a/crates/eps/src/import/ai/read/containers.rs b/crates/eps/src/import/ai/read/containers.rs index 02f6f8097..426416b75 100644 --- a/crates/eps/src/import/ai/read/containers.rs +++ b/crates/eps/src/import/ai/read/containers.rs @@ -913,7 +913,18 @@ impl<'a> Reader<'a> { let mut plain = drawn.clone(); (plain.visible, plain.opacity) = (true, 1.0); let page = self.page_in_doc(); - if !crate::import::ai::envelope::drawn_alike(&shown, &plain, layer, &shared, page, &mut self.envelope_spent) { + let mut alike = crate::import::ai::envelope::drawn_alike(&shown, &plain, layer, &shared, page, &mut self.envelope_spent); + // In Edit Contents, a grid that draws the contents elsewhere than the drawn art is checked + // again moved onto that art. + if !alike + && self.envelope_checks > 0 + && let Some(kind) = crate::import::ai::envelope::moved_onto(&shown, &plain) + { + self.envelope_checks -= 1; + (envelope.kind, shown.kind) = (kind.clone(), kind); + alike = crate::import::ai::envelope::drawn_alike(&shown, &plain, layer, &shared, page, &mut self.envelope_spent); + } + if !alike { self.warn(crate::import::ai::envelope::DRAWN); return; } diff --git a/crates/eps/src/import/ai/tests.rs b/crates/eps/src/import/ai/tests.rs index 7b208caf3..4f996e784 100644 --- a/crates/eps/src/import/ai/tests.rs +++ b/crates/eps/src/import/ai/tests.rs @@ -7880,6 +7880,63 @@ fn an_envelope_whose_contents_are_edited_opens_live_in_edit_contents() { assert_eq!(fill(&content[0]).color(), Some(Color::rgb(1.0, 0.0, 0.0))); } +/// While an envelope's contents are edited, its mesh is kept in its dictionary, and in some files it +/// lies away from the drawn art by a move, the contents with the drawn art. Where the mesh draws the +/// contents as large as the drawn art but elsewhere, the envelope opens live with its mesh moved +/// onto that art; where it draws them at another size, it keeps its drawn art. +#[test] +fn an_envelope_in_edit_contents_whose_mesh_stayed_behind_opens_live_on_its_drawn_art() { + use kurbo::Shape as _; + use vectorcraft_doc::live::EnvelopeKind; + // The top object of `an_envelope_whose_contents_are_edited_opens_live_in_edit_contents`. + let at = |(i, j): (usize, usize)| (40.0 + i as f64 * 120.0, 20.0 + j as f64 * 60.0); + let handle = |p: (usize, usize), q: (usize, usize)| match (p, q) { + ((0, 1), (1, 1)) => (70.0, 95.0), + ((1, 1), (0, 1)) => (130.0, 95.0), + _ => { + let ((x, y), (u, v)) = (at(p), at(q)); + (x + (u - x) / 3.0, y + (v - y) / 3.0) + } + }; + let mesh = mesh_text(1, 1, &at, &handle, None); + let mut bp = kurbo::BezPath::new(); + bp.move_to((40.0, 20.0)); + bp.curve_to((70.0, 5.0), (130.0, 5.0), (160.0, 20.0)); + bp.curve_to((160.0, 40.0), (160.0, 60.0), (160.0, 80.0)); + bp.curve_to((120.0, 80.0), (80.0, 80.0), (40.0, 80.0)); + bp.curve_to((40.0, 60.0), (40.0, 40.0), (40.0, 20.0)); + bp.close_path(); + let shaped = |a: Affine| EnvelopeKind::TopObject { path: vectorcraft_geom::PathData::from_bezpath(&(a * bp.clone())), corners: None }; + let keys = ENVELOPE_KEYS.replace("0 /Bool (AIDeformEditModeKey)", "1 /Bool (AIDeformEditModeKey)"); + // The contents and the drawn art lie 30 points right and 15 down of where the mesh in the + // dictionary draws them. + let moved = Affine::translate((30.0, 15.0)); + let contents = Rect::new(70.0, 35.0, 190.0, 95.0); + let read_with = |drawn: &EnvelopeKind| { + let block = format!("0 g\n{mesh}"); + let red = format!("{RED}{}f\n", rect_path(contents.x0, 100.0 - contents.y1, contents.x1, 100.0 - contents.y0)); + read_ok(&layer(&envelope_parts(&red, &keys, &block, 3, &drawn_box(drawn, contents, contents)))) + }; + let r = read_with(&shaped(moved)); + let a = art(&r.doc); + assert_eq!(a.len(), 1, "{a:#?}"); + let (content, kind, _, editing, _) = live_envelope(&a[0]); + assert!(editing); + let EnvelopeKind::TopObject { path, .. } = kind else { panic!("{kind:?}") }; + // Moved as far as the drawn art's box lies from the box of what the mesh draws. + let (got, want) = (path.to_bezpath().bounding_box(), (moved * bp.clone()).bounding_box()); + assert!((got.origin() - want.origin()).hypot() < 0.01 && (got.size() - want.size()).to_vec2().hypot() < 1e-6, "{got:?} {want:?}"); + assert_eq!(content.len(), 1, "{content:#?}"); + assert_eq!(content[0].geometric_bounds(), Some(contents)); + assert!(r.warnings.is_empty(), "{:?}", r.warnings); + // Drawn art moved and a tenth larger isn't the mesh's art moved: it stays. + let r = read_with(&shaped(moved * Affine::scale_about(1.1, (100.0, 50.0)))); + let a = art(&r.doc); + assert_eq!(a.len(), 1, "{a:#?}"); + assert!(matches!(a[0].kind, NodeKind::Group { .. }), "{a:#?}"); + assert_eq!(r.warnings, [super::envelope::DRAWN]); +} + /// An envelope that doesn't read (a warp style number past the list, a mesh envelope whose shape /// isn't a mesh, one whose plugin never closes it) keeps its drawn art, with one note; the art /// round it is read as ever. diff --git a/crates/eps/src/import/tests_native.rs b/crates/eps/src/import/tests_native.rs index 2b7b421bb..f9983c6e8 100644 --- a/crates/eps/src/import/tests_native.rs +++ b/crates/eps/src/import/tests_native.rs @@ -5846,6 +5846,67 @@ fn a_warp_of_type_without_a_saved_grid_keeps_its_drawn_art() { assert_eq!(super::tests::program_warnings(&r.warnings), [crate::import::ai::ENVELOPE_TYPE_DRAWN], "{:?}", r.warnings); } +/// [`type_envelope_on`] while its contents are edited: the text object in the group before its +/// dictionary, and `shape` in its contents' block, where the editing data then saves the mesh. +fn type_envelope_editing(keys: &str, shape: &str, drawn: &str) -> String { + let hidden = |s: &str| -> String { s.lines().map(|l| format!("%_{l}\n")).collect() }; + format!( + "{}{}%_X=\n{}%_X+\n%_ (AIDeformArtKey) ,\n%_1 /Int (AIDeformStyle) ,\n%_;\n%_\n%_9 () XW\nu\n{drawn}U\n9 () XW\n(Adobe Deform Plugin) 1 0 0 XP\n", + hidden(&format!("u\n{}U\n", text_object(0))), + hidden(&format!("/ArtDictionary :\n{}", keys.replace("0 /Bool (AIDeformEditModeKey)", "1 /Bool (AIDeformEditModeKey)"))), + hidden(&format!("0 g\n{shape}")), + ) +} + +/// While the contents of an envelope of type are edited, its grid is kept in its dictionary, and in +/// some files it lies away from the drawn art by a move, the type with the drawn art. A warp of type +/// whose saved grid draws the type as large as its drawn art but elsewhere opens live as a mesh +/// envelope on that grid moved onto the drawn art (#3179). +#[test] +fn a_warp_of_type_in_edit_contents_whose_grid_stayed_behind_opens_on_its_drawn_art() { + use vectorcraft_doc::live::EnvelopeKind; + let Some(family) = installed_family() else { return }; + let doc = type_envelope_text(&family, None); + let mut text = made_envelope_type(&doc); + text.visible = true; + // The drawn art is the grid's moved 10 points left and 25 down. + let by = kurbo::Vec2::new(-10.0, 25.0); + let EnvelopeKind::Mesh { rows, cols, points, handles } = rising_kind() else { panic!("a mesh") }; + let want: Vec = points.iter().map(|p| *p + by).collect(); + let kind = EnvelopeKind::Mesh { rows, cols, points: want.clone(), handles }; + let options = vectorcraft_doc::live::EnvelopeOptions::NEW; + let shaped = Node::new( + vectorcraft_doc::NodeId(1_000_000), + NodeKind::Envelope { + content: vec![std::sync::Arc::new(text)], + kind: Box::new(kind), + fidelity: 50.0, + editing: false, + options, + frame: Box::default(), + }, + ); + let (drawn, page) = drawn_envelope(&shaped); + let art = native_with_text( + &layer("Art", true, &format!("{}\n{}", square(10, 10), type_envelope_editing(&arc_keys(0.3), &rising_grid(), &drawn))), + &doc, + ); + let r = import(&eps(&format!("{}\n{page}", page_square(10, 10)), &art)).unwrap(); + assert_eq!(names(&r.document), ["Art"], "{:?}", r.warnings); + let (envelopes, loose) = envelopes_and_type(&r.document); + assert_eq!(envelopes, [vec!["FLUX".to_string()]], "{:?}", r.warnings); + assert!(loose.is_empty(), "{loose:?}"); + assert!(super::tests::program_warnings(&r.warnings).is_empty(), "{:?}", r.warnings); + let mut live = None; + r.document.walk(|n| { + if let NodeKind::Envelope { kind, editing, .. } = &n.kind { + live = Some(((**kind).clone(), *editing)); + } + }); + let Some((EnvelopeKind::Mesh { points, .. }, true)) = live else { panic!("not a mesh envelope in Edit Contents: {live:?}") }; + assert!(points.iter().zip(&want).all(|(p, q)| (*p - *q).hypot() < 0.05), "{points:?}"); +} + /// A warp of type tried again on its saved grid leaves a check for each envelope of type still to /// come: with one check too few for both, the next envelope, whose first try opens it, gets it. #[test] diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index a18b71705..56d25f12a 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File format parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-11 · **Change:** minor (`.ai` and EPS import, PDF import: tracking and manual kerning in horizontally scaled type take the Horizontal Scale, as the file's page spaces such type; `.ai` and EPS import: stroked type in an envelope maps from its glyphs grown by half its stroke, #3179; `.ai` and EPS import: warps of type open as mesh envelopes on their saved grid, #3179; `.ai` and EPS import: stroked type keeps its stroke's cap, join and miter limit from the text document, #6472; `.ai` save: type in a variable font's named instance names its design-space setting, #6388; `.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; `.ai` and EPS import: a note for type in an envelope, #3179; `.ai` and EPS import: the live envelope and warped type check sees white art; PDF import: DeviceN images of one ink or of process plates keep their inks, #6348; `.ai` import: images in a spot ink's Separation space keep their ink, #6350; PDF import: Separation images keep their ink, #6314; 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; PDF import: image masks open colorized by the color they are painted in, #6313; EPS and `.ai` editing data: a ligature the page names by a private-use glyph matches its text object; EPS import: CIDFonts only named, over a Unicode CMap, set as live type; EPS import: CMap mappings to base-font codes and glyph names; EPS import: CID-keyed fonts in FontSets (CFF) draw their glyphs; EPS import: `kshow` procedures kern live type; EPS import: embedded Type 1 fonts' glyphs and widths; EPS import: CID-keyed fonts draw their glyphs; EPS import: Type 42 fonts' characters and widths from the font they carry, show operators' spacing kept as tracking; EPS import: an image decoder's own buffers stop counting against the memory budget once it is decoded; EPS import: sampled functions interpolate between their samples; `.ai` and EPS type from the text document keeps its tab stops; it keeps auto leading and hyphenation options, and may show more of the file's last lines in an area frame; `.ai` and EPS import: Art brushes in the older form of their objects come into the Brushes panel; EPS import: gradients drawn as abutting strips come in as one gradient, without seams; EPS import: composite fonts draw their Type 42 glyphs; SVG import: filter and mask regions clip, #5876, #5875; SVG import: Gaussian blurs keep `color-interpolation-filters`, #5877; PDF and `.ai` export: objects' URLs as links, #5785; SVG import: `color-interpolation="linearRGB"` gradients, #5878; `.ai` import: paths with a Calligraphic brush open brushed, #372) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-11 · **Change:** 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; `.ai` and EPS import, PDF import: tracking and manual kerning in horizontally scaled type take the Horizontal Scale, as the file's page spaces such type; `.ai` and EPS import: stroked type in an envelope maps from its glyphs grown by half its stroke, #3179; `.ai` and EPS import: warps of type open as mesh envelopes on their saved grid, #3179; `.ai` and EPS import: stroked type keeps its stroke's cap, join and miter limit from the text document, #6472; `.ai` save: type in a variable font's named instance names its design-space setting, #6388; `.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; `.ai` and EPS import: a note for type in an envelope, #3179; `.ai` and EPS import: the live envelope and warped type check sees white art; PDF import: DeviceN images of one ink or of process plates keep their inks, #6348; `.ai` import: images in a spot ink's Separation space keep their ink, #6350; PDF import: Separation images keep their ink, #6314; 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; PDF import: image masks open colorized by the color they are painted in, #6313; EPS and `.ai` editing data: a ligature the page names by a private-use glyph matches its text object; EPS import: CIDFonts only named, over a Unicode CMap, set as live type; EPS import: CMap mappings to base-font codes and glyph names; EPS import: CID-keyed fonts in FontSets (CFF) draw their glyphs; EPS import: `kshow` procedures kern live type; EPS import: embedded Type 1 fonts' glyphs and widths; EPS import: CID-keyed fonts draw their glyphs; EPS import: Type 42 fonts' characters and widths from the font they carry, show operators' spacing kept as tracking; EPS import: an image decoder's own buffers stop counting against the memory budget once it is decoded; EPS import: sampled functions interpolate between their samples; `.ai` and EPS type from the text document keeps its tab stops; it keeps auto leading and hyphenation options, and may show more of the file's last lines in an area frame; `.ai` and EPS import: Art brushes in the older form of their objects come into the Brushes panel; EPS import: gradients drawn as abutting strips come in as one gradient, without seams; EPS import: composite fonts draw their Type 42 glyphs; SVG import: filter and mask regions clip, #5876, #5875; SVG import: Gaussian blurs keep `color-interpolation-filters`, #5877; PDF and `.ai` export: objects' URLs as links, #5785; SVG import: `color-interpolation="linearRGB"` gradients, #5878; `.ai` import: paths with a Calligraphic brush open brushed, #372) · **Target:** Adobe Illustrator 2026 (30.x) Every format Illustrator opens, places, saves or exports, against VectorCraft's format table (`FORMATS`, `OPEN_EXTS`, `PLACE_EXTS` in `crates/engine/src/cmd/fileio/formats.rs`). Illustrator's list comes from its public @@ -339,6 +339,7 @@ Stop opacity on translated artboards remains outside this regression coverage. | Date | Change | Summary | |---|---|---| +| 2026-11-11 | minor | `.ai` and EPS import: while an envelope's contents are edited, the editing data keeps its mesh (a mesh's, a top object's or a warp's grid) in its dictionary, and in some files that mesh lies away from the drawn art by a move, the contents with the drawn art, as if the mesh hadn't followed the envelope when it moved: the envelope drew its contents elsewhere and kept its drawn art. A mesh or top object saved in Edit Contents (or a warp of type tried on its saved grid) whose mesh draws the contents as large as the drawn art, within half a point over width and height together, but further from it than that, is now tried again, after every other way and with one more of the import's checks, with its mesh moved by as far as the drawn art's box lies from the box of what it draws; it opens live there where it then draws as the drawn art. In users' files, used locally and never committed, only envelopes saved in Edit Contents draw elsewhere than their drawn art, and nothing in their dictionaries says by how much they moved. Tests `ai::tests::an_envelope_in_edit_contents_whose_mesh_stayed_behind_opens_live_on_its_drawn_art`, `tests_native::a_warp_of_type_in_edit_contents_whose_grid_stayed_behind_opens_on_its_drawn_art` (#6570) | | 2026-11-11 | minor | `.ai` and EPS import, PDF import, SVG export and `.ai` save: type with a Horizontal Scale other than 100% and tracking or manual kerning is spaced as a `.ai`'s own page spaces it. Layout now measures tracking and manual kerning in thousandths of the em as the scale draws it (see type-parity.md, same date); they were added at the full em, so such type drifted from the page letter by letter. A `.ai`'s page writes that type's character spacing as the tracking itself in a text space as wide as the scale, which PDF scales by it (ISO 32000-1 §9.4.4). In 224 users' files (used locally, never committed), 93 render differently. Of the 25 whose render moved by more than 1% of its pixels against the page, 23 came closer. The note on the type differing from the page shows a smaller share in 5 files (11.0% to 4.4% at most) and a larger one in 2 (by 0.2 to 0.3 points). 9 more envelopes of type open live (113 to 122 of 203, none fewer in any file), and no file switched between its layers and its PDF part. PDF import: the tracking of horizontally scaled type is the file's `Tc` in thousandths of its size (it was that times the scale), the glyphs where they were (test `tests::text_placement::character_spacing_in_horizontally_scaled_type_is_the_files_tc`). SVG export writes the letter spacing in thousandths of the font size it writes (the vertical scale), so type scaled uniformly keeps its spacing in a viewer (test `text_layout::spacing_in_uniformly_scaled_type_takes_the_scale`); tracked type scaled horizontally alone still differs a little there, as before. A `.ai` saved here writes the same values as before; VectorCraft now lays them out as the file's own PDF part draws them. Scores and estimates unchanged. | | 2026-11-11 | minor | `.ai` and EPS import: stroked type in an envelope with Distort Appearance is tried mapped from its glyphs grown by as far as its strokes paint along a side (half the weight of a centred stroke), without what miter joins add at corners, for a stroke on its appearance or on a group round it as well as its characters'; it was mapped from its bounds with strokes, which reach as far as a miter join may at a corner. Tests `ai::envelope::tests::stroked_type_is_tried_mapped_from_its_glyphs_grown_by_half_its_stroke`, `type_with_a_character_stroke_is_tried_mapped_from_its_bounds_with_strokes`, `a_stroke_on_a_group_of_type_grows_the_box_by_half_its_weight` (part of #3179) | | 2026-11-11 | minor | `.ai` and EPS import: a warp of type (an envelope made with a warp around type) whose own warp, VectorCraft's, doesn't draw as its drawn art is tried again as a mesh envelope on the warp grid the file saves, with the same drawn-art check, budget and page comparison as #6421: where it passes, the type is editable and keeps the file's shape, and the warp's settings (style, bend) are lost. A second try is only made while more checks are left than there are envelopes of type and warped text objects still to come. Type with strokes in an envelope with Distort Appearance on is also tried mapped from its box with strokes, where the size the file saves for it doesn't already give that box (a rule chosen by measuring real files). An envelope's grid (warp, mesh or top object) now reads its way round from all its rows and columns, not its first row alone: a mesh bent far round (its end columns leaning more than halfway over) reads the way it runs, not turned a quarter round, and a grid symmetric about its middle reads the same wherever it is on the page. Tests `tests_native::a_warp_of_type_opens_as_a_mesh_on_its_saved_grid_and_keeps_its_shape_when_its_text_changes`, `a_warp_of_stroked_type_maps_from_its_bounds_with_strokes_where_its_drawn_art_does`, `a_warp_of_type_without_a_saved_grid_keeps_its_drawn_art`, `a_warp_of_type_tried_again_leaves_the_next_envelope_its_check`, `ai::tests::a_mesh_envelope_bent_far_round_opens_the_way_it_runs`, `a_mesh_envelope_bent_halfway_round_opens_the_same_way_wherever_it_is` (part of #3179) | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 764b7ebe8..240ba7dbf 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-11 · **Change:** minor (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-11 · **Change:** 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: 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) > > Vector Halftone preview update (2026-11-10): temporary dot selection marks are hidden, and the bounding box stays available (#6369). > @@ -1424,7 +1424,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur A `.ai`, or a PDF saved with Illustrator's editing data, whose editing data can't be decompressed opens from its PDF part with a warning that its layers weren't read and why, and an export or Save As to its own path asks first (`acknowledgeLoss`), as for a file whose layers the reader can't read (`crates/eps/src/import/tests_native.rs`, `crates/engine/src/cmd/fileio/tests_aiimport.rs`). A `.ai` or EPS opened from its editing data with one object on a shown layer that the reader can't read (an unknown operator inside a group, a pattern fill whose pattern it doesn't have, a symbol instance, a placed file without a usable preview) keeps its layers and groups: in that object's place, its layer gets a group of the page's objects inside the object's box, named after it, with a warning that lists what came in as the page's art and an export or Save As to its own path asking first (`acknowledgeLoss`). The file still opens from its page or PDF part when no box can be found, when live art or another unread object shares the page art inside it, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry the object's transparency (#4806; `crates/eps/src/import/tests_native.rs`, `crates/engine/src/cmd/fileio/tests_aiimport.rs`). A `.ai` or EPS opened from its editing data with one object on a shown layer that the reader can't read (an unknown operator inside a group, a pattern fill whose pattern it doesn't have, a symbol instance, a placed file without a usable preview) keeps its layers and groups: in that object's place, its layer gets a group of the page's objects inside the object's box, named after it, with a warning that lists what came in as the page's art and an export or Save As to its own path asking first (`acknowledgeLoss`). The file still opens from its page or PDF part when no box can be found, when live art or another unread object shares the page art inside it, when the object spans two artboards, or when the page art doesn't carry the object's transparency (#4806; `crates/eps/src/import/tests_native.rs`, `crates/engine/src/cmd/fileio/tests_aiimport.rs`). - In a `.ai` opened from its editing data, an envelope made with a warp, a mesh or a top object opens as a live envelope of its contents, with its warp style, bend and distortions, its mesh points and handles or its top object's outline, its Envelope Options, its Fidelity and whether its contents are being edited. When Distort Appearance is on and the size the file saves for the contents matches their bounds with strokes, the contents map from that stroked box, until an edit in Edit Contents changes how far the strokes reach; VectorCraft's new envelopes map from the geometric box, as do contents with no width or no height, such as a single straight line. An envelope that can't be read, or that VectorCraft draws differently from the file's drawn art, keeps that drawn art, with a note. An envelope of type opens live, its type laid out from the text document as its contents, where VectorCraft's own envelope of that type draws as the drawn art the file saves (compared as for the other envelopes, seen on white and on black, so white type counts, and only where the layers with it are compared with the page and draw as it, the envelope weighed as the art it draws); elsewhere, and where the type's font isn't available, a text or visibility variable binds the type, or the envelope is in a symbol's or a pattern's art, it keeps its drawn art, with its type hidden and a note that says the type isn't editable (#3179; `crates/eps/src/import/ai/tests.rs`, `crates/eps/src/import/tests_native.rs`). A warp of type whose own warp, VectorCraft's, doesn't draw as its drawn art is tried again as a mesh envelope on the grid the file saves for it: its type stays editable and keeps the file's shape, but the warp's settings (style, bend) are lost, as the document has no place for them on a mesh. Type with strokes in an envelope with Distort Appearance on is also tried mapped from its glyphs grown by as far as its strokes paint along a side (half the weight of a centred stroke), without what miter joins add at corners, where the size the file saves for it doesn't already give that box; the drawn-art check decides which one opens. An envelope's grid reads its way round from all its rows and columns, so a mesh bent far round reads the way it runs. + In a `.ai` opened from its editing data, an envelope made with a warp, a mesh or a top object opens as a live envelope of its contents, with its warp style, bend and distortions, its mesh points and handles or its top object's outline, its Envelope Options, its Fidelity and whether its contents are being edited. When Distort Appearance is on and the size the file saves for the contents matches their bounds with strokes, the contents map from that stroked box, until an edit in Edit Contents changes how far the strokes reach; VectorCraft's new envelopes map from the geometric box, as do contents with no width or no height, such as a single straight line. An envelope that can't be read, or that VectorCraft draws differently from the file's drawn art, keeps that drawn art, with a note. An envelope of type opens live, its type laid out from the text document as its contents, where VectorCraft's own envelope of that type draws as the drawn art the file saves (compared as for the other envelopes, seen on white and on black, so white type counts, and only where the layers with it are compared with the page and draw as it, the envelope weighed as the art it draws); elsewhere, and where the type's font isn't available, a text or visibility variable binds the type, or the envelope is in a symbol's or a pattern's art, it keeps its drawn art, with its type hidden and a note that says the type isn't editable (#3179; `crates/eps/src/import/ai/tests.rs`, `crates/eps/src/import/tests_native.rs`). A warp of type whose own warp, VectorCraft's, doesn't draw as its drawn art is tried again as a mesh envelope on the grid the file saves for it: its type stays editable and keeps the file's shape, but the warp's settings (style, bend) are lost, as the document has no place for them on a mesh. Type with strokes in an envelope with Distort Appearance on is also tried mapped from its glyphs grown by as far as its strokes paint along a side (half the weight of a centred stroke), without what miter joins add at corners, where the size the file saves for it doesn't already give that box; the drawn-art check decides which one opens. An envelope's grid reads its way round from all its rows and columns, so a mesh bent far round reads the way it runs. While an envelope's contents are edited, its mesh is kept in its dictionary, and in some files that mesh lies away from the drawn art by a move, the contents with the drawn art (as if the mesh hadn't followed the envelope when it moved): a mesh or top object saved in Edit Contents whose mesh draws the contents as large as the drawn art but elsewhere is tried again, after every other way, with its mesh moved onto that art, and opens there where it then draws as it. In a `.ai` opened from its editing data, Convert to Shape on a whole path or type object opens as a live Rectangle, Rounded Rectangle or Ellipse effect with its Absolute or Relative size, its corner radius, its place among the object's effects and whether it is hidden. A relative size in the file is added to each side. A relative Ellipse, or a setting VectorCraft doesn't read, keeps the drawn art (`crates/eps/src/import/ai/style/tests.rs`, `crates/eps/src/import/ai/tests.rs`, `crates/eps/src/import/tests_native.rs`). In a `.ai` opened from its editing data, Bas Relief in an art style takes its Detail, Smoothness and Light direction from the style's settings block, and an object other than type with it opens as itself with the live effect. A settings block that VectorCraft can't read (malformed, another effect's, a value out of range or a key it doesn't know) keeps the object's drawn look (part of #637; [raster effects reference](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-filter-effects/raster-effects-reference.html), [Photoshop File Formats Specification](https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/), [scripting the filter gallery](https://community.adobe.com/questions-712/scripting-support-for-the-filter-gallery-1064646); `crates/eps/src/import/ai/style/tests.rs`, `crates/eps/src/import/ai/tests.rs`). PDF export and `.ai` saves write a page with a side longer than 14,400 pt with a `UserUnit` (PDF 1.6), its longer side divided by 14,400 and rounded up to the next thousandth, so that no side is longer than 14,400 units (ISO 32000-1, Annex C.2); a PDF 1.5 file with such a page is written as PDF 1.6 with PDF 1.5's encryption, PDF/X files and PDF 1.3 and 1.4 files write it in points (in PDF 1.3 and 1.4, a page past 32,767 pt that needs a real number past 32,767 is refused with an error naming it), a page whose short side is 3 pt or longer but would be under 3 units at that `UserUnit` gets a smaller one that keeps the short side at 3 units, and the export's warnings give each such page's size and how it is written (#6382). From 5d7e2fa8e1ff7da1c63c513a89560e697640f232 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 06:13:29 -0800 Subject: [PATCH 2/8] MCP: ui.inspect leaves out paths outside the read roots Under --automation-read-root, ui.inspect returned the paths of documents still opening, the recent files, library panels' files and the folder preferences even when they lay outside the read roots. The reply now leaves them out, checked with file_access::check_read as ui.menu.list checks recent files and listed scripts: - opening and ui.recent_files keep only the readable paths, in order. - A library panel keeps its row without its path; a library loaded from outside the roots shows as loaded/ in its id and panel id, in ui.inspect and ui.docking. - The folder preferences in ui.engine_prefs and in an open Preferences dialog are left out; an empty folder and the scratch disk choices Startup and None stay. The app's own state is not changed. Without roots the replies are as before. Docs: docs/mcp.md, docs/control-protocol.md, docs/target-app-parity.md. --- crates/ui-egui/src/control.rs | 109 +++++++++++++++++++++++-- crates/ui-egui/src/tests/automation.rs | 65 +++++++++++++++ docs/control-protocol.md | 2 +- docs/mcp.md | 7 ++ docs/target-app-parity.md | 1 + 5 files changed, 178 insertions(+), 6 deletions(-) diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 7fd0d8edf..9f2744892 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -249,8 +249,9 @@ pub fn inspect(app: &VectorcraftApp, ctx: &egui::Context) -> Value { // the layout. if let Some(fields) = ui.as_object_mut() { fields.remove("floating_panels"); + leave_out_unreadable_paths(fields); } - json!({ + let mut inspected = json!({ "scriptRunning": app.session.script_running(), "scriptReport": app.session.script_report(), "tool": app.session.tool_id(), @@ -294,8 +295,9 @@ pub fn inspect(app: &VectorcraftApp, ctx: &egui::Context) -> Value { "nativeMenuBar": app.services.native_menu.is_some(), // Saves and exports still being written in the background (Background Save / Export). "background": app.background.jobs.iter().filter(|j| !j.label.is_empty()).map(|j| j.label.as_str()).collect::>(), - // Documents still being read off the UI thread (File › Open, Open Recent): their paths. - "opening": app.opening.pending.iter().map(|p| p.path.as_str()).collect::>(), + // Documents still being read off the UI thread (File › Open, Open Recent): their paths, without + // the ones outside the read root. + "opening": app.opening.pending.iter().map(|p| p.path.as_str()).filter(|p| readable(p)).collect::>(), // Files still being read off the UI thread for Place (the Place dialog's OK, drops): their // names, in the order they are placed. "placing": app.placing.pending.iter().map(|p| p.name.as_str()).collect::>(), @@ -305,7 +307,100 @@ pub fn inspect(app: &VectorcraftApp, ctx: &egui::Context) -> Value { // A native file dialog open off the UI thread (`open`, `place`, `save`, `folder`), waiting // for the person at the computer: what asked for it runs again when it answers. "fileDialog": app.picks.waiting_kind(), - }) + }); + hide_library_ids(&mut inspected, &unreadable_library_ids(app)); + inspected +} + +/// The ids of the loaded libraries (`loaded/`) whose file the request may not read, each with +/// the id the reply shows instead: `loaded/`, as a library loaded without a path is named. +fn unreadable_library_ids(app: &VectorcraftApp) -> Vec<(String, String)> { + app.ui + .library_panels + .iter() + .filter_map(|panel| { + let path = panel.id.strip_prefix("loaded/")?; + let name = vectorcraft_engine::cmd::fileio::file_name(path); + (name != path && !readable(path)).then(|| (panel.id.clone(), format!("loaded/{name}"))) + }) + .collect() +} + +/// Writes each id of `ids` as the id the reply shows instead, wherever `v` holds it: in a library's +/// id, in its panel's id (`library:swatches:loaded/`) and in keys. +fn hide_library_ids(v: &mut Value, ids: &[(String, String)]) { + if ids.is_empty() { + return; + } + let hide = |s: &mut String| { + for (id, shown) in ids { + if s.contains(id.as_str()) { + *s = s.replace(id.as_str(), shown); + } + } + }; + match v { + Value::String(s) => hide(s), + Value::Array(items) => items.iter_mut().for_each(|item| hide_library_ids(item, ids)), + Value::Object(fields) => { + for (mut key, mut item) in std::mem::take(fields) { + hide(&mut key); + hide_library_ids(&mut item, ids); + fields.insert(key, item); + } + } + _ => {} + } +} + +/// The preferences that hold a folder, by their keys in `ui.engine_prefs` and the Preferences dialog. +const PREF_FOLDERS: [&str; 6] = ["fontsFolder", "pluginsFolder", "scratchPrimary", "scratchSecondary", "recoveryFolder", "templatesFolder"]; + +/// The scratch disk choices that are not paths. +const SCRATCH_CHOICES: [&str; 2] = ["Startup", "None"]; + +/// May the request being served read `path`? Under an automation read root (a control client's +/// request), `ui.inspect` leaves out a path outside the root, as `ui.menu.list` leaves out a recent +/// file or a listed script outside it. +fn readable(path: &str) -> bool { + vectorcraft_engine::file_access::check_read(path).is_ok() +} + +/// Takes out of `ui.inspect`'s copy of the UI state the paths the request may not read: recent files, +/// a library panel's file and folder preferences (saved, and in an open Preferences dialog) outside +/// the read root. The UI state itself is not changed. +fn leave_out_unreadable_paths(ui: &mut serde_json::Map) { + if let Some(Value::Array(files)) = ui.get_mut("recent_files") { + files.retain(|f| f.as_str().is_some_and(readable)); + } + if let Some(Value::Array(panels)) = ui.get_mut("library_panels") { + for panel in panels.iter_mut().filter_map(Value::as_object_mut) { + if panel.get("path").and_then(Value::as_str).is_some_and(|p| !readable(p)) { + panel.remove("path"); + } + } + } + if let Some(Value::Object(prefs)) = ui.get_mut("engine_prefs") { + leave_out_unreadable_folders(prefs); + } + if let Some(Value::Object(dialog)) = ui.get_mut("dialog") + && dialog.get("kind").and_then(Value::as_str) == Some("preferences") + && let Some(Value::Object(fields)) = dialog.get_mut("fields") + { + leave_out_unreadable_folders(fields); + } +} + +/// Takes out of `prefs` the folder preferences ([`PREF_FOLDERS`]) the request may not read. An +/// empty value (no folder) and a scratch disk choice ([`SCRATCH_CHOICES`]) stay. +fn leave_out_unreadable_folders(prefs: &mut serde_json::Map) { + for key in PREF_FOLDERS { + let Some(path) = prefs.get(key).and_then(Value::as_str).map(str::trim) else { continue }; + if path.is_empty() || (key.starts_with("scratch") && SCRATCH_CHOICES.contains(&path)) || readable(path) { + continue; + } + prefs.remove(key); + } } fn key_from(name: &str) -> Option { @@ -348,7 +443,11 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlReques "engine.commands" => ok(all_commands(app)), "document.inspect" => wrap(app.run("document.inspect", p.clone())), "ui.inspect" => ok(inspect(app, ctx)), - "ui.docking" => ok(crate::panel_docking::inspect(app, ctx)), + "ui.docking" => { + let mut docking = crate::panel_docking::inspect(app, ctx); + hide_library_ids(&mut docking, &unreadable_library_ids(app)); + ok(docking) + } "ui.menu.list" => ok(serde_json::to_value(crate::menus::menu_entries(app)).unwrap_or_default()), "ui.contextMenu.list" => ok(serde_json::to_value(crate::menus::context_entries(app)).unwrap_or_default()), "ui.tool.select" => wrap(app.run("tool.select", json!({"tool": s("tool").unwrap_or("")}))), diff --git a/crates/ui-egui/src/tests/automation.rs b/crates/ui-egui/src/tests/automation.rs index 50c57db65..a369ee002 100644 --- a/crates/ui-egui/src/tests/automation.rs +++ b/crates/ui-egui/src/tests/automation.rs @@ -360,6 +360,71 @@ fn recent_files_outside_the_read_root_are_not_listed_or_opened() { let _ = std::fs::remove_dir_all(base); } +/// `ui.inspect` leaves out the paths outside the read root that the person's work put in it: files +/// still opening, recent files, a library panel's file and folder preferences, saved and in the +/// Preferences dialog. +#[test] +fn ui_inspect_leaves_out_paths_outside_the_read_root() { + let (mut app, tx, base) = confined("inspect"); + std::fs::copy(base.join("in/a.svg"), base.join("in/c.svg")).unwrap(); + let (a, b, c) = (s(base.join("in/a.svg")), s(base.join("outside/b.svg")), s(base.join("in/c.svg"))); + let svg = std::fs::read(&a).unwrap(); + for path in [&a, &b, &c] { + assert!(crate::opening::start(&mut app, path, svg.clone()).is_ok(), "{path} opens off the UI thread"); + } + app.ui.recent_files = vec![a.clone(), b.clone(), c.clone()]; + let panel = |id: &str, path: &str| -> crate::panels::library_panel::LibraryPanel { + serde_json::from_value(json!({"kind": "swatches", "id": id, "path": path})).unwrap() + }; + let theirs = format!("loaded/{b}"); + app.ui.library_panels = vec![panel("mine", &a), panel(&theirs, &b)]; + app.ui.open_panel = Some(format!("library:swatches:{theirs}")); + let (inside, outside) = (s(base.join("in")), s(base.join("outside"))); + app.ui.engine_prefs = json!({ + "fontsFolder": inside, "templatesFolder": outside, "recoveryFolder": outside, "scratchPrimary": "Startup", + "scratchSecondary": "../private", "pluginsFolder": "", + }); + app.ui.dialog = Some(crate::state::Dialog::new("preferences", json!({"fontsFolder": inside, "templatesFolder": outside}))); + + // The person sees every path. + let ctx = egui::Context::default(); + let person = crate::control::inspect(&app, &ctx); + assert_eq!(person["opening"], json!([a, b, c])); + assert_eq!(person["ui"]["recent_files"], json!([a, b, c])); + assert_eq!(person["ui"]["library_panels"][1]["path"], b); + assert_eq!(person["ui"]["engine_prefs"]["templatesFolder"], outside); + + // The client sees the ones inside the read root, in the same order. + let r = request(&mut app, &tx, "ui.inspect", json!({})); + let ui = &r["result"]["ui"]; + assert_eq!(r["result"]["opening"], json!([a, c]), "{r}"); + assert_eq!(ui["recent_files"], json!([a, c])); + assert_eq!(ui["library_panels"][0]["path"], a); + assert_eq!((ui["library_panels"][1]["id"].as_str(), ui["library_panels"][1].get("path")), (Some("loaded/b.svg"), None), "the panel stays"); + assert_eq!(ui["open_panel"], "library:swatches:loaded/b.svg"); + let prefs = ui["engine_prefs"].as_object().unwrap(); + assert_eq!( + (prefs["fontsFolder"].as_str(), prefs["scratchPrimary"].as_str(), prefs["pluginsFolder"].as_str()), + (Some(inside.as_str()), Some("Startup"), Some("")) + ); + assert!(["templatesFolder", "recoveryFolder", "scratchSecondary"].iter().all(|k| !prefs.contains_key(*k)), "{prefs:?}"); + let fields = ui["dialog"]["fields"].as_object().unwrap(); + assert_eq!((fields["fontsFolder"].as_str(), fields.get("templatesFolder")), (Some(inside.as_str()), None), "the Preferences dialog's copy too"); + let hidden = json!(outside).to_string(); + assert!(!r.to_string().contains(hidden.trim_matches('"')), "no path outside the root: {r}"); + let r = request(&mut app, &tx, "ui.docking", json!({})); + assert_eq!(r["result"]["flyout"], "library:swatches:loaded/b.svg", "{r}"); + assert_eq!(crate::panel_docking::inspect(&app, &ctx)["flyout"], json!(format!("library:swatches:{theirs}")), "the person's"); + assert_eq!(app.ui.recent_files, [a.clone(), b.clone(), c.clone()], "the UI state is not changed"); + assert_eq!(app.opening.pending.len(), 3, "and no open is canceled"); + + // A write root alone lets the client read none of them. + let write_only = AutomationRoots::new(&[], &[&base.join("out")]).unwrap(); + let listed = vectorcraft_engine::file_access::confine(write_only.as_ref(), || crate::control::inspect(&app, &ctx)); + assert_eq!((&listed["opening"], &listed["ui"]["recent_files"]), (&json!([]), &json!([])), "{listed}"); + let _ = std::fs::remove_dir_all(base); +} + /// Hidden logic-only ticks between the injection and its UI pass must not drain another step: /// one step per settled pass, not per hidden tick. #[test] diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 96e5baffb..04715474d 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -92,7 +92,7 @@ the two can still win that race. | `engine.execute` | `{command, params}` | run any engine or UI command (see `engine.commands`). A key of `params` that the command's params description doesn't name is an error naming it and the nearest key the command takes, before anything runs (`unknown parameter `widht`; did you mean `width`?`); see [Parameter keys](mcp.md#parameter-keys) | | `engine.commands` | | every command with label, shortcut, params doc, enablement | | `document.inspect` | `{depth?, childLimit?, ids?, kind?, limit?, offset?}` | layer tree, selection, the key object (`key`, null without one; a key ruler guide as `keyGuide`), history, paint defaults; `depth` / `childLimit` slice the layer tree as `document.node {summary: true}` does; `ids`, `kind`, `limit` and `offset` list just some nodes as `nodes` (with `total`) instead of the whole tree, see [mcp.md](mcp.md). Each node reports `opacity` below 1 and `blend` when it isn't Normal; objects and compound paths report `fill`, `stroke` and `strokeWidth`, a compound path's members `compoundMember: true` instead | -| `ui.inspect` | | tool, UI state, `screenMode` (0 normal, 1 full screen with menu bar, 2 full screen, 3 Presentation Mode), `taskBar` (`pinned`, and `rect` `[x, y, w, h]` while the Contextual Task Bar shows), `freeTransformWidget` (`[x, y, w, h]` while the Free Transform widget shows), view, canvas rect, window size, perf, background saves and exports still running, `nativeMenuBar` (true when the menus are in the macOS menu bar rather than the window; `ui.menu.list` lists the in-window menus either way), `fileDialog` (`"open"`, `"place"`, `"save"` or `"folder"` while a native file dialog is open, `null` otherwise; see below) | +| `ui.inspect` | | tool, UI state, `screenMode` (0 normal, 1 full screen with menu bar, 2 full screen, 3 Presentation Mode), `taskBar` (`pinned`, and `rect` `[x, y, w, h]` while the Contextual Task Bar shows), `freeTransformWidget` (`[x, y, w, h]` while the Free Transform widget shows), view, canvas rect, window size, perf, background saves and exports still running, `nativeMenuBar` (true when the menus are in the macOS menu bar rather than the window; `ui.menu.list` lists the in-window menus either way), `fileDialog` (`"open"`, `"place"`, `"save"` or `"folder"` while a native file dialog is open, `null` otherwise; see below), `opening` (the paths of the documents still being read). Under `--automation-read-root` the reply leaves out the paths outside the read roots (see `docs/mcp.md`) | | `ui.menu.list` / `ui.menu.invoke` | `{command, params}` | the full menu tree / invoke an item | | `ui.contextMenu.list` | | the canvas context menu for the current selection, flattened like `ui.menu.list` (`path` holds its submenus). `ui.click {x, y, button: "right"}` on the canvas opens it, after selecting the object there unless it is already selected. With the Artboard tool it lists the artboard entries (see `docs/mcp.md`), after selecting the artboard there unless it is already selected. While a menu opened over locked objects is open, it has an `Unlock` submenu with one item for each locked object under the pointer, topmost first and at most 20, labeled with the object's name, which runs `layer.setProps {ids: [id], locked: false}`. A locked group gets one item, and the locked objects inside it get none. Hidden objects, objects on hidden, locked or template layers, and in isolation mode objects outside the isolated group get no item | | `ui.tool.select` / `ui.tool.list` | `{tool}` | | diff --git a/docs/mcp.md b/docs/mcp.md index 9c2a286b7..595b4729d 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -115,6 +115,13 @@ the suite) keep the agent to the folders you give it: - `file.recentFiles` and the control channel's `ui.menu.list` leave out the recent files outside the read roots. The other File › Open Recent Files rows keep their ids (`file.openRecent3` still opens the third file), and `file.openRecentN` for a file outside the roots ends with `no such recent file`, as an empty slot does. +- `ui_inspect` (the control channel's `ui.inspect`) leaves out the paths outside the read roots: in `opening`, the + documents still being read; in `ui.recent_files`, the recent files; a library panel's `path` in `ui.library_panels`; + and the folder preferences in `ui.engine_prefs` and in an open Preferences dialog's `ui.dialog.fields` + (`fontsFolder`, `pluginsFolder`, `scratchPrimary`, `scratchSecondary`, `recoveryFolder`, `templatesFolder`; an + empty folder and the scratch disk choices `Startup` and `None` stay). A library loaded from a file outside the + roots shows as `loaded/` in its id and its panel's id, in `ui_inspect` and in `ui.docking`. The other + entries keep their order, and the app's own state is not changed. - Each flag may be given more than once (#3546): a path inside any of the read roots may be read, one inside any of the write roots written, so one session can read a project folder and a shared assets folder, or write to an output folder and a scratch folder, without widening a root to their common parent. A folder given twice diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index c8fdfcb7e..f030349ba 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -738,6 +738,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Scripts: `#strict on` (and `//@strict on`) runs the script with the checks the [`$` object](https://extendscript.docsforadobe.dev/extendscript-tools-features/dollar-object/) help page gives for `$.strict`: an assignment to a read-only property throws a `TypeError`, and so does a new property on an object that takes none. VectorCraft's own rules: a getter-only property counts as read-only, the objects that take no new properties are the non-extensible ones (`Object.preventExtensions`, `seal`, `freeze`, and the frozen `vc` and `$` objects), the last `#strict` line of the script and its includes sets the whole run, a file `$.evalFile` runs keeps its own line's checks for its code, and the rest of ES5 strict mode stays with `'use strict'`. Without the line, runs are unchanged. `crates/engine/src/tests/script_include.rs` (`strict_on_*`, `strict_mode_in_eval_file_is_scoped`), `crates/js/src/interp_tests.rs` (`strict_writes_are_separate_from_es5_strict`). - Scripts folder: File › Scripts lists the `.js` and `.jsx` files of the `Scripts` folder next to the preferences, each named after its file without the extension, with each subfolder as a submenu, above Check Script… and Other Script… (https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/install-and-run-scripts.html). VectorCraft's own rules: the folder is read again when the window becomes active and when the File menu opens, links are not followed, and one read stops at 2,048 entries, 512 scripts, 128 subfolders, 4 levels or 250 ms. `script.listInstalled` returns the listing. `crates/engine/src/tests/script_folder.rs`, `crates/ui-egui/src/tests/scripts_menu.rs`. - Open Recent Files under `--automation-read-root`: the control channel's `ui.menu.list` and `file.recentFiles` leave out the recent files outside the read roots, the other rows keep their ids, and `file.openRecentN` for one outside them answers `no such recent file`, as an empty slot does. `crates/ui-egui/src/tests/automation.rs`. + - `ui.inspect` under `--automation-read-root`: the documents still opening, the recent files, library panels' files and folder preferences (saved, and in an open Preferences dialog) outside the read roots are left out of the reply, a library loaded from outside them shows by its file name in `ui.inspect` and `ui.docking`, and the app's state is not changed. `crates/ui-egui/src/tests/automation.rs` (`ui_inspect_leaves_out_paths_outside_the_read_root`). - Scripts: `alert`, `confirm` and `prompt` open dialogs during a run, each with a Stop button that ends the run and puts every document back. In the app the run waits at the call for the person's answer; a run over MCP or the command line takes its answers in order from `script.run`'s `answers`, and the report lists each dialog with its answer. `crates/engine/src/tests/script_dialogs.rs`, `crates/ui-egui/src/tests/script_dialogs.rs`. - Parameter keys: the engine refuses a key that a command's params description doesn't name before the command runs, in every build and for every caller, including the app's own calls, the commands a command runs and the steps of `command.batch` and `action.batch`. An action recorded by an older version still plays from the Actions panel and the Batch dialog (`Session::replay`). `crates/engine/tests/it/param_keys.rs`. - MCP server with 25 tools (drawing, text, effects, Pathfinder, transforms, graphs, text wrap, export, screenshots, any command), attached to the running app or headless. Tool descriptions and replies agree with the engine: format lists come from its format table, `save_file` says it keeps the document's own format, `inspect_document` reports the paint type's characters show, and selection exports report the encoder's warnings. `--automation-read-root` / `--automation-write-root` (on `vectorcraft-cli mcp` and on `vectorcraft --control`) confine the files commands read and write to chosen folders, links followed, with the flags PhotoCraft takes (#832); each flag may be given more than once, a path inside any of its folders being allowed (#3546, `file_access::tests::several_roots_of_a_kind_each_grant_their_access`, `cli::mcp_automation_roots_take_several_folders`). A long export can be cancelled (`notifications/cancelled` stops it at the next page or group and writes nothing), reports page progress to a client that sent a progress token, and does not make other requests wait: engine-free ones answer at once, the rest are refused as busy after two seconds (#2165, #2171, #2172). From 6ae93b1222c0accf8bd91abb1821685ba81c83b2 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 07:12:15 -0800 Subject: [PATCH 3/8] 3D effects: Extrude & Bevel, Inflate and Rotate on a group keep each member's blending mode On a group, these effects drew every member's solids in Normal mode: only an opacity mask on a member or on a group inside the 3D group was applied to the solids it holds. A member's blending mode, and that of a group inside the 3D group, now apply the same way, to the solids of the members it holds together. Group Revolve already did this. --- crates/effects/src/solids.rs | 22 +++++++---- crates/effects/src/solids/tests_sources.rs | 43 ++++++++++++++++++++++ docs/three-d.md | 7 ++-- 3 files changed, 62 insertions(+), 10 deletions(-) diff --git a/crates/effects/src/solids.rs b/crates/effects/src/solids.rs index 3c433d344..95ef265cc 100644 --- a/crates/effects/src/solids.rs +++ b/crates/effects/src/solids.rs @@ -275,23 +275,30 @@ impl Scope { } } +/// Does `scope` draw its members' faces other than plainly: does it have an opacity mask or a +/// blending mode other than Normal? +fn composites(scope: &Scope) -> bool { + scope.mask.is_some() || scope.blend != BlendMode::Normal +} + /// `surfaces`, each with the scopes of the member it was built from, in groups with the opacity -/// masks of those scopes, so each mask applies to the faces of the members it holds. The members' -/// opacity is already in their paint ([`Profile::opacity`]), so these groups are opaque. +/// masks and blending modes of those scopes, so each applies to the faces of the members it holds. +/// The members' opacity is already in their paint ([`Profile::opacity`]), so these groups are +/// opaque. fn masked(id: NodeId, surfaces: Vec<(&[Scope], Arc)>) -> Vec> { let masks: Vec<(Vec, Arc)> = surfaces .into_iter() .map(|(scopes, s)| { - let masks = scopes.iter().filter(|s| s.mask.is_some()).map(|s| Scope { opacity: 1.0, blend: BlendMode::Normal, ..s.clone() }).collect(); + let masks = scopes.iter().filter(|s| composites(s)).map(|s| Scope { opacity: 1.0, ..s.clone() }).collect(); (masks, s) }) .collect(); crate::revolve::scoped(id, masks.iter().map(|(scopes, s)| (scopes.as_slice(), s.clone())).collect(), 0) } -/// Does any of `shapes` sit in a scope with an opacity mask? +/// Does any of `shapes` sit in a scope with an opacity mask or a blending mode other than Normal? fn has_masks(shapes: &[Profile]) -> bool { - shapes.iter().any(|s| s.scopes.iter().any(|s| s.mask.is_some())) + shapes.iter().any(|s| s.scopes.iter().any(composites)) } /// One shape a solid is built from: its outline and fill rule, the fills and strokes that paint @@ -550,8 +557,9 @@ pub(crate) fn reshape_members(n: &Node, before: &[Effect], shapes: &mut [Profile /// each in its own paint. When every one is opaque with normal blending and no colored light makes /// a surface one composite or masks it, their faces are drawn in one back-to-front order (where /// depths tie, as for the caps turned toward the viewer, in the order of the appearance); -/// otherwise each solid is a surface of its own, in that order. A member's opacity mask, or that of -/// a group it is in, masks the surfaces of the members it holds ([`masked`]). +/// otherwise each solid is a surface of its own, in that order. A member's opacity mask and +/// blending mode, and those of a group it is in, apply to the surfaces of the members it holds +/// ([`masked`]). fn art_for(n: &Node, fx: &Effect, shapes: &[Profile]) -> Option>> { let b = union_bounds(shapes)?; let p = crate::merged_params(&fx.id, &fx.params); diff --git a/crates/effects/src/solids/tests_sources.rs b/crates/effects/src/solids/tests_sources.rs index 2973b7113..51c783452 100644 --- a/crates/effects/src/solids/tests_sources.rs +++ b/crates/effects/src/solids/tests_sources.rs @@ -523,6 +523,49 @@ fn an_opacity_mask_on_a_member_or_a_group_in_a_three_d_group_masks_its_solids() } } +/// The leaf faces of `art`, and how many of them are drawn inside a group with blending mode `mode`. +fn faces_in_mode(art: &Node, mode: BlendMode) -> (usize, usize) { + fn walk(n: &Node, inside: bool, mode: BlendMode, out: &mut (usize, usize)) { + let inside = inside || (n.children().is_some() && n.blend == mode); + match n.children() { + Some(children) => children.iter().for_each(|c| walk(c, inside, mode, out)), + None => { + out.0 += 1; + out.1 += usize::from(inside); + } + } + } + let mut out = (0, 0); + walk(art, false, mode, &mut out); + out +} + +#[test] +fn a_blending_mode_on_a_member_or_a_group_in_a_three_d_group_applies_to_its_solids() { + let d = document(); + let mode = |mut n: Node, mode: BlendMode| { + n.blend = mode; + n + }; + let other = || Arc::new(rect(91, 600.0, 600.0, 650.0, 650.0, 0.0)); + let member = || rect(1, 100.0, 100.0, 150.0, 200.0, 2.0); + let cases = [ + ("a multiplied member", Node::group(NodeId(90), vec![Arc::new(mode(member(), BlendMode::Multiply)), other()])), + ( + "a member of a multiplied group", + Node::group(NodeId(90), vec![Arc::new(mode(Node::group(NodeId(80), vec![Arc::new(member())]), BlendMode::Multiply)), other()]), + ), + ]; + for (what, g) in cases { + for id in TYPES { + let g = with(g.clone(), id, json!({})); + let (faces, multiplied) = faces_in_mode(&art(&d, &g, id).expect("drawn"), BlendMode::Multiply); + assert!(multiplied > 0, "{id}: the faces of {what} are drawn in Multiply"); + assert!(multiplied < faces, "{id}: the other member's faces are not ({multiplied} of {faces})"); + } + } +} + #[test] fn resolved_objects_and_groups_in_a_three_d_group_keep_their_geometry_effects() { let mut d = document(); diff --git a/docs/three-d.md b/docs/three-d.md index e6dc279da..635c1285e 100644 --- a/docs/three-d.md +++ b/docs/three-d.md @@ -122,11 +122,12 @@ them all. The members stay editable: moving or editing one reshapes the solids. - Each member's own geometry effects shape it first, then those of each group inside the 3D group that holds it, applied to that group's members together; the 3D group's geometry effects before the 3D effect reshape the members, and those after it bend the result as a whole. The members' other effects, and the group's own fills and - strokes, are not drawn. A member's opacity applies to its solids; its blending mode does not. An opacity mask on + strokes, are not drawn. A member's opacity and blending mode apply to its solids, and the blending mode of a group + inside the 3D group applies to the solids of that group's members together. An opacity mask on a member masks its solids, and one on a group inside the 3D group masks the solids of that group's members together. The mask stays where it is on the page; it does not turn with the solids. A disabled mask is ignored. -- When a member, or a group inside the 3D group, has an opacity mask, each solid is drawn on its own, the backmost - member's first, as when a solid is transparent. +- When a member, or a group inside the 3D group, has an opacity mask or a blending mode other than Normal, each + solid is drawn on its own, the backmost member's first, as when a solid is transparent. - When every solid is opaque, all members' faces are drawn in one back-to-front order; otherwise each solid is drawn on its own, the backmost member's first. - Hidden members and guides are left out. A group holding a live object other than a blend (an envelope, a mesh, a From f60da17d3912d90b3cf51c680d6187472a64d363 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 06:48:04 -0800 Subject: [PATCH 4/8] Effects: a pasted SVG filter also takes a free capital letter when no lowercase name is free Filter names are case-sensitive, but a pasted filter at the size limit with a one-letter name only tried the letters a to z and the digits 0 to 9 as a new name. A paste was refused while every capital letter was still free. It now also tries A to Z. --- crates/engine/src/cmd/clipboard/resources.rs | 2 +- crates/engine/src/tests/svgfilter.rs | 28 +++++++++++++++++--- 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/cmd/clipboard/resources.rs b/crates/engine/src/cmd/clipboard/resources.rs index 534e0ec89..918033a60 100644 --- a/crates/engine/src/cmd/clipboard/resources.rs +++ b/crates/engine/src/cmd/clipboard/resources.rs @@ -1286,7 +1286,7 @@ impl Clipboard { ends.push(name.len()); let starts = ends.iter().rev().filter_map(|&end| name.get(..end).map(str::trim_end)).filter(|b| !b.is_empty()); let shorter = starts.map(|b| unique_name(b, |n| taken.contains(n))); - let single = ('a'..='z').chain('0'..='9').map(String::from).filter(|n| !taken.contains(n)); + let single = ('a'..='z').chain('0'..='9').chain('A'..='Z').map(String::from).filter(|n| !taken.contains(n)); let renamed = shorter.chain(single).find_map(|new| { let drawn = vectorcraft_render::effects::svg_filter::with_id(&xml, &new) .filter(|x| vectorcraft_render::effects::svg_filter::check(x).is_ok_and(|id| id == new))?; diff --git a/crates/engine/src/tests/svgfilter.rs b/crates/engine/src/tests/svgfilter.rs index 92f1e4987..ad4ac7e23 100644 --- a/crates/engine/src/tests/svgfilter.rs +++ b/crates/engine/src/tests/svgfilter.rs @@ -324,10 +324,10 @@ fn pasted_art_takes_no_name_the_documents_brushes_use() { assert_eq!(names, ["Shift 3"]); } -/// A pasted filter at the size limit with a one-letter name, when every free name it could take is -/// in use, is refused rather than pasted under the name of the document's filter. +/// A pasted filter at the size limit with a one-letter name takes a free capital letter when every +/// lowercase letter and digit is in use. #[test] -fn a_paste_with_no_free_name_for_its_filter_is_refused() { +fn a_renamed_paste_takes_a_free_capital_letter() { let mut s = session(); for n in ('a'..='z').chain('0'..='9') { run(&mut s, "svgFilter.define", json!({"xml": format!(r#""#)})); @@ -339,6 +339,28 @@ fn a_paste_with_no_free_name_for_its_filter_is_refused() { r#"{big}"# ); run(&mut s, "clipboard.importSvg", json!({"svg": svg})); + run(&mut s, "edit.pasteInPlace", json!({})); + let pasted = filter_effects(&s); + assert_eq!(pasted.len(), 1); + assert_eq!(pasted[0].0, "A"); + assert_eq!(vectorcraft_render::effects::svg_filter::check(&pasted[0].1), Ok("A".to_string())); + assert!(pasted[0].1.contains("dx=\"9\""), "its own filter"); +} + +/// A pasted filter at the size limit with a one-letter name, when every free name it could take is +/// in use, is refused rather than pasted under the name of the document's filter. +#[test] +fn a_paste_with_no_free_name_for_its_filter_is_refused() { + let mut s = session(); + for n in ('a'..='z').chain('0'..='9').chain('A'..='Z') { + run(&mut s, "svgFilter.define", json!({"xml": format!(r#""#)})); + } + let (open, close) = (r#""#); + let big = format!("{open}{}{close}", "a".repeat(vectorcraft_doc::SvgFilter::MAX_XML - open.len() - close.len())); + let svg = format!( + r#"{big}"# + ); + run(&mut s, "clipboard.importSvg", json!({"svg": svg})); let before = steps(&s); assert!(err(&mut s, "edit.pasteInPlace", json!({})).contains("no free name")); assert!(filter_effects(&s).is_empty(), "nothing pasted"); From ac8e39fa2ee65be1624faee8aaf7ec15e3d842a8 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 07:24:23 -0800 Subject: [PATCH 5/8] Effects: a pasted SVG filter also takes a free punctuation character when no letter or digit is free A filter's id can be any character, so when every letter and digit is in use a pasted filter at the size limit with a one-letter name now tries the other printable ASCII characters (`!` to `~`) before the paste is refused. --- crates/engine/src/cmd/clipboard/resources.rs | 12 +++-- crates/engine/src/tests/svgfilter.rs | 57 +++++++++++++++++--- 2 files changed, 59 insertions(+), 10 deletions(-) diff --git a/crates/engine/src/cmd/clipboard/resources.rs b/crates/engine/src/cmd/clipboard/resources.rs index 918033a60..b1377d2fe 100644 --- a/crates/engine/src/cmd/clipboard/resources.rs +++ b/crates/engine/src/cmd/clipboard/resources.rs @@ -1239,7 +1239,7 @@ impl Clipboard { /// filter when pasted back into the document it came from, and elsewhere keeps its own under a /// name no filter or effect of the document gives, so editing or deleting the document's filter leaves it alone. When that name would /// make the filter too long to draw, the free name is a shorter start of its name ("Shif"), else - /// a free letter or digit; the filter's id is always its name. An error when no free name fits. + /// a free letter, digit or other printable ASCII character; the filter's id is always its name. An error when no free name fits. fn filter_links(&self, d: &Document, same_doc: bool) -> Result> { let mut found = BTreeSet::new(); for n in &self.nodes { @@ -1281,12 +1281,18 @@ impl Clipboard { } } // Free names made from the starts of the name, longest first, then free letters and - // digits: the first under which the filter still draws. + // digits, then other single printable ASCII characters: the first under which the + // filter still draws. let mut ends: Vec = name.char_indices().map(|(i, _)| i).skip(1).collect(); ends.push(name.len()); let starts = ends.iter().rev().filter_map(|&end| name.get(..end).map(str::trim_end)).filter(|b| !b.is_empty()); let shorter = starts.map(|b| unique_name(b, |n| taken.contains(n))); - let single = ('a'..='z').chain('0'..='9').chain('A'..='Z').map(String::from).filter(|n| !taken.contains(n)); + let single = ('a'..='z') + .chain('0'..='9') + .chain('A'..='Z') + .chain(('!'..='~').filter(|c| !c.is_ascii_alphanumeric())) + .map(String::from) + .filter(|n| !taken.contains(n)); let renamed = shorter.chain(single).find_map(|new| { let drawn = vectorcraft_render::effects::svg_filter::with_id(&xml, &new) .filter(|x| vectorcraft_render::effects::svg_filter::check(x).is_ok_and(|id| id == new))?; diff --git a/crates/engine/src/tests/svgfilter.rs b/crates/engine/src/tests/svgfilter.rs index ad4ac7e23..10f0ad75c 100644 --- a/crates/engine/src/tests/svgfilter.rs +++ b/crates/engine/src/tests/svgfilter.rs @@ -330,7 +330,7 @@ fn pasted_art_takes_no_name_the_documents_brushes_use() { fn a_renamed_paste_takes_a_free_capital_letter() { let mut s = session(); for n in ('a'..='z').chain('0'..='9') { - run(&mut s, "svgFilter.define", json!({"xml": format!(r#""#)})); + run(&mut s, "svgFilter.define", json!({"xml": one_char_filter(n)})); } run(&mut s, "svgFilter.define", json!({"xml": r#""#})); let (open, close) = (r#""#); @@ -347,13 +347,24 @@ fn a_renamed_paste_takes_a_free_capital_letter() { assert!(pasted[0].1.contains("dx=\"9\""), "its own filter"); } -/// A pasted filter at the size limit with a one-letter name, when every free name it could take is -/// in use, is refused rather than pasted under the name of the document's filter. +/// A filter named `n`, with the characters a quoted attribute value cannot hold escaped. +fn one_char_filter(n: char) -> String { + let id = match n { + '"' => """.to_string(), + '&' => "&".to_string(), + '<' => "<".to_string(), + c => c.to_string(), + }; + format!(r#""#) +} + +/// A pasted filter at the size limit with a one-letter name takes a free punctuation character when +/// every letter and digit is in use. #[test] -fn a_paste_with_no_free_name_for_its_filter_is_refused() { +fn a_renamed_paste_takes_a_free_punctuation_character() { let mut s = session(); for n in ('a'..='z').chain('0'..='9').chain('A'..='Z') { - run(&mut s, "svgFilter.define", json!({"xml": format!(r#""#)})); + run(&mut s, "svgFilter.define", json!({"xml": one_char_filter(n)})); } let (open, close) = (r#""#); let big = format!("{open}{}{close}", "a".repeat(vectorcraft_doc::SvgFilter::MAX_XML - open.len() - close.len())); @@ -361,9 +372,41 @@ fn a_paste_with_no_free_name_for_its_filter_is_refused() { r#"{big}"# ); run(&mut s, "clipboard.importSvg", json!({"svg": svg})); - let before = steps(&s); + run(&mut s, "edit.pasteInPlace", json!({})); + let pasted = filter_effects(&s); + assert_eq!(pasted.len(), 1); + assert_eq!(pasted[0].0, "!"); + assert_eq!(vectorcraft_render::effects::svg_filter::check(&pasted[0].1), Ok("!".to_string())); + assert!(pasted[0].1.contains("dx=\"9\""), "its own filter"); +} + +/// A pasted filter at the size limit with a one-letter name, when every free name it could take is +/// in use, is refused rather than pasted under the name of the document's filter. +#[test] +fn a_paste_with_no_free_name_for_its_filter_is_refused() { + let mut s = session(); + for n in ('a'..='z').chain('0'..='9').chain('A'..='Z') { + run(&mut s, "svgFilter.define", json!({"xml": one_char_filter(n)})); + } + // A document holds at most 64 filters: the other one-character names are taken by effects + // whose filters were deleted. + for n in ('!'..='~').filter(|c| !c.is_ascii_alphanumeric()) { + let a = rect(&mut s, 10.0); + let name = run(&mut s, "svgFilter.define", json!({"xml": one_char_filter(n)}))["name"].as_str().unwrap().to_string(); + assert_eq!(name, n.to_string()); + run(&mut s, "svgFilter.apply", json!({"name": name, "ids": [a.0]})); + run(&mut s, "svgFilter.delete", json!({"name": name})); + } + let (open, close) = (r#""#); + let big = format!("{open}{}{close}", "a".repeat(vectorcraft_doc::SvgFilter::MAX_XML - open.len() - close.len())); + let svg = format!( + r#"{big}"# + ); + run(&mut s, "clipboard.importSvg", json!({"svg": svg})); + let (before, effects) = (steps(&s), filter_effects(&s).len()); + assert_eq!(effects, 32); assert!(err(&mut s, "edit.pasteInPlace", json!({})).contains("no free name")); - assert!(filter_effects(&s).is_empty(), "nothing pasted"); + assert_eq!(filter_effects(&s).len(), effects, "nothing pasted"); assert_eq!(steps(&s), before); } From 478d86cb5fd175720335827ab254e1059564e3b4 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 07:38:55 -0800 Subject: [PATCH 6/8] 3D effects: test that only the blended member's faces are drawn in its mode The blending-mode test for a member of a 3D group now checks that every face of the multiplied member, and no face of the other member, is drawn inside the Multiply group, telling the members apart by height. Before, it only checked that some faces were multiplied and some were not, so it also passed when the mode went to the wrong member. --- crates/effects/src/solids/tests_sources.rs | 25 ++++++++++++---------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/crates/effects/src/solids/tests_sources.rs b/crates/effects/src/solids/tests_sources.rs index 51c783452..b2f6daf1c 100644 --- a/crates/effects/src/solids/tests_sources.rs +++ b/crates/effects/src/solids/tests_sources.rs @@ -523,19 +523,17 @@ fn an_opacity_mask_on_a_member_or_a_group_in_a_three_d_group_masks_its_solids() } } -/// The leaf faces of `art`, and how many of them are drawn inside a group with blending mode `mode`. -fn faces_in_mode(art: &Node, mode: BlendMode) -> (usize, usize) { - fn walk(n: &Node, inside: bool, mode: BlendMode, out: &mut (usize, usize)) { +/// The leaf faces of `art` that draw something, each with its visual bounds and whether it is drawn +/// inside a group with blending mode `mode`. +fn faces_in_mode(art: &Node, mode: BlendMode) -> Vec<(Rect, bool)> { + fn walk(n: &Node, inside: bool, mode: BlendMode, out: &mut Vec<(Rect, bool)>) { let inside = inside || (n.children().is_some() && n.blend == mode); match n.children() { Some(children) => children.iter().for_each(|c| walk(c, inside, mode, out)), - None => { - out.0 += 1; - out.1 += usize::from(inside); - } + None => out.extend(n.visual_bounds().map(|b| (b, inside))), } } - let mut out = (0, 0); + let mut out = Vec::new(); walk(art, false, mode, &mut out); out } @@ -559,9 +557,14 @@ fn a_blending_mode_on_a_member_or_a_group_in_a_three_d_group_applies_to_its_soli for (what, g) in cases { for id in TYPES { let g = with(g.clone(), id, json!({})); - let (faces, multiplied) = faces_in_mode(&art(&d, &g, id).expect("drawn"), BlendMode::Multiply); - assert!(multiplied > 0, "{id}: the faces of {what} are drawn in Multiply"); - assert!(multiplied < faces, "{id}: the other member's faces are not ({multiplied} of {faces})"); + // The member spans y 100 to 200 and the other member y 600 to 650, and every type keeps + // its faces near those heights (Revolve turns them around a vertical axis): a face whose + // center is above y 400 belongs to the member. + let (mine, others): (Vec<_>, Vec<_>) = + faces_in_mode(&art(&d, &g, id).expect("drawn"), BlendMode::Multiply).into_iter().partition(|(b, _)| b.center().y < 400.0); + assert!(!mine.is_empty() && !others.is_empty(), "{id}: both members draw faces ({} and {})", mine.len(), others.len()); + assert!(mine.iter().all(|(_, m)| *m), "{id}: every face of {what} is drawn in Multiply"); + assert!(others.iter().all(|(_, m)| !m), "{id}: no face of the other member is drawn in Multiply"); } } } From b44cbdb048720d4a3a9446d432b04420651cd070 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 07:39:47 -0800 Subject: [PATCH 7/8] Gradients: picking a part of a symbol instance leaves no gradient stop selected A stop selected on a whole dynamic symbol instance's gradient stayed selected after Direct Selection picked an object of that instance's art, because the instance stayed the first selected object. The Gradient panel kept the stop highlighted and the Gradient tool took Delete and the arrow keys for it, while every gradient edit is refused for a picked object. The stop's owner now records whether a part is picked, so the stop is not selected while one is, and is selected again when the whole instance is. --- crates/engine/src/cmd/gradient.rs | 15 +++++++++++--- crates/engine/src/tests/dynamic_symbols.rs | 23 ++++++++++++++++++++++ 2 files changed, 35 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/cmd/gradient.rs b/crates/engine/src/cmd/gradient.rs index c73a23d6c..8a3485c49 100644 --- a/crates/engine/src/cmd/gradient.rs +++ b/crates/engine/src/cmd/gradient.rs @@ -80,19 +80,28 @@ fn select_stop(s: &mut Session, p: &Value) -> Result { } /// Whose gradient a selected stop belongs to: the active document, its first selected object -/// (None: the default paint), the proxy in front and the Appearance panel's active item. -/// Selecting other art, toggling the proxy or picking another item leaves no stop selected. +/// (None: the default paint), whether a part of a symbol instance's art is picked, the proxy in +/// front and the Appearance panel's active item. Selecting other art, picking a part of the +/// instance, toggling the proxy or picking another item leaves no stop selected. #[derive(Clone, Copy, Debug, PartialEq)] pub(crate) struct StopOwner { doc: Option, object: Option, + part: bool, fill: bool, item: Option, } impl StopOwner { pub(crate) fn of(s: &Session) -> Self { - Self { doc: s.active, object: s.active().and_then(|d| d.selection.objects.first().copied()), fill: s.fill_active, item: s.appearance_item() } + let st = s.active(); + Self { + doc: s.active, + object: st.and_then(|d| d.selection.objects.first().copied()), + part: st.is_some_and(|d| d.selection.symbol_part.is_some()), + fill: s.fill_active, + item: s.appearance_item(), + } } } diff --git a/crates/engine/src/tests/dynamic_symbols.rs b/crates/engine/src/tests/dynamic_symbols.rs index eab2f3067..f4ae97bb1 100644 --- a/crates/engine/src/tests/dynamic_symbols.rs +++ b/crates/engine/src/tests/dynamic_symbols.rs @@ -903,6 +903,29 @@ fn gradients_and_patterns_on_a_picked_object_are_taken() { assert_eq!((undo_steps(&s), &s.paint.fill), (undo, &fill), "nothing changes"); } +/// A stop selected on the whole instance's gradient is not selected once a part of its art is +/// picked, so the Gradient tool does not take Delete for it, and is selected again when the whole +/// instance is. +#[test] +fn picking_a_part_leaves_no_gradient_stop_selected() { + let mut s = session(); + let inst = pair(&mut s, "dynamic"); + let g = json!({"kind": "linear", "stops": [{"offset": 0, "color": "#ff0000"}, {"offset": 1, "color": "#0000ff"}]}); + s.execute("select.set", &json!({"ids": [inst.0]})).unwrap(); + s.execute("paint.setFill", &json!({"gradient": g})).unwrap(); + s.fill_active = true; + let view = ViewInfo::default(); + s.select_tool("gradient", view).unwrap(); + s.execute("gradient.selectStop", &json!({"index": 1})).unwrap(); + assert_eq!(s.selected_stop(), Some(1)); + assert!(s.tool_claims_key(vectorcraft_tools::ToolKey::Delete, view)); + s.execute("symbol.instance.selectPart", &json!({"id": inst.0, "path": [1]})).unwrap(); + assert_eq!(s.selected_stop(), None); + assert!(!s.tool_claims_key(vectorcraft_tools::ToolKey::Delete, view)); + s.execute("select.set", &json!({"ids": [inst.0]})).unwrap(); + assert_eq!(s.selected_stop(), Some(1)); +} + #[test] fn opacity_and_blend_on_a_picked_object_go_through_transparency_set() { let mut s = session(); From 774f7568dadc1e915be761a422051a470fd05645 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 07:40:36 -0800 Subject: [PATCH 8/8] MCP: ui.inspect hides every loaded library path outside the read roots Under --automation-read-root, ui.inspect and ui.docking hid a library's path only when its panel was in ui.library_panels. A library used as the Color Guide's limit, or named by a closed panel's place in ui.docking_hidden, a workspace layout or the docking layout, still showed its full loaded/ id. The ids to hide now come from every place the UI state refers to a library: the library panels, the Color Guide's limit, and the panel ids in the docking layout, closed panels, icon and collapsed lists and workspace layouts. Typed text such as a command palette query is not read as an id. Two hidden libraries with the same file name no longer share one id, which dropped one of them from an object keyed by panel id. They show as loaded/, loaded/ (2) and so on, in the order of their full ids, and a library has the same id in ui.inspect and ui.docking. Docs: docs/mcp.md. --- crates/ui-egui/src/control.rs | 98 +++++++++++++++++++--- crates/ui-egui/src/tests/automation.rs | 110 +++++++++++++++++++++++++ docs/mcp.md | 7 +- 3 files changed, 201 insertions(+), 14 deletions(-) diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 9f2744892..b02c3b6e1 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -308,22 +308,95 @@ pub fn inspect(app: &VectorcraftApp, ctx: &egui::Context) -> Value { // for the person at the computer: what asked for it runs again when it answers. "fileDialog": app.picks.waiting_kind(), }); - hide_library_ids(&mut inspected, &unreadable_library_ids(app)); + let ids = unreadable_library_ids(app); + hide_library_ids(&mut inspected, &ids); inspected } -/// The ids of the loaded libraries (`loaded/`) whose file the request may not read, each with -/// the id the reply shows instead: `loaded/`, as a library loaded without a path is named. +/// The fields of the UI state, by their keys in `ui.inspect`'s `ui`, that hold panel ids: the panel +/// popped out of the collapsed column, the docking layout, closed panels' places and groups, the panels +/// shown as icons or collapsed, and the older floating panels' record. +const PANEL_FIELDS: [&str; 7] = + ["open_panel", "docking", "docking_hidden", "docking_hidden_groups", "docking_icons", "docking_collapsed", "floating_panels"]; + +/// The ids of the loaded libraries (`loaded/`) that the UI state refers to and whose file the +/// request may not read, each with the id a reply shows instead: `loaded/`, as a library +/// loaded without a path is named. The UI state refers to a library by its bare id in a library panel +/// and the Color Guide's limit, and by a panel id (`library::loaded/`) in the fields of +/// [`PANEL_FIELDS`] and the workspaces' layouts. Text the person types (a command palette query, a +/// workspace's name) is not read. When another of those ids already shows that name, the next free one +/// of `loaded/ (2)`, `(3)` and so on is shown, in the sorted order of the full ids, so that +/// two libraries stay two and `ui.inspect` and `ui.docking` show a library under the same id. fn unreadable_library_ids(app: &VectorcraftApp) -> Vec<(String, String)> { - app.ui - .library_panels - .iter() - .filter_map(|panel| { - let path = panel.id.strip_prefix("loaded/")?; - let name = vectorcraft_engine::cmd::fileio::file_name(path); - (name != path && !readable(path)).then(|| (panel.id.clone(), format!("loaded/{name}"))) + let mut ids = std::collections::BTreeSet::new(); + let ui = serde_json::to_value(&app.ui).unwrap_or_default(); + for field in PANEL_FIELDS { + collect_library_panel_ids(&ui[field], &mut ids); + } + for workspace in ["custom_workspaces", "workspace_changes"].iter().filter_map(|k| ui[k].as_array()).flatten() { + for (_, layout) in workspace.as_object().into_iter().flatten().filter(|(k, _)| k.as_str() != "name") { + collect_library_panel_ids(layout, &mut ids); + } + } + collect_library_panel_ids(&json!(app.ui.transient_panel), &mut ids); + for id in app.ui.library_panels.iter().map(|p| p.id.as_str()).chain([app.ui.color_guide_limit.as_str()]) { + add_library_id(id, &mut ids); + } + let mut hidden = Vec::new(); + let mut taken = std::collections::BTreeSet::new(); + for id in ids { + let path = &id["loaded/".len()..]; + let name = vectorcraft_engine::cmd::fileio::file_name(path); + if name != path && !readable(path) { + hidden.push((id, name)); + } else { + taken.insert(id); + } + } + let mut hidden = hidden + .into_iter() + .map(|(id, name)| { + let mut shown = format!("loaded/{name}"); + let mut n = 2; + while taken.contains(&shown) { + shown = format!("loaded/{name} ({n})"); + n += 1; + } + taken.insert(shown.clone()); + (id, shown) }) - .collect() + .collect::>(); + // The longest first, so that an id that begins another one does not replace part of it. + hidden.sort_by_key(|(id, _)| std::cmp::Reverse(id.len())); + hidden +} + +/// Adds `id` to `ids` when it is a loaded library's id (`loaded/`). +fn add_library_id(id: &str, ids: &mut std::collections::BTreeSet) { + if id.len() > "loaded/".len() && id.starts_with("loaded/") { + ids.insert(id.to_string()); + } +} + +/// Adds to `ids` the loaded library id of every library panel id (`library::loaded/`) +/// that `v` holds as a string or a key. +fn collect_library_panel_ids(v: &Value, ids: &mut std::collections::BTreeSet) { + let add = |s: &str, ids: &mut std::collections::BTreeSet| { + if let Some((_, id)) = s.strip_prefix(crate::panels::library_panel::PREFIX).and_then(|rest| rest.split_once(':')) { + add_library_id(id, ids); + } + }; + match v { + Value::String(s) => add(s, ids), + Value::Array(items) => items.iter().for_each(|item| collect_library_panel_ids(item, ids)), + Value::Object(fields) => { + for (key, item) in fields { + add(key, ids); + collect_library_panel_ids(item, ids); + } + } + _ => {} + } } /// Writes each id of `ids` as the id the reply shows instead, wherever `v` holds it: in a library's @@ -445,7 +518,8 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlReques "ui.inspect" => ok(inspect(app, ctx)), "ui.docking" => { let mut docking = crate::panel_docking::inspect(app, ctx); - hide_library_ids(&mut docking, &unreadable_library_ids(app)); + let ids = unreadable_library_ids(app); + hide_library_ids(&mut docking, &ids); ok(docking) } "ui.menu.list" => ok(serde_json::to_value(crate::menus::menu_entries(app)).unwrap_or_default()), diff --git a/crates/ui-egui/src/tests/automation.rs b/crates/ui-egui/src/tests/automation.rs index a369ee002..f725a0819 100644 --- a/crates/ui-egui/src/tests/automation.rs +++ b/crates/ui-egui/src/tests/automation.rs @@ -425,6 +425,116 @@ fn ui_inspect_leaves_out_paths_outside_the_read_root() { let _ = std::fs::remove_dir_all(base); } +/// A closed panel's former place, as `ui.docking_hidden` keeps it. +fn hidden_place() -> craft_ui::docking::Location { + serde_json::from_value(json!({"floating": null, "anchor": null, "placement": {"Tab": {"before": null}}, "size": null, "stack": null})).unwrap() +} + +/// `ui.inspect` hides the path of a library loaded from outside the read root wherever the reply holds +/// its id, also when its panel is closed: here only the Color Guide's limit and a closed panel's place +/// name it. +#[test] +fn ui_inspect_hides_a_closed_librarys_path_outside_the_read_root() { + let (mut app, tx, base) = confined("inspect-closed-lib"); + let theirs = format!("loaded/{}", s(base.join("outside/b.svg"))); + app.ui.color_guide_limit = theirs.clone(); + app.ui.docking_hidden.insert(format!("library:swatches:{theirs}"), hidden_place()); + let r = request(&mut app, &tx, "ui.inspect", json!({})); + let ui = &r["result"]["ui"]; + assert_eq!(ui["color_guide_limit"], "loaded/b.svg", "{r}"); + assert!(ui["docking_hidden"].as_object().unwrap().contains_key("library:swatches:loaded/b.svg"), "{r}"); + let hidden = json!(s(base.join("outside"))).to_string(); + assert!(!r.to_string().contains(hidden.trim_matches('"')), "no path outside the root: {r}"); + assert_eq!(crate::control::inspect(&app, &egui::Context::default())["ui"]["color_guide_limit"], json!(theirs), "the person's"); + let _ = std::fs::remove_dir_all(base); +} + +/// Two libraries loaded from outside the read root with the same file name stay two in `ui.inspect`: +/// the second one shows as `loaded/ (2)`, the same in every place the reply names it. +#[test] +fn ui_inspect_keeps_two_hidden_libraries_with_one_file_name_apart() { + let (mut app, tx, base) = confined("inspect-same-name"); + let (x, y) = (format!("loaded/{}", s(base.join("outside/x/b.svg"))), format!("loaded/{}", s(base.join("outside/y/b.svg")))); + let panel = |id: &str| -> crate::panels::library_panel::LibraryPanel { serde_json::from_value(json!({"kind": "swatches", "id": id})).unwrap() }; + app.ui.library_panels = vec![panel(&x), panel(&y)]; + for id in [&x, &y] { + app.ui.docking_hidden.insert(format!("library:swatches:{id}"), hidden_place()); + } + app.ui.color_guide_limit = y.clone(); + let r = request(&mut app, &tx, "ui.inspect", json!({})); + let ui = &r["result"]["ui"]; + let ids: Vec<_> = ui["library_panels"].as_array().unwrap().iter().map(|p| p["id"].clone()).collect(); + assert_eq!(ids, [json!("loaded/b.svg"), json!("loaded/b.svg (2)")], "{r}"); + let keys: Vec<_> = ui["docking_hidden"].as_object().unwrap().keys().cloned().collect(); + assert_eq!(keys, ["library:swatches:loaded/b.svg", "library:swatches:loaded/b.svg (2)"], "no entry is dropped: {r}"); + assert_eq!(ui["color_guide_limit"], "loaded/b.svg (2)", "the same id everywhere"); + let _ = std::fs::remove_dir_all(base); +} + +/// `ui.inspect` and `ui.docking` show a hidden library under the same id, also when another hidden +/// library with the same file name is in only one of the replies (here as the Color Guide's limit). +#[test] +fn ui_inspect_and_ui_docking_give_a_hidden_library_the_same_id() { + let (mut app, tx, base) = confined("inspect-docking-same-id"); + let (x, y) = (format!("loaded/{}", s(base.join("outside/x/b.svg"))), format!("loaded/{}", s(base.join("outside/y/b.svg")))); + let panel: crate::panels::library_panel::LibraryPanel = serde_json::from_value(json!({"kind": "swatches", "id": y})).unwrap(); + app.ui.library_panels = vec![panel]; + app.ui.open_panel = Some(format!("library:swatches:{y}")); + app.ui.color_guide_limit = x.clone(); + let r = request(&mut app, &tx, "ui.inspect", json!({})); + let ui = &r["result"]["ui"]; + assert_eq!( + (ui["color_guide_limit"].as_str(), ui["open_panel"].as_str()), + (Some("loaded/b.svg"), Some("library:swatches:loaded/b.svg (2)")), + "{r}" + ); + let d = request(&mut app, &tx, "ui.docking", json!({})); + assert_eq!(d["result"]["flyout"], ui["open_panel"], "{d}"); + let _ = std::fs::remove_dir_all(base); +} + +/// `ui.inspect` rewrites only the ids of libraries the UI state refers to: a script's report line and +/// a command palette query that look like a library id, with no such library, stay as written, and +/// the query does not take the file name of a real library outside the root. +#[test] +fn ui_inspect_keeps_text_that_only_looks_like_a_library_id() { + let (mut app, tx, base) = confined("inspect-lookalike"); + let real = format!("loaded/{}", s(base.join("outside/x/d.svg"))); + let panel: crate::panels::library_panel::LibraryPanel = serde_json::from_value(json!({"kind": "swatches", "id": real})).unwrap(); + app.ui.library_panels = vec![panel]; + let query = format!("library:swatches:loaded/{}", s(base.join("outside/d.svg"))); + app.ui.palette_query = query.clone(); + let text = format!("loaded/{}", s(base.join("outside/c.svg"))); + let source = format!("vc.log({})", json!(text)); + app.session.execute("script.run", &json!({"source": source, "name": "t.js"})).unwrap(); + for _ in 0..10_000 { + if app.session.script_pump().is_some() { + break; + } + } + let r = request(&mut app, &tx, "ui.inspect", json!({})); + assert_eq!(r["result"]["scriptReport"]["log"], json!([text]), "{r}"); + assert_eq!(r["result"]["ui"]["palette_query"], json!(query), "{r}"); + assert_eq!(r["result"]["ui"]["library_panels"][0]["id"], "loaded/d.svg", "{r}"); + let _ = std::fs::remove_dir_all(base); +} + +/// A client without automation roots sees every loaded library's full id in `ui.inspect`. +#[test] +fn ui_inspect_shows_full_library_ids_without_a_read_root() { + let (tx, rx) = std::sync::mpsc::channel(); + let mut app = VectorcraftApp::new(Session::new(), Default::default()).with_control(rx); + let theirs = format!("loaded/{}", s(std::env::temp_dir().join("vc-ui-roots-unconfined").join("b.svg"))); + let panel: crate::panels::library_panel::LibraryPanel = serde_json::from_value(json!({"kind": "swatches", "id": theirs})).unwrap(); + app.ui.library_panels = vec![panel]; + app.ui.color_guide_limit = theirs.clone(); + app.ui.docking_hidden.insert(format!("library:swatches:{theirs}"), hidden_place()); + let r = request(&mut app, &tx, "ui.inspect", json!({})); + let ui = &r["result"]["ui"]; + assert_eq!((&ui["library_panels"][0]["id"], &ui["color_guide_limit"]), (&json!(theirs), &json!(theirs)), "{r}"); + assert!(ui["docking_hidden"].as_object().unwrap().contains_key(&format!("library:swatches:{theirs}")), "{r}"); +} + /// Hidden logic-only ticks between the injection and its UI pass must not drain another step: /// one step per settled pass, not per hidden tick. #[test] diff --git a/docs/mcp.md b/docs/mcp.md index 595b4729d..a3266538b 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -120,8 +120,11 @@ the suite) keep the agent to the folders you give it: and the folder preferences in `ui.engine_prefs` and in an open Preferences dialog's `ui.dialog.fields` (`fontsFolder`, `pluginsFolder`, `scratchPrimary`, `scratchSecondary`, `recoveryFolder`, `templatesFolder`; an empty folder and the scratch disk choices `Startup` and `None` stay). A library loaded from a file outside the - roots shows as `loaded/` in its id and its panel's id, in `ui_inspect` and in `ui.docking`. The other - entries keep their order, and the app's own state is not changed. + roots shows as `loaded/` wherever `ui_inspect` and `ui.docking` name it: its id, its panel's id, the + Color Guide's `ui.color_guide_limit` and a closed panel's key in `ui.docking_hidden`, also when its panel is + closed. When another hidden library has the same file name, the libraries take `loaded/`, + `loaded/ (2)`, `(3)` and so on in the order of their full ids, and each one keeps its id in both + replies. The other entries keep their order, and the app's own state is not changed. - Each flag may be given more than once (#3546): a path inside any of the read roots may be read, one inside any of the write roots written, so one session can read a project folder and a shared assets folder, or write to an output folder and a scratch folder, without widening a root to their common parent. A folder given twice