From 5bf0f337dfa888718173b37b8d3b6d59d6255e3c Mon Sep 17 00:00:00 2001 From: makesomethingshit Date: Tue, 10 Nov 2026 00:45:55 +0900 Subject: [PATCH 01/36] #6307: Keep numeric spinner arrows repeating while held --- crates/ui-egui/src/widgets/look_tests.rs | 46 ++++++++++++++++++------ crates/ui-egui/src/widgets/numeric.rs | 23 +++++++----- docs/roadmap.md | 5 +-- docs/target-app-parity.md | 6 ++-- docs/ui-parity.md | 4 ++- 5 files changed, 61 insertions(+), 23 deletions(-) diff --git a/crates/ui-egui/src/widgets/look_tests.rs b/crates/ui-egui/src/widgets/look_tests.rs index 582833127c..274861386b 100644 --- a/crates/ui-egui/src/widgets/look_tests.rs +++ b/crates/ui-egui/src/widgets/look_tests.rs @@ -429,17 +429,25 @@ fn a_popover_stays_open_for_a_click_on_its_anchor_or_during_a_panel_drag() { } /// #4267: a click on a spinner arrow steps once; an arrow held down steps again and again after a -/// pause, and letting go of it doesn't step once more. +/// pause even past the click timeout (#6307), and letting go doesn't step once more. #[test] fn a_held_spinner_arrow_repeats() { let ctx = themed(Default::default()); let value = std::cell::Cell::new(5.0); + let scroll = std::cell::Cell::new(0.0); + let spinner_rect = std::cell::Cell::new(Rect::NOTHING); let mut add = |ui: &mut Ui| { - ui.horizontal(|ui| { - if let Some(v) = spin_plain(ui, "repeat", value.get(), "", 0, 100.0, 1.0, 0.0, &[]) { - value.set(v); - } + let area = egui::ScrollArea::vertical().max_height(100.0).scroll_source(egui::scroll_area::ScrollSource::DRAG).show(ui, |ui| { + ui.add_space(35.0); + let row = ui.horizontal(|ui| { + if let Some(v) = spin_plain(ui, "repeat", value.get(), "", 0, 100.0, 1.0, 0.0, &[]) { + value.set(v); + } + }); + spinner_rect.set(Rect::from_min_size(row.response.rect.min, vec2(16.0, 26.0))); + ui.add_space(150.0); }); + scroll.set(area.state.clamped_offset().y); }; let shapes = frame(&ctx, 0.0, vec![], &mut add); let spinner = shapes @@ -460,13 +468,31 @@ fn a_held_spinner_arrow_repeats() { frame(&ctx, 1.0, vec![button(true)], &mut add); frame(&ctx, 1.3, vec![], &mut add); assert_eq!(value.get(), 6.0, "no step before the pause"); - for (i, t) in [1.5, 1.6, 1.7].into_iter().enumerate() { + for i in 0..17 { + let t = 1.5 + f64::from(i) * 0.1; frame(&ctx, t, vec![], &mut add); - assert_eq!(value.get(), 7.0 + i as f64, "a step every repeat at {t}"); + assert_eq!(value.get(), 7.0 + f64::from(i), "a step every repeat at {t}"); } - frame(&ctx, 1.75, vec![button(false)], &mut add); - frame(&ctx, 1.8, vec![], &mut add); - assert_eq!(value.get(), 9.0, "letting go doesn't step once more"); + frame(&ctx, 3.2, vec![button(false)], &mut add); + frame(&ctx, 3.3, vec![], &mut add); + assert_eq!(value.get(), 23.0, "letting go doesn't step once more"); + + let down = spinner_rect.get().center_bottom() - vec2(0.0, 6.0); + let modifiers = egui::Modifiers::SHIFT; + let button = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers }; + frame(&ctx, 3.9, vec![egui::Event::PointerMoved(down)], &mut add); + frame(&ctx, 4.0, vec![egui::Event::ModifiersChanged(modifiers), button(down, true)], &mut add); + frame(&ctx, 4.5, vec![], &mut add); + assert_eq!(value.get(), 13.0, "Shift+down steps by ten: press {down:?}, current {:?}", spinner_rect.get()); + frame(&ctx, 4.9, vec![], &mut add); + assert_eq!(value.get(), 3.0, "Shift+down repeats past the click timeout"); + + let outside = down - vec2(0.0, 45.0); + frame(&ctx, 5.0, vec![egui::Event::PointerMoved(outside)], &mut add); + frame(&ctx, 5.1, vec![], &mut add); + frame(&ctx, 5.2, vec![button(outside, false)], &mut add); + assert_eq!(value.get(), 3.0, "dragging away and releasing doesn't step"); + assert!(scroll.get() > 0.0, "a drag starting on the spinner can still scroll the panel"); } /// The Kerning field reads `(10+5)` as 15 whether it shows a manual value (`12`) or an automatic diff --git a/crates/ui-egui/src/widgets/numeric.rs b/crates/ui-egui/src/widgets/numeric.rs index 04ac7160f2..f6eabe0e47 100644 --- a/crates/ui-egui/src/widgets/numeric.rs +++ b/crates/ui-egui/src/widgets/numeric.rs @@ -855,13 +855,14 @@ fn spin_generic( sresp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, enabled, tl!("Step up or down"))); // A blank field (values that differ) has nothing to step from. A click steps once; an // arrow held down steps again and again after a pause, as a spin button does (#4267). - let repeat = held_repeat(ui, &sresp); - if (repeat == Some(true) || (sresp.clicked() && repeat.is_none())) - && let Some(p) = sresp.interact_pointer_pos() - && let Some(v) = value - { + let is_up = match held_repeat(ui, &sresp) { + Some(true) => Some(ui.rect_contains_pointer(up)), + None if sresp.clicked() => sresp.interact_pointer_pos().map(|p| up.contains(p)), + _ => None, + }; + if let (Some(is_up), Some(v)) = (is_up, value) { let modifiers = ui.input(|i| i.modifiers); - out = Some(SpinPick::Value(spinner_step(v, step, min, up.contains(p), modifiers, stops, fmt))); + out = Some(SpinPick::Value(spinner_step(v, step, min, is_up, modifiers, stops, fmt))); } let fw = if presets.is_empty() { width - 15.0 } else { width - 34.0 }; let keyed = if presets.is_empty() { None } else { alt_down_in_field(ui, Rect::from_x_y_ranges(sr.right()..=sr.right() + fw, sr.y_range())) }; @@ -914,9 +915,15 @@ const REPEAT_EVERY: f64 = 0.06; /// must not step once more. fn held_repeat(ui: &Ui, resp: &egui::Response) -> Option { let id = resp.id.with("repeat"); - let now = ui.input(|i| i.time); + let click_dist = ui.ctx().options(|o| o.input_options.max_click_dist); + let (now, primary_held) = ui.input(|i| { + let stayed = i.pointer.total_drag_delta().is_some_and(|d| d.length_sq() <= click_dist * click_dist); + (i.time, i.focused && i.pointer.primary_down() && stayed) + }); let state: Option<(f64, f64, bool)> = ui.data(|d| d.get_temp(id)); - if !resp.is_pointer_button_down_on() { + // Start from an egui press, but keep tracking it past the click timeout without taking drags from a scroll area (#6307). + let down_here = primary_held && resp.contains_pointer() && !resp.long_touched(); + if !resp.enabled() || !down_here || (state.is_none() && !resp.is_pointer_button_down_on()) { ui.data_mut(|d| d.remove::<(f64, f64, bool)>(id)); return state.filter(|(_, _, repeated)| *repeated).map(|_| false); } diff --git a/docs/roadmap.md b/docs/roadmap.md index fb546a4464..e927dd8a72 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # Roadmap: milestones and what's next -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-09 · **Change:** minor (interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; 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; M4/M7: `.ai`/EPS type keeps its tab stops, and right-aligned lines with tabs lay out as the file's page, #6147; M4/M7: `.ai`/EPS type from the text document lays its lines out closer to the file's page, within limits, #6137; numeric fields step on wheel hover without taking keyboard focus, #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; 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; Indian-language hyphenation review and license allowlist, #4947; Hindi catalog and contributor loanword preferences; full wording review in progress; Effect dialogs: a colour swatch beside a hex colour opens the Color Picker, #5365; shape tools move the selection from its centre point and turn it from the zone outside a box corner, #5477; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-10 · **Change:** minor (numeric spinner arrows keep repeating while held with the mouse (#6307); scores and estimates unchanged; interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; 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; M4/M7: `.ai`/EPS type keeps its tab stops, and right-aligned lines with tabs lay out as the file's page, #6147; M4/M7: `.ai`/EPS type from the text document lays its lines out closer to the file's page, within limits, #6137; numeric fields step on wheel hover without taking keyboard focus, #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; 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; Indian-language hyphenation review and license allowlist, #4947; Hindi catalog and contributor loanword preferences; full wording review in progress; Effect dialogs: a colour swatch beside a hex colour opens the Color Picker, #5365; shape tools move the selection from its centre point and turn it from the zone outside a box corner, #5477; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) ## Feature submission evidence @@ -153,7 +153,7 @@ native wording review, layout checks and a web Devanagari font remain. [Notes](l | M12 | Advanced color & art (CMYK/ICC, separations, Gradient Mesh, Live Paint, Image Trace, Graphs) | 🟡 Gradient Mesh, Live Paint, Image Trace (junction-returning pieces retain their positioned boundaries; a simpler contour walk with identical output; an edge's sub-pixel shift is the same from either side; notches read only their own piece; collapsed loops keep few segments; large images trace with a warning; Auto Color keeps rare distinct colours; quick presets trace at once and the Image Trace button opens the adjustment controls; Properties reopens them; tiny fitted contours retain islands and holes; Create Strokes: exact crop EDT, Guo–Hall thinning and maximal-disc graph simplification; 15 presets, the 14 documented ones each with an enhanced version; Ignore Color, Shapes, Auto Grouping and Gradients options; measured in `image-trace-quality.md`; closed outlines retain their interior after straightening; the Flat Logo mode places edges to a fraction of a pixel, restores sharp corners, snaps lines and circles, and refuses gradients; live trace result bounds and selection highlights exclude the retained bitmap except in source-image views), Recolor Artwork, colour management (ICC, soft proofing, separations preview), Graphs (all 9 tools, Graph Data/Type, group-selected series colours persist through regenerate/save/open) done; Graph Design and Marker designs, Transpose and Switch x/y, `\|` label line breaks, the pie options, drop shadows, the legend across the top, overlapping widths, First Row/Column in Front, filled lines, kept label formatting and the Graph Data worksheet done; graph Column designs pending | 6–10 | | M13 | Automation (Actions ✅ record/playback, persisted; variables, scripting, batch) | 🟡 MCP `add_text` exposes existing vertical point, area and path type; broader scripting and batch work remains | 18–27 | | M13 | Automation (Actions ✅ record/playback, persisted; variables, scripting, batch) | 🟡 | 18–27 | -| M14 | 1.0 polish (preferences, shortcut editor, workspaces, accessibility, packaging for all OSes) (status messages and readouts use stable responsive allocations sized for localized labels and ordinary coordinates; sample widths cached by language/unit/DPI; regression-tested and contributor-confirmed 2026-10-19; estimates unchanged) | 🟡 Interim macOS cursor delivery (#2292) uses existing artwork and host-owned AppKit cursors; headless coverage only, native menu/focus/handback and display validation pending. Dock groups collapse to their tabs by a double-click on a tab (UI.8); Preferences › General › Constrain Angle acts for the arrow keys, Shift drags and new objects (#394, 2026-10-21); numeric-field pointer selection and focus regression-tested (#1326, in part; short movements still select all); RPM runtime capabilities and glibc floor checked (#1275; real-distro install/start-up pending), Eyedropper cursor with precise-setting checks (#1269), Preferences, shortcut editor (chords with Shift and a punctuation key fire as typed; other apps' text shortcut sets imported), workspaces (each keeps its layout changes until Reset , UI.7), Window › Help Bar shown or hidden apart from the status bar and kept in saved workspaces, panels docked by dragging into any dock group (as tabs or a top/bottom split) or into the icon column, and closed from a dock tab's context menu (UI.6), a custom title bar on Windows/Linux with a Home button, content-sized dialogs, menus that scroll when longer than the window and macOS native menu dismissal done (M14.8), and native macOS save sheets with a Format menu (#866); titled PDF exports set `DisplayDocTitle` for viewers and accessibility checkers (#3646); accessibility and broader Windows/Linux packaging/QA pending; floating tab groups collapse into the side icon rail and opened rail panels fold back into it (#5925, in part), with pointer and native-window regression coverage; estimates unchanged | 18–28 | +| M14 | 1.0 polish (preferences, shortcut editor, workspaces, accessibility, packaging for all OSes) (status messages and readouts use stable responsive allocations sized for localized labels and ordinary coordinates; sample widths cached by language/unit/DPI; regression-tested and contributor-confirmed 2026-10-19; estimates unchanged) | 🟡 Interim macOS cursor delivery (#2292) uses existing artwork and host-owned AppKit cursors; headless coverage only, native menu/focus/handback and display validation pending. Dock groups collapse to their tabs by a double-click on a tab (UI.8); Preferences › General › Constrain Angle acts for the arrow keys, Shift drags and new objects (#394, 2026-10-21); numeric-field pointer selection and focus regression-tested (#1326, in part; short movements still select all); RPM runtime capabilities and glibc floor checked (#1275; real-distro install/start-up pending), Eyedropper cursor with precise-setting checks (#1269), Preferences, shortcut editor (chords with Shift and a punctuation key fire as typed; other apps' text shortcut sets imported), workspaces (each keeps its layout changes until Reset , UI.7), Window › Help Bar shown or hidden apart from the status bar and kept in saved workspaces, panels docked by dragging into any dock group (as tabs or a top/bottom split) or into the icon column, and closed from a dock tab's context menu (UI.6), a custom title bar on Windows/Linux with a Home button, content-sized dialogs, menus that scroll when longer than the window and macOS native menu dismissal done (M14.8), and native macOS save sheets with a Format menu (#866); titled PDF exports set `DisplayDocTitle` for viewers and accessibility checkers (#3646); accessibility and broader Windows/Linux packaging/QA pending; floating tab groups collapse into the side icon rail and opened rail panels fold back into it (#5925, in part), with pointer and native-window regression coverage; estimates unchanged; numeric spinner arrows keep repeating while held with the mouse (#6307) | 18–28 | | — | Interaction fidelity pass (every tool's modifiers, Properties panel per context, isolation, nuance; width-point creation and adjoining adjustment regression-tested; dialog layout reviewed) | ⬜ (new ruler-guide Alt orientation implemented; existing-guide Escape cancellation regression-tested; broader pass remains) | 60–90 | | — | Hardening at scale (big-file corpus, fuzzing, cross-platform + browser QA) | 🟡 PngSuite integrity and bounded fetching verified (#1729); real-world corpus/manual QA remain | 50–80 | | M14 follow-up | Color Picker layout (#2008) | ✅ Compact controls, centered header, stacked actions, aligned fields and screen sampling; narrow windows scroll vertically | Included above | @@ -190,6 +190,7 @@ separate. Scores and hours unchanged. [Behavior and command details](development | Date | Change | Summary | |---|---|---| +| 2026-11-10 | minor | Held numeric spinner arrows keep stepping past the click timeout and stop on release (#6307). Scores and estimates unchanged. | | 2026-11-09 | minor | Interim macOS canvas cursor delivery (#2292): existing artwork in aligned 1×/2× AppKit representations, visible system fallback and host-owned restoration. Headless routing, bitmap and ownership coverage; native timing, menus, focus/handback, mixed displays, Spotlight and full-screen Spaces remain unverified. Scores and estimates unchanged. | | 2026-11-09 | minor | M7: the custom dictionary commands and Ignore All (#4936); the dialogs and Auto Spell Check remain; scores and estimates unchanged. | | 2026-11-09 | minor | M4/G9: EPS import: the fonts an EPS carries draw their glyphs instead of being set as type in an installed font: composite (Type 0) fonts with the 8/8 mapping, plain Type 42 fonts (characters from their glyph names or character map, widths from the font), CID-keyed fonts over CMaps (Type 0 and Type 2 CIDFonts, CIDFonts in a FontSet's Compact Font Format data, base-font CMaps, named CIDFonts over predefined Unicode CMaps set as type) and embedded Type 1 fonts; the show operators' spacing along the baseline is kept as tracking, `kshow` included. Each step is charged to the interpreter's budgets. Hand-written test files only (no corpus file uses these fonts); scores and estimates unchanged. | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index d13cd142ea..6d5f4b80ef 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-09 · **Change:** minor (interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; harakat paint fallback clarified; current Guides feature-area rows consolidated using existing ruler evidence; scores and estimates unchanged; Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; 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-10 · **Change:** minor (numeric spinner arrows keep repeating while held with the mouse (#6307); scores and estimates unchanged; interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; harakat paint fallback clarified; current Guides feature-area rows consolidated using existing ruler evidence; scores and estimates unchanged; Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; 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) ## Feature submission evidence @@ -387,7 +387,7 @@ what keeps it from beta, not from alpha. | Dimension | % | One agent (h) | Kind | Doc | |---|---:|---:|---|---| | Features (depth, weighted) | 75% | 220–360 | estimated | this file | -| UI/UX fidelity (look ~80%, interaction ~45%) | 55% | 70–110 | estimated | [ui-parity.md](ui-parity.md); Tools sits inside left-side panels beside the canvas (layout choice; no rescoring) | +| UI/UX fidelity (look ~80%, interaction ~45%) | 55% | 70–110 | estimated | [ui-parity.md](ui-parity.md); Tools sits inside left-side panels beside the canvas (layout choice; no rescoring); held spinner arrows keep stepping (#6307) | | File formats | 75% | 15–30 | estimated | [file-format-parity.md](file-format-parity.md) | | Hardware (GPU, pen, touch, displays) | 35% | 25–45 | estimated | [hardware-parity.md](hardware-parity.md) | | Localization (the 12 key languages, equally weighted) | 74% | 50–85 | measured from catalogs | [localization-parity.md](localization-parity.md) | @@ -806,6 +806,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - The Control bar's underlined Transform link opens the Transform panel in a popover under it (reference point, X/Y/W/H, Rotate and Shear with their icons and angle presets, flips, shape properties, Scale Corners and Scale Strokes & Effects), as its Stroke link does the Stroke panel; X/Y/W/H also show inline while the bar has room. The reference point locator is nine squares, the outer eight joined around the box. As VectorCraft's own keyboard rule, the locator is one tab stop in the Control bar popover, the Transform panel, the Properties panel and the dialogs, the Arrange Tools dialogs included. While it has the focus it shows an outline, and ←, →, ↑ and ↓ move the reference point one square, stopping at the edges (`the_reference_point_grid_is_one_tab_stop`, `arrow_keys_move_the_reference_point`, `the_reference_point_locator_is_one_tab_stop_and_arrows_move_it`). - In isolation mode the Control bar has an Exit Isolation Mode button (`object.exitIsolation`), except while a pattern is edited. Outside isolation mode, with one group, compound path, path, image, mesh or placed file selected, it has an Isolate Selected Object button (`object.isolate`), which also isolates a path inside a group that Direct Selection selected. - While an opacity mask is edited, Exit Isolation Mode (Esc, the isolation bar's back arrow, the Control bar's button, and the Layers panel menu's and canvas context menu's Exit Isolation Mode) leaves mask editing as Stop Editing Opacity Mask does, in one undo step: the mask keeps the edits, and the masked object is selected. While a pattern is edited, the Exit Isolation Mode item of the Layers panel menu and of the canvas context menu saves the pattern and leaves pattern editing, as Done does. + - Numeric spinner arrows keep stepping while held with the mouse past the click timeout and stop on release (#6307; `a_held_spinner_arrow_repeats`). - Tab shows and hides the panels (in the Type tool it types a tab) without moving the keyboard focus onto a field, so the next shortcut or letter still works; in dialogs and panel fields it moves between fields, one press each — a numeric field's scrub label is not a tab stop of its own, and the Transform panel's, Properties panel's and Control bar's W/H link comes after the fields it sits between (`one_tab_moves_from_a_field_to_the_next`). A field's up/down spinner, the chevron that opens its presets (Stroke Weight, font size, Rotate, Export for Screens' scale) and the Transparency row's opacity slider chevron are not tab stops either. Alt+↓ with no other modifier in a field with presets opens them with the keyboard focus on the chosen one, instead of stepping the field down; Enter picks the focused preset without pressing a dialog's OK, and after a pick or Escape the field has the keyboard focus again (`tab_goes_from_field_to_field_past_the_spinner_and_the_preset_chevron`, `tab_goes_from_the_opacity_field_past_its_slider_chevron`, `alt_down_opens_a_fields_presets_for_the_keyboard`, `escape_closes_a_fields_presets_and_returns_to_the_field`). - Shift+Tab (`window.panels`) shows and hides the dock and the floating panels and leaves the toolbar and the Control bar as they are; hiding the panels with Tab or Shift+Tab also closes a panel popped out of the icon column. - The second Tab (`window.dock`) shows again only the bars the first Tab hid, after a restart too: a Control bar that was off stays off, a toolbar hidden with Window › Tools stays hidden, and panels shown with Shift+Tab in between stay shown. @@ -1901,6 +1902,7 @@ Edit › Copy and Cut put the selected ruler guides on the clipboard, with the s | Date | Change | Summary | |---|---|---| +| 2026-11-10 | minor | Held numeric spinner arrows keep stepping past the click timeout and stop on release (#6307). Scores and estimates unchanged. | | 2026-11-09 | minor | Interim macOS canvas cursor delivery (#2292): existing artwork in aligned 1×/2× AppKit representations, visible system fallback and host-owned restoration. Headless routing, bitmap and ownership coverage; native timing, menus, focus/handback, mixed displays, Spotlight and full-screen Spaces remain unverified. Scores and estimates unchanged. | | 2026-11-09 | minor | Type advanced: the custom dictionary commands (`text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary`, saved beside the preferences) and Ignore All for the active document (`text.spelling.ignoreAll`), #4936; evidence added; the dialogs and Auto Spell Check remain; scores and hours unchanged. | | 2026-11-09 | minor | File formats: EPS import: the fonts an EPS carries draw their glyphs instead of being set as type in an installed font: composite (Type 0) fonts with the 8/8 mapping, plain Type 42 fonts (characters from their glyph names or character map, widths from the font), CID-keyed fonts over CMaps (Type 0 and Type 2 CIDFonts, CIDFonts in a FontSet's Compact Font Format data, base-font CMaps, named CIDFonts over predefined Unicode CMaps set as type) and embedded Type 1 fonts; the show operators' spacing along the baseline is kept as tracking, `kshow` included. Each step is charged to the interpreter's budgets. Hand-written test files only (no corpus file uses these fonts); scores and estimates unchanged. | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 72f7502ee3..7396ddd417 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI and interaction parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-09 · **Change:** minor (interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; numeric fields step on wheel hover without taking keyboard focus, #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; Selection QoL: marquee modes and visible feedback, #5313; #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; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Layers: Rename (`ui.rename`) opens the name field of the selected layer, group or object, #5324; Properties in Edit Artboards: Transform section above the Artboards section, #5219; scrubby labels: the Control bar's Stroke: and Opacity: links and the Properties panel's Stroke link scrub their fields, #4088; Properties: Quick Actions has Repeat Options for one selected repeat, #5181; Direct Selection: Shift-click picks several blend spine points, which drag together, #5285; Direct Selection: Cmd/Ctrl held lends the Selection tool and shows its bounding box, #3901; Join tool trims two crossing open paths and joins them at the crossing, #5844; Control bar: Paragraph: link and Align left/center/right while type is selected, #5372; shape tools: a press on the selection's centre point moves it and one in the rotate zone outside a box corner turns it, #5477; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; Units › East Asian Type acts, with Q and Ha units; scores unchanged) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-10 · **Change:** minor (numeric spinner arrows keep repeating while held with the mouse (#6307); scores and estimates unchanged; interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; numeric fields step on wheel hover without taking keyboard focus, #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; Selection QoL: marquee modes and visible feedback, #5313; #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; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Layers: Rename (`ui.rename`) opens the name field of the selected layer, group or object, #5324; Properties in Edit Artboards: Transform section above the Artboards section, #5219; scrubby labels: the Control bar's Stroke: and Opacity: links and the Properties panel's Stroke link scrub their fields, #4088; Properties: Quick Actions has Repeat Options for one selected repeat, #5181; Direct Selection: Shift-click picks several blend spine points, which drag together, #5285; Direct Selection: Cmd/Ctrl held lends the Selection tool and shows its bounding box, #3901; Join tool trims two crossing open paths and joins them at the crossing, #5844; Control bar: Paragraph: link and Align left/center/right while type is selected, #5372; shape tools: a press on the selection's centre point moves it and one in the rotate zone outside a box corner turns it, #5477; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; Units › East Asian Type acts, with Q and Ha units; scores unchanged) ## Feature submission evidence @@ -268,6 +268,7 @@ tools, never checked side by side) isn't captured row by row. | Arrow keys nudge by Keyboard Increment (0.001–1296 pt); Shift ×10; Alt copies | done | | | Fields commit what was typed on a click away or a move to another field, as Enter does; Escape discards it | done | #1787 | | Number fields: Up/Down step, Shift ×10, Cmd/Ctrl ÷10, wheel over a hovered field without a click; steps, the wheel and label scrubs stop at the field's bounds (a stroke weight at 0, a miter limit at 1) | done | Shift+arrow in the Stroke weight field doesn't step by 10 (#991); bounds #3370 | +| Numeric spinner arrows step once on a click and keep repeating while held with the mouse | done | #6307; headless test `a_held_spinner_arrow_repeats` holds past egui's click timeout and checks release. | | Arithmetic in fields (`+ - * /`, and `%` of the value before it: `3cm*50%`, `50pt+25%`) and unit suffixes (`3 mm`, `1in`, `2p6`) | done | `Unit::parse`; mixed units in one expression not verified | | Numeric fields: focus click selects the whole value; dragging selects from the pressed character | partial | #1326; select-all waits for click release; px/pt/plain/optional fields, both directions and replacement text tested; native API input, 17 px partial replacement, Undo/Redo, saved-file round trip and screenshots verified; Tab, double-click, arrows and wheel preserved; short movements still classified as clicks can select all and remain open | | Units: pt, pc, in, mm, cm, px, ft-in, m, yd, ft; General/Stroke/Type units | done | units preference reverting to points on SVG output reported (#864) | @@ -494,6 +495,7 @@ separate. Scores and hours unchanged. [Behavior and command details](development | Date | Change | Summary | |---|---|---| +| 2026-11-10 | minor | Held numeric spinner arrows keep stepping past the click timeout and stop on release (#6307). Scores and estimates unchanged. | | 2026-11-09 | minor | Interim macOS canvas cursor delivery (#2292): existing artwork in aligned 1×/2× AppKit representations, visible system fallback and host-owned restoration. Headless routing, bitmap and ownership coverage; native timing, menus, focus/handback, mixed displays, Spotlight and full-screen Spaces remain unverified. Scores and estimates unchanged. | | 2026-11-08 | minor | Floating tab groups collapse into the side icon rail and opened rail panels fold back into it (#5925, in part); tab groups remain intact. Scores and estimates unchanged. | | 2026-11-09 | minor | Numeric fields step on wheel hover without taking focus; keyboard focus and existing rounding rules remain (#5949). Scores and estimates unchanged. | From 898db5d72d946de692e4eff39b7ab768db2ac3a5 Mon Sep 17 00:00:00 2001 From: makesomethingshit Date: Tue, 10 Nov 2026 00:53:11 +0900 Subject: [PATCH 02/36] #6307: Keep spinner evidence separate from shared status lines --- docs/roadmap.md | 4 +++- docs/target-app-parity.md | 4 +++- docs/ui-parity.md | 4 +++- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/docs/roadmap.md b/docs/roadmap.md index e927dd8a72..7c68b48178 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,8 @@ # Roadmap: milestones and what's next -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-10 · **Change:** minor (numeric spinner arrows keep repeating while held with the mouse (#6307); scores and estimates unchanged; interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; 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; M4/M7: `.ai`/EPS type keeps its tab stops, and right-aligned lines with tabs lay out as the file's page, #6147; M4/M7: `.ai`/EPS type from the text document lays its lines out closer to the file's page, within limits, #6137; numeric fields step on wheel hover without taking keyboard focus, #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; 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; Indian-language hyphenation review and license allowlist, #4947; Hindi catalog and contributor loanword preferences; full wording review in progress; Effect dialogs: a colour swatch beside a hex colour opens the Color Picker, #5365; shape tools move the selection from its centre point and turn it from the zone outside a box corner, #5477; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-09 · **Change:** minor (interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; 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; M4/M7: `.ai`/EPS type keeps its tab stops, and right-aligned lines with tabs lay out as the file's page, #6147; M4/M7: `.ai`/EPS type from the text document lays its lines out closer to the file's page, within limits, #6137; numeric fields step on wheel hover without taking keyboard focus, #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; 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; Indian-language hyphenation review and license allowlist, #4947; Hindi catalog and contributor loanword preferences; full wording review in progress; Effect dialogs: a colour swatch beside a hex colour opens the Color Picker, #5365; shape tools move the selection from its centre point and turn it from the zone outside a box corner, #5477; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) + +> Numeric spinner update (2026-11-10): held mouse arrows repeat past the click timeout; scores and estimates unchanged (#6307). ## Feature submission evidence diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 6d5f4b80ef..55346ddd8b 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,8 @@ # Parity with Adobe Illustrator -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-10 · **Change:** minor (numeric spinner arrows keep repeating while held with the mouse (#6307); scores and estimates unchanged; interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; harakat paint fallback clarified; current Guides feature-area rows consolidated using existing ruler evidence; scores and estimates unchanged; Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; 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-09 · **Change:** minor (interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; harakat paint fallback clarified; current Guides feature-area rows consolidated using existing ruler evidence; scores and estimates unchanged; Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; 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) + +> Numeric spinner update (2026-11-10): held mouse arrows repeat past the click timeout; scores and estimates unchanged (#6307). ## Feature submission evidence diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 7396ddd417..bc28800fde 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,8 @@ # UI and interaction parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-10 · **Change:** minor (numeric spinner arrows keep repeating while held with the mouse (#6307); scores and estimates unchanged; interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; numeric fields step on wheel hover without taking keyboard focus, #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; Selection QoL: marquee modes and visible feedback, #5313; #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; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Layers: Rename (`ui.rename`) opens the name field of the selected layer, group or object, #5324; Properties in Edit Artboards: Transform section above the Artboards section, #5219; scrubby labels: the Control bar's Stroke: and Opacity: links and the Properties panel's Stroke link scrub their fields, #4088; Properties: Quick Actions has Repeat Options for one selected repeat, #5181; Direct Selection: Shift-click picks several blend spine points, which drag together, #5285; Direct Selection: Cmd/Ctrl held lends the Selection tool and shows its bounding box, #3901; Join tool trims two crossing open paths and joins them at the crossing, #5844; Control bar: Paragraph: link and Align left/center/right while type is selected, #5372; shape tools: a press on the selection's centre point moves it and one in the rotate zone outside a box corner turns it, #5477; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; Units › East Asian Type acts, with Q and Ha units; scores unchanged) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-09 · **Change:** minor (interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; numeric fields step on wheel hover without taking keyboard focus, #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; Selection QoL: marquee modes and visible feedback, #5313; #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; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Layers: Rename (`ui.rename`) opens the name field of the selected layer, group or object, #5324; Properties in Edit Artboards: Transform section above the Artboards section, #5219; scrubby labels: the Control bar's Stroke: and Opacity: links and the Properties panel's Stroke link scrub their fields, #4088; Properties: Quick Actions has Repeat Options for one selected repeat, #5181; Direct Selection: Shift-click picks several blend spine points, which drag together, #5285; Direct Selection: Cmd/Ctrl held lends the Selection tool and shows its bounding box, #3901; Join tool trims two crossing open paths and joins them at the crossing, #5844; Control bar: Paragraph: link and Align left/center/right while type is selected, #5372; shape tools: a press on the selection's centre point moves it and one in the rotate zone outside a box corner turns it, #5477; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; Units › East Asian Type acts, with Q and Ha units; scores unchanged) + +> Numeric spinner update (2026-11-10): held mouse arrows repeat past the click timeout; scores and estimates unchanged (#6307). ## Feature submission evidence From 804e6d5b14111de3403ad5d57ee068e8956d4b7d Mon Sep 17 00:00:00 2001 From: makesomethingshit Date: Tue, 10 Nov 2026 00:57:12 +0900 Subject: [PATCH 03/36] #6307: Keep parity evidence away from the changing header --- docs/target-app-parity.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 55346ddd8b..e16c446913 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -2,8 +2,6 @@ > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-09 · **Change:** minor (interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; harakat paint fallback clarified; current Guides feature-area rows consolidated using existing ruler evidence; scores and estimates unchanged; Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; 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) -> Numeric spinner update (2026-11-10): held mouse arrows repeat past the click timeout; scores and estimates unchanged (#6307). - ## Feature submission evidence EPS embedded fonts, 2026-11-09 (#6023–#6067): the fonts an EPS carries draw their glyphs instead of being set as type in an installed font: composite (Type 0) fonts with the 8/8 mapping, plain Type 42 fonts (characters from their glyph names or character map, widths from the font), CID-keyed fonts over CMaps (Type 0 and Type 2 CIDFonts, CIDFonts in a FontSet's Compact Font Format data, base-font CMaps, named CIDFonts over predefined Unicode CMaps set as type) and embedded Type 1 fonts; the show operators' spacing along the baseline is kept as tracking, `kshow` included. Each step is charged to the interpreter's budgets. Hand-written test files only (no corpus file uses these fonts); scores and estimates unchanged. Evidence: `crates/eps/src/import/tests_composite.rs`, `tests_type42.rs`, `tests_cid.rs`, `tests_type1.rs`; details in [file-format-parity.md](file-format-parity.md). From e56f95270c4de329f447c1fca2868d4937507aed Mon Sep 17 00:00:00 2001 From: makesomethingshit Date: Tue, 10 Nov 2026 00:58:06 +0900 Subject: [PATCH 04/36] #6307: Preserve concurrent parity revision entries --- docs/target-app-parity.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index e16c446913..ac6a0073b2 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1904,6 +1904,7 @@ Edit › Copy and Cut put the selected ruler guides on the clipboard, with the s |---|---|---| | 2026-11-10 | minor | Held numeric spinner arrows keep stepping past the click timeout and stop on release (#6307). Scores and estimates unchanged. | | 2026-11-09 | minor | Interim macOS canvas cursor delivery (#2292): existing artwork in aligned 1×/2× AppKit representations, visible system fallback and host-owned restoration. Headless routing, bitmap and ownership coverage; native timing, menus, focus/handback, mixed displays, Spotlight and full-screen Spaces remain unverified. Scores and estimates unchanged. | +| 2026-11-10 | minor | Held numeric spinner arrows keep stepping past the click timeout and stop on release (#6307). Scores and estimates unchanged. | | 2026-11-09 | minor | Type advanced: the custom dictionary commands (`text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary`, saved beside the preferences) and Ignore All for the active document (`text.spelling.ignoreAll`), #4936; evidence added; the dialogs and Auto Spell Check remain; scores and hours unchanged. | | 2026-11-09 | minor | File formats: EPS import: the fonts an EPS carries draw their glyphs instead of being set as type in an installed font: composite (Type 0) fonts with the 8/8 mapping, plain Type 42 fonts (characters from their glyph names or character map, widths from the font), CID-keyed fonts over CMaps (Type 0 and Type 2 CIDFonts, CIDFonts in a FontSet's Compact Font Format data, base-font CMaps, named CIDFonts over predefined Unicode CMaps set as type) and embedded Type 1 fonts; the show operators' spacing along the baseline is kept as tracking, `kshow` included. Each step is charged to the interpreter's budgets. Hand-written test files only (no corpus file uses these fonts); scores and estimates unchanged. | | 2026-11-08 | minor | Type advanced: spell check engine, `text.spelling.check` lists unknown words per run language at `text.editRange` byte offsets and `text.spelling.suggest` gives suggestions, from craft-proof (#4936); evidence added; the dialog, Auto Spell Check and the custom dictionary remain; scores and hours unchanged. | From 9c4a17e3452904cbfeb06e00581b71f0914de16a Mon Sep 17 00:00:00 2001 From: makesomethingshit Date: Tue, 10 Nov 2026 00:59:32 +0900 Subject: [PATCH 05/36] #6307: Put spinner revision beside the numeric-field history --- docs/target-app-parity.md | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index ac6a0073b2..0bcf43a4d1 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1902,9 +1902,7 @@ Edit › Copy and Cut put the selected ruler guides on the clipboard, with the s | Date | Change | Summary | |---|---|---| -| 2026-11-10 | minor | Held numeric spinner arrows keep stepping past the click timeout and stop on release (#6307). Scores and estimates unchanged. | | 2026-11-09 | minor | Interim macOS canvas cursor delivery (#2292): existing artwork in aligned 1×/2× AppKit representations, visible system fallback and host-owned restoration. Headless routing, bitmap and ownership coverage; native timing, menus, focus/handback, mixed displays, Spotlight and full-screen Spaces remain unverified. Scores and estimates unchanged. | -| 2026-11-10 | minor | Held numeric spinner arrows keep stepping past the click timeout and stop on release (#6307). Scores and estimates unchanged. | | 2026-11-09 | minor | Type advanced: the custom dictionary commands (`text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary`, saved beside the preferences) and Ignore All for the active document (`text.spelling.ignoreAll`), #4936; evidence added; the dialogs and Auto Spell Check remain; scores and hours unchanged. | | 2026-11-09 | minor | File formats: EPS import: the fonts an EPS carries draw their glyphs instead of being set as type in an installed font: composite (Type 0) fonts with the 8/8 mapping, plain Type 42 fonts (characters from their glyph names or character map, widths from the font), CID-keyed fonts over CMaps (Type 0 and Type 2 CIDFonts, CIDFonts in a FontSet's Compact Font Format data, base-font CMaps, named CIDFonts over predefined Unicode CMaps set as type) and embedded Type 1 fonts; the show operators' spacing along the baseline is kept as tracking, `kshow` included. Each step is charged to the interpreter's budgets. Hand-written test files only (no corpus file uses these fonts); scores and estimates unchanged. | | 2026-11-08 | minor | Type advanced: spell check engine, `text.spelling.check` lists unknown words per run language at `text.editRange` byte offsets and `text.spelling.suggest` gives suggestions, from craft-proof (#4936); evidence added; the dialog, Auto Spell Check and the custom dictionary remain; scores and hours unchanged. | @@ -1913,6 +1911,7 @@ Edit › Copy and Cut put the selected ruler guides on the clipboard, with the s | 2026-11-07 | minor | File formats and Type core: `.ai`/EPS type from the text document lays its lines out closer to the file's page: auto leading per line at the paragraph's share, the paragraph's hyphenation options, possibly more of the last lines the file sets in an area frame of its own, and a line a soft return ends justified (#6137); evidence added; scores unchanged. | | 2026-11-06 | minor | `.ai` and EPS import: Art brushes in the older form of their objects come into the Brushes panel; evidence updated; scores unchanged. | | 2026-11-08 | minor | Panel numeric wheel turns do not seed canvas zoom glides; corrected the disabled-field scroll description (#5949). Scores and estimates unchanged. | +| 2026-11-10 | minor | Held numeric spinner arrows keep stepping past the click timeout and stop on release (#6307). Scores and estimates unchanged. | | 2026-11-07 | minor | Numeric fields step on wheel hover without taking focus; keyboard focus and existing rounding rules remain (#5949). Scores and estimates unchanged. | | 2026-11-08 | minor | Consolidated the four current Guides feature-area rows using existing global/artboard ruler mode, video ruler and status-bar evidence; remaining gaps, score and estimates unchanged. The earlier detailed missing-items table remains historical. | | 2026-11-07 | minor | Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged. | @@ -2267,4 +2266,4 @@ Edit › Copy and Cut put the selected ruler guides on the clipboard, with the s 2026-10-28 Flare interaction: the center drag turns only the rays by one degree per document point of increasing pointer y and back at the same rate for decreasing y. This is an application rule independent of event frequency. Shift keeps the current turn; Space during either drag moves the flare without changing its size or turn, including the first drag sample. Releasing a modifier resumes from the current pointer position without catching up skipped rotation. Scores and remaining estimates unchanged. ## EPS Save evidence (2026-10-26) -Ordinary Save and Save As keep EPS as EPS, with the chosen page frame, transparent margins and the complete editable VectorCraft document. Existing import-loss confirmation and transparency warnings remain; the Save TIFF preview is capped at 1024 pixels, while Export retains 2048. See [file-format-parity.md](file-format-parity.md#eps-save-and-source-protection) for limits and regression evidence. This narrows G9 save-back workflow coverage; native Illustrator editing-data fidelity, parity scores and hour estimates are unchanged. +Ordinary Save and Save As keep EPS as EPS, with the chosen page frame, transparent margins and the complete editable VectorCraft document. Existing import-loss confirmation and transparency warnings remain; the Save TIFF preview is capped at 1024 pixels, while Export retains 2048. See [file-format-parity.md](file-format-parity.md#eps-save-and-source-protection) for limits and regression evidence. This narrows G9 save-back workflow coverage; native Illustrator editing-data fidelity, parity scores and hour estimates are unchanged. \ No newline at end of file From 59304f9964acc0d3911fc3f35920e8b6d4761583 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Mon, 9 Nov 2026 14:27:25 -0800 Subject: [PATCH 06/36] Fix #6316: a Transform effect's copies of a group are whole groups below the original A Transform effect with copies on a group copied each member on its own, so one member's copies lay over the fills of the next member. Each copy is now a whole group, the original on top and each copy below the one before. A path's copies follow the same order. Guides are not copied, and the copies stop at the node limit. A group with its own fill or stroke, a clipping group and live type keep their previous copies. --- crates/effects/src/bake/tests.rs | 4 +- crates/effects/src/catalog.rs | 2 +- crates/effects/src/group/tests.rs | 5 +- crates/effects/src/reshape.rs | 132 ++++++++++++++++++++--- crates/effects/src/tests/transformfx.rs | 106 +++++++++++++++--- crates/engine/src/cmd/effectcmd/tests.rs | 6 +- docs/effects-parity.md | 2 +- 7 files changed, 221 insertions(+), 36 deletions(-) diff --git a/crates/effects/src/bake/tests.rs b/crates/effects/src/bake/tests.rs index a2959a843a..4751b137c9 100644 --- a/crates/effects/src/bake/tests.rs +++ b/crates/effects/src/bake/tests.rs @@ -603,7 +603,7 @@ fn a_baked_transform_copy_keeps_its_scaled_stroke() { let m = bake_appearance(None, &n).unwrap(); let w = widths(&m); assert_eq!(w.len(), 2, "{w:?}"); - assert!((w[0] - 102.0).abs() < 0.5 && (w[1] - 204.0).abs() < 0.5, "{w:?}"); + assert!((w[1] - 102.0).abs() < 0.5 && (w[0] - 204.0).abs() < 0.5, "the original on top: {w:?}"); assert!(baked_paths(&m).iter().all(|p| p.appearance.stroke().is_none()), "the strokes are outlined"); } @@ -619,7 +619,7 @@ fn a_baked_transform_copy_keeps_its_moved_pattern() { p => panic!("not a pattern: {p:?}"), }) .collect(); - assert_eq!(xs, vec![0.0, 30.0]); + assert_eq!(xs, vec![30.0, 0.0], "the original on top"); } #[test] diff --git a/crates/effects/src/catalog.rs b/crates/effects/src/catalog.rs index a8176880bd..95111b5fb4 100644 --- a/crates/effects/src/catalog.rs +++ b/crates/effects/src/catalog.rs @@ -247,7 +247,7 @@ pub fn effect_catalog() -> Vec { "distort.transform", "Transform…", DT, - "{scaleH: % (100), scaleV: % (100), moveH: pt (0; right = +), moveV: pt (0; down = +), rotate: deg (0; counter-clockwise; when the effect turns, reflects or scales the object unevenly, a gradient fitted to the object is fixed to its fit and turns with the object and with each copy), copies: 0..1000 (0; each copy transforms the last again; placed gradients transform with the object and with each copy), reflectX: bool (false; flips left to right), reflectY: bool (false; flips top to bottom), reference: 0..8 (4; the point of the bounds' 9-point grid it scales, rotates and reflects about: 0 top left, 4 centre, 8 bottom right), random: bool (false; each scale goes a random share of the way from 100 % to its value, each move and the angle a random share of theirs, differently for each object and the same on every redraw), objects: bool (true; Transform Objects: off, the geometry stays where it is and makes no copies, and only the two options below apply, once), patterns: bool (false; Transform Patterns: pattern fills and strokes transform along; with copies the original keeps its pattern and copy k takes the transformation k times), strokes: bool (false; Scale Strokes & Effects: stroke weights and dashes, and the distances of the effects on the fills and strokes, scale by the mean scale √|scaleH·scaleV|, copy k by its k-th power)}", + "{scaleH: % (100), scaleV: % (100), moveH: pt (0; right = +), moveV: pt (0; down = +), rotate: deg (0; counter-clockwise; when the effect turns, reflects or scales the object unevenly, a gradient fitted to the object is fixed to its fit and turns with the object and with each copy), copies: 0..1000 (0; each copy transforms the last again and is painted below it, the object on top; a group's copies each hold all its members; placed gradients transform with the object and with each copy), reflectX: bool (false; flips left to right), reflectY: bool (false; flips top to bottom), reference: 0..8 (4; the point of the bounds' 9-point grid it scales, rotates and reflects about: 0 top left, 4 centre, 8 bottom right), random: bool (false; each scale goes a random share of the way from 100 % to its value, each move and the angle a random share of theirs, differently for each object and the same on every redraw), objects: bool (true; Transform Objects: off, the geometry stays where it is and makes no copies, and only the two options below apply, once), patterns: bool (false; Transform Patterns: pattern fills and strokes transform along; with copies the original keeps its pattern and copy k takes the transformation k times), strokes: bool (false; Scale Strokes & Effects: stroke weights and dashes, and the distances of the effects on the fills and strokes, scale by the mean scale √|scaleH·scaleV|, copy k by its k-th power)}", json!({"scaleH": 100.0, "scaleV": 100.0, "moveH": 0.0, "moveV": 0.0, "rotate": 0.0, "copies": 0, "reflectX": false, "reflectY": false, "reference": 4, "random": false, "objects": true, "patterns": false, "strokes": false}), ), g( diff --git a/crates/effects/src/group/tests.rs b/crates/effects/src/group/tests.rs index 619d06a911..94456c7d57 100644 --- a/crates/effects/src/group/tests.rs +++ b/crates/effects/src/group/tests.rs @@ -594,7 +594,7 @@ fn a_groups_transform_moves_the_gradients_placed_on_an_images_own_fills() { }; for kind in [image as fn() -> NodeKind, instance] { assert_eq!(starts(kind(), serde_json::json!({"moveH": 30})), [30.0], "{:?}", kind()); - assert_eq!(starts(kind(), serde_json::json!({"moveH": 30, "copies": 2})), [0.0, 30.0, 60.0], "{:?}", kind()); + assert_eq!(starts(kind(), serde_json::json!({"moveH": 30, "copies": 2})), [60.0, 30.0, 0.0], "{:?}", kind()); } } @@ -677,7 +677,8 @@ fn a_transform_on_a_group_paints_each_copy_with_its_own_gradient() { let art = evaluate_container(None, &g).unwrap(); let copies = art.children().unwrap()[0].children().expect("the object and its copies are objects of their own"); assert_eq!(copies.len(), 3); - for (i, c) in copies.iter().enumerate() { + // The original is on top, after the copies below it. + for (i, c) in copies.iter().rev().enumerate() { // Each copy's own box: an unplaced gradient fits it, a placed one moved with it. assert!((c.geometric_bounds().unwrap().x0 - 30.0 * i as f64).abs() < 1e-6); match c.appearance.fill_paint() { diff --git a/crates/effects/src/reshape.rs b/crates/effects/src/reshape.rs index 070060dcf9..4db4bb3f5a 100644 --- a/crates/effects/src/reshape.rs +++ b/crates/effects/src/reshape.rs @@ -46,8 +46,8 @@ pub fn has_gradient_copies(n: &Node) -> bool { /// Is `n` a path or compound path with a visible Transform effect that makes copies and fills and /// strokes that paint the copies differently as one path than as separate objects /// ([`paints_copies_apart`])? Such an object is drawn through its art ([`outline_art`]), each copy -/// an object of its own above the one before ([`copy_leaf`]), as Expand Appearance makes it -/// (#5920). +/// an object of its own below the one before ([`copy_leaf`]), as Expand Appearance makes it +/// (#5920, #6316). pub(crate) fn has_layered_copies(n: &Node) -> bool { leaf_geometry(n).is_some_and(|(_, rule)| paints_copies_apart(&n.appearance, rule)) && has_transform_copies(n) } @@ -698,6 +698,8 @@ fn reshape_leaves_apart(art: &mut Node, fx: &[&Effect], budget: Budget, apart: b let seed = art.id.0; let mut used = art_cost(art); let max = budget.at_least(used); + // Read before the effects run: a path's own copies don't make it art of several objects. + let copy_whole = copies_whole(art); for e in fx { let Some(bounds) = art.geometric_bounds() else { break }; let params = merged_params(&e.id, &e.params); @@ -738,6 +740,14 @@ fn reshape_leaves_apart(art: &mut Node, fx: &[&Effect], budget: Budget, apart: b let options = crate::distort::TransformOptions::of(¶ms); (m, copies, options, crate::distort::transform_affine(bounds, ¶ms, seed)) }); + // Art of several objects is copied whole, so the copies don't interleave member by member. + if let Some((m, copies, options, _)) = transform + && copies > 0 + && copy_whole + { + used = copy_art(art, m, copies, copy_scaling(options), max); + continue; + } for_each_leaf(art, &mut |leaf| { let Some((path, rule)) = leaf_geometry(leaf) else { return }; // What the other paths leave of the budget. @@ -747,7 +757,7 @@ fn reshape_leaves_apart(art: &mut Node, fx: &[&Effect], budget: Budget, apart: b // Transform Patterns and Scale Strokes & Effects change each copy's fills and // strokes; a gradient paints each copy by itself, not the one piece the copies // make together; and Expand Appearance (`apart`) makes each copy an object. - // Fills and strokes that stack paint each copy above the one before (#5920). + // Fills and strokes that stack paint each copy below the one before (#5920, #6316). let separate = copies > 0 && (apart || leaf.appearance.has_unplaced_gradient() @@ -813,6 +823,19 @@ fn reshape_leaves_apart(art: &mut Node, fx: &[&Effect], budget: Budget, apart: b /// doesn't grow with them (a horizontal line stretched vertically). const MAX_STROKE_SCALE: f64 = 1.0e6; +/// How a Transform effect with `options` scales what it maps: strokes, effects and patterns as +/// its options say. +fn copy_scaling(options: crate::distort::TransformOptions) -> vectorcraft_doc::Scaling { + vectorcraft_doc::Scaling { + strokes: options.strokes, + effects: options.strokes.then_some(crate::scale_effect as fn(&mut Effect, f64)), + patterns: options.patterns, + // A map that flattens the path leaves its fitted gradients fitted, as `fixes_fit` does. + keep_fit_when_flat: true, + ..Default::default() + } +} + /// A Transform effect whose Transform Patterns or Scale Strokes & Effects is on (`options`), with /// map `m` and `copies`, applied to `leaf` (a path or compound path with geometry `path`) as Object /// › Transform with those options transforms an object (#885): the strokes' weights and dashes @@ -829,14 +852,7 @@ fn transform_leaf( options: crate::distort::TransformOptions, budget: Budget, ) -> (Budget, bool) { - let scaling = vectorcraft_doc::Scaling { - strokes: options.strokes, - effects: options.strokes.then_some(crate::scale_effect as fn(&mut Effect, f64)), - patterns: options.patterns, - // A map that flattens the path leaves its fitted gradients fitted, as `fixes_fit` does. - keep_fit_when_flat: true, - ..Default::default() - }; + let scaling = copy_scaling(options); if !options.objects { if options.strokes && let Some(k) = vectorcraft_doc::Scaling::factor(m).filter(|k| k.is_finite() && *k > 0.0) @@ -861,10 +877,92 @@ fn transform_leaf( (copy_leaf(leaf, path, m, copies, scaling, budget), true) } +/// Does a Transform effect's copies of `art` copy it whole ([`copy_art`])? They do when `art` is a +/// group or layer of several objects, at least one of which paints, with no type left in it and no +/// fill or stroke of its own that paints. Type that stays type is copied in its place by the baking +/// that keeps it, a clipping group's first member is its clip, and the art's own fills and strokes +/// paint the shape of everything under it, copies included. +fn copies_whole(art: &Node) -> bool { + fn parts(n: &Node, count: &mut usize, painted: &mut bool, text: &mut bool) { + match &n.kind { + NodeKind::Text(_) => *text = true, + NodeKind::Path { guide: false, .. } | NodeKind::Compound { .. } => { + *count += 1; + *painted |= n.appearance.items.iter().any(crate::paints); + } + NodeKind::Image(_) | NodeKind::SymbolInstance { .. } => { + *count += 1; + *painted = true; + } + NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } => { + *painted |= n.appearance.items.iter().any(crate::paints); + children.iter().for_each(|c| parts(c, count, painted, text)); + } + _ => {} + } + } + if art.clips() || !matches!(art.kind, NodeKind::Group { .. } | NodeKind::Layer { .. }) || art.appearance.items.iter().any(crate::paints) { + return false; + } + let (mut count, mut painted, mut text) = (0, false, false); + parts(art, &mut count, &mut painted, &mut text); + count > 1 && painted && !text +} + +/// Remove the guides anywhere under `n`. +fn without_guides(n: &mut Node) { + let Some(children) = n.children_mut() else { return }; + children.retain(|c| !matches!(c.kind, NodeKind::Path { guide: true, .. })); + children.iter_mut().for_each(|c| without_guides(Arc::make_mut(c))); +} + +/// Replace the members of `art` by groups of them: the original on top and `copies` more below it, +/// each mapped once more by `m` than the last (with `scaling`), as a Transform effect copies a +/// group, without its guides. The copies stop where [`copy_leaf`]'s would, before they pass `budget` or +/// [`crate::distort::MAX_COPY_OBJECTS`] nodes in all. Returns what the art costs after. +fn copy_art(art: &mut Node, m: Affine, copies: usize, scaling: vectorcraft_doc::Scaling, budget: Budget) -> Budget { + let Some(members) = art.children().cloned() else { return art_cost(art) }; + let original = Node::group(art.id, members); + let mut made = art_cost(&original); + let mut drawn = original.clone(); + without_guides(&mut drawn); + let mut nodes = 0usize; + original.walk(&mut |_| nodes += 1); + let mut one = 0usize; + drawn.walk(&mut |_| one += 1); + let mut placements: Vec> = vec![]; + let mut xf = Affine::IDENTITY; + for _ in 0..copies { + xf *= m; + if scaling.strokes && vectorcraft_doc::Scaling::factor(xf).is_some_and(|k| !(k.is_finite() && k <= MAX_STROKE_SCALE)) { + break; + } + nodes = nodes.saturating_add(one); + if nodes > crate::distort::MAX_COPY_OBJECTS { + break; + } + let mut copy = drawn.clone(); + copy.transform(xf, scaling); + let cost = art_cost(©); + if !budget.fits(made.plus(cost)) || !copy.geometric_bounds().is_some_and(crate::distort::within_reach) { + break; + } + placements.push(Arc::new(copy)); + made = made.plus(cost); + } + // The last copy is the lowest. + placements.reverse(); + placements.push(Arc::new(original)); + if let Some(children) = art.children_mut() { + *children = placements; + } + made +} + /// Replace `leaf` (a path or compound path with geometry `path`) by a group of it and `copies` -/// more, each mapped once more by `m` than the last (with `scaling`), as a Transform effect copies -/// an object. Each copy is an object of its own, so its gradients fit it (a placed gradient moves -/// with it) rather than spanning every copy, and its strokes and patterns can differ from the +/// more below it, each mapped once more by `m` than the last (with `scaling`), as a Transform effect +/// copies an object. Each copy is an object of its own, so its gradients fit it (a placed gradient +/// moves with it) rather than spanning every copy, and its strokes and patterns can differ from the /// others'. The copies stop where [`crate::distort::transform_within`]'s would, before they pass /// `budget`, and before one's strokes would scale past [`MAX_STROKE_SCALE`]. Returns what the /// copies cost, the original included. @@ -890,7 +988,9 @@ fn copy_leaf(leaf: &mut Node, path: &PathData, m: Affine, copies: usize, scaling placements.push(Arc::new(copy)); made = made.plus(cost); } - // The copies become nodes of their own under a group with the object's id and name. + // The copies become nodes of their own under a group with the object's id and name, the object + // on top. + placements.reverse(); let mut g = Node::group(leaf.id, placements); g.name = leaf.name.clone(); *leaf = g; @@ -1463,6 +1563,8 @@ fn copy_placed(n: &mut Node, m: Affine, copies: usize, room: &mut usize) { placements.push(Arc::new(copy)); } *room -= placements.len() - 1; + // The object stays on top of its copies, as a path does. + placements.reverse(); let mut g = Node::group(src.id, placements); g.name = src.name.clone(); *c = Arc::new(g); diff --git a/crates/effects/src/tests/transformfx.rs b/crates/effects/src/tests/transformfx.rs index 84368b6f64..e424a8293b 100644 --- a/crates/effects/src/tests/transformfx.rs +++ b/crates/effects/src/tests/transformfx.rs @@ -105,7 +105,7 @@ fn each_copy_scales_its_stroke_once_more() { let n = stroked(json!({"scaleH": 200, "scaleV": 200, "copies": 2, "strokes": true})); let art = reshape(None, &n, None).unwrap(); let widths: Vec = leaves(&art).iter().map(|p| stroke_of(p).0).collect(); - assert_eq!(widths, vec![2.0, 4.0, 8.0]); + assert_eq!(widths, vec![8.0, 4.0, 2.0], "the original on top"); // Without the option the copies keep the original's weight. let plain = stroked(json!({"scaleH": 200, "scaleV": 200, "copies": 2})); assert!(!has_transform_art(&plain)); @@ -127,7 +127,7 @@ fn transform_patterns_moves_each_copys_pattern() { assert!(has_transform_options(&n)); let art = reshape(None, &n, None).unwrap(); let xs: Vec = leaves(&art).iter().map(|p| pattern_xf(p).as_coeffs()[4]).collect(); - assert_eq!(xs, vec![0.0, 30.0, 60.0]); + assert_eq!(xs, vec![60.0, 30.0, 0.0], "the original on top"); // Without it, every copy's pattern stays put (the tiles don't follow the copies). let n = patterned(json!({"moveH": 30, "patterns": false, "strokes": false, "objects": true})); assert!(!has_transform_art(&n)); @@ -218,7 +218,7 @@ fn each_copy_of_a_strokes_own_transform_scales_its_weight_once_more() { let n = stroke_with_effect(json!({"scaleH": 200, "scaleV": 200, "copies": 2, "strokes": true})); let art = reshape(None, &n, None).unwrap(); let widths: Vec = stroked_leaves(&art).iter().map(|p| stroke_of(p).0).collect(); - assert_eq!(widths, vec![2.0, 4.0, 8.0]); + assert_eq!(widths, vec![8.0, 4.0, 2.0], "the original on top"); } #[test] @@ -268,7 +268,7 @@ fn a_fills_own_transform_moves_each_copys_pattern_with_transform_patterns() { assert!(has_transform_options(&n)); let art = reshape(None, &n, None).unwrap(); let xs: Vec = pattern_leaves(&art).iter().map(|p| pattern_xf(p).as_coeffs()[4]).collect(); - assert_eq!(xs, vec![0.0, 30.0, 60.0]); + assert_eq!(xs, vec![60.0, 30.0, 0.0], "the original on top"); // Off, the pattern stays put. assert!(!has_transform_art(&fill_with_effect(json!({"moveH": 30, "copies": 2})))); } @@ -294,7 +294,7 @@ fn a_strokes_own_transform_draws_bakes_expands_and_measures_alike() { let n = stroke_with_effect(json!({"scaleH": 200, "scaleV": 200, "copies": 1, "strokes": true})); let drawn = reshape(None, &n, None).unwrap(); let widths = |art: &Node| stroked_leaves(art).iter().map(|p| stroke_of(p).0).collect::>(); - assert_eq!(widths(&drawn), vec![2.0, 4.0]); + assert_eq!(widths(&drawn), vec![4.0, 2.0], "the original on top"); // Expand Appearance. assert_eq!(widths(&expand_outlined(None, &n, None).unwrap()), widths(&drawn)); // Export. @@ -394,9 +394,9 @@ fn filled_and_stroked(fill: Paint, params: Value) -> Node { } #[test] -fn copies_with_a_fill_and_a_stroke_are_drawn_one_above_another() { - // Black fill, white stroke, each copy scaled and turned (#5920): each copy is an object of its - // own above the one before, so its fill covers the strokes of the copies below. +fn copies_with_a_fill_and_a_stroke_are_drawn_one_below_another() { + // Black fill, white stroke, each copy scaled and turned (#5920, #6316): each copy is an object + // of its own below the one before, so the fills above cover the strokes of the copies below. let params = json!({"scaleH": 97, "scaleV": 97, "rotate": 10, "copies": 25}); let n = filled_and_stroked(Paint::solid(Color::BLACK), params.clone()); assert!(has_transform_art(&n)); @@ -407,10 +407,10 @@ fn copies_with_a_fill_and_a_stroke_are_drawn_one_above_another() { assert!(matches!(p.kind, NodeKind::Path { .. }), "one shape each, not one path of every copy"); assert!(!p.appearance.fill_paint().is_none() && p.appearance.stroke().is_some()); } - // The original is at the bottom, the smallest copy on top. - assert_eq!(paths[0].geometric_bounds().unwrap(), Rect::new(0.0, 0.0, 100.0, 100.0)); - let top = paths[25].geometric_bounds().unwrap(); - assert!(top.width() < 100.0 && top.height() < 100.0, "{top:?}"); + // The smallest copy is at the bottom, the original on top. + assert_eq!(paths[25].geometric_bounds().unwrap(), Rect::new(0.0, 0.0, 100.0, 100.0)); + let bottom = paths[0].geometric_bounds().unwrap(); + assert!(bottom.width() < 100.0 && bottom.height() < 100.0, "{bottom:?}"); // Drawn as Expand Appearance makes it. assert_eq!(leaves(&expand_outlined(None, &n, None).unwrap()).len(), 26); @@ -427,3 +427,85 @@ fn copies_with_a_fill_and_a_stroke_are_drawn_one_above_another() { // No copies: nothing to stack. assert!(!has_transform_art(&filled_and_stroked(Paint::solid(Color::BLACK), json!({"rotate": 10})))); } + +/// A group of two squares side by side, each with a black fill and a white stroke, with the +/// Transform effect `params`. +fn two_squares(params: Value) -> Node { + let square = |id, x| { + let a = Appearance::basic(Paint::solid(Color::BLACK), Paint::solid(Color::WHITE), 4.0); + std::sync::Arc::new(Node::path(NodeId(id), shapes::rectangle(Rect::new(x, 0.0, x + 100.0, 100.0)), a)) + }; + let mut g = Node::group(NodeId(20), vec![square(21, 0.0), square(22, 120.0)]); + g.appearance.effects.push(transform(params)); + g +} + +#[test] +fn a_groups_copies_are_whole_groups_below_the_original() { + let art = crate::evaluate_container(None, &two_squares(json!({"scaleH": 95, "scaleV": 95, "copies": 10}))).unwrap(); + let copies = art.children().unwrap(); + assert_eq!(copies.len(), 11, "the original and 10 copies"); + for c in copies { + assert_eq!(c.children().map(Vec::len), Some(2), "each copy holds both squares"); + } + // The smallest copy is at the bottom, the original on top with its members in their order. + let width = |i: usize| copies[i].geometric_bounds().unwrap().width(); + assert!((0..10).all(|i| width(i) < width(i + 1)), "{:?}", (0..11).map(width).collect::>()); + assert_eq!(copies[10].geometric_bounds().unwrap(), Rect::new(0.0, 0.0, 220.0, 100.0)); + let members = copies[10].children().unwrap(); + assert_eq!(members[0].geometric_bounds().unwrap(), Rect::new(0.0, 0.0, 100.0, 100.0)); + assert_eq!(members[1].geometric_bounds().unwrap(), Rect::new(120.0, 0.0, 220.0, 100.0)); + + // Without copies the members stay members of the art. + let art = crate::evaluate_container(None, &two_squares(json!({"scaleH": 50, "scaleV": 50}))).unwrap(); + assert_eq!(art.children().unwrap().iter().map(|c| c.id).collect::>(), [NodeId(21), NodeId(22)]); + + // A group's own fill paints the shape of all its copies, so each member keeps its place. + let mut own_fill = two_squares(json!({})); + own_fill.appearance = Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0).into(); + let copies = transform(json!({"moveH": 300, "copies": 2})); + crate::reshape::reshape_leaves_within(&mut own_fill, &[&copies], crate::geometry::Budget::EFFECTS, None); + assert_eq!(own_fill.children().map(Vec::len), Some(2), "a stack of copies per member"); + + // A guide in the group stays one guide. + let mut guided = two_squares(json!({})); + let mut guide = Node::path(NodeId(23), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default()); + if let NodeKind::Path { guide: on, .. } = &mut guide.kind { + *on = true; + } + if let Some(members) = guided.children_mut() { + members.push(std::sync::Arc::new(guide)); + } + guided.appearance.effects.clear(); + crate::reshape::reshape_leaves_within(&mut guided, &[&copies], crate::geometry::Budget::EFFECTS, None); + assert_eq!(guided.children().map(Vec::len), Some(3), "the original and 2 copies"); + let mut guides = 0; + guided.walk(&mut |n| guides += usize::from(matches!(n.kind, NodeKind::Path { guide: true, .. }))); + assert_eq!(guides, 1); + + // A clipping group's first member stays its clip. + let mut clipping = two_squares(json!({})); + if let NodeKind::Group { clip, .. } = &mut clipping.kind { + *clip = true; + } + clipping.appearance.effects.clear(); + crate::reshape::reshape_leaves_within(&mut clipping, &[&copies], crate::geometry::Budget::EFFECTS, None); + assert_eq!(clipping.children().map(Vec::len), Some(2), "the clip and the clipped member"); +} + +#[test] +fn a_groups_copies_stop_at_the_node_limit() { + // Empty groups cost no anchors, so only the count of nodes stops their copies. + let empties = (0..5_000).map(|i| std::sync::Arc::new(Node::group(NodeId(100 + i), vec![]))); + let mut g = two_squares(json!({})); + if let Some(members) = g.children_mut() { + members.extend(empties); + } + g.appearance.effects.clear(); + let copies = transform(json!({"moveH": 1, "copies": 1000})); + crate::reshape::reshape_leaves_within(&mut g, &[&copies], crate::geometry::Budget::EFFECTS, None); + let mut nodes = 0usize; + g.walk(&mut |_| nodes += 1); + assert!(nodes <= crate::distort::MAX_COPY_OBJECTS + 1, "{nodes}"); + assert!(g.children().unwrap().len() < 1001, "the limit stopped the copies"); +} diff --git a/crates/engine/src/cmd/effectcmd/tests.rs b/crates/engine/src/cmd/effectcmd/tests.rs index e3fca957b8..088094e556 100644 --- a/crates/engine/src/cmd/effectcmd/tests.rs +++ b/crates/engine/src/cmd/effectcmd/tests.rs @@ -581,7 +581,7 @@ fn expand_appearance_makes_each_copy_of_a_transform_an_object() { let copies = n.children().unwrap(); assert_eq!(copies.len(), 3, "the path and its two copies"); let xs: Vec = copies.iter().map(|c| c.geometric_bounds().unwrap().x0).collect(); - assert_eq!(xs, vec![100.0, 250.0, 400.0]); + assert_eq!(xs, vec![400.0, 250.0, 100.0], "the original on top"); for c in copies { assert!(matches!(c.kind, NodeKind::Path { .. }), "{}", c.kind_label()); assert_eq!(fill(c), Some(Color::rgb(1.0, 0.0, 0.0))); @@ -600,10 +600,10 @@ fn expand_appearance_makes_each_copy_of_a_transform_an_object() { s.execute("effect.expandAppearance", &json!({"ids":[id.0]})).unwrap(); let columns = node(&s, id).children().unwrap().to_vec(); assert_eq!(columns.len(), 3); - for (col, x) in columns.iter().zip([100.0, 250.0, 400.0]) { + for (col, x) in columns.iter().zip([400.0, 250.0, 100.0]) { let cells = col.children().unwrap_or_else(|| panic!("a column is a group: {}", col.kind_label())); let ys: Vec<(f64, f64)> = cells.iter().map(|c| (c.geometric_bounds().unwrap().x0, c.geometric_bounds().unwrap().y0)).collect(); - assert_eq!(ys, vec![(x, 100.0), (x, 250.0)]); + assert_eq!(ys, vec![(x, 250.0), (x, 100.0)]); assert!(cells.iter().all(|c| matches!(c.appearance.fill_paint(), Paint::Solid { .. }))); } } diff --git a/docs/effects-parity.md b/docs/effects-parity.md index 8f90ce5f4d..f2b9beda5a 100644 --- a/docs/effects-parity.md +++ b/docs/effects-parity.md @@ -22,7 +22,7 @@ Part of [target-app-parity.md](target-app-parity.md); open work is in [gaps.md]( |---|---|---:|---| | Convert to Shape | Rectangle, Rounded Rectangle, Ellipse | 3/3 | — | | Crop Marks | Crop Marks | 1/1 | — | -| Distort & Transform | Free Distort, Pucker & Bloat, Roughen, Transform, Tweak, Twist, Zig Zag | 7/7 | Free Distort's dialog has a handle on each corner of the object's bounds, moved by dragging or with the arrow keys; Zig Zag's ridges are evenly spaced along each segment (#1558), and Smooth points make a continuous wave whose peaks and valleys have handles along the path (#2343); Roughen with Smooth keeps turn-backs smooth instead of cusps that draw as spikes (#1646); Expand Appearance of a path whose Transform effect makes copies gives a group of one object per copy, each painted as the path, and a second Transform effect's copies nest in a group per copy of the first (#5612); drawn, each copy of a path with a fill and a stroke (or several, an even-odd fill or a partly transparent one) paints above the one before, its fill covering the strokes of the copies below, instead of one path of every copy (#5920) | +| Distort & Transform | Free Distort, Pucker & Bloat, Roughen, Transform, Tweak, Twist, Zig Zag | 7/7 | Free Distort's dialog has a handle on each corner of the object's bounds, moved by dragging or with the arrow keys; Zig Zag's ridges are evenly spaced along each segment (#1558), and Smooth points make a continuous wave whose peaks and valleys have handles along the path (#2343); Roughen with Smooth keeps turn-backs smooth instead of cusps that draw as spikes (#1646); Expand Appearance of a path whose Transform effect makes copies gives a group of one object per copy, each painted as the path, and a second Transform effect's copies nest in a group per copy of the first (#5612); drawn, each copy of a path with a fill and a stroke (or several, an even-odd fill or a partly transparent one) paints below the one before, the fills above covering the strokes of the copies below, instead of one path of every copy (#5920), and a group's copies each hold all its members, the original group on top (#6316) | | Path | Offset Path, Outline Object, Outline Stroke | 3/3 | With Use Preview Bounds on, Align, Distribute and the Transform panel still measure type that carries Outline Object by its layout box (preview bounds leave out effects) | | Pathfinder | Add, Intersect, Exclude, Subtract, Minus Back, Divide, Trim, Merge, Crop, Outline, Hard Mix, Soft Mix, Trap | 13/13 | Trap traps only solid fills (gradients and patterns hide what is below them but don't trap) and spreads each colour's visible area as a whole; the public page gives no default values, so Trap's 0.25 pt, 100% and 40% are VectorCraft's own | | Rasterize | Rasterize… (as a live effect) | 1/1 | — | From b07c8080933bb63cba6564ca614d52706c1b0d1a Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Mon, 9 Nov 2026 16:09:03 -0800 Subject: [PATCH 07/36] Revolve: type, groups and layers revolve, and the profile and face limits are higher Type sweeps as its glyph outlines and stays editable. On a group or layer every visible member sweeps about one axis on the edge of the members' bounds together, each in its own paint. A surface now takes up to 16,384 profile points and 163,840 faces. Curves take fewer samples, down to 4, when a surface passes those limits, and the command reports the failure below that, after the geometry effects before the Revolve and with the cumulative size of its paint copies counted. Mesh export turns the walls of holes toward them. Expand Appearance works on type with Extrude as well as Revolve. Fixes #3617 --- crates/effects/src/revolve.rs | 314 ++++++++++++++++++--- crates/effects/src/solids.rs | 38 ++- crates/engine/src/cmd/effectcmd.rs | 4 +- crates/engine/src/cmd/effectcmd/tests.rs | 122 ++++++++ crates/render/src/fx/bounds.rs | 4 +- crates/three-d/src/lib.rs | 150 ++++++++-- crates/three-d/src/mesh.rs | 7 +- crates/three-d/src/mesh/tests.rs | 43 ++- crates/three-d/src/tests.rs | 1 + crates/three-d/src/tests/caps.rs | 6 +- crates/three-d/src/tests/profile_limits.rs | 124 ++++++++ crates/three-d/src/tests/smooth.rs | 2 +- crates/ui-egui/src/i18n/ca.tsv | 2 +- crates/ui-egui/src/i18n/cs.tsv | 2 +- crates/ui-egui/src/i18n/de.tsv | 2 +- crates/ui-egui/src/i18n/el.tsv | 2 +- crates/ui-egui/src/i18n/es.tsv | 2 +- crates/ui-egui/src/i18n/fi.tsv | 2 +- crates/ui-egui/src/i18n/fr.tsv | 2 +- crates/ui-egui/src/i18n/hi.tsv | 2 +- crates/ui-egui/src/i18n/hu.tsv | 2 +- crates/ui-egui/src/i18n/it.tsv | 2 +- crates/ui-egui/src/i18n/ja.tsv | 2 +- crates/ui-egui/src/i18n/ka.tsv | 2 +- crates/ui-egui/src/i18n/ko.tsv | 2 +- crates/ui-egui/src/i18n/pl.tsv | 2 +- crates/ui-egui/src/i18n/pt-br.tsv | 2 +- crates/ui-egui/src/i18n/ru.tsv | 2 +- crates/ui-egui/src/i18n/sv.tsv | 2 +- crates/ui-egui/src/i18n/tr.tsv | 2 +- crates/ui-egui/src/i18n/uk.tsv | 2 +- crates/ui-egui/src/i18n/vi.tsv | 2 +- crates/ui-egui/src/i18n/zh-hans.tsv | 2 +- crates/ui-egui/src/i18n/zh-hant.tsv | 2 +- docs/mcp.md | 6 +- docs/revolve.md | 9 +- docs/target-app-parity.md | 1 + docs/three-d.md | 4 +- 38 files changed, 762 insertions(+), 117 deletions(-) create mode 100644 crates/three-d/src/tests/profile_limits.rs diff --git a/crates/effects/src/revolve.rs b/crates/effects/src/revolve.rs index fd423689f7..6dcc468014 100644 --- a/crates/effects/src/revolve.rs +++ b/crates/effects/src/revolve.rs @@ -7,6 +7,7 @@ use vectorcraft_doc::{Appearance, AppearanceItem, Effect, FillLayer, LineJoin, N use vectorcraft_geom::{PathData, Rect}; use vectorcraft_three_d::{Edge, Revolve}; +use crate::solids::{Profile, is_container, members, object_profile, profile, reshape_members, solid_paints, union_bounds}; use crate::util::{flag, num, text}; use crate::{GeomContext, apply_geometry_with, is_geometry, merged_params}; @@ -60,6 +61,7 @@ pub fn revolve_options(p: &Value) -> Revolve { shade: flag(p, "shade", true), smooth_shading: true, cap_ends: flag(p, "cap", true), + curve_samples: None, } } @@ -67,12 +69,61 @@ pub fn revolve_options(p: &Value) -> Revolve { /// by the evaluator; unsupported source kinds remain unchanged when rendered. pub fn validate_revolve(n: &Node, p: &Value) -> Result<(), String> { validate_revolve_params(p)?; - let path = profile(n).ok_or("Revolve currently requires a path or compound path. Select a profile drawn with the Pen tool.")?; - let b = path.bounds().ok_or("Revolve requires a profile with at least two anchors")?; - if !path.subpaths.iter().any(|sp| sp.anchors.len() >= 2) { - return Err("Revolve requires at least two anchors".into()); + let effects = &n.appearance.effects; + let before = effects.iter().position(|e| e.visible && e.id == REVOLVE).and_then(|i| effects.get(..i)).unwrap_or(effects); + if is_container(n) { + const EMPTY: &str = "Revolve requires a group holding a shape with at least two anchors"; + let mut shapes = members(n, "Revolve")?; + if !shapes.iter().any(|s| has_two_anchors(&s.path)) { + return Err(EMPTY.into()); + } + reshape_members(n, before, &mut shapes); + let b = union_bounds(&shapes).ok_or(EMPTY)?; + let options = shared_options(&shapes, revolve_options(p))?; + let mut copies = 0; + return shapes.iter().try_for_each(|s| check_surface(&s.path, b, options, surface_item(&s.items), &mut copies)); } - vectorcraft_three_d::revolve(&path, b, revolve_options(p)).map(|_| ()) + let (path, _) = object_profile(n, "Revolve")?; + if !has_two_anchors(&path) { + return Err("Revolve requires a shape with at least two anchors".into()); + } + let path = geometry(&path, before, &GeomContext::of(n)); + let b = path.bounds().ok_or("Revolve requires a shape with at least two anchors")?; + let paints = solid_paints(n); + check_surface(&path, b, revolve_options(p), surface_item(&paints), &mut 0) +} + +fn has_two_anchors(path: &PathData) -> bool { + path.subpaths.iter().any(|sp| sp.anchors.len() >= 2) +} + +/// The bytes the copies of `paint` take on `faces` faces: a face's fill, its shadow and their seam +/// strokes make up to four copies. +fn paint_copies(faces: usize, paint: &Paint) -> usize { + faces.saturating_mul(4).saturating_mul(paint_bytes(paint)) +} + +/// An error when `path` does not revolve or when its surface in `item`'s paint takes `copies`, the +/// bytes of an object's paint copies so far, past [`MAX_PAINT_COPIES`]. +fn check_surface(path: &PathData, b: Rect, options: Revolve, item: Option<&AppearanceItem>, copies: &mut usize) -> Result<(), String> { + let faces = vectorcraft_three_d::revolve(path, b, options)?; + *copies = copies.saturating_add(item.map_or(0, |i| paint_copies(faces.len(), &paint_of(i).0))); + if *copies > MAX_PAINT_COPIES { + return Err("Revolve paint is too complex for this surface; reduce Segments or simplify the gradient".into()); + } + Ok(()) +} + +/// `options` with one number of samples for every curve of `shapes`, the members of one group: the +/// members share Revolve's limits. +fn shared_options(shapes: &[Profile], options: Revolve) -> Result { + let samples = vectorcraft_three_d::revolve_curve_samples(shapes.iter().map(|s| &s.path), options)?; + Ok(Revolve { curve_samples: Some(samples), ..options }) +} + +/// The item whose paint an object's surface takes: its top fill, else its top stroke. +fn surface_item(items: &[AppearanceItem]) -> Option<&AppearanceItem> { + items.iter().rev().find(|i| matches!(i, AppearanceItem::Fill(_))).or_else(|| items.last()) } /// Check Revolve's parameter values `p` alone: its lights, `edge` and `cap`. @@ -87,38 +138,31 @@ pub(crate) fn validate_revolve_params(p: &Value) -> Result<(), String> { Ok(()) } -fn profile(n: &Node) -> Option { - match &n.kind { - NodeKind::Path { path, guide: false, clipping: false, .. } => Some(path.clone()), - NodeKind::Compound { children, .. } => { - Some(PathData::new(children.iter().filter_map(|c| c.path_data()).flat_map(|p| p.subpaths.iter().cloned()).collect())) - } - _ => None, - } -} - fn geometry(path: &PathData, fx: &[Effect], ctx: &GeomContext) -> PathData { apply_geometry_with(fx, path, path.bounds().unwrap_or(Rect::ZERO), ctx) } +/// One object's surface of revolution about the axis of `b`, and each face's depth. `copies` counts +/// the bytes of the paint copies of every surface of the object. #[allow(clippy::too_many_arguments)] fn surface( n: &Node, path: &PathData, + b: Rect, + options: Revolve, fx: &Effect, paint: Paint, opacity: f32, blend: vectorcraft_doc::color::BlendMode, expand: bool, -) -> Option { - let b = path.bounds()?; + copies: &mut usize, +) -> Option<(Vec, Node)> { // A loaded file or a profile edited past the limits keeps drawing its source path instead // of crashing the frame. Commands validate the same evaluator and report its errors. - let options = revolve_options(&merged_params(REVOLVE, &fx.params)); let colored = options.light_color != [1.0; 3] || options.lights.is_some(); let faces = vectorcraft_three_d::revolve(path, b, options).ok()?; - // A face's fill, its shadow and their seam strokes: up to four copies of the paint. - if faces.len().saturating_mul(4).saturating_mul(paint_bytes(&paint)) > MAX_PAINT_COPIES { + *copies = copies.saturating_add(paint_copies(faces.len(), &paint)); + if *copies > MAX_PAINT_COPIES { return None; } // Visibility is an expansion option, never a change to live geometry or export baking. @@ -146,6 +190,7 @@ fn surface( }) .collect() }); + let depths: Vec = faces.iter().map(|f: &vectorcraft_three_d::VisibleFace| f.depth).collect(); let children = faces .into_iter() .map(|face| { @@ -202,7 +247,15 @@ fn surface( let mut group = Node::group(n.id, children); group.opacity = opacity; group.blend = blend; - Some(group) + Some((depths, group)) +} + +/// A surface of one object: `shape`'s own, built with options derived from `fx`, about `b`. +fn object_surface(n: &Node, path: &PathData, fx: &Effect, item: &AppearanceItem, expand: bool) -> Option { + let b = path.bounds()?; + let (paint, opacity, blend) = paint_of(item); + let options = revolve_options(&merged_params(REVOLVE, &fx.params)); + surface(n, path, b, options, fx, paint, opacity, blend, expand, &mut 0).map(|(_, art)| art) } fn shading_paint(g: vectorcraft_three_d::ShadeGradient, color: impl Fn(f64) -> Color, opacity: impl Fn(f64) -> f32) -> Paint { @@ -237,26 +290,23 @@ pub(crate) fn expand_revolve_art(n: &Node) -> Option { /// each of them. Each mesh adds its triangles to `total` as it is built, and the first that takes /// `total` past `limit` stops with an error before the next is built. pub(crate) fn revolve_meshes(n: &Node, total: &mut usize, limit: usize) -> Result, String> { - let path = profile(n).ok_or("Revolve currently requires a path or compound path")?; + if is_container(n) { + return group_meshes(n, total, limit); + } + let (path, rule) = object_profile(n, "Revolve")?; let ctx = GeomContext::of(n); let mesh = |path: &PathData, fx: &Effect, item: &AppearanceItem| -> Result { - let b = path.bounds().ok_or("Revolve requires a profile with at least two anchors")?; + let b = path.bounds().ok_or("Revolve requires a shape with at least two anchors")?; let (paint, opacity, _) = paint_of(item); - let mesh = vectorcraft_three_d::revolve_mesh(path, b, revolve_options(&merged_params(REVOLVE, &fx.params)))?; + let mesh = vectorcraft_three_d::revolve_mesh(path, rule, b, revolve_options(&merged_params(REVOLVE, &fx.params)))?; Ok(crate::SolidMesh { mesh, color: crate::solids::mesh_color(&paint, opacity) }) }; if let Some(index) = n.appearance.effects.iter().position(|e| e.visible && e.id == REVOLVE) { let before = n.appearance.effects.get(..index).unwrap_or_default(); let Some(fx) = n.appearance.effects.get(index) else { return Ok(vec![]) }; let path = geometry(&path, before, &ctx); - let item = n - .appearance - .items - .iter() - .rev() - .find(|i| matches!(i, AppearanceItem::Fill(_)) && crate::paints(i)) - .or_else(|| n.appearance.items.iter().rev().find(|i| crate::paints(i))) - .ok_or("the revolved object has no fill or stroke")?; + let paints = solid_paints(n); + let item = surface_item(&paints).ok_or("the revolved object has no fill or stroke")?; let mut out = vec![]; crate::solids::keep_mesh(&mut out, total, mesh(&path, fx, item)?, limit)?; return Ok(out); @@ -274,11 +324,37 @@ pub(crate) fn revolve_meshes(n: &Node, total: &mut usize, limit: usize) -> Resul Ok(out) } +/// [`revolve_meshes`] of group or layer `n`: one mesh for each member, about one shared axis. +fn group_meshes(n: &Node, total: &mut usize, limit: usize) -> Result, String> { + let index = n + .appearance + .effects + .iter() + .position(|e| e.visible && e.id == REVOLVE) + .ok_or("a 3D effect on a group's fill or stroke draws nothing to export")?; + let Some(fx) = n.appearance.effects.get(index) else { return Ok(vec![]) }; + let mut shapes = members(n, "Revolve")?; + reshape_members(n, n.appearance.effects.get(..index).unwrap_or_default(), &mut shapes); + let b = union_bounds(&shapes).ok_or("Revolve requires a group holding a shape with at least two anchors")?; + let options = shared_options(&shapes, revolve_options(&merged_params(REVOLVE, &fx.params)))?; + let mut out = vec![]; + for s in &shapes { + let Some(item) = surface_item(&s.items) else { continue }; + let (paint, opacity, _) = paint_of(item); + let mesh = vectorcraft_three_d::revolve_mesh(&s.path, s.rule, b, options)?; + crate::solids::keep_mesh(&mut out, total, crate::SolidMesh { mesh, color: crate::solids::mesh_color(&paint, opacity * s.opacity) }, limit)?; + } + Ok(out) +} + fn art(n: &Node, expand: bool) -> Option { if !has_revolve(n) { return None; } - let path = profile(n)?; + if is_container(n) { + return group_art(n, expand); + } + let (path, _) = profile(n)?; let ctx = GeomContext::of(n); let mut children = vec![]; let mut remaining = n.appearance.effects.clone(); @@ -286,16 +362,10 @@ fn art(n: &Node, expand: bool) -> Option { let fx = n.appearance.effects.get(index)?; let path = geometry(&path, n.appearance.effects.get(..index)?, &ctx); // An object's surface uses its top visible fill, falling back to its stroke. - let item = n - .appearance - .items - .iter() - .rev() - .find(|i| matches!(i, AppearanceItem::Fill(_)) && crate::paints(i)) - .or_else(|| n.appearance.items.iter().rev().find(|i| crate::paints(i)))?; - let (paint, opacity, blend) = paint_of(item); + let paints = solid_paints(n); + let item = surface_item(&paints)?; let expand = expand && !n.appearance.effects.iter().skip(index + 1).any(|e| e.visible && is_geometry(&e.id)); - children.push(Arc::new(surface(n, &path, fx, paint, opacity, blend, expand)?)); + children.push(Arc::new(object_surface(n, &path, fx, item, expand)?)); // Later geometry acts on the projected art as a whole through container evaluation. remaining = n .appearance @@ -312,9 +382,8 @@ fn art(n: &Node, expand: bool) -> Option { if let Some(index) = item.effects().iter().position(|e| e.visible && e.id == REVOLVE) { let fx = item.effects().get(index)?; let p = geometry(&path, item.effects().get(..index)?, &ctx.item(item)); - let (paint, opacity, blend) = paint_of(item); let expand = expand && !item.effects().iter().skip(index + 1).any(|e| e.visible && is_geometry(&e.id)); - let mut art = surface(n, &p, fx, paint, opacity, blend, expand)?; + let mut art = object_surface(n, &p, fx, item, expand)?; art.appearance.effects = item.effects().iter().skip(index + 1).filter(|e| e.id != REVOLVE).cloned().collect(); children.push(Arc::new(art)); } else { @@ -328,6 +397,52 @@ fn art(n: &Node, expand: bool) -> Option { Some(art) } +/// [`art`] of group or layer `n`: every member swept about one axis on the bounds of all of them, +/// each in its own paint. Opaque members' faces are drawn in one back-to-front order. `None` when +/// the effect is only on the group's own fills or strokes or when its members can't be revolved. +fn group_art(n: &Node, expand: bool) -> Option { + let index = n.appearance.effects.iter().position(|e| e.visible && e.id == REVOLVE)?; + let fx = n.appearance.effects.get(index)?; + let mut shapes = members(n, "").ok()?; + reshape_members(n, n.appearance.effects.get(..index)?, &mut shapes); + let b = union_bounds(&shapes)?; + let options = shared_options(&shapes, revolve_options(&merged_params(REVOLVE, &fx.params))).ok()?; + let expand = expand && !n.appearance.effects.iter().skip(index + 1).any(|e| e.visible && is_geometry(&e.id)); + let mut copies = 0; + let mut surfaces = vec![]; + let mut opaque = true; + for s in &shapes { + let Some(item) = surface_item(&s.items) else { continue }; + let (paint, opacity, blend) = paint_of(item); + let opacity = opacity * s.opacity; + opaque &= opacity >= 1.0 && blend == Default::default(); + surfaces.push(surface(n, &s.path, b, options, fx, paint, opacity, blend, expand, &mut copies)?); + } + if surfaces.is_empty() { + return None; + } + let plain = surfaces.iter().all(|(depths, s)| matches!(&s.kind, NodeKind::Group { children, clip: false } if children.len() == depths.len())); + let children = if opaque && plain { + let mut faces = vec![]; + for (depths, s) in surfaces { + if let NodeKind::Group { children, .. } = s.kind { + faces.extend(depths.into_iter().zip(children)); + } + } + // A stable sort: faces at the same depth keep the members' order. + faces.sort_by(|a, b| a.0.total_cmp(&b.0)); + vec![Arc::new(Node::group(n.id, faces.into_iter().map(|(_, face)| face).collect()))] + } else { + surfaces.into_iter().map(|(_, s)| Arc::new(s)).collect() + }; + let remaining = + n.appearance.effects.iter().enumerate().filter(|(i, e)| *i != index && (!is_geometry(&e.id) || *i > index)).map(|(_, e)| e.clone()).collect(); + let mut art = n.clone(); + art.kind = NodeKind::Group { children, clip: false }; + art.appearance = Appearance { effects: remaining, ..Default::default() }.into(); + Some(art) +} + #[cfg(test)] mod tests { use super::*; @@ -495,6 +610,117 @@ mod tests { assert_eq!(expand_revolve_art(&n).unwrap().children().unwrap()[0], expanded.children().unwrap()[0]); } + /// A closed rectangle `x` to `x + 10` across, filled solid. + fn member(id: u64, x: f64) -> Arc { + let mut b = BezPath::new(); + b.move_to((x, 0.0)); + b.line_to((x + 10.0, 0.0)); + b.line_to((x + 10.0, 40.0)); + b.line_to((x, 40.0)); + b.close_path(); + Arc::new(Node::path(NodeId(id), PathData::from_bezpath(&b), Appearance::basic(Paint::solid(Color::rgb(0.8, 0.2, 0.1)), Paint::None, 0.0))) + } + + fn group_with(params: Value, children: Vec>) -> Node { + let mut g = Node::group(NodeId(9), children); + g.appearance.effects.push(crate::new_effect(REVOLVE, ¶ms).unwrap()); + g + } + + #[test] + fn a_groups_members_share_the_axis_on_the_edge_of_them_all() { + let mut hidden = (*member(3, -500.0)).clone(); + hidden.visible = false; + let nested = Arc::new(Node::group(NodeId(5), vec![member(4, 90.0)])); + let children = vec![member(1, 0.0), nested, Arc::new(hidden)]; + // Left edge, 5 pt out: the axis at -5, the far wall (100) 105 from it. + let g = group_with(json!({"segments":32,"offset":5,"rotationX":0}), children.clone()); + let b = revolve_art(&g).unwrap().visual_bounds().unwrap(); + assert!((b.x0 + 110.0).abs() < 1.5 && (b.x1 - 100.0).abs() < 1.5, "{b:?}"); + // Right edge, 5 pt out: the axis at 105, the far wall (0) 105 from it. + let g = group_with(json!({"segments":32,"offset":5,"edge":"right","rotationX":0}), children); + let b = revolve_art(&g).unwrap().visual_bounds().unwrap(); + assert!((b.x1 - 210.0).abs() < 1.5 && (b.x0 - 0.0).abs() < 1.5, "{b:?}"); + } + + #[test] + fn opaque_members_share_one_back_to_front_order_and_others_keep_their_own_surfaces() { + let g = group_with(json!({"segments":16}), vec![member(1, 0.0), member(2, 50.0)]); + let art = revolve_art(&g).unwrap(); + assert_eq!(art.children().unwrap().len(), 1, "one order for every face"); + assert!(art.children().unwrap()[0].children().unwrap().len() > 16); + let mut faded = (*member(2, 50.0)).clone(); + faded.opacity = 0.5; + let g = group_with(json!({"segments":16}), vec![member(1, 0.0), Arc::new(faded)]); + let art = revolve_art(&g).unwrap(); + let surfaces = art.children().unwrap(); + assert_eq!(surfaces.len(), 2, "a transparent member stays a surface of its own"); + assert_eq!(surfaces[1].opacity, 0.5); + } + + fn star(id: u64) -> Arc { + let anchors = (0..1200) + .map(|i| { + let (s, c) = (i as f64 * std::f64::consts::PI / 600.0).sin_cos(); + let r = if i % 2 == 0 { 100.0 } else { 60.0 }; + vectorcraft_geom::Anchor::corner(vectorcraft_geom::Point::new(200.0 + r * c, 200.0 + r * s)) + }) + .collect(); + Arc::new(Node::path( + NodeId(id), + PathData::new(vec![vectorcraft_geom::SubPath::new(anchors, true)]), + Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0), + )) + } + + #[test] + fn a_groups_members_share_revolves_limits() { + let alone = group_with(json!({}), vec![star(1)]); + assert!(validate_revolve(&alone, &json!({})).is_ok()); + let both = group_with(json!({}), vec![star(1), star(2)]); + let refused = validate_revolve(&both, &json!({})).unwrap_err(); + assert!(refused.contains("reduce Segments"), "{refused}"); + assert_eq!(revolve_art(&both), None, "past the limits the source draws"); + } + + #[test] + fn validation_measures_the_profile_after_the_geometry_effects_before_revolve() { + let roughen = crate::new_effect("distort.roughen", &json!({"size": 1, "detail": 100})).unwrap(); + let mut group = group_with(json!({}), vec![star(1)]); + assert!(validate_revolve(&group, &json!({})).is_ok()); + group.appearance.effects.insert(0, roughen.clone()); + assert!(validate_revolve(&group, &json!({})).is_err(), "the group's reshaped member"); + assert_eq!(revolve_art(&group), None); + let mut object = (*star(1)).clone(); + assert!(validate_revolve(&object, &json!({})).is_ok()); + object.appearance.effects.insert(0, roughen); + assert!(validate_revolve(&object, &json!({})).is_err(), "a reshaped object not yet revolved"); + } + + #[test] + fn paint_copies_past_the_budget_are_refused_for_the_whole_group() { + use vectorcraft_doc::color::{Gradient, GradientKind, GradientPaint, GradientStop}; + let painted = |id: u64, x: f64, stops: usize| { + let stops = (0..stops).map(|i| GradientStop::new(i as f32 / stops as f32, Color::BLACK)).collect(); + let paint = Paint::Gradient(Box::new(GradientPaint::new(Gradient::new(GradientKind::Linear, stops)))); + let mut node = (*member(id, x)).clone(); + node.appearance = Appearance::basic(paint, Paint::None, 0.0).into(); + Arc::new(node) + }; + let plain = member(1, 0.0); + let NodeKind::Path { path: profile, .. } = &plain.kind else { panic!("a path") }; + let faces = vectorcraft_three_d::revolve(profile, profile.bounds().unwrap(), revolve_options(&json!({}))).unwrap().len(); + let stops = MAX_PAINT_COPIES * 6 / 10 / (faces * 4) / std::mem::size_of::(); + let one = group_with(json!({}), vec![painted(1, 0.0, stops)]); + assert_eq!(validate_revolve(&one, &json!({})), Ok(())); + let two = group_with(json!({}), vec![painted(1, 0.0, stops), painted(2, 20.0, stops)]); + let refused = validate_revolve(&two, &json!({})).unwrap_err(); + assert!(refused.contains("simplify the gradient"), "{refused}"); + assert_eq!(revolve_art(&two), None, "past the budget the source draws"); + let object = painted(3, 0.0, stops * 2); + assert!(validate_revolve(&object, &json!({})).is_err(), "one object past the budget"); + } + #[test] fn transparency_unknown_paints_and_later_distortion_keep_complete_surfaces() { let mut n = profile_node(); diff --git a/crates/effects/src/solids.rs b/crates/effects/src/solids.rs index 3390cc71fa..3330cde752 100644 --- a/crates/effects/src/solids.rs +++ b/crates/effects/src/solids.rs @@ -138,7 +138,7 @@ pub fn inflate_options(p: &Value) -> Inflate { } /// The shape a solid is made from: a path's or a compound path's outline. -fn profile(n: &Node) -> Option<(PathData, FillRule)> { +pub(crate) fn profile(n: &Node) -> Option<(PathData, FillRule)> { match &n.kind { NodeKind::Path { path, rule, guide: false, clipping: false, .. } => Some((path.clone(), *rule)), NodeKind::Compound { children, rule } => { @@ -156,7 +156,7 @@ fn profile(n: &Node) -> Option<(PathData, FillRule)> { /// What a 3D effect paints `n` with: its own fills and strokes, else, for type that has none, the /// fill and stroke of its first painted run (its characters' colours). -fn solid_paints(n: &Node) -> Vec { +pub(crate) fn solid_paints(n: &Node) -> Vec { let own: Vec = n.appearance.items.iter().filter(|i| crate::paints(i)).cloned().collect(); if !own.is_empty() || !matches!(n.kind, NodeKind::Text(_)) { return own; @@ -207,29 +207,39 @@ pub fn validate_solid(n: &Node, id: &str, p: &Value) -> Result<(), String> { let b = union_bounds(&shapes).ok_or_else(|| format!("{label} requires a group holding a shape with at least two anchors"))?; return shapes.iter().try_for_each(|s| facets(id, &s.path, s.rule, b, p).map(|_| ())); } - let (path, rule) = profile(n).ok_or_else(|| { - format!("{label} currently requires a path, compound path, type or group. Convert images and other objects to paths first.") - })?; + let (path, rule) = object_profile(n, label)?; let b = path.bounds().ok_or_else(|| format!("{label} requires a shape with at least two anchors"))?; facets(id, &path, rule, b, p).map(|_| ()) } +/// [`profile`], or the error a command reports for object `n`: type with no outlines to build from +/// (empty, or only spaces) is a shape without anchors, and any other object is the wrong kind. +pub(crate) fn object_profile(n: &Node, label: &str) -> Result<(PathData, FillRule), String> { + profile(n).ok_or_else(|| { + if matches!(n.kind, NodeKind::Text(_)) { + format!("{label} requires a shape with at least two anchors") + } else { + format!("{label} currently requires a path, compound path, type or group. Convert images and other objects to paths first.") + } + }) +} + /// One shape a solid is built from: its outline and fill rule, the fills and strokes that paint /// it, and the opacity of the objects it is in (1 for the object with the effect itself). -struct Profile { - path: PathData, - rule: FillRule, - items: Vec, - opacity: f32, +pub(crate) struct Profile { + pub(crate) path: PathData, + pub(crate) rule: FillRule, + pub(crate) items: Vec, + pub(crate) opacity: f32, } /// Is `n` a group or layer (a 3D effect on it turns its members about one shared axis)? -fn is_container(n: &Node) -> bool { +pub(crate) fn is_container(n: &Node) -> bool { matches!(n.kind, NodeKind::Group { clip: false, .. } | NodeKind::Layer { .. }) } /// The bounds of every shape together: the centre a group's members turn about. -fn union_bounds(shapes: &[Profile]) -> Option { +pub(crate) fn union_bounds(shapes: &[Profile]) -> Option { shapes.iter().filter_map(|s| s.path.bounds()).reduce(|a, c| a.union(c)) } @@ -245,7 +255,7 @@ const MAX_MESH_TRIANGLES: usize = 2_000_000; /// effects and painted with its own fills and strokes. Like the effect on a group in the /// reference app, they turn about one shared axis (the centre of their bounds together) rather /// than each about its own. An error names what the group holds that can't become a solid. -fn members(n: &Node, label: &str) -> Result, String> { +pub(crate) fn members(n: &Node, label: &str) -> Result, String> { fn walk(n: &Node, opacity: f32, label: &str, out: &mut Vec) -> Result<(), String> { for c in n.children().into_iter().flatten() { if !c.visible || matches!(c.kind, NodeKind::Path { guide: true, .. }) { @@ -385,7 +395,7 @@ fn solid_parts(shapes: &[Profile], fallback: Rect) -> (Vec<(&AppearanceItem, f32 /// `shapes`, the members of group or layer `n`, reshaped by the visible geometry effects `before` /// its 3D effect, about the bounds of all of them. -fn reshape_members(n: &Node, before: &[Effect], shapes: &mut [Profile]) { +pub(crate) fn reshape_members(n: &Node, before: &[Effect], shapes: &mut [Profile]) { if before.iter().any(|e| e.visible && is_geometry(&e.id)) && let Some(b) = union_bounds(shapes) { diff --git a/crates/engine/src/cmd/effectcmd.rs b/crates/engine/src/cmd/effectcmd.rs index 297279b64d..028996f615 100644 --- a/crates/engine/src/cmd/effectcmd.rs +++ b/crates/engine/src/cmd/effectcmd.rs @@ -865,8 +865,8 @@ fn expanded_node(d: &mut Document, mut n: Node, brushes: &[vectorcraft_brush::Br // keeps it as its only member). Some((image, false)) => Some(rasterfx::replace_with_image(v, image)), image => { - let m = if container && effects::has_solid(&v) { - // A group's or layer's 3D effect: the shaded faces of its members' solids. + let m = if (container || matches!(n.kind, NodeKind::Text(_))) && (effects::has_solid(&v) || effects::has_revolve(&v)) { + // A 3D effect on a group or layer, or on type: the shaded faces of its solids. effects::expand_leaf(d, &v, &mut stroke_art) } else if container { // A group's or layer's own fills and strokes become art among its members. diff --git a/crates/engine/src/cmd/effectcmd/tests.rs b/crates/engine/src/cmd/effectcmd/tests.rs index e3fca957b8..eaca5d1146 100644 --- a/crates/engine/src/cmd/effectcmd/tests.rs +++ b/crates/engine/src/cmd/effectcmd/tests.rs @@ -193,6 +193,128 @@ fn type_extrudes_as_its_glyphs_and_stays_editable() { assert_ne!(effects::solid_art(&node(&s, id)).unwrap(), art, "the solid follows the edited text"); } +/// #3617: Revolve takes live type and stays type, updating when the text changes, and saves, +/// exports, expands and undoes as one step. +#[test] +fn type_revolves_as_its_glyphs_and_stays_editable() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + let t = s.execute("text.create", &json!({"x": 50, "y": 100, "text": "O", "size": 72})).unwrap(); + let id = NodeId(t["id"].as_u64().unwrap()); + s.execute("paint.setFill", &json!({"ids": [id.0], "color": "#cc2200"})).unwrap(); + s.execute("effect.apply", &json!({"effect": "threeD.revolve", "ids": [id.0], "item": null, "params": {"segments": 16}})).unwrap(); + let n = node(&s, id); + assert!(matches!(n.kind, NodeKind::Text(_)), "still type"); + let art = effects::revolve_art(&n).expect("a surface of the glyphs"); + let mut tinted = false; + art.walk(&mut |c| { + if let Some([r, g, b, _]) = c.appearance.fill_paint().color().map(|c| c.to_rgba8(1.0)) { + tinted |= r > g && r > b; + } + }); + assert!(tinted, "shaded from the characters' red"); + let doc = &s.doc().unwrap().doc; + let restored = vectorcraft_format::load(&vectorcraft_format::save(doc, false)).unwrap(); + assert_eq!(effects::revolve_art(restored.node(id).unwrap()).unwrap(), art, "the same art after a save"); + let meshes = effects::three_d_meshes(&n).unwrap(); + assert_eq!(meshes.len(), 1, "one mesh of every contour of the type"); + s.execute("text.setText", &json!({"ids": [id.0], "text": "OO"})).unwrap(); + assert_ne!(effects::revolve_art(&node(&s, id)).unwrap(), art, "the surface follows the edited text"); + s.execute("effect.expandAppearance", &json!({"ids": [id.0]})).unwrap(); + assert!(!effects::has_revolve(&node(&s, id)), "expanded"); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(matches!(node(&s, id).kind, NodeKind::Text(_))); + assert!(effects::has_revolve(&node(&s, id))); + // Extrude & Bevel on type expands the same way. + s.execute("effect.remove", &json!({"index": 0, "ids": [id.0], "item": null})).unwrap(); + s.execute("effect.apply", &json!({"effect": "threeD.extrude", "ids": [id.0], "item": null})).unwrap(); + s.execute("effect.expandAppearance", &json!({"ids": [id.0]})).unwrap(); + assert!(!effects::has_solid(&node(&s, id)), "expanded"); +} + +/// Revolve refuses type with no outlines, and a group with no visible shape, without adding an +/// effect. Extrude & Bevel gives the same reason for empty type. +#[test] +fn revolve_refuses_type_and_groups_without_a_shape() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + for text in ["", " "] { + let t = s.execute("text.create", &json!({"x": 50, "y": 100, "text": text, "size": 72})).unwrap(); + let id = NodeId(t["id"].as_u64().unwrap()); + for effect in ["threeD.revolve", "threeD.extrude"] { + let err = s.execute("effect.apply", &json!({"effect": effect, "ids": [id.0], "item": null})).unwrap_err().to_string(); + assert!(err.contains("at least two anchors"), "{effect}, {text:?}: {err}"); + assert!(node(&s, id).appearance.effects.is_empty()); + } + } + let (mut s, a) = session_with_rect(); + let b = s.execute("shape.rectangle", &json!({"x": 300, "y": 300, "width": 20, "height": 20})).unwrap()["id"].as_u64().unwrap(); + s.execute("select.set", &json!({"ids": [a.0, b]})).unwrap(); + let g = NodeId(s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap()); + for member in [a.0, b] { + s.execute("select.set", &json!({"ids": [member]})).unwrap(); + s.execute("object.hide", &json!({})).unwrap(); + } + let err = s.execute("effect.apply", &json!({"effect": "threeD.revolve", "ids": [g.0], "item": null})).unwrap_err().to_string(); + assert!(err.contains("group holding a shape"), "{err}"); + assert!(node(&s, g).appearance.effects.is_empty()); +} + +/// Revolve on a group sweeps every member about one axis on the left edge of their bounds, keeps +/// each member's paint, follows an edited member, expands and undoes as one step. +#[test] +fn revolve_on_a_group_shares_one_axis_and_members_stay_editable() { + let (mut s, a) = session_with_rect(); + s.execute("paint.setFill", &json!({"ids": [a.0], "color": "#cc2200"})).unwrap(); + let b = s.execute("shape.rectangle", &json!({"x": 250, "y": 100, "width": 50, "height": 100})).unwrap()["id"].as_u64().unwrap(); + s.execute("paint.setFill", &json!({"ids": [b], "color": "#0022cc"})).unwrap(); + s.execute("select.set", &json!({"ids": [a.0, b]})).unwrap(); + let g = NodeId(s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap()); + s.execute("effect.apply", &json!({"effect": "threeD.revolve", "ids": [g.0], "item": null, "params": {"segments": 16, "rotationX": 90}})).unwrap(); + assert_eq!(node(&s, g).children().unwrap().len(), 2, "the members stay as they were"); + let art = effects::revolve_art(&node(&s, g)).expect("a surface of the members"); + // The axis is the left edge of both members together (x = 100), so the right member's far + // wall sweeps out to 200 pt from it on either side: x = 300 and x = -100. + let bounds = art.visual_bounds().unwrap(); + assert!((bounds.x1 - 300.0).abs() < 2.0 && (bounds.x0 + 100.0).abs() < 2.0, "{bounds:?}"); + let (mut red, mut blue) = (false, false); + art.walk(&mut |c| { + if let Some([r, _, b, _]) = c.appearance.fill_paint().color().map(|c| c.to_rgba8(1.0)) { + red |= r > b; + blue |= b > r; + } + }); + assert!(red && blue, "each member keeps its own paint"); + assert_eq!(effects::three_d_meshes(&node(&s, g)).unwrap().len(), 2, "a mesh for each member"); + let svg = s.execute("document.serialize", &json!({"format": "svg"})).unwrap(); + let svg = svg["data"].as_str().or_else(|| svg["text"].as_str()).map(str::to_string).unwrap_or_else(|| svg.to_string()); + assert!(vectorcraft_testkit::svg_shapes(&svg) >= 4, "both members exported as vector faces"); + s.execute("select.set", &json!({"ids": [a.0]})).unwrap(); + s.execute("object.move", &json!({"dx": -40, "dy": 0})).unwrap(); + assert_ne!(effects::revolve_art(&node(&s, g)).unwrap(), art, "a moved member reshapes the surface"); + s.execute("effect.expandAppearance", &json!({"ids": [g.0]})).unwrap(); + assert!(!effects::has_revolve(&node(&s, g)), "expanded"); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(effects::has_revolve(&node(&s, g))); +} + +/// A group holding what can't be swept is refused as a whole, and a clipping group is named. +#[test] +fn revolve_refuses_a_group_with_a_member_it_can_not_sweep() { + let (mut s, a) = session_with_rect(); + let b = s.execute("shape.ellipse", &json!({"x": 120, "y": 120, "width": 60, "height": 60})).unwrap()["id"].clone(); + s.execute("select.set", &json!({"ids": [a.0, b]})).unwrap(); + let clip = s.execute("object.clippingMask.make", &json!({})).unwrap()["id"].clone(); + let err = s.execute("effect.apply", &json!({"effect": "threeD.revolve", "ids": [clip.as_u64().unwrap()], "item": null})).unwrap_err().to_string(); + assert!(err.contains("path, compound path, type or group"), "{err}"); + let c = s.execute("shape.rectangle", &json!({"x": 250, "y": 250, "width": 50, "height": 50})).unwrap()["id"].clone(); + s.execute("select.set", &json!({"ids": [clip, c]})).unwrap(); + let g = NodeId(s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap()); + let err = s.execute("effect.apply", &json!({"effect": "threeD.revolve", "ids": [g.0], "item": null})).unwrap_err().to_string(); + assert!(err.contains("Revolve on a group") && err.contains("clipping groups"), "{err}"); + assert!(node(&s, g).appearance.effects.is_empty()); +} + /// Scripts from before the Perspective angle give `perspective` as a 0–100 % strength: it is /// stored as the angle that shows the same view, and a later angle replaces an earlier strength. #[test] diff --git a/crates/render/src/fx/bounds.rs b/crates/render/src/fx/bounds.rs index 8ad62cf4ce..bc4e11c127 100644 --- a/crates/render/src/fx/bounds.rs +++ b/crates/render/src/fx/bounds.rs @@ -170,8 +170,8 @@ fn shape_bounds(symbols: &SymbolBoxes, n: &Node) -> Option { match &n.kind { // A group's or layer's 3D effect: the art of its members' solids. NodeKind::Layer { .. } | NodeKind::Group { clip: false, .. } - if effects::has_solid(n) - && let Some(art) = effects::solid_art(n) => + if (effects::has_solid(n) || effects::has_revolve(n)) + && let Some(art) = effects::revolve_art(n).or_else(|| effects::solid_art(n)) => { drawn_bounds(symbols, &art) } diff --git a/crates/three-d/src/lib.rs b/crates/three-d/src/lib.rs index 6ce5f3501e..54bc0c5a6f 100644 --- a/crates/three-d/src/lib.rs +++ b/crates/three-d/src/lib.rs @@ -18,11 +18,16 @@ mod caps; mod mesh; pub use mesh::{Mesh, extrude_mesh, inflate_meshes, plane_mesh, revolve_mesh}; -use vectorcraft_geom::{BezPath, ParamCurve, PathData, Point, Rect, Vec2, kurbo::ParamCurveDeriv}; +use vectorcraft_geom::{BezPath, FillRule, ParamCurve, PathData, Point, Rect, Shape, Vec2, kurbo::ParamCurveDeriv}; /// Resource limits shared by desktop, CLI and browser evaluation. -const MAX_PROFILE_POINTS: usize = 1024; +const MAX_PROFILE_POINTS: usize = 16_384; const MAX_FACES: usize = 32768; +/// Most faces Revolve builds for one object, its members together: about 460 MB of drawn art. +const MAX_REVOLVE_FACES: usize = 163_840; +/// Samples a curved profile segment takes, and the fewest it takes on a surface over its budget. +const CURVE_SAMPLES: usize = 16; +const MIN_CURVE_SAMPLES: usize = 4; #[derive(Clone, Copy, Debug, Default, PartialEq)] struct V3(f64, f64, f64); @@ -86,6 +91,9 @@ pub struct Revolve { /// Close circular endpoint rings of open profiles. Partial sweeps get sectors; /// the angular cut sides are left open. Enabled by default. pub cap_ends: bool, + /// Samples for each curved profile segment, from [`revolve_curve_samples`] for the surfaces of + /// one object. `None` plans them from the profile alone. + pub curve_samples: Option, } /// The default light's Rotation and Height: the light of the earlier default direction (45° to the @@ -114,6 +122,7 @@ impl Default for Revolve { shade: true, smooth_shading: true, cap_ends: true, + curve_samples: None, } } } @@ -307,6 +316,11 @@ impl Revolve { ..self } } + /// How many steps a sanitized sweep takes. + fn steps(self) -> usize { + ((self.segments as f64 * self.angle / 360.0).ceil() as usize).max(1) + } + fn rotate(self, v: V3) -> V3 { let (sx, cx) = self.rotation_x.to_radians().sin_cos(); let (sy, cy) = self.rotation_y.to_radians().sin_cos(); @@ -449,16 +463,117 @@ fn project_face(vertices: &[V3], normals: Option<&[V3]>, o: Revolve, origin: Poi /// Evaluate a profile in document space. All subpaths share one axis from `bounds`. /// Invalid or excessively complex inputs return an error, never a partial surface. pub fn revolve(path: &PathData, bounds: Rect, options: Revolve) -> Result, String> { - revolve_faces(path, bounds, options, None) + revolve_faces(path, FillRule::NonZero, bounds, options, None) } -/// [`revolve`], or with `mesh` the surface's faces before projection, kept there instead of drawn. -fn revolve_faces(path: &PathData, bounds: Rect, options: Revolve, mut mesh: Option<&mut Vec>) -> Result, String> { +const TOO_COMPLEX_PROFILE: &str = "Revolve profile is too complex"; +const TOO_COMPLEX_SURFACE: &str = "Revolve surface is too complex; reduce Segments or simplify the profile"; + +/// The samples each curved segment takes when `paths`, the profiles of one object's surfaces, are +/// revolved with `options`: [`CURVE_SAMPLES`] when every surface fits the limits together, else as +/// many as fit, the same number for every curve. An error when [`MIN_CURVE_SAMPLES`] do not fit. +/// The count is made before any face is built. +pub fn revolve_curve_samples<'a>(paths: impl IntoIterator, options: Revolve) -> Result { let o = options.sanitized(); + let steps = o.steps(); + let per_edge = if o.shade && o.smooth_shading { steps.saturating_mul(2) } else { steps }; + let (mut anchors, mut contours, mut lines, mut curves, mut caps) = (0usize, 0usize, 0usize, 0usize, 0usize); + for sp in paths.into_iter().flat_map(|p| &p.subpaths) { + anchors = anchors.saturating_add(sp.anchors.len()); + if anchors > MAX_PROFILE_POINTS { + return Err(TOO_COMPLEX_PROFILE.into()); + } + contours += 1; + let segments = if sp.closed { sp.anchors.len() } else { sp.anchors.len().saturating_sub(1) }; + for i in 0..segments { + if sp.segment_is_line(i) { + lines += 1; + } else { + curves += 1; + } + } + if o.cap_ends && !sp.closed && segments > 0 { + caps += 2; + } + } + let points = MAX_PROFILE_POINTS.saturating_sub(contours); + if lines.saturating_add(curves.saturating_mul(MIN_CURVE_SAMPLES)) > points { + return Err(TOO_COMPLEX_PROFILE.into()); + } + let edges = (MAX_REVOLVE_FACES.saturating_sub(caps.saturating_mul(steps)) / per_edge).min(points); + if curves == 0 { + return if lines > edges { Err(TOO_COMPLEX_SURFACE.into()) } else { Ok(CURVE_SAMPLES) }; + } + let samples = (edges.saturating_sub(lines) / curves).min(CURVE_SAMPLES); + if samples < MIN_CURVE_SAMPLES { Err(TOO_COMPLEX_SURFACE.into()) } else { Ok(samples) } +} + +/// Most path elements the search for holes tests a point against, each winding test counting its +/// contour's elements. +const MAX_HOLE_WORK: usize = 16_000_000; + +/// For each segment of each subpath of `path`, whether it walls a hole under `rule`: the fill is +/// outside its closed contour at the segment's middle and not inside. An error past +/// [`MAX_HOLE_WORK`]. +fn hole_segments(path: &PathData, rule: FillRule) -> Result>, String> { + let contours: Vec<(BezPath, Rect)> = path + .subpaths + .iter() + .map(|sp| { + let contour = PathData::new(vec![sp.clone()]).to_bezpath(); + let bounds = contour.bounding_box(); + (contour, bounds) + }) + .collect(); + let closed = |j: usize| path.subpaths.get(j).is_some_and(|sp| sp.closed); + let mut work = 0usize; + let mut holes = Vec::with_capacity(contours.len()); + for (i, (sp, (own, _))) in path.subpaths.iter().zip(&contours).enumerate() { + if !sp.closed { + holes.push(vec![]); + continue; + } + let direction = if own.area() < 0.0 { -1 } else { 1 }; + let mut segments = Vec::with_capacity(sp.anchors.len()); + for segment in 0..sp.anchors.len() { + let p = sp.segment(segment).eval(0.5); + let mut others = 0i32; + for (_, (contour, _)) in contours.iter().enumerate().filter(|(j, (_, bounds))| *j != i && closed(*j) && bounds.contains(p)) { + work = work.saturating_add(contour.elements().len()); + if work > MAX_HOLE_WORK { + return Err(TOO_COMPLEX_PROFILE.into()); + } + others += contour.winding(p); + } + segments.push(match rule { + FillRule::EvenOdd => others % 2 != 0, + FillRule::NonZero => others != 0 && others + direction == 0, + }); + } + holes.push(segments); + } + Ok(holes) +} + +/// [`revolve`], or with `mesh` the surface's faces before projection, kept there instead of drawn, +/// each turned out of the region `rule` fills. +fn revolve_faces( + path: &PathData, + rule: FillRule, + bounds: Rect, + options: Revolve, + mut mesh: Option<&mut Vec>, +) -> Result, String> { + let o = options.sanitized(); + let curve_samples = match o.curve_samples { + Some(samples) => samples.clamp(1, CURVE_SAMPLES), + None => revolve_curve_samples([path], o)?, + }; + let holes = if mesh.is_some() { hole_segments(path, rule)? } else { vec![] }; // A face kept for a mesh: its normal follows from its corners. let keep = |mesh: &mut Vec, face: Vec, normal: V3| { mesh.push((vec![face], normal)); - if mesh.len() > MAX_FACES { Err("Revolve surface is too complex; reduce Segments or simplify the profile".to_string()) } else { Ok(()) } + if mesh.len() > MAX_REVOLVE_FACES { Err(TOO_COMPLEX_SURFACE.to_string()) } else { Ok(()) } }; if ![bounds.x0, bounds.y0, bounds.x1, bounds.y1].iter().all(|v| v.is_finite() && v.abs() <= 1e8) { return Err("Revolve requires finite profile coordinates within 100 million points".into()); @@ -470,7 +585,7 @@ fn revolve_faces(path: &PathData, bounds: Rect, options: Revolve, mut mesh: Opti let center_y = (bounds.y0 + bounds.y1) * 0.5; let radius = (bounds.x0 - axis).abs().max((bounds.x1 - axis).abs()); let eye = eye_distance(o.perspective, radius.max(bounds.height().abs()).max(1.0)); - let steps = ((o.segments as f64 * o.angle / 360.0).ceil() as usize).max(1); + let steps = o.steps(); let sweep = o.angle.to_radians(); let smooth = o.shade && o.smooth_shading; let [lx, ly, lz] = o.light_direction(); @@ -489,32 +604,33 @@ fn revolve_faces(path: &PathData, bounds: Rect, options: Revolve, mut mesh: Opti if let Some(face) = face { faces.push(face); mesh_cost += cost; - if mesh_cost > MAX_FACES { - return Err("Revolve surface is too complex; reduce Segments or simplify the profile".into()); + if mesh_cost > MAX_REVOLVE_FACES { + return Err(TOO_COMPLEX_SURFACE.into()); } } Ok(()) }; let mut count = 0; let mut curve_budget = 0; - for sp in &path.subpaths { + for (contour, sp) in path.subpaths.iter().enumerate() { let mut profile = Vec::new(); let mut tangents = Vec::new(); + let mut hole_edges = Vec::new(); if let Some(first) = sp.anchors.first() { profile.push(first.p); count += 1; if count > MAX_PROFILE_POINTS { - return Err("Revolve profile is too complex".into()); + return Err(TOO_COMPLEX_PROFILE.into()); } } let segments = if sp.closed { sp.anchors.len() } else { sp.anchors.len().saturating_sub(1) }; - // Bound work before sampling any data. Curves get sixteen samples, straight edges one. + // Bound work before sampling any data. Straight edges get one sample. if segments > MAX_PROFILE_POINTS { - return Err("Revolve profile is too complex".into()); + return Err(TOO_COMPLEX_PROFILE.into()); } for i in 0..segments { let curve = sp.segment(i); - let samples = if sp.segment_is_line(i) { 1 } else { 16 }; + let samples = if sp.segment_is_line(i) { 1 } else { curve_samples }; for k in 1..=samples { let t0 = (k - 1) as f64 / samples as f64; let t1 = k as f64 / samples as f64; @@ -537,10 +653,11 @@ fn revolve_faces(path: &PathData, bounds: Rect, options: Revolve, mut mesh: Opti if v.hypot2() > 1e-20 { v } else { b - a } }; tangents.push([tangent(t0), tangent(t1)]); + hole_edges.push(holes.get(contour).and_then(|h| h.get(i)).copied().unwrap_or(false)); profile.push(b); count += 1; if count > MAX_PROFILE_POINTS { - return Err("Revolve profile is too complex".into()); + return Err(TOO_COMPLEX_PROFILE.into()); } } } @@ -565,8 +682,9 @@ fn revolve_faces(path: &PathData, bounds: Rect, options: Revolve, mut mesh: Opti let area: f64 = ring.iter().zip(ring.iter().cycle().skip(1)).take(ring.len()).map(|(p, q)| p.0 * q.1 - q.0 * p.1).sum(); if area < 0.0 { -1.0 } else { 1.0 } }; - for (i, (pair, [ta, tb])) in profile.windows(2).zip(tangents).enumerate() { + for (i, ((pair, [ta, tb]), hole)) in profile.windows(2).zip(tangents).zip(hole_edges).enumerate() { let [a, b] = pair else { continue }; + let turn = if hole { -turn } else { turn }; if ![a.x, a.y, b.x, b.y].iter().all(|v| v.is_finite() && v.abs() <= 1e8) { return Err("Revolve profile contains invalid coordinates".into()); } diff --git a/crates/three-d/src/mesh.rs b/crates/three-d/src/mesh.rs index 2f2f698441..d1970c2028 100644 --- a/crates/three-d/src/mesh.rs +++ b/crates/three-d/src/mesh.rs @@ -76,10 +76,11 @@ pub fn inflate_meshes( } /// Revolve's surface ([`crate::revolve`]) as a mesh. As the effect draws it, a sweep of less than -/// 360° caps its ends with sectors and leaves its two cut sides open ([`Revolve::cap_ends`]). -pub fn revolve_mesh(path: &PathData, bounds: Rect, options: Revolve) -> Result { +/// 360° caps its ends with sectors and leaves its two cut sides open ([`Revolve::cap_ends`]). The +/// walls of holes under `rule`, such as the counter of an O, face into the hole. +pub fn revolve_mesh(path: &PathData, rule: FillRule, bounds: Rect, options: Revolve) -> Result { let mut faces = vec![]; - crate::revolve_faces(path, bounds, options, Some(&mut faces))?; + crate::revolve_faces(path, rule, bounds, options, Some(&mut faces))?; mesh(&faces) } diff --git a/crates/three-d/src/mesh/tests.rs b/crates/three-d/src/mesh/tests.rs index 3251cbb3ee..27706f0b50 100644 --- a/crates/three-d/src/mesh/tests.rs +++ b/crates/three-d/src/mesh/tests.rs @@ -231,7 +231,7 @@ fn a_revolved_profile_is_a_closed_cylinder() { let p = PathData::from_bezpath(&b); let r = p.bounds().unwrap(); let o = Revolve { offset: 10.0, edge: Edge::Left, segments: 64, ..Revolve::default() }; - let m = revolve_mesh(&p, r, o).unwrap(); + let m = revolve_mesh(&p, FillRule::NonZero, r, o).unwrap(); assert!(closed(&m)); let exact = 2.0 * std::f64::consts::PI * 10.0 * 100.0 + 2.0 * std::f64::consts::PI * 100.0; assert!((area(&m) - exact).abs() / exact < 0.01, "{} vs {exact}", area(&m)); @@ -246,7 +246,7 @@ fn a_partial_revolve_caps_its_ends_with_sectors() { let p = PathData::from_bezpath(&b); let r = p.bounds().unwrap(); let o = Revolve { angle: 90.0, offset: 10.0, segments: 64, ..Revolve::default() }; - let m = revolve_mesh(&p, r, o).unwrap(); + let m = revolve_mesh(&p, FillRule::NonZero, r, o).unwrap(); let exact = 0.25 * (2.0 * std::f64::consts::PI * 10.0 * 100.0 + 2.0 * std::f64::consts::PI * 100.0); assert!((area(&m) - exact).abs() / exact < 0.01, "{} vs {exact}", area(&m)); } @@ -261,7 +261,7 @@ fn a_revolved_surface_has_unit_normals_facing_out_whatever_its_length_and_direct let p = PathData::from_bezpath(&b); let r = p.bounds().unwrap(); let o = Revolve { offset: 10.0, edge: Edge::Left, segments: 64, ..Revolve::default() }; - let m = revolve_mesh(&p, r, o).unwrap(); + let m = revolve_mesh(&p, FillRule::NonZero, r, o).unwrap(); assert!(!m.is_empty(), "{from} to {to}: the revolve made no triangles"); assert!(m.normals.iter().all(|n| (dot(*n, *n) - 1.0).abs() < 1e-9), "{from} to {to}: a normal is not unit length"); assert!(closed(&m), "{from} to {to}"); @@ -269,6 +269,43 @@ fn a_revolved_surface_has_unit_normals_facing_out_whatever_its_length_and_direct } } +/// The radial direction of every triangle's normal at the wall `x` pt from the axis, each as the +/// sign of the normal's part along the way from the axis: 1 away from it, -1 toward it. +fn wall_directions(m: &Mesh, x: f64) -> Vec { + m.triangles + .iter() + .filter_map(|t| { + let [a, b, c] = corners(m, *t); + let centre = [(a[0] + b[0] + c[0]) / 3.0, 0.0, (a[2] + b[2] + c[2]) / 3.0]; + let radius = centre[0].hypot(centre[2]); + let n = m.normals[t[0] as usize]; + ((radius - x).abs() < 1.0 && n[1].abs() < 0.5).then(|| dot(n, centre).signum()) + }) + .collect() +} + +#[test] +fn the_wall_of_a_counter_faces_into_the_hole() { + // A square ring 20 pt thick, its axis at the outer square's left edge minus 10: the outer wall + // reaches 110 pt from the axis, and the hole's far wall 90 pt from it. + let ring = |hole_clockwise: bool, rule: FillRule| { + let mut b = BezPath::new(); + square(&mut b, 0.0, 0.0, 100.0, false); + square(&mut b, 20.0, 20.0, 60.0, hole_clockwise); + let (p, r) = shape(&b); + revolve_mesh(&p, rule, r, Revolve { offset: 10.0, segments: 32, ..Revolve::default() }).unwrap() + }; + for (clockwise, rule) in [(true, FillRule::NonZero), (true, FillRule::EvenOdd), (false, FillRule::EvenOdd)] { + let m = ring(clockwise, rule); + assert!(wall_directions(&m, 110.0).iter().all(|s| *s > 0.0), "outer wall, {clockwise} {rule:?}"); + let hole = wall_directions(&m, 90.0); + assert!(!hole.is_empty() && hole.iter().all(|s| *s < 0.0), "the hole's far wall faces the axis, {clockwise} {rule:?}"); + } + // Wound the same way under the nonzero rule, the inner square is not a hole. + let m = ring(false, FillRule::NonZero); + assert!(wall_directions(&m, 90.0).iter().all(|s| *s > 0.0)); +} + #[test] fn inflate_puffs_the_front_toward_the_viewer() { let mut b = BezPath::new(); diff --git a/crates/three-d/src/tests.rs b/crates/three-d/src/tests.rs index e2b6985fbc..1b0ffa3ffc 100644 --- a/crates/three-d/src/tests.rs +++ b/crates/three-d/src/tests.rs @@ -4,6 +4,7 @@ mod caps; mod classic; mod lighting; mod multiple_lights; +mod profile_limits; mod smooth; #[test] diff --git a/crates/three-d/src/tests/caps.rs b/crates/three-d/src/tests/caps.rs index 32fc5ac0b3..cae43f4a5a 100644 --- a/crates/three-d/src/tests/caps.rs +++ b/crates/three-d/src/tests/caps.rs @@ -93,14 +93,14 @@ fn caps_share_surface_limits_and_malformed_inputs_stay_bounded() { assert!(faces.len() <= 384); let mut excessive = BezPath::new(); excessive.move_to((40.0, 0.0)); - for i in 1..=1024 { + for i in 1..=MAX_PROFILE_POINTS { excessive.line_to((40.0, i as f64)); } - assert!(revolve(&PathData::from_bezpath(&excessive), Rect::new(0.0, 0.0, 40.0, 1024.0), options()).is_err()); + assert!(revolve(&PathData::from_bezpath(&excessive), Rect::new(0.0, 0.0, 40.0, MAX_PROFILE_POINTS as f64), options()).is_err()); assert!(revolve(&PathData::default(), bounds, options()).unwrap().is_empty()); // Viewed along the axis, the side walls disappear but the caps still consume // the same face budget. Repeated rings cannot bypass it through degenerate walls. - let repeated = PathData::new(vec![path.subpaths[0].clone(); 129]); + let repeated = PathData::new(vec![path.subpaths[0].clone(); 1025]); let o = Revolve { segments: 128, ..options() }; assert!(revolve(&repeated, bounds, Revolve { cap_ends: false, ..o }).unwrap().is_empty()); assert!(revolve(&repeated, bounds, o).unwrap_err().contains("surface is too complex")); diff --git a/crates/three-d/src/tests/profile_limits.rs b/crates/three-d/src/tests/profile_limits.rs new file mode 100644 index 0000000000..025f2d9dfa --- /dev/null +++ b/crates/three-d/src/tests/profile_limits.rs @@ -0,0 +1,124 @@ +use crate::*; +use vectorcraft_geom::{Anchor, SubPath}; + +/// A closed star of `points` tips: twice as many straight edges. +fn star(points: usize) -> PathData { + let anchors = (0..points * 2) + .map(|i| { + let (s, c) = (i as f64 * std::f64::consts::PI / points as f64).sin_cos(); + let r = if i % 2 == 0 { 100.0 } else { 60.0 }; + Anchor::corner(Point::new(200.0 + r * c, 200.0 + r * s)) + }) + .collect(); + PathData::new(vec![SubPath::new(anchors, true)]) +} + +/// An open wave of `anchors` smooth anchors: one curved segment between each two. +fn wave(anchors: usize) -> PathData { + let anchors = (0..anchors) + .map(|i| { + let p = Point::new(100.0 + if i % 2 == 0 { 0.0 } else { 20.0 }, i as f64); + Anchor::smooth(p, p + Vec2::new(6.0, 0.3)) + }) + .collect(); + PathData::new(vec![SubPath::new(anchors, false)]) +} + +fn revolved(path: &PathData, options: Revolve) -> Result, String> { + revolve(path, path.bounds().unwrap(), options) +} + +#[test] +fn a_star_of_600_points_revolves_at_the_default_segments() { + let faces = revolved(&star(600), Revolve::default()).unwrap(); + // The tip on the axis gives two edges with a side of no length. + assert!((1198 * 64 * 2..=1200 * 64 * 2).contains(&faces.len()), "{}", faces.len()); + let refused = revolved(&star(600), Revolve { segments: 128, ..Revolve::default() }).unwrap_err(); + assert!(refused.contains("reduce Segments"), "{refused}"); +} + +#[test] +fn a_curved_profile_past_the_old_limit_takes_fewer_samples_for_each_curve() { + let wave = wave(200); + assert!(revolved(&wave, Revolve::default()).is_ok()); + let samples = revolve_curve_samples([&wave], Revolve::default()).unwrap(); + assert!((MIN_CURVE_SAMPLES..CURVE_SAMPLES).contains(&samples), "{samples}"); +} + +#[test] +fn a_profile_within_the_limits_keeps_every_sample() { + assert_eq!(revolve_curve_samples([&wave(50)], Revolve::default()), Ok(CURVE_SAMPLES)); + assert_eq!(revolve_curve_samples([&star(5)], Revolve::default()), Ok(CURVE_SAMPLES)); +} + +#[test] +fn two_thousand_curves_fit_at_low_segments_and_are_refused_before_any_face_at_high() { + let wave = wave(2001); + let samples = revolve_curve_samples([&wave], Revolve { segments: 8, ..Revolve::default() }).unwrap(); + assert!((MIN_CURVE_SAMPLES..CURVE_SAMPLES).contains(&samples), "{samples}"); + assert!(revolved(&wave, Revolve { segments: 8, ..Revolve::default() }).is_ok()); + let refused = revolve_curve_samples([&wave], Revolve { segments: 128, ..Revolve::default() }).unwrap_err(); + assert!(refused.contains("reduce Segments"), "{refused}"); + assert_eq!(revolved(&wave, Revolve { segments: 128, ..Revolve::default() }), Err(refused)); +} + +#[test] +fn too_many_anchors_are_refused_as_a_profile() { + let refused = revolve_curve_samples([&wave(MAX_PROFILE_POINTS + 1)], Revolve { segments: 8, ..Revolve::default() }).unwrap_err(); + assert_eq!(refused, TOO_COMPLEX_PROFILE); +} + +#[test] +fn a_profile_at_the_point_limit_is_taken_and_one_past_it_is_refused() { + let straight = |anchors: usize| { + let anchors = (0..anchors).map(|i| Anchor::corner(Point::new(100.0 + (i % 2) as f64, i as f64))).collect(); + PathData::new(vec![SubPath::new(anchors, false)]) + }; + let narrow = Revolve { angle: 1.0, cap_ends: false, segments: 8, ..Revolve::default() }; + assert_eq!(revolve_curve_samples([&straight(MAX_PROFILE_POINTS)], narrow), Ok(CURVE_SAMPLES)); + assert_eq!(revolve_curve_samples([&straight(MAX_PROFILE_POINTS + 1)], narrow), Err(TOO_COMPLEX_PROFILE.to_string())); +} + +#[test] +fn the_profiles_of_one_object_share_one_budget() { + let (one, other) = (star(600), star(600)); + let o = Revolve::default(); + assert!(revolve_curve_samples([&one], o).is_ok()); + assert!(revolve_curve_samples([&other], o).is_ok()); + let refused = revolve_curve_samples([&one, &other], o).unwrap_err(); + assert!(refused.contains("reduce Segments"), "{refused}"); + // Contours of one profile count together as well. + let both = PathData::new(one.subpaths.iter().chain(&other.subpaths).cloned().collect()); + assert_eq!(revolve_curve_samples([&both], o), Err(refused)); +} + +#[test] +fn deeply_nested_contours_stop_the_search_for_holes() { + // Concentric triangles: each tests every contour outside it. + let triangle = |r: f64| { + let anchors = [(0.0, -r), (r, r), (-r, r)].iter().map(|(x, y)| Anchor::corner(Point::new(200_000.0 + x, 200_000.0 + y))).collect(); + SubPath::new(anchors, true) + }; + let nested = PathData::new((1..=4000).map(|i| triangle(i as f64)).collect()); + assert_eq!(hole_segments(&nested, FillRule::NonZero), Err(TOO_COMPLEX_PROFILE.to_string())); + let few = PathData::new((1..=4).map(|i| triangle(i as f64)).collect()); + assert_eq!(hole_segments(&few, FillRule::EvenOdd).map(|h| h.len()), Ok(4)); +} + +#[test] +fn overlapping_contours_are_holes_only_where_they_overlap() { + let square = |x: f64, y: f64, side: f64| { + let corners = [(x, y), (x + side, y), (x + side, y + side), (x, y + side)]; + SubPath::new(corners.iter().map(|(x, y)| Anchor::corner(Point::new(*x, *y))).collect(), true) + }; + let overlapping = PathData::new(vec![square(0.0, 0.0, 100.0), square(40.0, 40.0, 100.0)]); + let expected = vec![vec![false, true, true, false], vec![true, false, false, true]]; + assert_eq!(hole_segments(&overlapping, FillRule::EvenOdd), Ok(expected)); +} + +#[test] +fn given_samples_are_used_for_each_curve() { + let wave = wave(4); + let at = |samples| revolved(&wave, Revolve { curve_samples: Some(samples), cap_ends: false, segments: 8, ..Revolve::default() }).unwrap().len(); + assert_eq!(at(4) * 2, at(8)); +} diff --git a/crates/three-d/src/tests/smooth.rs b/crates/three-d/src/tests/smooth.rs index c651723262..085255ecbc 100644 --- a/crates/three-d/src/tests/smooth.rs +++ b/crates/three-d/src/tests/smooth.rs @@ -118,6 +118,6 @@ fn unshaded_legacy_degenerate_and_high_complexity_surfaces_stay_bounded() { } assert!(revolve(&p, b, Revolve { smooth_shading: true, angle: 0.0, ..o }).unwrap().is_empty()); let mut p = path("M40 0"); - p.subpaths[0].anchors.extend((1..400).map(|i| vectorcraft_geom::Anchor::corner(Point::new(40.0 + (i % 2) as f64, i as f64)))); + p.subpaths[0].anchors.extend((1..1100).map(|i| vectorcraft_geom::Anchor::corner(Point::new(40.0 + (i % 2) as f64, i as f64)))); assert!(revolve(&p, p.bounds().unwrap(), Revolve { smooth_shading: true, segments: 128, offset: 30.0, ..o }).is_err()); } diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 53ebd517f5..8bf4166ef9 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -5912,7 +5912,7 @@ digits Malayalam Malaiàlam @msg {key} must be a finite coordinate {key} ha de ser una coordenada finita @msg {key} must be an array of non-negative integer object ids {key} ha de ser una matriu d’identificadors d’objecte enters no negatius @msg {key} must be Unix seconds (an integer) or null {key} ha de ser segons Unix (un enter) o null -@msg {label} currently requires a path or compound path. Convert type, images and groups to paths first. {label} actualment requereix un traçat o un traçat compost. Converteix primer els textos, les imatges i els grups en traçats. +@msg {label} currently requires a path, compound path, type or group. Convert images and other objects to paths first. {label} actualment requereix un traçat, un traçat compost, text o un grup. Converteix primer les imatges i altres objectes en traçats. @msg {label} keeps 1 of the document's {n} artboards: the file reopens with one. Turn on Preserve Editing Capabilities to keep them all. {label} conserva 1 dels {n} taulers del document: el fitxer es reobre amb un. Activa Conserva les capacitats d’edició per conservar-los tots. @msg {label} produced an empty result {label} ha produït un resultat buit @msg {label} requires a shape with at least two anchors {label} requereix una forma amb com a mínim dos punts d’ancoratge diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 3c9090be2a..1d4c776a8c 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -5587,7 +5587,7 @@ mark-style Japanese Japonské Bevel both sides Zkosení na obou stranách Presets Předvolby @msg An object takes one 3D effect. Edit the existing effect's options, or remove it first. Objekt může mít jen jeden 3D efekt. Upravte volby stávajícího efektu, nebo jej nejprve odstraňte. -@msg {label} currently requires a path or compound path. Convert type, images and groups to paths first. {label} zatím vyžaduje cestu nebo složenou cestu. Nejprve převeďte text, obrazy a skupiny na cesty. +@msg {label} currently requires a path, compound path, type or group. Convert images and other objects to paths first. {label} zatím vyžaduje cestu, složenou cestu, text nebo skupinu. Obrazy a další objekty nejprve převeďte na cesty. @msg {label} requires a shape with at least two anchors {label} vyžaduje tvar alespoň se dvěma kotevními body @msg The shape is too complex for a 3D effect Tvar je pro 3D efekt příliš složitý @msg The 3D surface is too complex; simplify the shape 3D povrch je příliš složitý; zjednodušte tvar diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 6c6c48238f..0ba5a6f4ef 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -5154,7 +5154,7 @@ lighting Light Licht Bevel both sides Abgeflachte Kante beidseitig Presets Vorgaben @msg An object takes one 3D effect. Edit the existing effect's options, or remove it first. Ein Objekt kann nur einen 3D-Effekt haben. Bearbeiten Sie die Optionen des vorhandenen Effekts oder entfernen Sie ihn zuerst. -@msg {label} currently requires a path or compound path. Convert type, images and groups to paths first. {label} erfordert derzeit einen Pfad oder zusammengesetzten Pfad. Wandeln Sie Text, Bilder und Gruppen zuerst in Pfade um. +@msg {label} currently requires a path, compound path, type or group. Convert images and other objects to paths first. {label} erfordert derzeit einen Pfad, zusammengesetzten Pfad, Text oder eine Gruppe. Wandeln Sie Bilder und andere Objekte zuerst in Pfade um. @msg {label} requires a shape with at least two anchors {label} erfordert eine Form mit mindestens zwei Ankerpunkten @msg The shape is too complex for a 3D effect Die Form ist für einen 3D-Effekt zu komplex @msg The 3D surface is too complex; simplify the shape Die 3D-Oberfläche ist zu komplex; vereinfachen Sie die Form diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index b2d4f2a6ea..7fa049316c 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -4948,7 +4948,7 @@ digits Malayalam Μαλαγιάλαμ Bevel Inside Εσωτερική λοξότμηση Bevel both sides Λοξότμηση και των δύο πλευρών @msg An object takes one 3D effect. Edit the existing effect's options, or remove it first. Ένα αντικείμενο δέχεται ένα εφέ 3D. Επεξεργαστείτε τις επιλογές του υπάρχοντος εφέ ή αφαιρέστε το πρώτα. -@msg {label} currently requires a path or compound path. Convert type, images and groups to paths first. Το {label} απαιτεί προς το παρόν διαδρομή ή σύνθετη διαδρομή. Μετατρέψτε πρώτα το κείμενο, τις εικόνες και τις ομάδες σε διαδρομές. +@msg {label} currently requires a path, compound path, type or group. Convert images and other objects to paths first. Το {label} απαιτεί προς το παρόν διαδρομή, σύνθετη διαδρομή, κείμενο ή ομάδα. Μετατρέψτε πρώτα τις εικόνες και τα άλλα αντικείμενα σε διαδρομές. @msg {label} requires a shape with at least two anchors το {label} απαιτεί σχήμα με τουλάχιστον δύο σημεία αγκύρωσης @msg The shape is too complex for a 3D effect Το σχήμα είναι πολύ σύνθετο για εφέ 3D @msg The 3D surface is too complex; simplify the shape Η επιφάνεια 3D είναι πολύ σύνθετη· απλοποιήστε το σχήμα diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 9d50594110..ae983373a0 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -5528,7 +5528,7 @@ mark-style Japanese Japonesas Bevel both sides Bisel en ambos lados Presets Ajustes preestablecidos @msg An object takes one 3D effect. Edit the existing effect's options, or remove it first. Un objeto admite un solo efecto 3D. Edite las opciones del efecto existente o quítelo primero. -@msg {label} currently requires a path or compound path. Convert type, images and groups to paths first. {label} requiere por ahora un trazado o un trazado compuesto. Convierta primero el texto, las imágenes y los grupos en trazados. +@msg {label} currently requires a path, compound path, type or group. Convert images and other objects to paths first. {label} requiere por ahora un trazado, un trazado compuesto, texto o un grupo. Convierta primero las imágenes y otros objetos en trazados. @msg {label} requires a shape with at least two anchors {label} requiere una forma con al menos dos puntos de ancla @msg The shape is too complex for a 3D effect La forma es demasiado compleja para un efecto 3D @msg The 3D surface is too complex; simplify the shape La superficie 3D es demasiado compleja; simplifique la forma diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index dbdf5d111e..682a667ad7 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -5471,7 +5471,7 @@ mark-style Japanese Japanilainen Bevel both sides Viiste molemmin puolin Presets Esiasetukset @msg An object takes one 3D effect. Edit the existing effect's options, or remove it first. Objektilla voi olla yksi 3D-tehoste. Muokkaa olemassa olevan tehosteen asetuksia tai poista se ensin. -@msg {label} currently requires a path or compound path. Convert type, images and groups to paths first. {label} vaatii tällä hetkellä polun tai yhdistelmäpolun. Muunna teksti, kuvat ja ryhmät ensin poluiksi. +@msg {label} currently requires a path, compound path, type or group. Convert images and other objects to paths first. {label} vaatii tällä hetkellä polun, yhdistelmäpolun, tekstin tai ryhmän. Muunna kuvat ja muut objektit ensin poluiksi. @msg {label} requires a shape with at least two anchors {label} vaatii muodon, jossa on vähintään kaksi ankkuria @msg The shape is too complex for a 3D effect Muoto on liian monimutkainen 3D-tehosteelle @msg The 3D surface is too complex; simplify the shape 3D-pinta on liian monimutkainen; yksinkertaista muotoa diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 7c6d1fb0e9..a2d7f60812 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -5158,7 +5158,7 @@ lighting Light Lumière Bevel both sides Biseau des deux côtés Presets Paramètres prédéfinis @msg An object takes one 3D effect. Edit the existing effect's options, or remove it first. Un objet n'accepte qu'un seul effet 3D. Modifiez les options de l'effet existant ou supprimez-le d'abord. -@msg {label} currently requires a path or compound path. Convert type, images and groups to paths first. {label} nécessite pour l'instant un tracé ou un tracé transparent. Convertissez d'abord le texte, les images et les groupes en tracés. +@msg {label} currently requires a path, compound path, type or group. Convert images and other objects to paths first. {label} nécessite pour l'instant un tracé, un tracé transparent, du texte ou un groupe. Convertissez d'abord les images et les autres objets en tracés. @msg {label} requires a shape with at least two anchors {label} nécessite une forme comportant au moins deux points d'ancrage @msg The shape is too complex for a 3D effect La forme est trop complexe pour un effet 3D @msg The 3D surface is too complex; simplify the shape La surface 3D est trop complexe ; simplifiez la forme diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 1dac03decb..90b483dc0c 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -5400,7 +5400,7 @@ antiAlias Sharp शार्प @msg {key} must name a registered panel {key} एक पंजीकृत पैनल का नाम होना चाहिए @msg {kind} files have no transparency, and the file would have {what}: flatten it first (object.flattenTransparency) {kind} फ़ाइलों में पारदर्शिता नहीं होती और इस फ़ाइल में {what} होगा: पहले इसे फ़्लैट करें (object.flattenTransparency) @msg {kind} images can't be opened (see document.formats) {kind} इमेज ओपन नहीं हो सकतीं (document.formats देखें) -@msg {label} currently requires a path or compound path. Convert type, images and groups to paths first. {label} वर्तमान में एक पाथ या यौगिक पाथ की आवश्यकता होती है। पहले पाथ के लिए प्रकार, इमेज और समूहों को परिवर्तित करें। +@msg {label} currently requires a path, compound path, type or group. Convert images and other objects to paths first. {label} के लिए वर्तमान में एक पाथ, यौगिक पाथ, टाइप या समूह आवश्यक है। पहले इमेज और अन्य ऑब्जेक्ट को पाथ में परिवर्तित करें। @msg {label} keeps 1 of the document's {n} artboards: the file reopens with one. Turn on Preserve Editing Capabilities to keep them all. {label} डॉक्यूमेंट के {n} आर्टबोर्ड में से 1 रखता है: फ़ाइल एक के साथ फिर से खुलती है। उन्हें रखने के लिए संरक्षित संपादन क्षमताओं को चालू करें। @msg {label} must be a number {label} एक नंबर होना चाहिए @msg {label} must be a number above 0 and at most {max} {label} 0 से ऊपर और सबसे {max} पर होना चाहिए diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 87842df37e..58537a6c2d 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -5570,7 +5570,7 @@ imageTrace Outline Körvonal Bevel both sides Élletörés mindkét oldalon Presets Készletek @msg An object takes one 3D effect. Edit the existing effect's options, or remove it first. Egy objektumnak csak egy 3D effektusa lehet. Szerkessze a meglévő effektus beállításait, vagy előbb távolítsa el. -@msg {label} currently requires a path or compound path. Convert type, images and groups to paths first. {label} egyelőre görbét vagy összetett görbét igényel. Előbb alakítsa görbévé a szöveget, a képeket és a csoportokat. +@msg {label} currently requires a path, compound path, type or group. Convert images and other objects to paths first. {label} egyelőre görbét, összetett görbét, szöveget vagy csoportot igényel. Előbb alakítsa görbévé a képeket és az egyéb objektumokat. @msg {label} requires a shape with at least two anchors {label} legalább két szerkesztőpontos alakzatot igényel @msg The shape is too complex for a 3D effect Az alakzat túl összetett a 3D effektushoz @msg The 3D surface is too complex; simplify the shape A 3D felület túl összetett; egyszerűsítse az alakzatot diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index f10e1f272e..6e843ce152 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -5196,7 +5196,7 @@ lighting Light Luce Bevel both sides Smusso su entrambi i lati Presets Predefiniti @msg An object takes one 3D effect. Edit the existing effect's options, or remove it first. Un oggetto può avere un solo effetto 3D. Modificate le opzioni dell'effetto esistente oppure rimuovetelo prima. -@msg {label} currently requires a path or compound path. Convert type, images and groups to paths first. {label} richiede per ora un tracciato o un tracciato composto. Convertite prima testo, immagini e gruppi in tracciati. +@msg {label} currently requires a path, compound path, type or group. Convert images and other objects to paths first. {label} richiede per ora un tracciato, un tracciato composto, testo o un gruppo. Convertite prima immagini e altri oggetti in tracciati. @msg {label} requires a shape with at least two anchors {label} richiede una forma con almeno due punti di ancoraggio @msg The shape is too complex for a 3D effect La forma è troppo complessa per un effetto 3D @msg The 3D surface is too complex; simplify the shape La superficie 3D è troppo complessa; semplificate la forma diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index b2c2b37a0f..8e2335c247 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -5238,7 +5238,7 @@ lighting Light ライト Bevel both sides 両面にベベル Presets プリセット @msg An object takes one 3D effect. Edit the existing effect's options, or remove it first. 1 つのオブジェクトに適用できる 3D 効果は 1 つです。既存の効果のオプションを編集するか、先に削除してください。 -@msg {label} currently requires a path or compound path. Convert type, images and groups to paths first. {label} には現在、パスまたは複合パスが必要です。テキスト、画像、グループは先にパスに変換してください。 +@msg {label} currently requires a path, compound path, type or group. Convert images and other objects to paths first. {label} には現在、パス、複合パス、テキスト、またはグループが必要です。画像やその他のオブジェクトは先にパスに変換してください。 @msg {label} requires a shape with at least two anchors {label} には 2 つ以上のアンカーポイントを持つシェイプが必要です @msg The shape is too complex for a 3D effect シェイプが複雑すぎるため 3D 効果を適用できません @msg The 3D surface is too complex; simplify the shape 3D サーフェスが複雑すぎます。シェイプを単純化してください diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index a625c7bf51..915bf18314 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -4959,7 +4959,7 @@ font-technology Variable ცვლადი Bevel both sides წაკვეთა ორივე მხარეს Presets პრესეტები @msg An object takes one 3D effect. Edit the existing effect's options, or remove it first. ობიექტს ერთი 3D ეფექტი შეიძლება ჰქონდეს. შეცვალეთ არსებული ეფექტის პარამეტრები ან ჯერ წაშალეთ ის. -@msg {label} currently requires a path or compound path. Convert type, images and groups to paths first. {label}: ამჟამად საჭიროა ტრაექტორია ან შედგენილი ტრაექტორია. ჯერ გარდაქმენით ტექსტი, გამოსახულებები და ჯგუფები ტრაექტორიებად. +@msg {label} currently requires a path, compound path, type or group. Convert images and other objects to paths first. {label}: ამჟამად საჭიროა ტრაექტორია, შედგენილი ტრაექტორია, ტექსტი ან ჯგუფი. ჯერ გარდაქმენით გამოსახულებები და სხვა ობიექტები ტრაექტორიებად. @msg {label} requires a shape with at least two anchors {label}: საჭიროა ფიგურა მინიმუმ ორი საყრდენი წერტილით @msg The shape is too complex for a 3D effect ფიგურა ზედმეტად რთულია 3D ეფექტისთვის @msg The 3D surface is too complex; simplify the shape 3D ზედაპირი ზედმეტად რთულია; გაამარტივეთ ფიგურა diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index e281e83162..6dbb47203c 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -5157,7 +5157,7 @@ lighting Light 광원 Bevel both sides 양면 베벨 Presets 사전 설정 @msg An object takes one 3D effect. Edit the existing effect's options, or remove it first. 오브젝트에는 3D 효과를 하나만 적용할 수 있습니다. 기존 효과의 옵션을 편집하거나 먼저 제거하십시오. -@msg {label} currently requires a path or compound path. Convert type, images and groups to paths first. {label}에는 현재 패스 또는 컴파운드 패스가 필요합니다. 문자, 이미지 및 그룹을 먼저 패스로 변환하십시오. +@msg {label} currently requires a path, compound path, type or group. Convert images and other objects to paths first. {label}에는 현재 패스, 컴파운드 패스, 문자 또는 그룹이 필요합니다. 이미지 및 기타 개체를 먼저 패스로 변환하십시오. @msg {label} requires a shape with at least two anchors {label}에는 고정점이 두 개 이상인 모양이 필요합니다 @msg The shape is too complex for a 3D effect 모양이 너무 복잡하여 3D 효과를 적용할 수 없습니다 @msg The 3D surface is too complex; simplify the shape 3D 표면이 너무 복잡합니다. 모양을 단순화하십시오 diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 8d1be1679e..6356fb8b35 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -5417,7 +5417,7 @@ mark-style Japanese Japońskie Bevel both sides Faza po obu stronach Presets Ustawienia @msg An object takes one 3D effect. Edit the existing effect's options, or remove it first. Obiekt może mieć jeden efekt 3D. Edytuj opcje istniejącego efektu lub najpierw go usuń. -@msg {label} currently requires a path or compound path. Convert type, images and groups to paths first. {label} wymaga obecnie ścieżki lub ścieżki złożonej. Najpierw przekonwertuj tekst, obrazy i grupy na ścieżki. +@msg {label} currently requires a path, compound path, type or group. Convert images and other objects to paths first. {label} wymaga obecnie ścieżki, ścieżki złożonej, tekstu lub grupy. Najpierw przekonwertuj obrazy i inne obiekty na ścieżki. @msg {label} requires a shape with at least two anchors {label} wymaga kształtu z co najmniej dwoma punktami @msg The shape is too complex for a 3D effect Kształt jest zbyt złożony dla efektu 3D @msg The 3D surface is too complex; simplify the shape Powierzchnia 3D jest zbyt złożona; uprość kształt diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index ebe44a68cf..7e30236acb 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -5527,7 +5527,7 @@ mark-style Japanese Japonesas Bevel both sides Chanfro nos Dois Lados Presets Predefinidos @msg An object takes one 3D effect. Edit the existing effect's options, or remove it first. Um objeto aceita apenas um efeito 3D. Edite as opções do efeito existente ou remova-o primeiro. -@msg {label} currently requires a path or compound path. Convert type, images and groups to paths first. {label} exige, por enquanto, um caminho ou caminho composto. Converta antes texto, imagens e grupos em caminhos. +@msg {label} currently requires a path, compound path, type or group. Convert images and other objects to paths first. {label} exige, por enquanto, um caminho, caminho composto, texto ou grupo. Converta antes imagens e outros objetos em caminhos. @msg {label} requires a shape with at least two anchors {label} exige uma forma com pelo menos dois pontos de ancoragem @msg The shape is too complex for a 3D effect A forma é complexa demais para um efeito 3D @msg The 3D surface is too complex; simplify the shape A superfície 3D é complexa demais; simplifique a forma diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index dc6402e831..67cece3c24 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -5190,7 +5190,7 @@ lighting Light Свет Bevel both sides Фаска с обеих сторон Presets Наборы @msg An object takes one 3D effect. Edit the existing effect's options, or remove it first. У объекта может быть только один 3D-эффект. Измените параметры существующего эффекта или сначала удалите его. -@msg {label} currently requires a path or compound path. Convert type, images and groups to paths first. {label}: пока требуется контур или составной контур. Сначала преобразуйте текст, изображения и группы в контуры. +@msg {label} currently requires a path, compound path, type or group. Convert images and other objects to paths first. {label}: пока требуется контур, составной контур, текст или группа. Сначала преобразуйте изображения и другие объекты в контуры. @msg {label} requires a shape with at least two anchors {label}: требуется фигура не менее чем с двумя опорными точками @msg The shape is too complex for a 3D effect Фигура слишком сложна для 3D-эффекта @msg The 3D surface is too complex; simplify the shape 3D-поверхность слишком сложна; упростите фигуру diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index a8ce1904d1..fab8dd2b1a 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -5062,7 +5062,7 @@ lighting Light Ljus Bevel both sides Fasning på båda sidor Presets Förinställningar @msg An object takes one 3D effect. Edit the existing effect's options, or remove it first. Ett objekt kan bara ha en 3D-effekt. Redigera alternativen för den befintliga effekten eller ta bort den först. -@msg {label} currently requires a path or compound path. Convert type, images and groups to paths first. {label} kräver för närvarande en bana eller sammansatt bana. Konvertera text, bilder och grupper till banor först. +@msg {label} currently requires a path, compound path, type or group. Convert images and other objects to paths first. {label} kräver för närvarande en bana, sammansatt bana, text eller grupp. Konvertera bilder och andra objekt till banor först. @msg {label} requires a shape with at least two anchors {label} kräver en form med minst två ankarpunkter @msg The shape is too complex for a 3D effect Formen är för komplex för en 3D-effekt @msg The 3D surface is too complex; simplify the shape 3D-ytan är för komplex; förenkla formen diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index c2804006cd..6719a2b014 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -4858,7 +4858,7 @@ lighting Light Işık Bevel both sides Her iki tarafa da eğim verin Presets Ön ayarlar @msg An object takes one 3D effect. Edit the existing effect's options, or remove it first. Bir nesne bir 3D efekti alır. Mevcut efektin seçeneklerini düzenleyin veya önce onu kaldırın. -@msg {label} currently requires a path or compound path. Convert type, images and groups to paths first. {label} şu anda bir yol veya bileşik yol gerektiriyor. Önce türü, görüntüleri ve grupları yollara dönüştürün. +@msg {label} currently requires a path, compound path, type or group. Convert images and other objects to paths first. {label} şu anda bir yol, bileşik yol, yazı veya grup gerektiriyor. Önce görüntüleri ve diğer nesneleri yollara dönüştürün. @msg {label} requires a shape with at least two anchors {label} en az iki bağlantı noktasına sahip bir şekil gerektirir @msg The shape is too complex for a 3D effect Şekli 3 boyutlu bir efekt için fazla karmaşık @msg The 3D surface is too complex; simplify the shape 3 boyutlu yüzey çok karmaşık; şekli basitleştir diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 0108c96a50..efc018cb06 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -5176,7 +5176,7 @@ lighting Light Світло Bevel both sides Фаска з обох боків Presets Набори @msg An object takes one 3D effect. Edit the existing effect's options, or remove it first. Об'єкт може мати лише один 3D-ефект. Змініть параметри наявного ефекту або спершу вилучіть його. -@msg {label} currently requires a path or compound path. Convert type, images and groups to paths first. {label}: поки що потрібен контур або складений контур. Спершу перетворіть текст, зображення та групи на контури. +@msg {label} currently requires a path, compound path, type or group. Convert images and other objects to paths first. {label}: поки що потрібен контур, складений контур, текст або група. Спершу перетворіть зображення та інші об'єкти на контури. @msg {label} requires a shape with at least two anchors {label}: потрібна фігура щонайменше з двома опорними точками @msg The shape is too complex for a 3D effect Фігура надто складна для 3D-ефекту @msg The 3D surface is too complex; simplify the shape 3D-поверхня надто складна; спростіть фігуру diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 5b979b003f..875757c256 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -5171,7 +5171,7 @@ theme Appearance Giao diện @msg {C}: no effect at index {from} {C}: không có hiệu ứng tại chỉ mục {from} @msg {depth}-bit PSD files in this colour mode can't be read tệp PSD {depth}-bit ở chế độ màu này không thể đọc được @msg {key} must be a finite coordinate {key} phải là tọa độ hữu hạn -@msg {label} currently requires a path or compound path. Convert type, images and groups to paths first. {label} hiện yêu cầu một đường dẫn hoặc đường dẫn phức. Hãy chuyển văn bản, hình ảnh và nhóm thành đường dẫn trước. +@msg {label} currently requires a path, compound path, type or group. Convert images and other objects to paths first. {label} hiện yêu cầu một đường dẫn, đường dẫn phức, văn bản hoặc nhóm. Hãy chuyển hình ảnh và các đối tượng khác thành đường dẫn trước. @msg {label} produced an empty result {label} tạo ra kết quả trống @msg {label} requires a shape with at least two anchors {label} yêu cầu một hình dạng có ít nhất hai điểm neo @msg {label}: lost track of a shape's style {label}: mất dấu kiểu của hình dạng diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index d7b40b95d3..3d5937c833 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -5457,7 +5457,7 @@ lighting Light 光源 Bevel both sides 双面斜角 Presets 预设 @msg An object takes one 3D effect. Edit the existing effect's options, or remove it first. 一个对象只能应用一种 3D 效果。请编辑现有效果的选项,或先将其移去。 -@msg {label} currently requires a path or compound path. Convert type, images and groups to paths first. {label}目前需要路径或复合路径。请先将文字、图像和编组转换为路径。 +@msg {label} currently requires a path, compound path, type or group. Convert images and other objects to paths first. {label}目前需要路径、复合路径、文字或编组。请先将图像和其他对象转换为路径。 @msg {label} requires a shape with at least two anchors {label}需要至少有两个锚点的形状 @msg The shape is too complex for a 3D effect 形状过于复杂,无法应用 3D 效果 @msg The 3D surface is too complex; simplify the shape 3D 表面过于复杂;请简化形状 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 9e27d9c7b7..1221ceaed0 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -5283,7 +5283,7 @@ lighting Light 光源 Bevel both sides 雙面斜角 Presets 預設集 @msg An object takes one 3D effect. Edit the existing effect's options, or remove it first. 一個物件只能套用一種 3D 效果。請編輯現有效果的選項,或先將其移除。 -@msg {label} currently requires a path or compound path. Convert type, images and groups to paths first. {label}目前需要路徑或複合路徑。請先將文字、影像和群組轉換為路徑。 +@msg {label} currently requires a path, compound path, type or group. Convert images and other objects to paths first. {label}目前需要路徑、複合路徑、文字或群組。請先將影像和其他物件轉換為路徑。 @msg {label} requires a shape with at least two anchors {label}需要至少有兩個錨點的形狀 @msg The shape is too complex for a 3D effect 形狀過於複雜,無法套用 3D 效果 @msg The 3D surface is too complex; simplify the shape 3D 表面過於複雜;請簡化形狀 diff --git a/docs/mcp.md b/docs/mcp.md index 81dd913448..7ab4e07de5 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -2347,10 +2347,12 @@ default) white ones knock out instead; `"discard"` writes none. ## Revolve (initial 3D effect) -`effect.apply {effect: "threeD.revolve", params: {...}, ids?, item?}` applies a live Revolve to a path -or compound path. `effect.dialog` opens compact options with artboard rotation and light gizmos, `effect.setParams` edits it, and +`effect.apply {effect: "threeD.revolve", params: {...}, ids?, item?}` applies a live Revolve to a path, +compound path, type, or a group or layer of them. `effect.dialog` opens compact options with artboard rotation and light gizmos, `effect.setParams` edits it, and `effect.expandAppearance` bakes it into shaded vector faces. Native saves keep the editable source; SVG/PDF exports bake the same faces the canvas draws. Only one Revolve is supported per profile. +Type sweeps as its glyph outlines and stays type. On a group or layer, every visible member sweeps about one axis on the chosen edge of the members' bounds together, in its own paint, and the members stay editable. A group holding a clipping group, an image, a symbol instance or another object that can't be swept is refused, and so is type with no outlines or a group with no shape in it. +A surface takes at most 16,384 profile points and 163,840 faces, the members of a group together, counted after any geometry effects before the Revolve. A straight segment takes one point, a curve takes at least four, and each contour takes one. Each curved segment takes 16 samples; on a surface over those limits every curve takes as many as fit, down to 4, and the call is refused below that with "reduce Segments or simplify the profile". The call is also refused with "reduce Segments or simplify the gradient" when the faces' copies of the paint would take more than 256 MiB. Mesh export refuses a profile as too complex when its closed contours nest so deeply that finding the holes would take more than 16,000,000 path element tests. Parameters: `angle` (degrees, 0..360, default 360), `offset` (points, 0..100000, default 0), `edge` (`"left"` or `"right"`), `rotationX/Y/Z` (degrees, defaults 0/0/0), `perspectiveAngle` diff --git a/docs/revolve.md b/docs/revolve.md index 2ea788f905..836c3faa30 100644 --- a/docs/revolve.md +++ b/docs/revolve.md @@ -2,7 +2,10 @@ This initial implementation follows the scope agreed in [issue #605](https://github.com/storytold/vectorcraft/issues/605#issuecomment-6070543567). -Draw an open profile with the Pen tool, select it, then choose **Effect > 3D and Materials > Revolve**. +Select a path, compound path, type, or a group or layer of them, then choose **Effect > 3D and Materials > Revolve**. +Type stays editable and sweeps as its glyph outlines. On a group or layer, every visible member sweeps about one axis +on the chosen edge of the members' bounds together, in its own paint, as [the help page](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/create-3d-graphics/create-3d-objects.html) +describes for a group. The profile's horizontal distance from a vertical axis is its radius. **Axis side** chooses the left or right edge of the profile's bounds; **Offset** moves the axis outward. For a cylinder made from a vertical line, give it a nonzero offset. A vertical line at zero offset lies on the axis and has no surface. @@ -88,13 +91,13 @@ with the standard command. - Lighting offers Standard, Diffuse, Top Left and Right, using the [shared original rigs](three-d.md#presets). Other effective settings show Custom; changing the view does not change the recognized lighting preset. -- Paths and compound paths only. Type, images and groups must be converted to paths first. +- Paths, compound paths, type, and groups or layers of them only. Images and other objects must be converted to paths first. - Open profiles have circular end caps by default; turning Cap off keeps them open. Partial-sweep angular cut sides remain open. Closed profile boundaries are swept as provided. - One Revolve per source object. Edit its options rather than applying another. - Object-level Revolve uses the top visible fill, falling back to a stroke. Per-item Revolve uses that item's paint. - Smooth diffuse shading, one to ten directional lights, no textures, cast shadows or physical materials. Smooth shading interpolates lighting in projected triangles; it does not smooth the silhouette or add specular highlights. - Faces use back-to-front depth sorting. Caps facing the camera cover their rim seams; caps facing away sit behind the body. Intersecting profiles can have incorrect visibility. -- Curves use sixteen samples per segment; profiles are limited to 1,024 samples and surfaces to 32,768 faces. Smooth shaded side quads become two triangles and share that same limit. A continuous cap retains its original radial mesh cost within this limit. Projected ring curves have a 0.001-point fitting tolerance and a shared 32,768-curve limit. +- Curves use sixteen samples per segment, and as few as four when a surface would otherwise pass its limits. Surfaces are limited to 16,384 profile points and 163,840 faces, the members of a group together. A straight segment takes one point, a curve takes at least four, and each contour takes one. Smooth shaded side quads become two triangles and share the face limit. A continuous cap retains its original radial mesh cost within this limit. Projected ring curves have a 0.001-point fitting tolerance and a shared 32,768-curve limit. - Axis, bounds and ordinary selection handles continue to refer to the editable 2D source profile. - Extrude & Bevel, Inflate and Rotate are described in [three-d.md](three-d.md). Materials remain unimplemented. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index c37d5234c3..9fe8d2c213 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1040,6 +1040,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Extrude & Bevel, Inflate and Rotate (Effect › 3D and Materials): live effects in the `three-d` crate with depth, twist, taper, solid or hollow caps, five original bevel shapes (width, height, repeat, spacing, inside, both sides), Inflate's volume, both sides, twist, taper and cap, Rotate's turned fills and strokes, Position presets, the rotation and light gizmos, SVG/PDF vector output and Expand Appearance; one 3D effect per object; covered by geometry, effect, canvas-versus-export, command and dialog tests ([three-d.md](three-d.md)). - Export 3D Object (Asset Export's menu): an object with Extrude & Bevel, Revolve, Inflate or Rotate becomes a 3D asset whose Format is USDA (the default), USDZ, glTF or OBJ, and Export writes each of its solids as a mesh with a material in its paint's color (`assets.add3D`, `assets.set3DFormat`, `assets.export3D`; [export 3D vector artwork](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/create-3d-graphics/export-3d-vector-artwork.html)). - Initial live Revolve (#605, #846): a portable geometry and shading crate (`three-d`), editable path and compound profiles, compact options with artboard rotation and light gizmos, SVG/PDF vector output and Expand Appearance that can trim covered faces. Open ends have continuous caps by default, shading is smooth with one to ten colored lights, and intersecting surfaces' visibility is approximate ([revolve.md](revolve.md)). + - Revolve takes type, as its glyph outlines, and groups or layers, whose members sweep about one axis as [the help page](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/create-3d-graphics/create-3d-objects.html) describes; a surface can have up to 16,384 profile points, and curves take fewer samples when a surface would pass 163,840 faces (#3617). - Live effects with previewing dialogs: Distort & Transform, Path, Convert to Shape, 15 Warp styles, Round Corners, Scribble, Effect → Pathfinder (all 10 operations, live on groups; several loose objects are grouped first, in one undo step), Color Adjustments (Brightness/Contrast, Curves, Levels, Hue/Saturation, Shift to Color, Temperature/Tint, on vectors, live type and embedded images), and raster drop shadow, glows and feather. SVG and PDF export keep live effects (geometry baked; SVG raster effects as filters). Zig Zag spaces its ridges evenly along each segment, so lines and curves get a regular wave in Corner and Smooth (#1558). Warp › Arc (the effect and Envelope › Make with Warp) bends in the object's real proportions: a circular arc with concentric edges and no fold-over at 100%, so Arc on art that isn't square draws differently from before (#1581, fixes #1578). `effect.apply` and `effect.setParams` give an error for a misspelt or wrongly typed effect parameter instead of running the effect with its default (#1623, #1592). Roughen with Smooth keeps turn-backs smooth instead of making cusps that draw as spikes (#1646, #1643). Scribble draws one zig-zag per region, with no lines across holes and notches; convex shapes are unchanged (#1651, #1648). On the canvas, in Expand Appearance and in exports, Effect → Pathfinder uses the glyph outlines of type in the group, and results painted like the type keep its opacity and blend mode. - Cmd/Ctrl+4 (`pathfinder.repeat`) repeats the last successful Pathfinder panel operation, direct `object.pathfinder.*` command or `command.batch` step (including Actions playback) on the current selection; the panel menu names it. `action.batch`, Trap, compound-shape edits and live effects do not set history. Public references: [Default keyboard shortcuts](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html), [Pathfinder panel overview](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/pathfinder-panel-overview.html). - Pathfinder Options ([Pathfinder panel overview](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/pathfinder-panel-overview.html)): the panel menu's Pathfinder Options… sets Precision (0.001–10 pt: results refit to within it, thinner pieces dropped), Remove Redundant Points and Divide and Outline Will Remove Unpainted Artwork (`object.pathfinder.options`, kept in the preferences) for every Shape Mode and Pathfinder operation; the defaults keep the earlier output (`pathops` `precision_drops_pieces_thinner_than_it`, engine `tests_pathops` Pathfinder Options tests, `panels::pathfinder` dialog test). diff --git a/docs/three-d.md b/docs/three-d.md index f3088cc8d1..0e99010613 100644 --- a/docs/three-d.md +++ b/docs/three-d.md @@ -232,8 +232,8 @@ out, perspective). No Adobe software, assets, presets or bevel shapes were used. on top can still lie a few points behind another nearby piece. - Extrude & Bevel and Rotate retain planar shading. Colored Inflate uses smooth vector lighting; white-light Inflate keeps its original polygon shading; bounded cell triangulation leaves a cell flat when it contains a complete hole or an unusually complex/degenerate contour. Its geometric silhouette is still a polygon approximation. - Paths, compound paths, type and groups or layers of them (see [Groups](#groups)); images, symbol instances, - clipping groups and other live objects have to be converted first. Revolve on a group is not built yet. One 3D - effect per object. + clipping groups and other live objects have to be converted first. Revolve on a group sweeps its members about + one axis ([revolve.md](revolve.md)). One 3D effect per object. - Curves are followed within 1/2000 of the shape's size (at least 0.05 pt), so a small curve takes a few points and a large one up to 32. Extrude & Bevel and Inflate take up to 2,048 outline points (a stroke's outline is counted on its own), Rotate (which only turns the outline) 65,536. The limit is counted on the shape as drawn and, From 35db931810cff174e09bc4a28d207b01061481a4 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Mon, 9 Nov 2026 16:24:13 -0800 Subject: [PATCH 08/36] Fix #6316: a group copies each member in place when a member makes more art A group with a visible member that carries a geometry, Pathfinder or 3D effect, also in an opacity mask, keeps its previous copies. Copies stop at the budget with what each copy's own effects make, and a group's copies count the art of opacity masks. The PDF and SVG tests for a group's image copies now expect the original on top. --- crates/effects/src/catalog.rs | 2 +- crates/effects/src/reshape.rs | 86 +++++++++--- crates/effects/src/tests/transformfx.rs | 167 ++++++++++++++++++++++++ crates/pdf/src/tests.rs | 4 +- crates/svg/tests/it/container_export.rs | 2 +- docs/effects-parity.md | 2 +- 6 files changed, 241 insertions(+), 22 deletions(-) diff --git a/crates/effects/src/catalog.rs b/crates/effects/src/catalog.rs index 95111b5fb4..e9bbaf4bbf 100644 --- a/crates/effects/src/catalog.rs +++ b/crates/effects/src/catalog.rs @@ -247,7 +247,7 @@ pub fn effect_catalog() -> Vec { "distort.transform", "Transform…", DT, - "{scaleH: % (100), scaleV: % (100), moveH: pt (0; right = +), moveV: pt (0; down = +), rotate: deg (0; counter-clockwise; when the effect turns, reflects or scales the object unevenly, a gradient fitted to the object is fixed to its fit and turns with the object and with each copy), copies: 0..1000 (0; each copy transforms the last again and is painted below it, the object on top; a group's copies each hold all its members; placed gradients transform with the object and with each copy), reflectX: bool (false; flips left to right), reflectY: bool (false; flips top to bottom), reference: 0..8 (4; the point of the bounds' 9-point grid it scales, rotates and reflects about: 0 top left, 4 centre, 8 bottom right), random: bool (false; each scale goes a random share of the way from 100 % to its value, each move and the angle a random share of theirs, differently for each object and the same on every redraw), objects: bool (true; Transform Objects: off, the geometry stays where it is and makes no copies, and only the two options below apply, once), patterns: bool (false; Transform Patterns: pattern fills and strokes transform along; with copies the original keeps its pattern and copy k takes the transformation k times), strokes: bool (false; Scale Strokes & Effects: stroke weights and dashes, and the distances of the effects on the fills and strokes, scale by the mean scale √|scaleH·scaleV|, copy k by its k-th power)}", + "{scaleH: % (100), scaleV: % (100), moveH: pt (0; right = +), moveV: pt (0; down = +), rotate: deg (0; counter-clockwise; when the effect turns, reflects or scales the object unevenly, a gradient fitted to the object is fixed to its fit and turns with the object and with each copy), copies: 0..1000 (0; each copy transforms the last again and is painted below it, the object on top; a group's copies each hold all its members, and a group with a member that carries a geometry, Pathfinder or 3D effect copies each member in its place; placed gradients transform with the object and with each copy), reflectX: bool (false; flips left to right), reflectY: bool (false; flips top to bottom), reference: 0..8 (4; the point of the bounds' 9-point grid it scales, rotates and reflects about: 0 top left, 4 centre, 8 bottom right), random: bool (false; each scale goes a random share of the way from 100 % to its value, each move and the angle a random share of theirs, differently for each object and the same on every redraw), objects: bool (true; Transform Objects: off, the geometry stays where it is and makes no copies, and only the two options below apply, once), patterns: bool (false; Transform Patterns: pattern fills and strokes transform along; with copies the original keeps its pattern and copy k takes the transformation k times), strokes: bool (false; Scale Strokes & Effects: stroke weights and dashes, and the distances of the effects on the fills and strokes, scale by the mean scale √|scaleH·scaleV|, copy k by its k-th power)}", json!({"scaleH": 100.0, "scaleV": 100.0, "moveH": 0.0, "moveV": 0.0, "rotate": 0.0, "copies": 0, "reflectX": false, "reflectY": false, "reference": 4, "random": false, "objects": true, "patterns": false, "strokes": false}), ), g( diff --git a/crates/effects/src/reshape.rs b/crates/effects/src/reshape.rs index 4db4bb3f5a..f6c5cf2aea 100644 --- a/crates/effects/src/reshape.rs +++ b/crates/effects/src/reshape.rs @@ -881,7 +881,8 @@ fn transform_leaf( /// group or layer of several objects, at least one of which paints, with no type left in it and no /// fill or stroke of its own that paints. Type that stays type is copied in its place by the baking /// that keeps it, a clipping group's first member is its clip, and the art's own fills and strokes -/// paint the shape of everything under it, copies included. +/// paint the shape of everything under it, copies included. A member that [`makes_art`] is copied +/// in its place as well: each copy would make that art again, past what [`copy_art`] counts. fn copies_whole(art: &Node) -> bool { fn parts(n: &Node, count: &mut usize, painted: &mut bool, text: &mut bool) { match &n.kind { @@ -906,7 +907,59 @@ fn copies_whole(art: &Node) -> bool { } let (mut count, mut painted, mut text) = (0, false, false); parts(art, &mut count, &mut painted, &mut text); - count > 1 && painted && !text + count > 1 && painted && !text && !art.children().into_iter().flatten().any(|c| makes_art(c)) +} + +/// Does `n` or anything visible under it, the art of its opacity masks included, carry a visible +/// effect that makes art when it is drawn (its own or that of a fill or stroke that paints), or is +/// any of them a compound shape? Raster effects, color adjustments and unknown effects make none. +fn makes_art(n: &Node) -> bool { + let makes = |e: &Effect| { + e.visible + && (is_geometry(&e.id) + || crate::is_pathfinder(&e.id) + || crate::is_three_d(&e.id) + || e.id == crate::CROP_MARKS + || e.id == crate::addons::halftone::HALFTONE) + }; + n.visible + && (n.is_compound_shape() + || n.appearance.effects.iter().any(makes) + || n.appearance.items.iter().any(|i| crate::paints(i) && i.effects().iter().any(makes)) + || n.extras().mask.as_deref().is_some_and(|m| makes_art(&m.art)) + || n.children().into_iter().flatten().any(|c| makes_art(c))) +} + +/// What the paths under `n` cost together, those of its opacity masks included. +fn copy_cost(n: &Node) -> Budget { + let mut cost = Budget { anchors: 0, lines: 0 }; + vectorcraft_doc::swatches::walk_with_masks(n, &mut |c| { + if let NodeKind::Path { guide: false, path, .. } = &c.kind { + cost = cost.plus(Budget::of(path)); + } + }); + cost +} + +/// What the geometry effects of `leaf` (with geometry `path`) and of its fills and strokes add to +/// what `path` costs when it is drawn. +fn effects_cost(leaf: &Node, path: &PathData) -> Budget { + let none = Budget { anchors: 0, lines: 0 }; + let items: Vec<&AppearanceItem> = leaf.appearance.items.iter().filter(|i| crate::paints(i) && crate::has_geometry(i.effects())).collect(); + if items.is_empty() && !crate::has_geometry(&leaf.appearance.effects) { + return none; + } + let Some(bounds) = path.bounds() else { return none }; + let ctx = GeomContext { seed: leaf.id.0, ..GeomContext::of(leaf) }; + let own = crate::apply_geometry_with(&leaf.appearance.effects, path, bounds, &ctx); + let item_cost = |i: &&AppearanceItem| { + let b = own.bounds()?; + Some(Budget::of(&crate::apply_geometry_with(i.effects(), &own, b, &ctx.item(i)))) + }; + // The object's geometry is drawn as it is only by what has no geometry effect of its own. + let plain = items.is_empty() || leaf.appearance.items.iter().any(|i| crate::paints(i) && !crate::has_geometry(i.effects())); + let base = if plain { Budget::of(&own) } else { none }; + base.plus(items.iter().filter_map(item_cost).fold(none, Budget::plus)).minus(Budget::of(path)) } /// Remove the guides anywhere under `n`. @@ -919,17 +972,16 @@ fn without_guides(n: &mut Node) { /// Replace the members of `art` by groups of them: the original on top and `copies` more below it, /// each mapped once more by `m` than the last (with `scaling`), as a Transform effect copies a /// group, without its guides. The copies stop where [`copy_leaf`]'s would, before they pass `budget` or -/// [`crate::distort::MAX_COPY_OBJECTS`] nodes in all. Returns what the art costs after. +/// [`crate::distort::MAX_COPY_OBJECTS`] nodes in all, the art of opacity masks counted. Returns what +/// the art costs after. fn copy_art(art: &mut Node, m: Affine, copies: usize, scaling: vectorcraft_doc::Scaling, budget: Budget) -> Budget { let Some(members) = art.children().cloned() else { return art_cost(art) }; let original = Node::group(art.id, members); - let mut made = art_cost(&original); + let mut made = copy_cost(&original); let mut drawn = original.clone(); without_guides(&mut drawn); - let mut nodes = 0usize; - original.walk(&mut |_| nodes += 1); - let mut one = 0usize; - drawn.walk(&mut |_| one += 1); + let mut nodes = original.count_with_masks(); + let one = drawn.count_with_masks(); let mut placements: Vec> = vec![]; let mut xf = Affine::IDENTITY; for _ in 0..copies { @@ -943,7 +995,7 @@ fn copy_art(art: &mut Node, m: Affine, copies: usize, scaling: vectorcraft_doc:: } let mut copy = drawn.clone(); copy.transform(xf, scaling); - let cost = art_cost(©); + let cost = copy_cost(©); if !budget.fits(made.plus(cost)) || !copy.geometric_bounds().is_some_and(crate::distort::within_reach) { break; } @@ -964,25 +1016,25 @@ fn copy_art(art: &mut Node, m: Affine, copies: usize, scaling: vectorcraft_doc:: /// copies an object. Each copy is an object of its own, so its gradients fit it (a placed gradient /// moves with it) rather than spanning every copy, and its strokes and patterns can differ from the /// others'. The copies stop where [`crate::distort::transform_within`]'s would, before they pass -/// `budget`, and before one's strokes would scale past [`MAX_STROKE_SCALE`]. Returns what the -/// copies cost, the original included. +/// `budget` with what each copy's own geometry effects make ([`effects_cost`]), and before one's +/// strokes would scale past [`MAX_STROKE_SCALE`]. Returns what the copies cost, the original +/// included. fn copy_leaf(leaf: &mut Node, path: &PathData, m: Affine, copies: usize, scaling: vectorcraft_doc::Scaling, budget: Budget) -> Budget { - let mut made = Budget::of(path); + let mut made = Budget::of(path).plus(effects_cost(leaf, path)); let mut placements: Vec> = vec![Arc::new(leaf.clone())]; let mut xf = Affine::IDENTITY; for _ in 0..copies { xf *= m; // A copy scaled up is drawn with more lines. - let cost = Budget::of(&path.transformed(xf)); - if !budget.fits(made.plus(cost)) { - break; - } + let moved = path.transformed(xf); if scaling.strokes && vectorcraft_doc::Scaling::factor(xf).is_some_and(|k| !(k.is_finite() && k <= MAX_STROKE_SCALE)) { break; } let mut copy = leaf.clone(); copy.transform(xf, scaling); - if !copy.geometric_bounds().is_some_and(crate::distort::within_reach) { + // Each copy's own geometry effects make a different amount, as the copy and its effects scale. + let cost = Budget::of(&moved).plus(effects_cost(©, &moved)); + if !budget.fits(made.plus(cost)) || !copy.geometric_bounds().is_some_and(crate::distort::within_reach) { break; } placements.push(Arc::new(copy)); diff --git a/crates/effects/src/tests/transformfx.rs b/crates/effects/src/tests/transformfx.rs index e424a8293b..de69116b79 100644 --- a/crates/effects/src/tests/transformfx.rs +++ b/crates/effects/src/tests/transformfx.rs @@ -509,3 +509,170 @@ fn a_groups_copies_stop_at_the_node_limit() { assert!(nodes <= crate::distort::MAX_COPY_OBJECTS + 1, "{nodes}"); assert!(g.children().unwrap().len() < 1001, "the limit stopped the copies"); } + +#[test] +fn a_groups_copies_count_the_art_of_opacity_masks() { + let empties = (0..5_000).map(|i| std::sync::Arc::new(Node::group(NodeId(100 + i), vec![]))).collect(); + let mut g = two_squares(json!({})); + if let Some(members) = g.children_mut() { + let mask = vectorcraft_doc::OpacityMask::new(Node::group(NodeId(99), empties), true); + std::sync::Arc::make_mut(&mut members[0]).extras_mut().mask = Some(Box::new(mask)); + } + g.appearance.effects.clear(); + let copies = transform(json!({"moveH": 1, "copies": 1000})); + crate::reshape::reshape_leaves_within(&mut g, &[&copies], crate::geometry::Budget::EFFECTS, None); + let nodes = g.count_with_masks(); + assert!(nodes <= crate::distort::MAX_COPY_OBJECTS + 1, "{nodes}"); +} + +/// The anchors `n` draws with every effect inside it evaluated, group by group and member by +/// member as drawing and baking evaluate them. The count stops once it passes `limit`. +fn drawn_anchors(n: &Node, limit: usize) -> usize { + fn count(n: &Node, limit: usize, total: &mut usize) { + if *total > limit { + return; + } + if n.children().is_some() && !matches!(n.kind, NodeKind::Compound { .. }) { + let art = crate::evaluate_container(None, n).unwrap_or_else(|| n.clone()); + art.children().into_iter().flatten().for_each(|c| count(c, limit, total)); + return; + } + if let Some(art) = reshape(None, n, None) { + count(&art, limit, total); + return; + } + let Some((path, _)) = crate::reshape::leaf_geometry(n) else { return }; + let Some(bounds) = path.bounds() else { return }; + let own = crate::apply_geometry(&n.appearance.effects, &path, bounds); + let items: Vec = n + .appearance + .items + .iter() + .filter(|i| crate::has_geometry(i.effects())) + .filter_map(|i| Some(crate::apply_geometry(i.effects(), &own, own.bounds()?).anchor_count())) + .collect(); + *total += if items.is_empty() { own.anchor_count() } else { items.iter().sum() }; + } + let mut total = 0; + count(n, limit, &mut total); + total +} + +#[test] +fn a_groups_copies_keep_what_the_effects_inside_them_make_to_the_budget() { + let most = crate::geometry::MAX_EFFECT_ANCHORS; + let copies = |key: &str| transform(json!({key: 1, "copies": 1000})); + + // Copies of a group whose member group makes copies of its own. + let inner = two_squares(json!({"moveH": 1, "copies": 1000})); + let mut outer = Node::group(NodeId(30), vec![std::sync::Arc::new(inner)]); + outer.appearance.effects.push(copies("moveV")); + let drawn = drawn_anchors(&outer, 2 * most); + assert!(drawn <= most, "a group inside: {drawn}"); + + // Copies of a group whose member path makes copies of its own. + let mut own = two_squares(json!({"moveH": 1, "copies": 1000})); + if let Some(members) = own.children_mut() { + std::sync::Arc::make_mut(&mut members[0]).appearance.effects.push(copies("moveV")); + } + let drawn = drawn_anchors(&own, 2 * most); + assert!(drawn <= most, "a path's own effect: {drawn}"); + + // Copies of a group whose member's fill makes copies of its own. + let mut item = two_squares(json!({"moveH": 1, "copies": 1000})); + if let Some(members) = item.children_mut() { + std::sync::Arc::make_mut(&mut members[0]).appearance.items[0].effects_mut().push(copies("moveV")); + } + let drawn = drawn_anchors(&item, 2 * most); + assert!(drawn <= most, "a fill's own effect: {drawn}"); +} + +#[test] +fn copies_with_scaled_effects_keep_what_each_one_makes_to_the_budget() { + // Each copy's Roughen is twice as large as the last's, and the rough edges take more lines. + let roughen = Effect { id: "distort.roughen".into(), params: json!({"size": 40, "relative": false, "detail": 20}), visible: true }; + let lines = |budget: crate::geometry::Budget| { + let mut g = two_squares(json!({})); + if let Some(members) = g.children_mut() { + let m = std::sync::Arc::make_mut(&mut members[0]); + m.appearance.items.retain(AppearanceItem::is_fill); + m.appearance.effects.push(roughen.clone()); + members.truncate(1); + } + g.appearance.effects.clear(); + let copies = transform(json!({"scaleH": 200, "scaleV": 200, "copies": 5, "strokes": true})); + crate::reshape::reshape_leaves_within(&mut g, &[&copies], budget, None); + let (mut count, mut lines) = (0, 0); + for leaf in leaves(&g) { + count += 1; + let Some((path, _)) = crate::reshape::leaf_geometry(leaf) else { continue }; + let drawn = crate::apply_geometry(&leaf.appearance.effects, &path, path.bounds().unwrap()); + lines += crate::geometry::Budget::of(&drawn).lines; + } + (count, lines) + }; + let (all, most) = lines(crate::geometry::Budget::EFFECTS); + assert_eq!(all, 6, "the original and 5 copies"); + let cap = most / 2; + let budget = crate::geometry::Budget { anchors: crate::geometry::MAX_EFFECT_ANCHORS, lines: cap }; + let (count, drawn) = lines(budget); + assert!(count < all, "the budget stopped the copies"); + assert!(drawn <= cap, "{drawn} > {cap}"); +} + +#[test] +fn a_groups_copies_are_whole_only_when_nothing_inside_makes_more_art() { + let copies = transform(json!({"moveH": 300, "copies": 2})); + let effect = |id: &str| Effect { id: id.into(), params: json!({}), visible: true }; + let roughen = effect("distort.roughen"); + // Two squares under a group with `change` made to the first member. + let whole = |change: &dyn Fn(&mut Node)| { + let mut g = two_squares(json!({})); + if let Some(members) = g.children_mut() { + change(std::sync::Arc::make_mut(&mut members[0])); + } + g.appearance.effects.clear(); + crate::reshape::reshape_leaves_within(&mut g, &[&copies], crate::geometry::Budget::EFFECTS, None); + g.children().unwrap().len() == 3 + }; + // A group of one square with effect `e`, in place of the member. + let in_group = |m: &mut Node, e: &Effect| { + let square = Node::path(NodeId(40), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default()); + let mut g = Node::group(NodeId(41), vec![std::sync::Arc::new(square), std::sync::Arc::new(m.clone())]); + g.appearance.effects.push(e.clone()); + *m = g; + }; + assert!(whole(&|_| {})); + // Effects that only change pixels, and effects turned off, are copied with the art. + assert!(whole(&|m| m.appearance.effects.push(effect("stylize.dropShadow")))); + assert!(whole(&|m| m.appearance.effects.push(Effect { visible: false, ..roughen.clone() }))); + // Effects that draw nothing are copied with the art as well. + assert!( + whole(&|m| { + m.visible = false; + m.appearance.effects.push(roughen.clone()); + }), + "a hidden member's effect" + ); + assert!( + whole(&|m| { + *m.appearance.items[0].paint_mut() = Paint::None; + m.appearance.items[0].effects_mut().push(roughen.clone()); + }), + "the effect of a fill that paints nothing" + ); + assert!(whole(&|m| m.appearance.effects.push(effect("no.such.effect"))), "an unknown effect"); + // Effects that make more art than a copy holds keep each member's copies in its place. + assert!(!whole(&|m| m.appearance.effects.push(roughen.clone())), "a member's own effect"); + assert!(!whole(&|m| m.appearance.items[0].effects_mut().push(roughen.clone())), "a fill's own effect"); + assert!(!whole(&|m| in_group(m, &roughen)), "a group's own effect"); + assert!(!whole(&|m| in_group(m, &effect("pathfinder.add"))), "a Pathfinder effect"); + assert!( + !whole(&|m| { + let mut art = Node::group(NodeId(43), vec![]); + in_group(&mut art, &roughen); + m.extras_mut().mask = Some(Box::new(vectorcraft_doc::OpacityMask::new(art, true))); + }), + "an effect in an opacity mask" + ); +} diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs index a8a22b00fb..365b653890 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -749,7 +749,7 @@ fn an_image_whose_pixels_cant_be_decoded_is_left_out_with_one_note() { } /// #2023: a group's Transform effect copies an image among its members as it does the paths: the -/// PDF draws the image three times, where the effect put it. +/// PDF draws the image three times, where the effect put it, the original on top. #[test] fn a_group_transform_effect_exports_its_image_copies() { let mut d = doc(200.0, 100.0); @@ -776,7 +776,7 @@ fn a_group_transform_effect_exports_its_image_copies() { let out = roundtrip(&d); let lefts: Vec = leaves(&out).iter().filter(|n| matches!(n.kind, NodeKind::Image(_))).filter_map(|n| n.geometric_bounds()).map(|b| b.x0.round()).collect(); - assert_eq!(lefts, [20.0, 70.0, 120.0]); + assert_eq!(lefts, [120.0, 70.0, 20.0]); } /// Build a PDF with a correct xref table from object bodies. diff --git a/crates/svg/tests/it/container_export.rs b/crates/svg/tests/it/container_export.rs index 5f34b6cc3a..9518d31d21 100644 --- a/crates/svg/tests/it/container_export.rs +++ b/crates/svg/tests/it/container_export.rs @@ -92,5 +92,5 @@ fn a_group_transform_copies_its_images() { .filter(|l| l.starts_with(" Date: Mon, 9 Nov 2026 16:45:04 -0800 Subject: [PATCH 09/36] Measure: both ends land on glyph guides with Snap to Glyph on MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Measure and Measure & Inspect tools ignored View › Snap to Glyph: their press and end points snapped only to targets that hold no glyph guides. With Snap to Glyph, Smart Guides and Alignment Guides on, and Snap to Grid and Snap to Pixel off, a drag's press and end land within the snapping tolerance on the guides of live type that the Character panel's options allow, except Anchor Point. A Shift-drag's end slides along its 45° step to them, and the guide shows during the drag. With Snap to Glyph off, the tools snap to the same targets as before and show no guide. The targets are kept while the document, the window and the snapping options stay the same, so a drag lays out the type once. --- crates/tools/src/addons/measure_inspect.rs | 62 ++++- crates/tools/src/guides/snap.rs | 22 ++ crates/tools/src/guides/targets.rs | 11 + crates/tools/src/xform/measure.rs | 291 ++++++++++++++++++++- docs/mcp.md | 4 +- docs/target-app-parity.md | 1 + docs/type-parity.md | 2 +- docs/ui-parity.md | 2 +- 8 files changed, 377 insertions(+), 18 deletions(-) diff --git a/crates/tools/src/addons/measure_inspect.rs b/crates/tools/src/addons/measure_inspect.rs index 1ea8a9324d..b7cb13a6e1 100644 --- a/crates/tools/src/addons/measure_inspect.rs +++ b/crates/tools/src/addons/measure_inspect.rs @@ -11,6 +11,7 @@ use vectorcraft_doc::{NodeId, Unit}; use vectorcraft_geom::{Point, Vec2}; use vectorcraft_pathops::addons::pathtools::{Inspection, inspect}; +use crate::guides::DrawSnap; use crate::xform::measure; use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey}; @@ -27,6 +28,8 @@ pub struct MeasureInspectTool { before: Option<(Point, Point)>, /// The path under the pointer and what it reads there. hover: Option<(NodeId, Inspection)>, + /// Snapping for both ends of a measurement. + snap: DrawSnap, } /// An area of `pt2` square points in `unit` (`12.5 mm²`). @@ -87,7 +90,7 @@ impl Tool for MeasureInspectTool { match ev.kind { PointerKind::Move => self.hover = Self::inspect_at(cx, ev.pos), PointerKind::Down => { - let (p, _) = crate::guides::snap_draw(cx, ev.pos, &[]); + let p = self.snap.measure(cx, ev.pos, None); self.before = self.line; self.line = Some((p, p)); self.dragging = true; @@ -95,7 +98,7 @@ impl Tool for MeasureInspectTool { } PointerKind::Drag | PointerKind::Up if self.dragging => { if let Some((a, _)) = self.line { - self.line = Some((a, crate::xform::measure_end_point(cx, a, ev.pos, ev.mods.shift))); + self.line = Some((a, crate::xform::measure_end_point(cx, &mut self.snap, a, ev.pos, ev.mods.shift))); } self.dragging = ev.kind == PointerKind::Drag; } @@ -138,6 +141,7 @@ impl Tool for MeasureInspectTool { out.push(Overlay::Anchor { p: at, color: ORANGE, filled: i.anchor.is_some(), size: 6.0 }); out.push(Overlay::Measure { p: at + off, text: readout(cx, i) }); } + out.extend(self.snap.guides().iter().filter(|_| self.dragging).cloned()); out } @@ -161,7 +165,8 @@ impl Tool for MeasureInspectTool { mod tests { use super::*; use crate::testutil::*; - use vectorcraft_doc::Selection; + use vectorcraft_doc::{CharStyle, Document, Node, NodeKind, Selection, TextObject}; + use vectorcraft_text::{FontDb, layout as layout_text}; #[test] fn a_drag_measures_and_hovering_a_path_reads_it() { @@ -227,4 +232,55 @@ mod tests { assert!((o["distance"].as_f64().unwrap() - 36.0).abs() < 1e-9, "on the 9 pt grid: {o}"); assert!(o["dy"].as_f64().unwrap().abs() < 1e-9, "kept horizontal: {o}"); } + + /// With Snap to Glyph on, both ends and a Shift-drag's end land on the guides of live type. + #[test] + fn its_ends_land_on_glyph_guides_with_snap_to_glyph() { + let mut d = Document::new(500.0, 500.0); + let (l, id) = (d.layers[0].id, d.alloc_id()); + let text = TextObject::point(Point::new(100.0, 300.0), "Hg", CharStyle { size: 60.0, ..Default::default() }); + d.insert(Some(l), 0, Node::new(id, NodeKind::Text(Box::new(text.clone())))).unwrap(); + let lay = layout_text(FontDb::global(), &text); + let base = (text.xf * lay.line_xf * Point::new(lay.lines[0].x0, lay.lines[0].baseline)).y; + let (s, p) = (Selection::default(), paint()); + let on = ToolContext { snap_to_glyph: true, ..cx(&d, &s, &p) }; + let run = |c: &ToolContext, from: Point, to: Point, shift: bool| { + let mods = Mods { shift, ..Mods::default() }; + let mut t = MeasureInspectTool::default(); + t.pointer(c, &PointerEvent::new(PointerKind::Down, from.x, from.y)); + t.pointer(c, &PointerEvent::new(PointerKind::Up, to.x, to.y).with_mods(mods)); + let o = t.options(); + let a = Point::new(o["x"].as_f64().unwrap(), o["y"].as_f64().unwrap()); + (a, a + Vec2::new(o["dx"].as_f64().unwrap(), o["dy"].as_f64().unwrap())) + }; + let (from, to) = (Point::new(400.0, base + 2.0), Point::new(450.0, base - 40.0)); + assert_eq!(run(&on, from, to, false).0, Point::new(400.0, base)); + let (from, to) = (Point::new(300.0, base - 100.0), Point::new(401.0, base + 1.0)); + let landed = run(&on, from, to, true).1; + assert!(landed.distance(Point::new(400.0, base)) < 1e-9, "{landed:?}"); + let off = ToolContext { snap_to_glyph: false, ..on }; + let stayed = run(&off, from, to, true).1; + assert!(stayed.distance(to) < 1e-9, "{stayed:?}"); + } + + /// The guide an end lands on shows while the drag lasts. + #[test] + fn a_drag_shows_the_glyph_guide_it_lands_on() { + let mut d = Document::new(500.0, 500.0); + let (l, id) = (d.layers[0].id, d.alloc_id()); + let text = TextObject::point(Point::new(100.0, 300.0), "Hg", CharStyle { size: 60.0, ..Default::default() }); + d.insert(Some(l), 0, Node::new(id, NodeKind::Text(Box::new(text.clone())))).unwrap(); + let lay = layout_text(FontDb::global(), &text); + let base = (text.xf * lay.line_xf * Point::new(lay.lines[0].x0, lay.lines[0].baseline)).y; + let (s, p) = (Selection::default(), paint()); + let on = ToolContext { snap_to_glyph: true, ..cx(&d, &s, &p) }; + let mut t = MeasureInspectTool::default(); + t.pointer(&on, &PointerEvent::new(PointerKind::Down, 400.0, 50.0)); + let (drag, up) = (PointerKind::Drag, PointerKind::Up); + let count = |t: &MeasureInspectTool| t.overlays(&on).iter().filter(|o| !matches!(o, Overlay::Measure { .. })).count(); + t.pointer(&on, &PointerEvent::new(drag, 450.0, base + 2.0)); + assert!(count(&t) > 1, "the measured line and the guide"); + t.pointer(&on, &PointerEvent::new(up, 450.0, base + 2.0)); + assert_eq!(count(&t), 1, "the measured line alone"); + } } diff --git a/crates/tools/src/guides/snap.rs b/crates/tools/src/guides/snap.rs index 0b117b95b5..9d530f4db5 100644 --- a/crates/tools/src/guides/snap.rs +++ b/crates/tools/src/guides/snap.rs @@ -288,6 +288,11 @@ pub fn square(d: Vec2) -> Vec2 { /// state: hovering gathers them again only after the document, the selection, what is left out, /// the window or the snapping options changed; a press always gathers them afresh, and the drag /// it starts keeps them, the document then showing the drag's own previews. +/// +/// The Measure and Measure & Inspect tools keep one too, for the ends of a drag only. They do not +/// ask it while hovering. [`Self::measure`] gathers [`Targets::for_measure`] instead, and a press +/// reuses those targets while nothing they depend on changed. It has guides to show only while +/// Snap to Glyph gives guides. #[derive(Default)] pub struct DrawSnap { /// The targets, and what they were gathered for (none: for one drag only). @@ -348,6 +353,23 @@ impl DrawSnap { self.snap(cx, p, from) } + /// Snap an end of a measurement at `p` ([`Targets::for_measure`]), reusing the targets while + /// nothing they depend on changed, so a drag lays out the type once. With Snap to Glyph giving + /// no guides, [`Self::guides`] stays empty. + pub fn measure(&mut self, cx: &ToolContext, p: Point, from: Option<&Leave>) -> Point { + let key = SnapKey::of(cx, &[]); + let glyphs = key.snap_to_glyph.is_some(); + if self.targets.as_ref().is_none_or(|(k, _)| k.as_ref() != Some(&key)) { + let snap = PointSnap::new(cx, || Targets::for_measure(cx.doc, visible(cx), key.snap_to_glyph)); + self.targets = Some((Some(key), snap)); + } + let q = self.snap(cx, p, from); + if !glyphs { + self.guides.clear(); + } + q + } + /// Snap the point pressed at `p` onto the art's anchors and paths alone, within the Smart /// Guides snapping tolerance (the Knife's straight cut). The targets serve this press only. pub fn press_on_paths(&mut self, cx: &ToolContext, p: Point) -> Point { diff --git a/crates/tools/src/guides/targets.rs b/crates/tools/src/guides/targets.rs index 17b24ec16f..e4039e6d9c 100644 --- a/crates/tools/src/guides/targets.rs +++ b/crates/tools/src/guides/targets.rs @@ -213,6 +213,17 @@ impl Targets { t.index() } + /// Targets for the ends of a measurement: [`Self::collect`]'s, and with `glyphs` (Snap to Glyph + /// on) the guides those options ask for from the live text in `visible` (all of it for None), + /// without the outlines' anchor points, which Anchor Point gives the Pen and moved objects. + pub fn for_measure(doc: &Document, visible: Option, glyphs: Option) -> Self { + let mut t = Self::collect(doc, &[], None); + if let Some(o) = glyphs { + t.push_glyphs(doc, &[], visible, GlyphSnap { anchors: false, ..o }); + } + t.index() + } + /// Targets for a dragged direction handle of path `id`: the other art, and the anchors of `id` /// (its bounds and the segments through the handle move with it, so they would chase it). pub fn for_handle(doc: &Document, id: NodeId) -> Self { diff --git a/crates/tools/src/xform/measure.rs b/crates/tools/src/xform/measure.rs index 62b2071806..e3a0090561 100644 --- a/crates/tools/src/xform/measure.rs +++ b/crates/tools/src/xform/measure.rs @@ -7,6 +7,7 @@ use serde_json::{Value, json}; use vectorcraft_geom::{Point, Vec2}; use super::CYAN; +use crate::guides::DrawSnap; use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey}; #[derive(Default)] @@ -15,6 +16,8 @@ pub struct MeasureTool { dragging: bool, /// The line measured before the drag under way, which Escape puts back. before: Option<(Point, Point)>, + /// Snapping for both ends, its targets kept while nothing they depend on changes. + snap: DrawSnap, } /// (distance, angle in degrees counter-clockwise, dx, dy) @@ -29,10 +32,10 @@ pub fn measure(a: Point, b: Point) -> (f64, f64, f64, f64) { /// tool snaps a Shift-drawn end. With Snap to Point alone, the point `p` snaps to (an anchor, or /// in line with a ruler guide) is projected instead. When the snapped point lies nearer another /// step (the grid rounds it past the halfway angle) or behind the start, it is turned back onto -/// the pointer's step. -pub(crate) fn end_point(cx: &ToolContext, a: Point, p: Point, shift: bool) -> Point { +/// the pointer's step. With View › Snap to Glyph on, the guides of live type are targets too. +pub(crate) fn end_point(cx: &ToolContext, snap: &mut DrawSnap, a: Point, p: Point, shift: bool) -> Point { if !shift { - return crate::guides::snap_draw(cx, p, &[]).0; + return snap.measure(cx, p, None); } let d = vectorcraft_geom::constrain_angle_from(p - a, 45.0, cx.constrain_angle); let len = d.hypot(); @@ -41,10 +44,10 @@ pub(crate) fn end_point(cx: &ToolContext, a: Point, p: Point, shift: bool) -> Po } let u = d / len; let to_point = cx.snap_to_point && !(cx.smart_guides || cx.snap_to_grid || cx.snap_to_pixel); - let s = if to_point { crate::guides::snap_draw(cx, p, &[]).0 } else { p }; + let s = if to_point { snap.measure(cx, p, None) } else { p }; let q = a + u * (s - a).dot(u); let leave = crate::guides::Leave::segment(cx, a, true); - let r = crate::guides::PointSnap::new(cx, || crate::guides::Targets::collect(cx.doc, &[], None)).snap_from(cx, q, Some(&leave)).0; + let r = snap.measure(cx, q, Some(&leave)); let v = r - a; if v.cross(u).abs() <= 1e-9 * v.hypot().max(1.0) && v.dot(u) >= 0.0 { r } else { a + u * v.hypot() } } @@ -61,14 +64,14 @@ impl Tool for MeasureTool { fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec { match ev.kind { PointerKind::Down => { - let (p, _) = crate::guides::snap_draw(cx, ev.pos, &[]); + let p = self.snap.measure(cx, ev.pos, None); self.before = self.line; self.line = Some((p, p)); self.dragging = true; } PointerKind::Drag | PointerKind::Up if self.dragging => { if let Some((a, _)) = self.line { - self.line = Some((a, end_point(cx, a, ev.pos, ev.mods.shift))); + self.line = Some((a, end_point(cx, &mut self.snap, a, ev.pos, ev.mods.shift))); } self.dragging = ev.kind == PointerKind::Drag; } @@ -86,10 +89,8 @@ impl Tool for MeasureTool { } fn overlays(&self, cx: &ToolContext) -> Vec { - let Some((a, b)) = self.line else { return vec![] }; - if a == b { - return vec![]; - } + let guides = self.snap.guides().iter().filter(|_| self.dragging).cloned(); + let Some((a, b)) = self.line.filter(|(a, b)| a != b) else { return guides.collect() }; let mut out = vec![Overlay::Line { a, b, color: CYAN, dashed: false }]; // Measurement Labels decides the readout; the line the tool draws stays. if cx.measurement_labels { @@ -99,6 +100,7 @@ impl Tool for MeasureTool { text: format!("D: {}\nAngle: {ang:.1}°\n{}", cx.len(dist), cx.offset_label(dx, dy)), }); } + out.extend(guides); out } @@ -121,7 +123,11 @@ impl Tool for MeasureTool { mod tests { use super::*; use crate::testutil::*; - use vectorcraft_doc::Selection; + use crate::{GlyphSnap, ScreenFrame}; + use vectorcraft_doc::{CharStyle, Document, Node, NodeKind, Selection, TextObject}; + use vectorcraft_geom::kurbo::{ParamCurve, Shape}; + use vectorcraft_geom::{Affine, Rect}; + use vectorcraft_text::{FontDb, layout as layout_text}; #[test] fn horizontal_drag_reports_positive_zero_angle() { @@ -324,4 +330,265 @@ mod tests { assert_eq!(t.options(), first); assert!((t.options()["distance"].as_f64().unwrap() - 50.0).abs() < 1e-9); } + + /// A document holding point type `text` at (100, 300), 60 pt, turned `angle` radians about + /// where it starts, and the type. + fn type_doc(text: &str, angle: f64, visible: bool, locked: bool) -> (Document, TextObject) { + let mut d = Document::new(500.0, 500.0); + let (l, id) = (d.layers[0].id, d.alloc_id()); + let at = Point::new(100.0, 300.0); + let mut t = TextObject::point(at, text, CharStyle { size: 60.0, ..Default::default() }); + t.xf = Affine::rotate_about(angle, at) * t.xf; + let mut n = Node::new(id, NodeKind::Text(Box::new(t.clone()))); + (n.visible, n.locked) = (visible, locked); + d.insert(Some(l), 0, n).unwrap(); + (d, t) + } + + /// Where the baseline of `t`'s first line starts on the page, and its unit direction. + fn baseline(t: &TextObject) -> (Point, Vec2) { + let lay = layout_text(FontDb::global(), t); + let (m, line) = (t.xf * lay.line_xf, &lay.lines[0]); + let (at, along) = (m * Point::new(line.x0, line.baseline), m * Point::new(line.x0 + 1.0, line.baseline)); + (at, (along - at) / (along - at).hypot()) + } + + /// The y of the x-height of `t`'s first glyph, in type that isn't turned. + fn x_height(t: &TextObject) -> f64 { + let lay = layout_text(FontDb::global(), t); + let g = &lay.glyphs[0]; + (t.xf * g.xf * Point::new(0.0, -FontDb::global().face_by_id(g.font_id).unwrap().x_height())).y + } + + /// The bounds on the page of glyph `i` of `t`, which isn't turned. + fn glyph_bounds(t: &TextObject, i: usize) -> Rect { + t.xf.transform_rect_bbox(layout_text(FontDb::global(), t).glyphs[i].outline.bounding_box()) + } + + /// Where a measurement dragged from `from` to `to` starts and ends. + fn measured(tool: &mut dyn Tool, cx: &ToolContext, from: Point, to: Point, shift: bool) -> (Point, Point) { + let mods = Mods { shift, ..Mods::default() }; + tool.pointer(cx, &PointerEvent::new(PointerKind::Down, from.x, from.y)); + tool.pointer(cx, &PointerEvent::new(PointerKind::Drag, to.x, to.y).with_mods(mods)); + tool.pointer(cx, &PointerEvent::new(PointerKind::Up, to.x, to.y).with_mods(mods)); + let o = tool.options(); + let a = Point::new(o["x"].as_f64().unwrap(), o["y"].as_f64().unwrap()); + (a, a + Vec2::new(o["dx"].as_f64().unwrap(), o["dy"].as_f64().unwrap())) + } + + /// Snap to Glyph: both ends land on a line of type's baseline and x-height and on its glyphs' + /// bounds, each only while its option is on. With Snap to Glyph off they stay at the pointer. + #[test] + fn its_ends_land_on_glyph_guides_with_snap_to_glyph() { + let (d, t) = type_doc("Hg", 0.0, true, false); + let (s, p) = (Selection::default(), paint()); + let on = ToolContext { snap_to_glyph: true, ..cx(&d, &s, &p) }; + let (base, x) = (baseline(&t).0.y, x_height(&t)); + let (from, to) = (Point::new(400.0, base + 2.0), Point::new(450.0, x + 2.0)); + let (a, b) = measured(&mut MeasureTool::default(), &on, from, to, false); + assert_eq!(a, Point::new(400.0, base), "the press lands on the baseline"); + assert_eq!(b, Point::new(450.0, x), "the end lands on the x-height"); + let off = ToolContext { snap_to_glyph: false, ..on }; + assert_eq!(measured(&mut MeasureTool::default(), &off, from, to, false), (from, to)); + // The Character panel's options: without Baseline and Glyph Bounds (the bottom of `H` is + // on the baseline) the press stays, and without x-height the end does. + let o = GlyphSnap { baseline: false, bounds: false, x_height: false, ..GlyphSnap::default() }; + let without = ToolContext { glyph_snap: o, ..on }; + assert_eq!(measured(&mut MeasureTool::default(), &without, from, to, false), (from, to)); + // A corner of the box of `H`, which only Glyph Bounds gives there. + let corner = Point::new(glyph_bounds(&t, 0).x1, glyph_bounds(&t, 0).y0); + let near = corner + Vec2::new(1.5, -1.0); + let bounds = ToolContext { glyph_snap: GlyphSnap { anchors: false, ..GlyphSnap::default() }, ..on }; + let landed = measured(&mut MeasureTool::default(), &bounds, Point::new(400.0, 50.0), near, false).1; + assert!(landed.distance(corner) < 1e-9, "{landed:?} vs {corner:?}"); + let plain = measured(&mut MeasureTool::default(), &off, Point::new(400.0, 50.0), near, false).1; + assert!((plain.x - corner.x).abs() > 1e-3, "{plain:?}"); + } + + /// A Shift-drag's end stays on its 45° step and slides along it to where it meets a glyph guide. + #[test] + fn a_shift_drag_slides_along_its_step_to_a_glyph_guide() { + let (d, t) = type_doc("Hg", 0.0, true, false); + let (s, p) = (Selection::default(), paint()); + let on = ToolContext { snap_to_glyph: true, ..cx(&d, &s, &p) }; + let x = x_height(&t); + let (from, to) = (Point::new(300.0, x - 100.0), Point::new(401.0, x + 1.0)); + let landed = measured(&mut MeasureTool::default(), &on, from, to, true).1; + assert!(landed.distance(Point::new(400.0, x)) < 1e-9, "{landed:?}"); + let off = ToolContext { snap_to_glyph: false, ..on }; + let stayed = measured(&mut MeasureTool::default(), &off, from, to, true).1; + assert!(stayed.distance(to) < 1e-9, "{stayed:?}"); + } + + /// Proximity Guides pull an end, with every other option off. Anchor Point does not. + #[test] + fn proximity_guides_pull_an_end_and_a_glyphs_anchor_points_do_not() { + let (d, t) = type_doc("HHHH", 0.0, true, false); + let (s, p) = (Selection::default(), paint()); + let none = GlyphSnap { baseline: false, x_height: false, bounds: false, proximity: false, angular: false, anchors: false }; + let on = ToolContext { snap_to_glyph: true, ..cx(&d, &s, &p) }; + let off = ToolContext { snap_to_glyph: false, ..on }; + let start = Point::new(400.0, 50.0); + let end = |c: &ToolContext, to: Point| measured(&mut MeasureTool::default(), c, start, to, false).1; + let lay = layout_text(FontDb::global(), &t); + let edge = (t.xf * lay.glyphs[1].origin).x; + let to = Point::new(edge + 2.0, 100.0); + assert_eq!(end(&ToolContext { glyph_snap: GlyphSnap { proximity: true, ..none }, ..on }, to).x, edge); + assert_eq!(end(&off, to).x, to.x); + let anchor = t.xf * lay.glyphs[1].outline.segments().next().unwrap().start(); + let to = anchor + Vec2::new(1.5, -1.0); + assert_eq!(end(&ToolContext { glyph_snap: GlyphSnap { anchors: true, ..none }, ..on }, to), end(&off, to)); + } + + /// While a drag snaps with Snap to Glyph on, the guide shows; the drag's end hides it, and with + /// Snap to Glyph off there is only the measured line and its readout. + #[test] + fn a_drag_shows_the_glyph_guide_it_lands_on() { + let (d, t) = type_doc("Hg", 0.0, true, false); + let (s, p) = (Selection::default(), paint()); + let on = ToolContext { snap_to_glyph: true, ..cx(&d, &s, &p) }; + let to = Point::new(450.0, baseline(&t).0.y + 2.0); + let guides = |c: &ToolContext, to: Point, up: bool| { + let mut tool = MeasureTool::default(); + tool.pointer(c, &PointerEvent::new(PointerKind::Down, 400.0, 50.0)); + tool.pointer(c, &PointerEvent::new(if up { PointerKind::Up } else { PointerKind::Drag }, to.x, to.y)); + tool.overlays(c).iter().filter(|o| !matches!(o, Overlay::Measure { .. })).count() - 1 + }; + assert!(guides(&on, to, false) > 0, "the guide shows during the drag"); + assert_eq!(guides(&on, to, true), 0, "the guide goes when the drag ends"); + // Off, an end still lines up with a rectangle's top edge, as it always has, with no guide. + let (rect, _) = doc_with_rect(); + let off = ToolContext { snap_to_glyph: false, ..cx(&rect, &s, &p) }; + let near = Point::new(450.0, 101.0); + assert_eq!(measured(&mut MeasureTool::default(), &off, Point::new(400.0, 50.0), near, false).1.y, 100.0); + assert_eq!(guides(&off, near, false), 0, "no guide with Snap to Glyph off"); + } + + /// In rotated type the guides run at its angle: the end lands on the baseline, only with + /// Angular Guides on. + #[test] + fn its_ends_land_on_the_guides_of_rotated_type() { + let (d, t) = type_doc("H", 30f64.to_radians(), true, false); + let (s, p) = (Selection::default(), paint()); + let on = ToolContext { snap_to_glyph: true, ..cx(&d, &s, &p) }; + let (at, dir) = baseline(&t); + let to = at + dir * 250.0 + Vec2::new(-dir.y, dir.x) * 2.0; + let off_line = |c: &ToolContext| (measured(&mut MeasureTool::default(), c, Point::new(20.0, 20.0), to, false).1 - at).cross(dir).abs(); + assert!(off_line(&on) < 1e-6, "{}", off_line(&on)); + let flat = ToolContext { glyph_snap: GlyphSnap { angular: false, ..GlyphSnap::default() }, ..on }; + assert!(off_line(&flat) > 1.0, "{}", off_line(&flat)); + } + + /// A paragraph gives glyph bounds only: no x-height, and the box of a glyph on a later line. + #[test] + fn a_paragraph_gives_the_measure_tool_glyph_bounds_only() { + let (d, t) = type_doc("H\nH", 0.0, true, false); + let (s, p) = (Selection::default(), paint()); + let on = ToolContext { snap_to_glyph: true, ..cx(&d, &s, &p) }; + let start = Point::new(400.0, 50.0); + let to = Point::new(450.0, x_height(&t) + 2.0); + assert_eq!(measured(&mut MeasureTool::default(), &on, start, to, false).1, to, "no x-height guide"); + let lay = layout_text(FontDb::global(), &t); + assert_eq!(lay.lines.len(), 2); + let top = glyph_bounds(&t, lay.glyphs.len() - 1).y0; + assert_eq!(measured(&mut MeasureTool::default(), &on, start, Point::new(450.0, top - 2.0), false).1, Point::new(450.0, top)); + } + + /// Hidden type gives no guides. Locked type does, as it does for the drawing tools. + #[test] + fn hidden_type_gives_no_glyph_guides_and_locked_type_does() { + let (s, p) = (Selection::default(), paint()); + let drag = |d: &Document, base: f64| { + let c = ToolContext { snap_to_glyph: true, ..cx(d, &s, &p) }; + measured(&mut MeasureTool::default(), &c, Point::new(400.0, 50.0), Point::new(450.0, base + 2.0), false).1 + }; + let (hidden, t) = type_doc("Hg", 0.0, false, false); + let base = baseline(&t).0.y; + assert_eq!(drag(&hidden, base).y, base + 2.0); + let (locked, _) = type_doc("Hg", 0.0, true, true); + assert_eq!(drag(&locked, base).y, base); + } + + /// Grid and pixel snapping come first; Snap to Glyph needs Smart Guides and Alignment Guides. + #[test] + fn glyph_guides_keep_their_place_among_the_other_snaps() { + let (d, t) = type_doc("Hg", 0.0, true, false); + let (s, p) = (Selection::default(), paint()); + let on = ToolContext { snap_to_glyph: true, ..cx(&d, &s, &p) }; + let base = baseline(&t).0.y; + let (start, to) = (Point::new(400.0, 50.0), Point::new(450.0, base + 2.0)); + let end = |c: &ToolContext| measured(&mut MeasureTool::default(), c, start, to, false).1; + assert_eq!(end(&on), Point::new(to.x, base)); + let grid = ToolContext { snap_to_grid: true, ..on }; + assert_eq!(end(&grid), vectorcraft_geom::snap::snap_point_to_grid(to, grid.grid_step())); + assert_eq!(end(&ToolContext { snap_to_pixel: true, ..on }), Point::new(to.x.round(), to.y.round())); + assert_eq!(end(&ToolContext { smart_guides: false, snap_to_point: true, ..on }), to); + assert_eq!(end(&ToolContext { alignment_guides: false, ..on }), to); + } + + /// The snapping distance is in screen pixels, so a zoomed-out view reaches farther; type + /// outside the window gives no guides. + #[test] + fn glyph_guides_follow_the_view() { + let (d, t) = type_doc("Hg", 0.0, true, false); + let (s, p) = (Selection::default(), paint()); + let on = ToolContext { snap_to_glyph: true, ..cx(&d, &s, &p) }; + let base = baseline(&t).0.y; + let (start, to) = (Point::new(400.0, 50.0), Point::new(450.0, base - 5.0)); + let end = |c: &ToolContext| measured(&mut MeasureTool::default(), c, start, to, false).1; + assert_eq!(end(&on), to, "5 pt is out of reach at 100%"); + assert_eq!(end(&ToolContext { zoom: 0.25, ..on }), Point::new(to.x, base)); + let frame = |origin: Point| ScreenFrame { origin, right: Vec2::new(1.0, 0.0), down: Vec2::new(0.0, 1.0), size: (800.0, 600.0) }; + let near = Point::new(to.x, base + 2.0); + let end = |c: &ToolContext| measured(&mut MeasureTool::default(), c, start, near, false).1; + assert_eq!(end(&ToolContext { screen: Some(frame(Point::ZERO)), ..on }), Point::new(to.x, base)); + assert_eq!(end(&ToolContext { screen: Some(frame(Point::new(0.0, 1000.0))), ..on }), near); + } + + /// A drag lays out the type once: it keeps the guides its press gathered until the document + /// or a snapping option changes. + #[test] + fn a_drag_keeps_the_glyph_guides_its_press_gathered() { + let (d, t) = type_doc("Hg", 0.0, true, false); + let (empty, s, p) = (Document::new(500.0, 500.0), Selection::default(), paint()); + let on = ToolContext { snap_to_glyph: true, ..cx(&d, &s, &p) }; + let base = baseline(&t).0.y; + let to = Point::new(450.0, base + 2.0); + let end = |tool: &MeasureTool| { + let o = tool.options(); + o["y"].as_f64().unwrap() + o["dy"].as_f64().unwrap() + }; + let mut tool = MeasureTool::default(); + tool.pointer(&on, &PointerEvent::new(PointerKind::Down, 400.0, 50.0)); + // The same revision: the guides of the press still serve, though this document has no type. + let later = ToolContext { snap_to_glyph: true, ..cx(&empty, &s, &p) }; + tool.pointer(&later, &PointerEvent::new(PointerKind::Drag, to.x, to.y)); + assert_eq!(end(&tool), base); + // A new revision gathers them again. + let edited = ToolContext { revision: (1, 0), ..later }; + tool.pointer(&edited, &PointerEvent::new(PointerKind::Drag, to.x, to.y)); + assert_eq!(end(&tool), to.y); + // So does turning Snap to Glyph off during the drag. + let mut tool = MeasureTool::default(); + tool.pointer(&on, &PointerEvent::new(PointerKind::Down, 400.0, 50.0)); + tool.pointer(&ToolContext { snap_to_glyph: false, ..on }, &PointerEvent::new(PointerKind::Drag, to.x, to.y)); + assert_eq!(end(&tool), to.y); + } + + /// Escape brings back the last result, and the next drag still lands on the guides. + #[test] + fn escape_leaves_the_glyph_guides_for_the_next_drag() { + let (d, t) = type_doc("Hg", 0.0, true, false); + let (s, p) = (Selection::default(), paint()); + let on = ToolContext { snap_to_glyph: true, ..cx(&d, &s, &p) }; + let base = baseline(&t).0.y; + let mut tool = MeasureTool::default(); + let to = Point::new(450.0, base + 2.0); + let first = measured(&mut tool, &on, Point::new(400.0, 50.0), to, false); + tool.pointer(&on, &PointerEvent::new(PointerKind::Down, 300.0, 60.0)); + tool.pointer(&on, &PointerEvent::new(PointerKind::Drag, 320.0, base + 2.0)); + tool.key(&on, ToolKey::Escape, Mods::default()); + assert!((tool.options()["y"].as_f64().unwrap() - first.0.y).abs() < 1e-9); + assert_eq!(measured(&mut tool, &on, Point::new(300.0, 60.0), to, false).1, Point::new(450.0, base)); + } } diff --git a/docs/mcp.md b/docs/mcp.md index b23b9ed2af..990c26ea65 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -7680,7 +7680,9 @@ Tools: `bezier` or `arc`; E cycles it. Shift steps the length by the keyboard increment. - **Measure & Inspect tool** (`measureInspect`): a drag measures distance, angle and offsets, as the Measure tool does (`tool.options` reports the last one); hovering a path reads it, as `addon.pathtools.inspect` does. Nothing goes into - the document. + the document. With Snap to Glyph, Smart Guides and Alignment Guides on, and Snap to Grid and Snap to Pixel off, both ends + of either tool's drag land within the snapping tolerance on the guides of live type, except Anchor Point, and the guide + shows during the drag. - **Parametric Shape Tool** (`parametricShape`): draws shapes that stay editable as numbers. A drag from the centre runs `addon.pathtools.shape.create`: the drag's length is the shape's size (height 2 × the distance, width in the last shape's proportions), its direction the rotation (Shift: 45° steps). While dragging, ↑/↓ change the kind's count or diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 9ba9a4036a..641c84a3b1 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -789,6 +789,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Save / Don't Save / Cancel before closing or quitting with unsaved documents (tabs, Close, Close All, Quit, window close). - Four brightness themes, persistent preferences. - **Snapping quick access (#6164):** Control bar › Snapping includes Snap to Glyph (`view.snapToGlyph`) and Alignment Guides (`prefs.set`, `alignmentGuides`). The headless pointer/accessibility test `the_control_bar_snapping_menu_shares_glyph_and_alignment_settings` covers both commands, independent state, checked states after reopening, and all eight menu rows within a 1200 × 400-point window. Floating-panel parity remains unverified; scores and estimates unchanged. + - **Measure tools and Snap to Glyph:** with Snap to Glyph, Smart Guides and Alignment Guides on, and Snap to Grid and Snap to Pixel off, both ends of a Measure or Measure & Inspect drag land within the snapping tolerance on the guides of live type that the Character panel's options allow, except Anchor Point, and a Shift-drag's end slides along its 45° step to them. The guide shows during the drag. This is VectorCraft's own rule (`Targets::for_measure`, `xform::measure::tests::its_ends_land_on_glyph_guides_with_snap_to_glyph`); scores and estimates unchanged. - Preferences that act (#394, in part): Show Tool Tips, Double Click To Isolate, Zoom with Mouse Wheel (the wheel zooms about the pointer, Shift-wheel scrolls up and down, Cmd/Ctrl-wheel sideways; Alt/Option-wheel and a trackpad pinch zoom about the pointer either way), Use Precise Cursors; Selection & Anchor Display › Tolerance (objects, anchors and handles), Object Selection by Path Only, Command Click to Select Objects Behind (cycling down the stack), Zoom to Selection, anchor Size, Highlight anchors on mouse over; Type › Number of Recent Fonts (up to 15); Guides & Grid › guide and grid Color and Style (lines or dots) and Grids In Back. Then Snap to Point (its distance, for moves, drawn points and reference points), Move Locked and Hidden Artwork with Artboard, Enable Rubber Band for the Pen and Curvature tools, Handles style, Show handles when multiple anchors are selected, Hide Corner Widget for angles greater than; General › Transform Pattern Tiles (the transforms' and dialogs' Transform Patterns, Align and Distribute, art moved or duplicated with an artboard, and objects moved whole with direct-selected anchors) and Select Same Tint %; Type › Size/Leading, Tracking and Baseline Shift increments (`type.step`, Cmd+Shift+. and Cmd+Shift+, for the size, the Type tool's Alt+arrows), Enable Missing Glyph Protection and Fill New Type Objects With Placeholder Text. Then Smart Guides › Color, Alignment Guides, Anchor/Path Labels, Measurement Labels, Transform Tools, Object Highlighting (with Smart Guides on) and Snapping Tolerance (4 px by default, for every smart guide snap), then Construction Guides and their Angles (#506: a drawn point near a line at one of those angles through the point it leaves from lands on it). Then Type › Type Object Selection by Path Only (type is picked on the baseline of any of its lines, area type's frame or the path of type on a path, no longer anywhere in its bounds; the Type tools and the Eyedropper still take a click among the characters). Then General › Anti-aliased Artwork (off, the canvas draws the art with hard edges). Then Guides & Grid › Show Pixel Grid (Above 600% Zoom), a line at every document pixel over the art in Pixel Preview, turning with a rotated view as the document grid now does too. Then General › Show The Home Screen When No Documents Are Open (off, closing the last document leaves an empty window; the Home button still opens the screen). Then Units › Numbers Without Units Are Points (on by default: a number typed with no unit into a field in picas is read in points, a typed unit still wins; dimmed unless a unit is Picas). Then User Interface › Auto-Collapse Iconic Panels (off by default: on, a click away from a panel popped out of the icon column puts it away). Then Performance › Animated Zoom (on, with GPU Performance, which it needs and which dims it when off: the Zoom tool dragged sideways zooms about the press, right in and left out, and held still zooms on, Alt out; off, a drag zooms to the area dragged across, now drawn as a marquee), User Interface › Large Tabs (taller document tabs) and Scale Cursor Proportional to UI (off, the tool cursors keep their size whatever the UI Scaling), and File Handling › Display Bitmaps as Anti-aliased Images in Pixel Preview (off by default: Pixel Preview draws images with each pixel taking its nearest image pixel). Type › Auto Size New Area Type works too (new area type gets Auto Size). They reach agents too (`prefs.set`, then `pointer_gesture` or `press_key`; `ui.wheel` on the control channel). Then Smart Guides › Spacing Guides (moves space the selection evenly with its row or column, see Drawing aids). Then Hyphenation › Exceptions (comma-separated words that never break, or break only at their hyphens; `prefs.set` and restore push them into the hyphenator, #394). Then Type › Show Font Names in English (off, font menus label a family by its native-language name when the font has one; documents still use the English family, #394). Then General › Display Print Size at 100% Zoom (off by default: View › Actual Size is one document point per screen point; on, one document inch fills 96 screen points, an inch at the reference density display scaling is set against; `view.actualSize` / `prefs.set`, #394). Then Guides & Grid › Gridline every and Subdivisions (changing either sets the grid of every open document; new and opened documents take that grid too, except a native file and the native document an SVG, PDF, `.ai` or EPS file carries, which open with the grid they were saved with; `file.new`, `document.open`, `file.newFromTemplate`, `file.revert`, #394). Then Appearance of Black › On Screen (Display All Blacks Accurately by default: pure CMYK black shows through the CMYK profile, a dark grey; Display All Blacks as Rich Black shows it on the canvas as RGB black, also in CMYK documents drawn by ink planes, while rich blacks, black tints and images show as before; `RenderOptions::rich_black`, #394). Then Appearance of Black › Printing / Exporting (Output All Blacks Accurately by default; Output All Blacks as Rich Black draws pure CMYK black as RGB black in RGB raster exports and Print as Bitmap; CMYK ink exports and the document stay unchanged; `prefs.blackOutput` / `richBlack`, #394). Then Units › Identify Objects By (Object Name by default: Layers, Variables and Layer Options show and edit free names; XML ID shows and edits AI-style XML ids, `_` for a space and `_xHH_` escapes, and `layer.setProps` / rename refuse invalid ids; the document still stores the object name, #394). Then Type › Highlight Alternate Glyphs (off by default: on, pink behind OpenType substitutions on screen — ligatures, contextual forms; Document Setup's Highlight Substituted Glyphs stays separate; `prefs.set`, #394). About 14 stored options still do nothing yet (the rest of #394: Open Documents As Tabs, Real-time Drawing and Editing, GPU Performance beyond Animated Zoom, hyphenation language, scratch disks, the legacy clipboard, Touch Workspace). - Preferences that act (#394, in part): Show Tool Tips, Double Click To Isolate, Zoom with Mouse Wheel (the wheel zooms about the pointer, Shift-wheel scrolls up and down, Cmd/Ctrl-wheel sideways; Alt/Option-wheel and a trackpad pinch zoom about the pointer either way), Use Precise Cursors; Selection & Anchor Display › Tolerance (objects, anchors and handles), Object Selection by Path Only, Command Click to Select Objects Behind (cycling down the stack), Zoom to Selection, anchor Size, Highlight anchors on mouse over; Type › Number of Recent Fonts (up to 15); Guides & Grid › guide and grid Color and Style (lines or dots) and Grids In Back. Then Snap to Point (its distance, for moves, drawn points and reference points), Move Locked and Hidden Artwork with Artboard, Enable Rubber Band for the Pen and Curvature tools, Handles style, Show handles when multiple anchors are selected, Hide Corner Widget for angles greater than; General › Transform Pattern Tiles (the transforms' and dialogs' Transform Patterns, Align and Distribute, art moved or duplicated with an artboard, and objects moved whole with direct-selected anchors) and Select Same Tint %; Type › Size/Leading, Tracking and Baseline Shift increments (`type.step`, Cmd+Shift+. and Cmd+Shift+, for the size, the Type tool's Alt+arrows), Enable Missing Glyph Protection and Fill New Type Objects With Placeholder Text. Then Smart Guides › Color, Alignment Guides, Anchor/Path Labels, Measurement Labels, Transform Tools, Object Highlighting (with Smart Guides on) and Snapping Tolerance (4 px by default, for every smart guide snap), then Construction Guides and their Angles (#506: a drawn point near a line at one of those angles through the point it leaves from lands on it). Then Type › Type Object Selection by Path Only (type is picked on the baseline of any of its lines, area type's frame or the path of type on a path, no longer anywhere in its bounds; the Type tools and the Eyedropper still take a click among the characters). Then General › Anti-aliased Artwork (off, the canvas draws the art with hard edges). Then Guides & Grid › Show Pixel Grid (Above 600% Zoom), a line at every document pixel over the art in Pixel Preview, turning with a rotated view as the document grid now does too. Then General › Show The Home Screen When No Documents Are Open (off, closing the last document leaves an empty window; the Home button still opens the screen). Then Units › Numbers Without Units Are Points (on by default: a number typed with no unit into a field in picas is read in points, a typed unit still wins; dimmed unless a unit is Picas). Then User Interface › Auto-Collapse Iconic Panels (off by default: on, a click away from a panel popped out of the icon column puts it away). Then Performance › Animated Zoom (on, with GPU Performance, which it needs and which dims it when off: the Zoom tool dragged sideways zooms about the press, right in and left out, and held still zooms on, Alt out; off, a drag zooms to the area dragged across, now drawn as a marquee), User Interface › Large Tabs (taller document tabs) and Scale Cursor Proportional to UI (off, the tool cursors keep their size whatever the UI Scaling), and File Handling › Display Bitmaps as Anti-aliased Images in Pixel Preview (off by default: Pixel Preview draws images with each pixel taking its nearest image pixel). Type › Auto Size New Area Type works too (new area type gets Auto Size). They reach agents too (`prefs.set`, then `pointer_gesture` or `press_key`; `ui.wheel` on the control channel). Then Smart Guides › Spacing Guides (moves space the selection evenly with its row or column, see Drawing aids). Then Hyphenation › Exceptions (comma-separated words that never break, or break only at their hyphens; `prefs.set` and restore push them into the hyphenator, #394). Then Type › Show Font Names in English (off, font menus label a family by its native-language name when the font has one; documents still use the English family, #394). Then General › Display Print Size at 100% Zoom (off by default: View › Actual Size is one document point per screen point; on, one document inch fills 96 screen points, an inch at the reference density display scaling is set against; `view.actualSize` / `prefs.set`, #394). Then Guides & Grid › Gridline every and Subdivisions (changing either sets the grid of every open document; new and opened documents take that grid too, except a native file and the native document an SVG, PDF, `.ai` or EPS file carries, which open with the grid they were saved with; `file.new`, `document.open`, `file.newFromTemplate`, `file.revert`, #394). Then Appearance of Black › On Screen (Display All Blacks Accurately by default: pure CMYK black shows through the CMYK profile, a dark grey; Display All Blacks as Rich Black shows it on the canvas as RGB black, also in CMYK documents drawn by ink planes, while rich blacks, black tints and images show as before; `RenderOptions::rich_black`, #394). Then Appearance of Black › Printing / Exporting (Output All Blacks Accurately by default; Output All Blacks as Rich Black draws pure CMYK black as RGB black in RGB raster exports and Print as Bitmap; CMYK ink exports and the document stay unchanged; `prefs.blackOutput` / `richBlack`, #394). Then Units › Identify Objects By (Object Name by default: Layers, Variables and Layer Options show and edit free names; XML ID shows and edits AI-style XML ids, `_` for a space and `_xHH_` escapes, and `layer.setProps` / rename refuse invalid ids; the document still stores the object name, #394). Then Type › Highlight Alternate Glyphs (on by default: pink behind OpenType substitutions on screen — ligatures, contextual forms; Document Setup's Highlight Substituted Glyphs stays separate; `prefs.set`, #394). About 14 stored options still do nothing yet (the rest of #394: Open Documents As Tabs, Real-time Drawing and Editing, GPU Performance beyond Animated Zoom, hyphenation language, scratch disks, the legacy clipboard, Touch Workspace). - The desktop app keeps the Color panel's Show Options or Hide Options between sessions, and `ui.colorPanel` sets its mode and options as its panel menu does. diff --git a/docs/type-parity.md b/docs/type-parity.md index daec5c927b..ea09866b42 100644 --- a/docs/type-parity.md +++ b/docs/type-parity.md @@ -53,7 +53,7 @@ against Illustrator's public documentation; **estimated** unless noted. | Caret: the arrows and Delete step over whole clusters (Thai consonant with its marks, combining accents, emoji); Backspace removes one character | done | in text with right-to-left characters the arrows move in visual order and stop on both sides of a character drawn with no width, such as a zero width non-joiner | | Touch Type tool | done | moves, scales and rotates one character through its kerning, baseline shift, scales and rotation (`text.touchType`); a character with a preceding logical character on the same line can move along the line in either reading direction; the first logical character moves only up and down. Positive `dx` follows reading order. These direction rules are VectorCraft's choices. | | Retype (identify fonts in outlines/images) | missing | Illustrator's uses a cloud model | -| Snap to Glyph | done | View › Snap to Glyph (`view.snapToGlyph`): with Smart Guides on, new point type and drawing tools snap to live-text baselines and glyph bounds, the corners of each glyph's box included, turning with rotated type (#5112; `guides::tests::a_drawn_point_lands_on_a_corner_of_a_glyphs_bounds`, `Targets::for_drawing`, `tools::text_tests::snap_to_glyph_lines_up_new_point_type_with_a_baseline`). Character panel menu › Show Snap to Glyph Options: Baseline, x-height, Glyph Bounds, Proximity Guides, Angular Guides and Anchor Point each turn their guides on or off (`view.snapToGlyph.options`, all on by default; #4232), after a Snap to Glyph checkbox for View › Snap to Glyph; showing them scrolls the panel to them (#5273) | +| Snap to Glyph | done | View › Snap to Glyph (`view.snapToGlyph`): with Smart Guides on, new point type and drawing tools snap to live-text baselines and glyph bounds, the corners of each glyph's box included, turning with rotated type (#5112; `guides::tests::a_drawn_point_lands_on_a_corner_of_a_glyphs_bounds`, `Targets::for_drawing`, `tools::text_tests::snap_to_glyph_lines_up_new_point_type_with_a_baseline`). With Alignment Guides on as well, and Snap to Grid and Snap to Pixel off, both ends of a Measure or Measure & Inspect drag snap to those guides and show them during the drag (`Targets::for_measure`, `xform::measure::tests::its_ends_land_on_glyph_guides_with_snap_to_glyph`). That the Measure tools take the guides is VectorCraft's choice; they leave out Anchor Point, which [the help page](https://helpx.adobe.com/illustrator/desktop/design-with-text/special-characters-glyphs/snap-to-glyph.html) limits to the Pen tool and objects moved with the Selection tool. Character panel menu › Show Snap to Glyph Options: Baseline, x-height, Glyph Bounds, Proximity Guides, Angular Guides and Anchor Point each turn their guides on or off (`view.snapToGlyph.options`, all on by default; #4232), after a Snap to Glyph checkbox for View › Snap to Glyph; showing them scrolls the panel to them (#5273) | | Thai, Lao, Khmer, Myanmar: shaping (marks, reordering), lines broken between words, word-by-word caret and double-click | done | words found by ICU4X's line segmenter: dictionaries on the desktop, LSTM models on the web | | Middle Eastern: bidirectional layout, Paragraph Direction | done | PDF export keeps harakat and zero-advance Noto Arabic dot glyphs in one compatible batch per layout line, with per-glyph vertical offsets (#6309). `crates/pdf/src/tests/realtext.rs` (`mark_text_*`) checks decoded cluster mappings, mixed Arabic/Latin/digits, contour placement against outline export, vertical columns, differing path rotations and PDF/X-1a/3/4. Editable-text import still needs ActualText support (#6165); separately styled or differently rotated clusters and external viewer search/copy remain outside this export-only evidence. | | Middle Eastern and Indic: digit types (Character panel menu › Digits: Default, Arabic, Hindi, Farsi; Devanagari, Bengali, Gurmukhi, Gujarati, Odia, Tamil, Telugu, Kannada, Malayalam) | done | `text.setFormat` / `text.setRangeStyle {digits}` (`"devanagari"`, `"bengali"`, `"gurmukhi"`, `"gujarati"`, `"odia"`, `"tamil"`, `"telugu"`, `"kannada"`, `"malayalam"` added, #4942); the text keeps the typed digits, the glyphs and SVG export follow the style; carried by character styles. Not yet: `.ai` export of the setting; Indic digits typed as such stay as typed under another system. Evidence: `text::tests::indic_digits_map_to_their_scripts_block`, `tests::bidi::indic_digits_draw_the_scripts_own_digits`, `typecmd::area_tests::indic_digits_round_trip_through_the_commands_and_the_file` | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 72f7502ee3..9b386a82a0 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -222,7 +222,7 @@ tools, never checked side by side) isn't captured row by row. | Distance Guides: Alt/Option over another object, or an empty part of an artboard, shows the selection's distances to it (Smart Guides › Invoke Distance Guides) | done | Selection tool hover with Smart Guides on: the gap between facing edges, else the offsets of matching edges, to another object; the left, right, top and bottom distances to the artboard; labelled in the General unit. The selection is measured as a whole. Nothing while moving or nudging, which the public pages don't describe. [Use Distance Guides](https://helpx.adobe.com/illustrator/desktop/measure-and-align/grids-and-guides/use-distance-guides-for-accurate-placement.html), [Smart Guides options](https://helpx.adobe.com/illustrator/desktop/measure-and-align/grids-and-guides/smart-guides-options.html) | | Snapping tolerance preference | done | | | Snap to Point, Snap to Grid, Snap to Pixel; Pixel Preview | done | #1840: with Snap to Grid on, anchors and objects snap to the grid even when Snap to Pixel is also ticked (#1816 waits for the reporter); #3835: a straight segment dragged with Direct Selection lands its anchors on the grid, the box round them putting an edge on a gridline on each axis, as a moved object does | -| Snap to Glyph | done | View › Snap to Glyph (`view.snapToGlyph`); new type and drawing snap to live-text baselines and glyph bounds when Smart Guides are on (#3124). Character panel menu › Show Snap to Glyph Options: a Snap to Glyph checkbox (View › Snap to Glyph), then a checkbox for each guide (Baseline, x-height, Glyph Bounds, Proximity Guides, Angular Guides, Anchor Point), dimmed while Snap to Glyph is off (#4232); showing them scrolls the panel to them (#5273) | +| Snap to Glyph | done | View › Snap to Glyph (`view.snapToGlyph`); new type and drawing snap to live-text baselines and glyph bounds when Smart Guides are on (#3124). With Alignment Guides on as well, and Snap to Grid and Snap to Pixel off, both ends of a Measure or Measure & Inspect drag snap to them too; that is VectorCraft's choice. Character panel menu › Show Snap to Glyph Options: a Snap to Glyph checkbox (View › Snap to Glyph), then a checkbox for each guide (Baseline, x-height, Glyph Bounds, Proximity Guides, Angular Guides, Anchor Point), dimmed while Snap to Glyph is off (#4232); showing them scrolls the panel to them (#5273) | | Tangent and perpendicular snapping for line endpoints (new in 30.0) | done | #2530: tangent snapping to curves and perpendicular snapping to straight segments for lines drawn with the Line Segment, Pen and Curvature tools, and for an existing line whose end anchor is dragged with Direct Selection (the only anchor selected, an open path's end on a straight segment; Shift keeps its angle step instead; Group Selection moves the path whole and does not snap so); mid-line anchors, curved segments' ends and curves as perpendicular targets are excluded, as the pages say. Saved Preferences › Smart Guides › Geometric Guides controls, on by default. [Perpendicular](https://helpx.adobe.com/illustrator/desktop/measure-and-align/plot-and-measure/snap-to-perpendicular.html) and [tangent](https://helpx.adobe.com/illustrator/desktop/measure-and-align/plot-and-measure/snap-to-tangent.html) pages; test `dragged_line_ends_snap_perpendicular_to_segments_and_tangent_to_curves` | | Snapping panel/popover consolidating the snap options (30.0) | partial | Control bar › Snapping provides quick access through existing view commands and prefs.set, including Snap to Glyph and Alignment Guides (#6164); no View › Snapping submenu. Floating-panel parity remains unverified. | | Customisable rotation snap angle (#973) | missing | Illustrator: Constrain Angle and construction angles only | From f91f16d99381f10c383767f4636df153c8a60856 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Mon, 9 Nov 2026 17:01:16 -0800 Subject: [PATCH 10/36] Docs: list the groups whose Transform copies stay per member A clipping group, a group with a fill or stroke of its own and a group whose type is kept as type still copy each member in place, and a group's copies leave out its guides. --- docs/effects-parity.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/effects-parity.md b/docs/effects-parity.md index 2ae68bd2ec..b4ec1003bf 100644 --- a/docs/effects-parity.md +++ b/docs/effects-parity.md @@ -22,7 +22,7 @@ Part of [target-app-parity.md](target-app-parity.md); open work is in [gaps.md]( |---|---|---:|---| | Convert to Shape | Rectangle, Rounded Rectangle, Ellipse | 3/3 | — | | Crop Marks | Crop Marks | 1/1 | — | -| Distort & Transform | Free Distort, Pucker & Bloat, Roughen, Transform, Tweak, Twist, Zig Zag | 7/7 | Free Distort's dialog has a handle on each corner of the object's bounds, moved by dragging or with the arrow keys; Zig Zag's ridges are evenly spaced along each segment (#1558), and Smooth points make a continuous wave whose peaks and valleys have handles along the path (#2343); Roughen with Smooth keeps turn-backs smooth instead of cusps that draw as spikes (#1646); Expand Appearance of a path whose Transform effect makes copies gives a group of one object per copy, each painted as the path, and a second Transform effect's copies nest in a group per copy of the first (#5612); drawn, each copy of a path with a fill and a stroke (or several, an even-odd fill or a partly transparent one) paints below the one before, the fills above covering the strokes of the copies below, instead of one path of every copy (#5920), and a group's copies each hold all its members, the original group on top (#6316), except that a group with a member that carries a geometry, Pathfinder or 3D effect copies each member in its place | +| Distort & Transform | Free Distort, Pucker & Bloat, Roughen, Transform, Tweak, Twist, Zig Zag | 7/7 | Free Distort's dialog has a handle on each corner of the object's bounds, moved by dragging or with the arrow keys; Zig Zag's ridges are evenly spaced along each segment (#1558), and Smooth points make a continuous wave whose peaks and valleys have handles along the path (#2343); Roughen with Smooth keeps turn-backs smooth instead of cusps that draw as spikes (#1646); Expand Appearance of a path whose Transform effect makes copies gives a group of one object per copy, each painted as the path, and a second Transform effect's copies nest in a group per copy of the first (#5612); drawn, each copy of a path with a fill and a stroke (or several, an even-odd fill or a partly transparent one) paints below the one before, the fills above covering the strokes of the copies below, instead of one path of every copy (#5920), and a group's copies each hold all its members except guides, the original group on top (#6316); a clipping group, a group with a fill or stroke of its own, a group whose type is kept as type, and a group with a member that carries a geometry, Pathfinder or 3D effect copy each member in its place | | Path | Offset Path, Outline Object, Outline Stroke | 3/3 | With Use Preview Bounds on, Align, Distribute and the Transform panel still measure type that carries Outline Object by its layout box (preview bounds leave out effects) | | Pathfinder | Add, Intersect, Exclude, Subtract, Minus Back, Divide, Trim, Merge, Crop, Outline, Hard Mix, Soft Mix, Trap | 13/13 | Trap traps only solid fills (gradients and patterns hide what is below them but don't trap) and spreads each colour's visible area as a whole; the public page gives no default values, so Trap's 0.25 pt, 100% and 40% are VectorCraft's own | | Rasterize | Rasterize… (as a live effect) | 1/1 | — | From dd914586ffacf2b805ac6fc76c25f0f9911bea02 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Mon, 9 Nov 2026 17:03:20 -0800 Subject: [PATCH 11/36] Guides: Lock Guides by Default in Preferences locks guides when the app starts Preferences > Guides & Grid gets a Lock Guides by Default checkbox (lockGuidesByDefault), off by default. With it on, the app starts with View > Guides > Lock Guides on. Changing the checkbox sets the lock at once; an OK that leaves it unchanged keeps the session's lock, and View > Guides > Lock Guides still toggles the lock for the session without changing the preference. Reset Preferences turns it off and unlocks guides when it was on. Headless sessions start unlocked unless prefs.set turns it on, and older preference files read it as off. Lock Guides works as before: new guides can still be dragged out of a ruler or added with guide.add while guides are locked. The lock code moves from view.guides.lock into Session::set_guides_locked so the preference and the menu share it. Docs: docs/mcp.md, docs/target-app-parity.md. --- crates/engine/src/cmd/docmenu.rs | 39 +++++---- crates/engine/src/cmd/prefscmds.rs | 7 ++ crates/engine/src/prefs.rs | 3 + crates/engine/src/tests/menucmds.rs | 34 ++++++++ crates/engine/src/tests/prefs.rs | 104 +++++++++++++++++++++++ crates/ui-egui/src/i18n/bg.tsv | 1 + crates/ui-egui/src/i18n/bs.tsv | 1 + crates/ui-egui/src/i18n/ca.tsv | 1 + crates/ui-egui/src/i18n/cs.tsv | 1 + crates/ui-egui/src/i18n/da.tsv | 1 + crates/ui-egui/src/i18n/de.tsv | 1 + crates/ui-egui/src/i18n/el.tsv | 1 + crates/ui-egui/src/i18n/es.tsv | 1 + crates/ui-egui/src/i18n/fi.tsv | 1 + crates/ui-egui/src/i18n/fr.tsv | 1 + crates/ui-egui/src/i18n/hi.tsv | 1 + crates/ui-egui/src/i18n/hu.tsv | 1 + crates/ui-egui/src/i18n/id.tsv | 1 + crates/ui-egui/src/i18n/it.tsv | 1 + crates/ui-egui/src/i18n/ja.tsv | 1 + crates/ui-egui/src/i18n/ka.tsv | 1 + crates/ui-egui/src/i18n/ko.tsv | 1 + crates/ui-egui/src/i18n/nl.tsv | 1 + crates/ui-egui/src/i18n/pl.tsv | 1 + crates/ui-egui/src/i18n/pt-br.tsv | 1 + crates/ui-egui/src/i18n/ru.tsv | 1 + crates/ui-egui/src/i18n/sv.tsv | 1 + crates/ui-egui/src/i18n/tr.tsv | 1 + crates/ui-egui/src/i18n/uk.tsv | 1 + crates/ui-egui/src/i18n/vi.tsv | 1 + crates/ui-egui/src/i18n/zh-hans.tsv | 1 + crates/ui-egui/src/i18n/zh-hant.tsv | 1 + crates/ui-egui/src/prefs_dialog/tests.rs | 48 +++++++++++ docs/mcp.md | 5 ++ docs/target-app-parity.md | 2 +- 35 files changed, 252 insertions(+), 17 deletions(-) diff --git a/crates/engine/src/cmd/docmenu.rs b/crates/engine/src/cmd/docmenu.rs index a475bc743b..5f8508f4e9 100644 --- a/crates/engine/src/cmd/docmenu.rs +++ b/crates/engine/src/cmd/docmenu.rs @@ -183,7 +183,7 @@ pub fn specs() -> Vec { "Lock Guides", ["View", "Guides"], Some("Cmd+Alt+;"), - "{locked?: bool} toggle (or set) the session's guide lock; locking deselects the ruler guides and guide paths in every open document → {locked}", + "{locked?: bool} toggle (or set) the session's guide lock, leaving the lockGuidesByDefault preference as it is; locking deselects the ruler guides and guide paths in every open document; guide.add and a guide dragged out of a ruler still make new guides while locked → {locked}", has_doc, lock_guides ), @@ -277,6 +277,27 @@ impl Session { pub fn guides_locked(&self) -> bool { self.menu.guides_locked } + + /// Set View → Guides → Lock Guides. Locked guides can't stay selected, ruler guides or guide + /// paths, in any open document ([`Session::select`] lets those go in the active one). + pub(crate) fn set_guides_locked(&mut self, locked: bool) { + self.menu.guides_locked = locked; + if !locked { + return; + } + let guides_selected = + self.doc().is_ok_and(|st| !st.selection.guides.is_empty() || st.selection.objects.iter().any(|id| crate::is_guide_path(&st.doc, *id))); + // It fails only without a document, and then no guide is selected. + if guides_selected { + let _ = self.select(|_, sel| sel.guides.clear()); + } + let active = self.active; + for (i, st) in self.docs.iter_mut().enumerate() { + if Some(i) != active && crate::deselect_hidden(&self.menu, &st.doc, &mut st.selection) { + st.revision += 1; + } + } + } } // ---------- Select ---------- @@ -717,21 +738,7 @@ fn release_guides(s: &mut Session, p: &Value) -> Result { fn lock_guides(s: &mut Session, p: &Value) -> Result { let v = switch_or(p, "locked", !s.menu.guides_locked, "view.guides.lock")?; - s.menu.guides_locked = v; - // Locked guides can't stay selected, ruler guides or guide paths, in any open document - // ([`Session::select`] lets those go in the active one). - let st = s.doc()?; - if v && (!st.selection.guides.is_empty() || st.selection.objects.iter().any(|id| crate::is_guide_path(&st.doc, *id))) { - s.select(|_, sel| sel.guides.clear())?; - } - if v { - let active = s.active; - for (i, st) in s.docs.iter_mut().enumerate() { - if Some(i) != active && crate::deselect_hidden(&s.menu, &st.doc, &mut st.selection) { - st.revision += 1; - } - } - } + s.set_guides_locked(v); Ok(json!({ "locked": v })) } diff --git a/crates/engine/src/cmd/prefscmds.rs b/crates/engine/src/cmd/prefscmds.rs index f3cf6416f8..e8f6f5770b 100644 --- a/crates/engine/src/cmd/prefscmds.rs +++ b/crates/engine/src/cmd/prefscmds.rs @@ -227,6 +227,7 @@ pub const PREF_SPECS: &[PrefSpec] = &[ // Guides & Grid p!("guideColor", "Guides & Grid", "Guides", "Color", color), p!("guideStyle", "Guides & Grid", "Guides", "Style", choice(LINE_STYLE)), + p!("lockGuidesByDefault", "Guides & Grid", "Guides", "Lock Guides by Default", bool), p!("gridColor", "Guides & Grid", "Grid", "Color", color), p!("gridStyle", "Guides & Grid", "Grid", "Style", choice(LINE_STYLE)), p!("gridlineEvery", "Guides & Grid", "Grid", "Gridline every", len(1.0, 16383.0, General)), @@ -690,10 +691,16 @@ impl Session { let fonts_folder_changed = p.fonts_folder != self.prefs.fonts_folder; let hyphen_exceptions_changed = p.hyphenation_exceptions != self.prefs.hyphenation_exceptions; let corner_radius_changed = p.corner_radius != self.prefs.corner_radius; + let lock_guides_changed = p.lock_guides_by_default != self.prefs.lock_guides_by_default; if p.trace_presets != self.prefs.trace_presets { self.trace_preset_changes.replaced(); } self.prefs = p; + // Guides & Grid › Lock Guides by Default sets the lock when it changes, the saved value at + // start included. Otherwise the lock stays as View › Guides › Lock Guides left it. + if lock_guides_changed { + self.set_guides_locked(self.prefs.lock_guides_by_default); + } if corner_radius_changed { // General › Corner Radius is the Rounded Rectangle tool's radius (#394): a tool already // made (active, or lent a selection tool for a drag) takes the new value. diff --git a/crates/engine/src/prefs.rs b/crates/engine/src/prefs.rs index d3b0345264..5c21a6aa59 100644 --- a/crates/engine/src/prefs.rs +++ b/crates/engine/src/prefs.rs @@ -114,6 +114,8 @@ pub struct Prefs { // Guides & Grid pub guide_color: String, pub guide_style: String, + /// Lock Guides by Default: the app starts with View › Guides › Lock Guides on. + pub lock_guides_by_default: bool, pub grid_color: String, pub grid_style: String, pub gridline_every: f64, @@ -421,6 +423,7 @@ impl Default for Prefs { identify_objects_by: s("objectName"), guide_color: s("#4affff"), guide_style: s("lines"), + lock_guides_by_default: false, grid_color: s("#c8c8c8"), grid_style: s("lines"), gridline_every: 72.0, diff --git a/crates/engine/src/tests/menucmds.rs b/crates/engine/src/tests/menucmds.rs index 1bc75a82a4..e59f764574 100644 --- a/crates/engine/src/tests/menucmds.rs +++ b/crates/engine/src/tests/menucmds.rs @@ -3687,6 +3687,40 @@ fn ruler_guides_and_lock() { assert!(s.execute("guide.remove", &json!({"index": 0})).is_err()); } +/// While guides are locked, a guide dragged out of a ruler and `guide.add` still make new guides, +/// each in one undo step and not selected. The ruler guides and guide paths in the document, the +/// new guides included, can't be selected, moved or deleted. +#[test] +fn new_guides_can_be_made_while_guides_are_locked() { + use vectorcraft_tools::{PointerEvent, PointerKind}; + let mut s = session(); + let path = guide_path(&mut s, 300.0); + s.execute("guide.add", &json!({"vertical": false, "pos": 50})).unwrap(); + s.execute("select.none", &json!({})).unwrap(); + s.execute("view.guides.lock", &json!({"locked": true})).unwrap(); + let v = ViewInfo { smart_guides: false, ..Default::default() }; + let n = undo_len(&s); + s.ruler_guide(true, &PointerEvent::new(PointerKind::Drag, 153.0, 30.0), true, v).unwrap(); + s.ruler_guide(true, &PointerEvent::new(PointerKind::Up, 153.0, 30.0), true, v).unwrap(); + assert_eq!(guides(&s), [(false, 50.0), (true, 153.0)]); + assert_eq!(undo_len(&s), n + 1); + assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "New Guide"); + assert_eq!(s.execute("guide.add", &json!({"vertical": true, "pos": 200})).unwrap()["index"], 2); + assert_eq!(guides(&s).len(), 3); + assert_eq!(selected_guides(&s), Vec::::new()); + for (id, params) in [ + ("guide.select", json!({"indexes": [1]})), + ("guide.move", json!({"index": 1, "pos": 170})), + ("guide.remove", json!({"index": 1})), + ("guide.setLayer", json!({"index": 1, "layer": s.doc().unwrap().current_layer().unwrap().0})), + ] { + assert!(matches!(s.execute(id, ¶ms), Err(EngineError::Disabled(..))), "{id}"); + } + assert_eq!(guides(&s).len(), 3); + sel(&mut s, &[path]); + assert!(selected(&s).is_empty(), "a guide path can't be selected"); +} + #[test] fn invalid_guide_lock_parameters_keep_guides_selected() { let mut s = session(); diff --git a/crates/engine/src/tests/prefs.rs b/crates/engine/src/tests/prefs.rs index 52755f2cbc..8e3c043e4e 100644 --- a/crates/engine/src/tests/prefs.rs +++ b/crates/engine/src/tests/prefs.rs @@ -199,6 +199,110 @@ fn reset_category_and_all() { assert!(s.execute("prefs.reset", &json!({"category": "Bogus"})).is_err()); } +/// A rectangle made into a guide path; nothing is selected after. +fn guide_path(s: &mut Session, x: f64) -> NodeId { + let id = square(s, x, 0.0); + s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); + s.execute("view.guides.make", &json!({})).unwrap(); + id +} + +/// Lock Guides by Default is off, and preferences saved before it existed leave guides unlocked. +#[test] +fn lock_guides_by_default_is_off_and_older_preferences_leave_guides_unlocked() { + assert!(!Prefs::default().lock_guides_by_default); + let older: Prefs = serde_json::from_value(json!({"keyboardIncrement": 4})).unwrap(); + assert!(!older.lock_guides_by_default); + let mut s = Session::new(); + s.apply_prefs(older); + assert!(!s.guides_locked()); + assert!(s.execute("prefs.set", &json!({"key": "lockGuidesByDefault", "value": "yes"})).is_err()); + assert!(!s.guides_locked() && !s.prefs.lock_guides_by_default); +} + +/// Preferences saved with the option on start a session with guides locked. A document opened in +/// it has its ruler guides and guide paths locked, and new guides can still be made. View › Guides +/// › Lock Guides then unlocks them for the session and leaves the preference on. +#[test] +fn saved_lock_guides_by_default_locks_a_new_session_and_the_documents_it_opens() { + let file = std::env::temp_dir().join(format!("vc-lock-guides-{}.vectorcraft", std::process::id())); + let file = file.to_string_lossy().to_string(); + let mut unlocked = new_doc(); + assert!(!unlocked.guides_locked(), "a session without the preference"); + let path = guide_path(&mut unlocked, 100.0); + unlocked.execute("guide.add", &json!({"vertical": true, "pos": 50})).unwrap(); + unlocked.execute("document.save", &json!({"path": file})).unwrap(); + + // The preference is set with no document open, as a headless session can. + let mut first = Session::new(); + first.execute("prefs.set", &json!({"key": "lockGuidesByDefault", "value": true})).unwrap(); + let saved = first.prefs.to_json(); + assert_eq!(saved["lockGuidesByDefault"], json!(true)); + + let mut s = Session::new(); + s.apply_prefs(serde_json::from_value(saved).unwrap()); + assert!(s.guides_locked()); + s.execute("document.open", &json!({"path": file})).unwrap(); + let _ = std::fs::remove_file(&file); + assert!(s.guides_locked(), "opening a document keeps the lock"); + assert!(matches!(s.execute("guide.select", &json!({"indexes": [0]})), Err(EngineError::Disabled(..)))); + s.execute("select.set", &json!({"ids": [path.0]})).unwrap(); + assert!(s.doc().unwrap().selection.objects.is_empty(), "a guide path can't be selected"); + assert_eq!(s.execute("guide.add", &json!({"vertical": false, "pos": 20})).unwrap()["index"], 1); + + assert_eq!(s.execute("view.guides.lock", &json!({})).unwrap(), json!({"locked": false})); + assert!(s.prefs.lock_guides_by_default, "the menu leaves the preference as it is"); + s.execute("guide.select", &json!({"indexes": [0]})).unwrap(); + s.execute("select.set", &json!({"ids": [path.0]})).unwrap(); + assert_eq!(s.doc().unwrap().selection.objects, [path]); +} + +/// Changing the preference sets the lock at once and deselects guides in every open document. +/// A change to any other preference, or setting it to the value it has, keeps the session's lock. +#[test] +fn changing_lock_guides_by_default_sets_the_lock_and_other_changes_keep_it() { + let mut s = new_doc(); + s.execute("guide.add", &json!({"vertical": true, "pos": 50})).unwrap(); + s.execute("guide.select", &json!({"indexes": [0]})).unwrap(); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + let path = guide_path(&mut s, 100.0); + s.execute("select.set", &json!({"ids": [path.0]})).unwrap(); + let undo = s.doc().unwrap().history.undo.len(); + + set_pref(&mut s, "lockGuidesByDefault", json!(true)); + assert!(s.guides_locked()); + assert!(s.doc().unwrap().selection.objects.is_empty(), "the guide path in the active document"); + assert_eq!(s.doc().unwrap().history.undo.len(), undo, "no undo step"); + s.execute("document.activate", &json!({"index": 0})).unwrap(); + assert!(s.doc().unwrap().selection.guides.is_empty(), "the ruler guide in the other document"); + + s.execute("view.guides.lock", &json!({"locked": false})).unwrap(); + set_pref(&mut s, "keyboardIncrement", json!(4)); + set_pref(&mut s, "lockGuidesByDefault", json!(true)); + assert!(!s.guides_locked(), "the session's unlock stays"); + + s.execute("view.guides.lock", &json!({"locked": true})).unwrap(); + set_pref(&mut s, "lockGuidesByDefault", json!(false)); + assert!(!s.guides_locked(), "turning the preference off unlocks guides"); +} + +/// Reset turns the option off and unlocks guides. With the option already off, a reset keeps a +/// lock set from the View menu. +#[test] +fn resetting_lock_guides_by_default_unlocks_guides_only_when_it_was_on() { + let mut s = new_doc(); + for params in [json!({"category": "Guides & Grid"}), json!({})] { + set_pref(&mut s, "lockGuidesByDefault", json!(true)); + assert!(s.guides_locked()); + s.execute("prefs.reset", ¶ms).unwrap(); + assert!(!s.prefs.lock_guides_by_default, "{params}"); + assert!(!s.guides_locked(), "{params}"); + } + s.execute("view.guides.lock", &json!({"locked": true})).unwrap(); + s.execute("prefs.reset", &json!({})).unwrap(); + assert!(s.guides_locked()); +} + /// `prefs.set` refuses a recovery or plug-ins folder that is missing or is a file. Nothing changes, /// other values in the same call included, and the document gets no undo step. #[test] diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 97f8b70064..8290243b4a 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -1826,6 +1826,7 @@ font-technology Variable Променливи Lock (⌘2) Заключване (⌘2) Lock Grid Заключване на мрежата Lock Guides Заключване на водачите + Lock Guides by Default Заключване на водачите по подразбиране Lock Others Заключване на другите Lock Slices Заключване на фрагментите Lock Station Point Заключване на базовата точка diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index 00859a6552..a053e2d854 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -600,6 +600,7 @@ Hide Guides Sakrij vodilice Show Guides Prikaži vodilice Lock Guides Zaključaj vodilice + Lock Guides by Default Zaključaj vodilice po zadanom Unlock Guides Otključaj vodilice Make Guides Napravi vodilice Release Guides Oslobodi vodilice diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 53ebd517f5..ed8904f2e0 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -2339,6 +2339,7 @@ digits Malayalam Malaiàlam Lock Artboards Bloca els taulers Lock Grid Bloca la quadrícula Lock Guides Bloca les guies + Lock Guides by Default Bloca les guies per defecte Lock Others Bloca la resta Lock Selected Bloca la selecció Lock Slices Bloca els talls diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 3c9090be2a..5ff23db0bb 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -609,6 +609,7 @@ Hide Guides Skrýt vodítka Show Guides Zobrazit vodítka Lock Guides Zamknout vodítka + Lock Guides by Default Ve výchozím stavu zamknout vodítka Unlock Guides Odemknout vodítka Make Guides Vytvořit vodítka Release Guides Uvolnit vodítka diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index df445718bf..fd76bd327c 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -559,6 +559,7 @@ Hide Guides Skjul hjælpelinjer Show Guides Vis hjælpelinjer Lock Guides Lås hjælpelinjer + Lock Guides by Default Lås hjælpelinjer som standard Unlock Guides Lås hjælpelinjer op Make Guides Opret hjælpelinjer Release Guides Frigør hjælpelinjer diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 6c6c48238f..2733a424c6 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -1893,6 +1893,7 @@ file-filter Templates Vorlagen Lock (⌘2) Sperren (⌘2) Lock Grid Raster sperren Lock Guides Hilfslinien sperren + Lock Guides by Default Hilfslinien standardmäßig sperren Lock Others Andere sperren Lock Slices Slices sperren Lock Station Point Standpunkt sperren diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index b2d4f2a6ea..6332db247d 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -1725,6 +1725,7 @@ font-technology Variable Μεταβλητές Location: Θέση: Lock Κλείδωμα Lock Guides Κλείδωμα οδηγών + Lock Guides by Default Κλείδωμα οδηγών από προεπιλογή Lock Slices Κλείδωμα τομών Low Χαμηλή Luminosity Φωτεινότητα diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 9d50594110..e310599bd8 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -1896,6 +1896,7 @@ font-technology Variable Variables Lock (⌘2) Bloquear (⌘2) Lock Grid Bloquear cuadrícula Lock Guides Bloquear guías + Lock Guides by Default Bloquear guías de forma predeterminada Lock Others Bloquear otros Lock Slices Bloquear sectores Lock Station Point Bloquear punto de estación diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index dbdf5d111e..6a3e24f755 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -600,6 +600,7 @@ Hide Guides Piilota apuviivat Show Guides Näytä apuviivat Lock Guides Lukitse apuviivat + Lock Guides by Default Lukitse apuviivat oletuksena Unlock Guides Avaa apuviivojen lukitus Make Guides Luo apuviivat Release Guides Vapauta apuviivat diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 7c6d1fb0e9..092a473f84 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -1890,6 +1890,7 @@ font-technology Variable Variables Lock (⌘2) Verrouiller (⌘2) Lock Grid Verrouiller la grille Lock Guides Verrouiller les repères + Lock Guides by Default Verrouiller les repères par défaut Lock Others Verrouiller les autres Lock Slices Verrouiller les tranches Lock Station Point Verrouiller le point d’observation diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 1dac03decb..8aae344e20 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -2555,6 +2555,7 @@ antiAlias Sharp शार्प Lock Artboards लॉक आर्टबोर्ड Lock Grid लॉक ग्रिड Lock Guides गाइड्स लॉक करें + Lock Guides by Default गाइड्स डिफ़ॉल्ट रूप से लॉक करें Lock Others लॉक अन्य Lock Selected लॉक सिलेक्टेड Lock Slices स्लाइस लॉक करें diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 87842df37e..7c48b0fedb 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -2377,6 +2377,7 @@ file-filter Templates Sablonok Lock (⌘2) Zárolás (⌘2) Lock Grid Rács zárolása Lock Guides Segédvonalak zárolása + Lock Guides by Default Segédvonalak zárolása alapértelmezetten Lock Others Többi zárolása Lock Slices Szeletek zárolása Lock Station Point Nézőpont zárolása diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 476a6988f5..165314cac8 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -559,6 +559,7 @@ Hide Guides Sembunyikan panduan Show Guides Tampilkan panduan Lock Guides Kunci panduan + Lock Guides by Default Kunci panduan secara default Unlock Guides Buka kunci panduan Make Guides Buat panduan Release Guides Lepaskan panduan diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index f10e1f272e..fea1d4bab4 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -1925,6 +1925,7 @@ file-filter Templates Modelli Lock (⌘2) Blocca (⌘2) Lock Grid Blocca griglia Lock Guides Blocca guide + Lock Guides by Default Blocca guide per impostazione predefinita Lock Others Blocca altri Lock Slices Blocca sezioni Lock Station Point Blocca punto di osservazione diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index b2c2b37a0f..49f59d70e6 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -627,6 +627,7 @@ digits Malayalam マラヤーラム数字 Guides ガイド Hide Guides ガイドを隠す Lock Guides ガイドをロック + Lock Guides by Default ガイドを初期設定でロック Make Guides ガイドを作成 Release Guides ガイドを解除 Clear Guides ガイドを消去 diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index a625c7bf51..4a2baf29aa 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -521,6 +521,7 @@ Hide Guides დამხმარე ხაზების დამალვა Show Guides დამხმარე ხაზების ჩვენება Lock Guides დამხმარე ხაზების ჩაკეტვა + Lock Guides by Default დამხმარე ხაზების ნაგულისხმევად ჩაკეტვა Unlock Guides დამხმარე ხაზების განბლოკვა Make Guides დამხმარე ხაზების შექმნა Release Guides დამხმარე ხაზების მოხსნა diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index e281e83162..3703592182 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -622,6 +622,7 @@ digits Malayalam 말라얄람어 Guides 안내선 Hide Guides 안내선 숨기기 Lock Guides 안내선 잠그기 + Lock Guides by Default 기본적으로 안내선 잠그기 Make Guides 안내선 만들기 Release Guides 안내선 해제 Clear Guides 안내선 지우기 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 073203ca3d..98e8f812c9 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -573,6 +573,7 @@ Hide Guides Hulplijnen verbergen Show Guides Hulplijnen tonen Lock Guides Hulplijnen vergrendelen + Lock Guides by Default Hulplijnen standaard vergrendelen Unlock Guides Hulplijnen ontgrendelen Make Guides Hulplijnen maken Release Guides Hulplijnen vrijgeven diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 8d1be1679e..aeb5f1212a 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -566,6 +566,7 @@ Hide Guides Ukryj linie pomocnicze Show Guides Pokaż linie pomocnicze Lock Guides Zablokuj linie pomocnicze + Lock Guides by Default Domyślnie blokuj linie pomocnicze Unlock Guides Odblokuj linie pomocnicze Make Guides Utwórz linie pomocnicze Release Guides Uwolnij linie pomocnicze diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index ebe44a68cf..95b722dfe0 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -1617,6 +1617,7 @@ antiAlias Sharp Nítido Lock (⌘2) Bloquear (⌘2) Lock Grid Bloquear Grade Lock Guides Bloquear Guias + Lock Guides by Default Bloquear Guias por Padrão Lock Slices Bloquear Fatias Lock Station Point Bloquear Ponto de Estação Lossless (lossy WebP isn't available yet) Sem Perdas (WebP com perdas ainda não está disponível) diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index dc6402e831..0a657eadc5 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -1922,6 +1922,7 @@ file-filter Templates Шаблоны Lock (⌘2) Заблокировать (⌘2) Lock Grid Заблокировать сетку Lock Guides Заблокировать направляющие + Lock Guides by Default Блокировать направляющие по умолчанию Lock Others Заблокировать остальные Lock Slices Заблокировать фрагменты Lock Station Point Заблокировать точку стояния diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index a8ce1904d1..1662ac4317 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -1837,6 +1837,7 @@ font-technology Variable Variabla Lock (⌘2) Lås (⌘2) Lock Grid Lås rutnät Lock Guides Lås stödlinjer + Lock Guides by Default Lås stödlinjer som standard Lock Others Lås övriga Lock Slices Lås skivor Lock Station Point Lås betraktarpunkt diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index c2804006cd..6d1af1aee5 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -1730,6 +1730,7 @@ font-technology Variable Değişken Lock (⌘2) Kilitle (⌘2) Lock Grid Izgarayı Kilitle Lock Guides Kilit Kılavuzları + Lock Guides by Default Kılavuzları Varsayılan Olarak Kilitle Lock Others Diğerlerini Kilitle Lock Slices Dilimleri Kilitle Lock Station Point Kilit İstasyon Noktası diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 0108c96a50..0c20063903 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -1919,6 +1919,7 @@ file-filter Templates Шаблони Lock (⌘2) Заблокувати (⌘2) Lock Grid Заблокувати сітку Lock Guides Заблокувати напрямні + Lock Guides by Default Блокувати напрямні за замовчуванням Lock Others Заблокувати решту Lock Slices Заблокувати фрагменти Lock Station Point Заблокувати точку стояння diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 5b979b003f..62b1ef58e4 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -2203,6 +2203,7 @@ digits Hindi Hindi Lock (⌘2) Khóa (⌘2) Lock Grid Khóa lưới Lock Guides Khóa đường căn + Lock Guides by Default Khóa đường căn theo mặc định Lock Others Khóa các mục khác Lock Slices Khóa lát cắt Lock Station Point Khóa điểm trạm diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index d7b40b95d3..b74d628186 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -1806,6 +1806,7 @@ file-filter Templates 模板 Lock (⌘2) 锁定 (⌘2) Lock Grid 锁定网格 Lock Guides 锁定参考线 + Lock Guides by Default 默认锁定参考线 Lock Slices 锁定切片 Lock Station Point 锁定基准点 Lossless (lossy WebP isn't available yet) 无失真 (尚未提供有失真的 WebP) diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 9e27d9c7b7..b413d78860 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2800,6 +2800,7 @@ file-filter Templates 範本 Lock (⌘2) 鎖定 (⌘2) Lock Grid 鎖定格點 Lock Guides 鎖定參考線 + Lock Guides by Default 預設鎖定參考線 Lock Others 鎖定其他 Lock Slices 鎖定切片 Lock Station Point 鎖定基準點 diff --git a/crates/ui-egui/src/prefs_dialog/tests.rs b/crates/ui-egui/src/prefs_dialog/tests.rs index 9ef8f3ed74..93b84f488d 100644 --- a/crates/ui-egui/src/prefs_dialog/tests.rs +++ b/crates/ui-egui/src/prefs_dialog/tests.rs @@ -225,6 +225,54 @@ fn prefs_persist_through_ui_state() { assert_eq!(b.session.prefs.gridline_every, 50.0); } +/// Guides & Grid › Lock Guides by Default: shown in its category, applied by OK, saved with the +/// UI state and read back as locked guides. A file without the key leaves guides unlocked. +#[test] +fn lock_guides_by_default_shows_applies_and_restores() { + let mut a = app(); + a.run("file.new", json!({"width": 400, "height": 400})).unwrap(); + open(&mut a, Some("Guides & Grid")); + let text = crate::tests_labels::painted_text(&mut a, |a, ui| show(a, ui.ctx())); + assert!(text.contains("Lock Guides by Default"), "{text}"); + assert_eq!(a.ui.dialog.as_ref().unwrap().fields["lockGuidesByDefault"], json!(false)); + a.ui.dialog.as_mut().unwrap().fields.insert("lockGuidesByDefault".into(), json!(true)); + confirm(&mut a).unwrap(); + assert!(a.session.guides_locked()); + assert_eq!(crate::menus::dynamic_label(&a, "view.guides.lock", ""), "Unlock Guides"); + assert_eq!(a.ui.engine_prefs["lockGuidesByDefault"], json!(true)); + + let saved = serde_json::to_vec(&a.ui).unwrap(); + let mut b = app(); + assert!(!b.session.guides_locked()); + b.ui = serde_json::from_slice::(&saved).unwrap().sanitized(); + restore(&mut b); + assert!(b.session.guides_locked()); + + let mut older = app(); + older.ui.engine_prefs = json!({"gridlineEvery": 50}); + restore(&mut older); + assert!(!older.session.guides_locked()); +} + +/// OK with the option unchanged keeps the lock from the View menu. OK after turning the option +/// off, as Reset Preferences does in the dialog, unlocks guides. +#[test] +fn preferences_ok_changes_the_guide_lock_only_with_the_option() { + let mut a = app(); + a.run("file.new", json!({"width": 400, "height": 400})).unwrap(); + a.run("view.guides.lock", json!({"locked": true})).unwrap(); + open(&mut a, Some("Guides & Grid")); + confirm(&mut a).unwrap(); + assert!(a.session.guides_locked(), "the option did not change"); + a.run("view.guides.lock", json!({"locked": false})).unwrap(); + a.run("prefs.set", json!({"key": "lockGuidesByDefault", "value": true})).unwrap(); + assert!(a.session.guides_locked()); + open(&mut a, Some("Guides & Grid")); + a.ui.dialog.as_mut().unwrap().fields.insert("lockGuidesByDefault".into(), Prefs::default().to_json()["lockGuidesByDefault"].clone()); + confirm(&mut a).unwrap(); + assert!(!a.session.guides_locked(), "the option went off"); +} + #[test] fn old_pref_files_keep_their_brightness() { let mut a = app(); diff --git a/docs/mcp.md b/docs/mcp.md index 81dd913448..ed42e6f901 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1786,6 +1786,11 @@ View › Guides › Lock Guides (`view.guides.lock`) deselects them and the path document, and refuses these commands until unlocked. While it is on, a command that selects art, such as `edit.undo`, `edit.redo`, `object.exitIsolation`, `object.unlockAll`, `object.showAll` or `edit.paste`, leaves the guides out. `view.guides.lock` rejects a `locked` value that is not a boolean. Omitting `locked` toggles the current value. +While guides are locked, `guide.add` and a guide dragged out of a ruler still make new guides, which are locked too. +Preferences › Guides & Grid › Lock Guides by Default (`lockGuidesByDefault`, off by default) locks guides when the +desktop app starts. Changing the preference, with `prefs.set` or `prefs.reset`, sets the lock to the new value at once. +`view.guides.lock` changes the lock for the session and does not change the preference. A headless session starts +with guides unlocked, as the preference is off until `prefs.set` turns it on. `view.guides.release {index?, ids?}` (View › Guides › Release Guides) turns guides into paths → `{count}`. With `index` it releases that ruler guide, and with `ids` it releases those guide paths (and the guide paths inside them). diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index c37d5234c3..e189784767 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -969,7 +969,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur The Blob Brush Tool Options' tip preview is the Angle slider for screen readers, with the Angle in degrees as its value: a click or a drag in it points the Angle at the pointer and gives it the keyboard focus, Tab reaches it after the Roundness field, and the arrow keys step the Angle by 1° (10° with Shift, Home and End to −180° and 180°). The Blob Brush Tool Options also have Fidelity, which the reference app's [Blob Brush options](https://web.archive.org/web/20261012042029/https://helpx.adobe.com/illustrator/desktop/paint-and-fill/learn-painting-basics/blob-brush-options-and-best-practices.html) list as setting how far the pointer moves before the path gets a new anchor point. The range and default are VectorCraft's own values: the Pencil's logarithmic slider from Accurate (0.01 pt) to Smooth (20 pt) and its field (up to 100 pt), 1.5 pt by default and kept with the tool's options. The brush follows the stroke fitted at the Fidelity, as a Pencil stroke is fitted: a low value follows the drag closely, jitter included, and a higher one gives smoother curves (`path.blob` `fidelity`). A Blob Brush stroke merges with the topmost path of its fill color and no stroke that it touches, and with the other such paths it touches in the same group or layer that are next to that path in stacking order with no other kind of object between them; matching paths in other groups or layers are left alone, as the Blob Brush tool guidelines in [Paint with fills and strokes](https://web.archive.org/web/20241219085603/https://helpx.adobe.com/illustrator/using/painting-fills-strokes.html) describe (`path.blob`). -- **Ruler guides as layer objects:** a guide goes on the current layer, shows, hides, locks and is deleted with it, is listed in the Layers panel (its row selects it) and moves to another layer (`guide.setLayer`, or its row dragged onto that layer's rows); guides on a hidden or locked layer can't be selected, moved or deleted, Duplicate copies a layer's guides and Merge Selected and Flatten Artwork keep them on the merged layer; guides from files saved before guides had layers go on the top visible, unlocked layer when the document opens. Not done: guides stacked among the art of their layer (they are listed above it), a guide row's own eye and lock. Lock Guides also locks the objects made into guides: they can't be selected, moved or deleted (#1543); they are drawn in the guide colour and style of Preferences, and a dragged selection snaps to ruler guides with Smart Guides on (#1583, fixes #1534). The Transform panel shows a selected ruler guide's X or Y, and typing a value moves it (#1659, part of #1597). This is not established by the documentation we could find: the public help pages on guides, rulers and the Transform, Control and Properties panels (read again on 2026-10-14) describe selecting and dragging unlocked guides and the panels' X and Y for a selection, not a guide's position in a panel, and the one public statement of it is a user's [community answer](https://community.adobe.com/questions-652/adding-a-new-custom-guide-785586), which is not documentation. It is kept as a convenience pending the owner's decision, with no parity claim and nothing counted towards a score. +- **Ruler guides as layer objects:** a guide goes on the current layer, shows, hides, locks and is deleted with it, is listed in the Layers panel (its row selects it) and moves to another layer (`guide.setLayer`, or its row dragged onto that layer's rows); guides on a hidden or locked layer can't be selected, moved or deleted, Duplicate copies a layer's guides and Merge Selected and Flatten Artwork keep them on the merged layer; guides from files saved before guides had layers go on the top visible, unlocked layer when the document opens. Not done: guides stacked among the art of their layer (they are listed above it), a guide row's own eye and lock. Lock Guides also locks the objects made into guides: they can't be selected, moved or deleted (#1543); they are drawn in the guide colour and style of Preferences, and a dragged selection snaps to ruler guides with Smart Guides on (#1583, fixes #1534). Preferences › Guides & Grid › Lock Guides by Default locks guides when the app starts; it is off by default, and new guides can still be dragged out of a ruler while guides are locked. The Transform panel shows a selected ruler guide's X or Y, and typing a value moves it (#1659, part of #1597). This is not established by the documentation we could find: the public help pages on guides, rulers and the Transform, Control and Properties panels (read again on 2026-10-14) describe selecting and dragging unlocked guides and the panels' X and Y for a selection, not a guide's position in a panel, and the one public statement of it is a user's [community answer](https://community.adobe.com/questions-652/adding-a-new-custom-guide-785586), which is not documentation. It is kept as a convenience pending the owner's decision, with no parity claim and nothing counted towards a score. - **Releasing ruler guides:** View › Guides › Release Guides turns the selected ruler guides into paths with no fill or stroke. An artboard guide becomes a line across its artboard, and a canvas guide becomes a line across the artboards (`view.guides.release {index?, ids?}`). A Cmd/Ctrl+Shift double-click with the Selection, Direct Selection or Group Selection tool releases the ruler guide or guide path under the pointer. - **Layers panel:** every layer, sublayer, group and object is a clickable row at any depth: a click highlights it and makes its layer current (new art goes there), Shift-click a range, Ctrl/Cmd-click toggles. Sublayers are no longer treated as objects: clicks, marquees, Select All and the Select menu take the art inside them, and each sublayer selects in its own colour. Selection squares on rows with selected art (click selects a row's art, Shift adds or removes; dragging the square moves the selection to another row, Alt copies), the eye and lock on every row with drag-down-the-column (one undo step), Ctrl/Cmd-click an eye for per-layer Outline, Alt-click an eye or lock to hide or lock the other layers (again: show or unlock them all), triangles with Alt to open or close everything inside, drag and drop rows above, below or into layers and groups with a drop line (Alt copies) and a dimmed copy of the row under the pointer (layers dropped in the empty space under the last row go below the bottom layer, #2260; dropped on the trash rows are deleted, on New Layer duplicated, as in Illustrator), inline rename, Delete key on highlighted rows. Buttons and the full panel menu: New Layer/Sublayer (Alt: options, Ctrl: on top), Duplicate, Delete, Options for Selection, clipping mask, isolation, Locate Object, Merge Selected, Flatten Artwork, Collect in New Layer, Release to Layers (Sequence and Build), Reverse Order, Template, Hide/Outline/Lock Others and Show/Preview/Unlock All, Paste Remembers Layers, Panel Options (row size, thumbnails per kind, Show Layers Only). Layer Options: name, colour (presets, each with a chip of its colour, or custom), Template, Lock, Show, Print, Preview (outline drawing and clicking) and Dim Images to %. Every operation is a command (`layer.move`, `layer.highlight`, `layer.merge`…) with validation against cycles and invalid nesting. A document opens with only its top-level layers open; the rows left open are saved in the native file (nothing is written for the default) and reopen that way. Only the rows scrolled into view are laid out, from a row list made once per change: a layer of 5,000 groups of 10 paths, all open, draws the panel in 0.4 ms a frame instead of about 29 s (#638). The Filter button at the right of Search All keeps the rows that are of an object kind ticked under By Object (paths, shapes, text, images, effects, groups, linked files, guides) and in a state ticked under By Layer (locked, hidden), with the rows that hold them; a section with nothing ticked takes every row. The filter combines with the search, the button is lit while a filter is on, and Clear All in its popup turns the filter off (`ui.layersFilter`). The filter lasts the session. From cb086ef1b7d3417c174ffb1b1ca3e232d8cbb559 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Mon, 9 Nov 2026 17:03:43 -0800 Subject: [PATCH 12/36] Part of #6361: Cut takes direct-selected anchors out of the path With anchors picked with Direct Selection, Edit > Cut removed only the segments between them, so the picked anchors stayed in the path. Cut now removes picked anchors and the segments attached to them from a path or a compound path's member, as Clear does. Picked segments still lose only those segments. When a path has picked segments, Copy and Cut leave out anchors picked on its other contours, as Clear does. Docs: docs/target-app-parity.md. --- crates/engine/src/cmd/clipboard.rs | 3 +- crates/engine/src/cmd/edit.rs | 6 +- crates/engine/src/cmd/path.rs | 2 +- crates/engine/src/tests/draw2.rs | 12 +-- .../engine/tests/it/copy_direct_selection.rs | 90 +++++++++++++++++-- docs/target-app-parity.md | 1 + 6 files changed, 97 insertions(+), 17 deletions(-) diff --git a/crates/engine/src/cmd/clipboard.rs b/crates/engine/src/cmd/clipboard.rs index 6d1e7284c9..92d01bb7e9 100644 --- a/crates/engine/src/cmd/clipboard.rs +++ b/crates/engine/src/cmd/clipboard.rs @@ -115,7 +115,8 @@ pub(crate) fn subpath_selected_segments( let n = sp.anchors.len(); let mut selected_segs = BTreeSet::new(); - let has_explicit_segs = seg_refs.is_some_and(|segments| segments.iter().any(|&(s, _)| s == si)); + // Segments picked anywhere on the path leave anchors out of it, as Clear does. + let has_explicit_segs = seg_refs.is_some_and(|segments| !segments.is_empty()); if has_explicit_segs { if let Some(segments) = seg_refs { for &(s, seg) in segments { diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 5809835e3b..80574d2d58 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -1063,8 +1063,12 @@ fn cut_direct_selection(s: &mut Session, roots: &[NodeId]) -> Result { let cuts = super::clipboard::subpath_selected_segments(&sp, si, seg_refs, anchor_refs); if cuts.is_empty() { subpaths.push(sp); - } else { + } else if seg_refs.is_some_and(|segments| !segments.is_empty()) { subpaths.extend(super::path::without_segments(sp, &cuts)); + } else { + // Picked anchors leave the path with their connections, as Clear removes them. + let gone: BTreeSet = anchor_refs.into_iter().flatten().filter_map(|&(s, a)| (s == si).then_some(a)).collect(); + subpaths.extend(super::path::without_anchors(sp, &gone)); } } subpaths.retain(|sp| sp.anchors.len() >= 2); diff --git a/crates/engine/src/cmd/path.rs b/crates/engine/src/cmd/path.rs index cb82cfe3fa..5d7f935818 100644 --- a/crates/engine/src/cmd/path.rs +++ b/crates/engine/src/cmd/path.rs @@ -1329,7 +1329,7 @@ pub(super) fn replace_deleted_pieces(d: &mut Document, beside: &mut Beside, orig /// Remove anchors and their incident segments, keeping each surviving stretch as an open path. /// Only a closed path can have a surviving stretch that wraps past the last anchor. -fn without_anchors(sp: SubPath, removed: &BTreeSet) -> Vec { +pub(super) fn without_anchors(sp: SubPath, removed: &BTreeSet) -> Vec { let n = sp.anchors.len(); let gone: BTreeSet = removed.iter().copied().filter(|&i| i < n).collect(); if gone.is_empty() { diff --git a/crates/engine/src/tests/draw2.rs b/crates/engine/src/tests/draw2.rs index 7a05a58964..c0946a465d 100644 --- a/crates/engine/src/tests/draw2.rs +++ b/crates/engine/src/tests/draw2.rs @@ -2698,19 +2698,15 @@ fn pen_cmd_selected_anchors_are_what_copy_and_cut_take() { assert_eq!(s.doc().unwrap().selection.anchors[&id], [(0, 1), (0, 2)].into()); s.execute("edit.copy", &json!({})).unwrap(); assert_eq!(pasted(&mut s), [2]); - // Cut takes the segment between them. The two surviving runs of an ordinary path - // become independent objects, and this path keeps the first run (#3380). + // Cut takes the two anchors out of the path, as Clear does (#6361): each side keeps one + // anchor, which makes no path, so nothing is left. s.execute("edit.undo", &json!({})).unwrap(); let original = path(&s, id); let picked = s.doc().unwrap().selection.clone(); s.execute("edit.cut", &json!({})).unwrap(); let left = paths(&s); - assert_eq!(left.len(), 2); - assert_eq!(left[0].0, id); - assert!(left.iter().all(|(_, p)| p.subpaths.len() == 1 && !p.subpaths[0].closed)); - assert_eq!(left[0].1.subpaths[0].anchors, original.subpaths[0].anchors[..2]); - assert_eq!(left[1].1.subpaths[0].anchors, original.subpaths[0].anchors[2..]); - assert_eq!(s.doc().unwrap().selection.objects, left.iter().map(|(id, _)| *id).collect::>()); + assert!(left.is_empty()); + assert!(s.doc().unwrap().selection.objects.is_empty()); assert_eq!(s.clipboard.nodes.len(), 1); assert_eq!(s.clipboard.nodes[0].path_data().unwrap().subpaths[0].anchors, original.subpaths[0].anchors[1..3]); s.execute("edit.undo", &json!({})).unwrap(); diff --git a/crates/engine/tests/it/copy_direct_selection.rs b/crates/engine/tests/it/copy_direct_selection.rs index 957d778299..c2713befe5 100644 --- a/crates/engine/tests/it/copy_direct_selection.rs +++ b/crates/engine/tests/it/copy_direct_selection.rs @@ -192,7 +192,7 @@ fn pen_tool_drawing_copies_whole_path_not_single_anchor() { } #[test] -fn cut_adjacent_anchors_clipboard_plus_remaining_equals_original() { +fn cut_adjacent_anchors_takes_them_out_of_the_source() { let mut s = Session::new(); run(&mut s, "file.new", json!({"width": 200, "height": 200})); let r = rect(&mut s, 10.0, 10.0, 100.0, 50.0); @@ -207,15 +207,93 @@ fn cut_adjacent_anchors_clipboard_plus_remaining_equals_original() { assert_eq!(clip_path.subpaths[0].segment_count(), 1); assert_eq!(clip_path.subpaths[0].anchors.len(), 2); - // Remaining source path must have the remaining 3 segments (segments 0, 2, 3), not 1. + // The source keeps anchors 3 and 0 and the one segment between them. let src_node = s.doc().unwrap().doc.node(r).unwrap(); let src_path = src_node.path_data().unwrap(); - assert_eq!(src_path.subpaths[0].segment_count(), 3); - assert_eq!(src_path.subpaths[0].anchors.len(), 4); + assert_eq!(src_path.subpaths[0].segment_count(), 1); + assert_eq!(src_path.subpaths[0].anchors.len(), 2); assert!(!src_path.subpaths[0].closed); +} - // Sum of segments in clipboard + remaining = 1 + 3 = 4 (the original 4 segments). - assert_eq!(clip_path.subpaths[0].segment_count() + src_path.subpaths[0].segment_count(), 4); +/// Every contour left in the document after `command` runs on anchors `picked` of a four-anchor +/// open path, as `[x, y]` lists. With `compound`, the path is a member of a compound path. +fn contours_after(command: &str, picked: Value, compound: bool) -> Vec> { + let mut s = Session::new(); + run(&mut s, "file.new", json!({"width": 200, "height": 200})); + let id = NodeId(run(&mut s, "path.create", json!({"anchors": [{"x": 10, "y": 20}]}))["id"].as_u64().unwrap()); + run(&mut s, "path.appendAnchor", json!({"id": id.0, "x": 60, "y": 20})); + run(&mut s, "path.appendAnchor", json!({"id": id.0, "x": 100, "y": 80})); + run(&mut s, "path.appendAnchor", json!({"id": id.0, "x": 150, "y": 80})); + if compound { + let other = rect(&mut s, 10.0, 100.0, 40.0, 40.0); + run(&mut s, "select.set", json!({"ids": [id.0, other.0]})); + run(&mut s, "object.compoundPath.make", json!({})); + } + run(&mut s, "select.anchors", json!({"id": id.0, "anchors": picked, "mode": "set"})); + run(&mut s, command, json!({})); + check_session(&s).unwrap(); + contours(&s) +} + +/// Every contour in the document, as `[x, y]` lists. +fn contours(s: &Session) -> Vec> { + let mut left = vec![]; + for layer in &s.doc().unwrap().doc.layers { + layer.walk(&mut |n| { + if let Some(path) = n.path_data() { + left.extend(path.subpaths.iter().map(|sp| sp.anchors.iter().map(|a| [a.p.x, a.p.y]).collect::>())); + } + }); + } + left +} + +#[test] +fn cut_end_anchors_leaves_the_source_as_clear_does() { + for compound in [false, true] { + let picked = || json!([[0, 0], [0, 1]]); + let left = contours_after("edit.cut", picked(), compound); + assert_eq!(left, contours_after("edit.clear", picked(), compound), "compound: {compound}"); + assert_eq!(left[0], [[100.0, 80.0], [150.0, 80.0]], "compound: {compound}"); + } +} + +#[test] +fn cut_interior_anchors_leaves_the_source_as_clear_does() { + let picked = || json!([[0, 1], [0, 2]]); + let left = contours_after("edit.cut", picked(), true); + assert_eq!(left, contours_after("edit.clear", picked(), true)); + assert!(left.iter().flatten().all(|p| p[0] != 60.0 && p[0] != 100.0), "the picked anchors are gone: {left:?}"); +} + +#[test] +fn cut_one_end_anchor_of_a_compound_member_shortens_it() { + let left = contours_after("edit.cut", json!([[0, 3]]), true); + assert_eq!(left[0], [[10.0, 20.0], [60.0, 20.0], [100.0, 80.0]]); +} + +#[test] +fn cut_of_an_anchor_with_a_segment_on_another_contour_leaves_the_source_as_clear_does() { + let after = |command: &str| { + let mut s = Session::new(); + run(&mut s, "file.new", json!({"width": 400, "height": 400})); + let id = NodeId(run(&mut s, "path.create", json!({"anchors": [{"x": 10, "y": 20}]}))["id"].as_u64().unwrap()); + let contour = |y: i32| json!({"anchors": [{"x": 10, "y": y}, {"x": 60, "y": y}, {"x": 110, "y": y}], "closed": false}); + run(&mut s, "path.setAnchors", json!({"id": id.0, "subpaths": [contour(20), contour(120)]})); + run(&mut s, "select.anchors", json!({"id": id.0, "anchors": [[0, 0]], "mode": "set"})); + run(&mut s, "select.segments", json!({"id": id.0, "segments": [[1, 1]], "mode": "add"})); + run(&mut s, command, json!({})); + check_session(&s).unwrap(); + s + }; + let cut = after("edit.cut"); + assert_eq!(contours(&cut), contours(&after("edit.clear"))); + assert!(contours(&cut).contains(&vec![[10.0, 20.0], [60.0, 20.0], [110.0, 20.0]]), "the picked anchor stays: {:?}", contours(&cut)); + // The clipboard holds what left the source: the picked segment alone. + assert_eq!(cut.clipboard.nodes.len(), 1); + let clip = cut.clipboard.nodes[0].path_data().unwrap(); + assert_eq!(clip.subpaths.len(), 1); + assert_eq!(clip.subpaths[0].anchors.iter().map(|a| [a.p.x, a.p.y]).collect::>(), [[60.0, 120.0], [110.0, 120.0]]); } #[test] diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index fb59612c6e..e55f41eafb 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -578,6 +578,7 @@ since 2026-10-01. - **Clipping paths on their outline (G1):** the selection tools (Selection, Direct Selection, Group Selection), Object Highlighting and a right-click take a clipping path on its outline before the art it clips; Select Behind lists the same; the eyedropper, the tools that act on what is drawn and the selection tools' double-clicks, loaded text and guide release take the art. Doc and tool regression tests; score unchanged. - **Copy and Cut of anchors picked with a lent Direct Selection (#6123):** with the Pen or the Curvature tool, Cmd/Ctrl held lends Direct Selection for the gesture; the anchors and segments it picks are what Edit › Copy and Cut take once the key is let go, as with Direct Selection chosen, while the last anchor the Pen leaves selected as it draws still makes Copy take the whole path (#2122). A Cmd gesture that leaves the selection as it was (a Shift marquee over nothing) picks nothing. Regression test: `tests::draw2::pen_cmd_selected_anchors_are_what_copy_and_cut_take`. +- **Cut of picked anchors (#6361):** Edit › Cut removes direct-selected anchors from their path and from a compound path's member, with the segments attached to them, as Clear does. Picked segments still lose only those segments. Regression tests: `cut_*_anchors_*` in `crates/engine/tests/it/copy_direct_selection.rs`. - **Escape in Selection tool drags:** Escape during a Selection tool move or Alt-drag copy, a bounding-box scale or rotation, a marquee, or a corner widget, pie widget or bracket drag cancels it: the art returns to its state at the press, no copy is left, no undo step is added, the release does nothing, and the tool stays chosen. This is VectorCraft's own behavior. Regression tests: `escape_cancels_*` in `select/tests.rs` and `pie.rs`, and `escape_cancels_a_selection_tool_move_copy_scale_or_rotation` in `tests/selectescape.rs`. - **Global Edit: Shift-click leaves a match out:** while Global Edit is on, a Shift-click with the Selection tool on a highlighted match, or on an object inside a matched group, leaves that match out (`select.globalEditExclude`): it is no longer highlighted and later edits leave it as it is; the object the edit started from stays, and Global Edit ends when fewer than two matches are left ([Update assets quickly with Global Edit](https://helpx.adobe.com/ph_fil/illustrator/how-to/global-editing-sensei.html)). Regression tests: `a_shift_click_*`, `an_object_left_out_*`, `a_group_left_out_*` and `leaving_out_all_but_one_*` in `crates/engine/src/tests/globaledit.rs`. - **Global Edit Options in the Control bar:** while Select › Start Global Edit can run, the Control bar shows a Global Edit Options dropdown that opens the same options as the Properties panel's (Match: Appearance, Size; On Artboards; Include Objects On Canvas; Result(s)); both set the same options. The dropdown is off while Global Edit is on; it sits after Recolor Artwork, ahead of the X/Y/W/H fields ([Edit similar objects globally](https://helpx.adobe.com/illustrator/desktop/manage-objects/edit-objects/edit-similar-objects.html)). Regression test: `the_control_bar_shows_the_global_edit_options` in `crates/ui-egui/src/chrome/tests.rs`. From aca44ae14ec3685a6156711113edfc37edc2573b Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Mon, 9 Nov 2026 17:15:41 -0800 Subject: [PATCH 13/36] Render: the glow pan test checks fractional pans, and background docs say straight alpha a_coarser_glow_kept_whole_draws_after_a_pan_as_made_again compared only whole-pixel pans with a fresh render. At a fractional pan it now compares the pixels right of the rectangle, where only the glow draws, with a fresh render at the whole pixel nearest the pan (either neighbor on a half pixel). RenderOptions::background and render_node_thumbnail's background are straight-alpha colors, as over_background reads them; their docs said premultiplied. --- crates/render/src/fx/tests.rs | 15 ++++++++++++++- crates/render/src/options.rs | 2 +- crates/render/src/renderer.rs | 2 +- 3 files changed, 16 insertions(+), 3 deletions(-) diff --git a/crates/render/src/fx/tests.rs b/crates/render/src/fx/tests.rs index 560a3675cc..80f3387365 100644 --- a/crates/render/src/fx/tests.rs +++ b/crates/render/src/fx/tests.rs @@ -3,7 +3,7 @@ use std::sync::Arc; use serde_json::json; use vectorcraft_doc::color::{Color, Paint}; use vectorcraft_doc::{Appearance, Document, Effect, Node}; -use vectorcraft_geom::{Affine, Rect, shapes}; +use vectorcraft_geom::{Affine, Point, Rect, shapes}; use super::{DRAG_PIXEL_BUDGET, Drawn, PIXEL_BUDGET, drag_scale, filter_region, filter_region_within, pan_margin}; use crate::tests_fontchange::{fonts_steady, with_fonts_unchanged}; @@ -214,6 +214,19 @@ fn a_coarser_glow_kept_whole_draws_after_a_pan_as_made_again() { fresh.threads = 2; if pan.0.fract() == 0.0 && pan.1.fract() == 0.0 { assert!(panned.pixels == fresh.render(&d, size.0, size.1, xf, &opts).pixels, "blur {blur}, pan {pan:?}"); + } else { + // Right of the rectangle's soft edge the frame holds only the glow, which the kept raster + // places at the whole pixel nearest the pan, where a fresh render draws it too. A pan of + // half a pixel is as near to the pixels on both sides, so either one is accepted. + let right = (xf * Point::new(rect.x1, rect.y0)).x.ceil() as usize + 3; + let row = size.0 as usize * 4; + let glow = |p: &[u8]| p.chunks(row).flat_map(|r| r[right * 4..].to_vec()).collect::>(); + let nearest = |v: f64| if (v - v.trunc()).abs() == 0.5 { vec![v.floor(), v.ceil()] } else { vec![v.round()] }; + let found = nearest(pan.0).into_iter().flat_map(|x| nearest(pan.1).into_iter().map(move |y| (x, y))).any(|whole| { + let at = Affine::translate(whole) * base; + glow(&panned.pixels) == glow(&fresh.render(&d, size.0, size.1, at, &opts).pixels) + }); + assert!(found, "blur {blur}, pan {pan:?}: the glow is drawn at the whole pixel nearest the pan"); } } } diff --git a/crates/render/src/options.rs b/crates/render/src/options.rs index 93d0f07b9a..8a152e11f2 100644 --- a/crates/render/src/options.rs +++ b/crates/render/src/options.rs @@ -35,7 +35,7 @@ pub fn raster_size(region: Rect, scale: f64) -> Result<(u32, u32), String> { pub struct RenderOptions { /// Outline (wireframe) view: 1 px black paths, no paint. pub outline: bool, - /// Pasteboard colour behind everything (premultiplied RGBA8); `None` = transparent. + /// Pasteboard color behind everything (straight-alpha RGBA8); `None` = transparent. pub background: Option<[u8; 4]>, /// Draw artboards as white rectangles (screen view). Export sets this to false. pub artboards: bool, diff --git a/crates/render/src/renderer.rs b/crates/render/src/renderer.rs index f5bcecaaa1..a5bfcbf26e 100644 --- a/crates/render/src/renderer.rs +++ b/crates/render/src/renderer.rs @@ -375,7 +375,7 @@ impl Renderer { } /// Render any node (also one outside the tree, e.g. opacity-mask art) fitted into - /// `size`×`size` pixels, optionally over a solid premultiplied background. A symbol instance + /// `size`×`size` pixels, optionally over a solid straight-alpha background. A symbol instance /// is fitted by its symbol's art in `doc`. pub fn render_node_thumbnail(&mut self, doc: &Document, n: &Node, size: u32, background: Option<[u8; 4]>) -> Option { self.render_node_thumbnail_with(doc, n, size, background, &RenderOptions::default()) From bb142cc063fca72704709b95b336613a7e9ca372 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Mon, 9 Nov 2026 17:19:13 -0800 Subject: [PATCH 14/36] Revolve: the fills and strokes of one object share the paint-copy budget Each fill or stroke with its own Revolve counted the copies of its paint from zero, so an object with several of them could draw past the 256 MiB budget. They now share one count, and past it the source path draws, as for the members of a group. effect.apply and effect.setParams on a fill or stroke now check the Revolve in that item's paint, after the item's geometry effects before it, together with the object's other items that carry a Revolve. Before, they checked the object's top fill. docs/revolve.md marks the axis position, type handling and member paint as VectorCraft's own rules, and docs/mcp.md says how the budget is counted. --- crates/effects/src/lib.rs | 2 +- crates/effects/src/revolve.rs | 93 ++++++++++++++++++++++-- crates/effects/src/solids.rs | 11 +++ crates/engine/src/cmd/effectcmd.rs | 10 ++- crates/engine/src/cmd/effectcmd/tests.rs | 25 +++++++ crates/three-d/src/mesh/tests.rs | 6 +- docs/mcp.md | 2 +- docs/revolve.md | 6 +- 8 files changed, 140 insertions(+), 15 deletions(-) diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index e5b3201a45..82db95b5b2 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -87,7 +87,7 @@ pub use revolve::{REVOLVE, has_revolve, revolve_art, revolve_options, validate_r pub use solids::{ CLASSIC_EXTRUDE, EXTRUDE, INFLATE, ROTATE, ROTATION_PRESETS, SHADING_COLORS, SOLIDS, SolidMesh, THREE_D_TYPES, classic_extrude_options, classic_shading, extrude_options, has_solid, inflate_options, is_three_d, solid_art, three_d_meshes, three_d_meshes_of, three_d_params_as, - validate_solid, validate_three_d, validate_three_d_params, view_options, + validate_solid, validate_three_d, validate_three_d_at, validate_three_d_params, view_options, }; pub use stylize::{ScribbleSetting, scribble_setting_id, scribble_settings}; pub use svg_filter::SVG_FILTER; diff --git a/crates/effects/src/revolve.rs b/crates/effects/src/revolve.rs index 6dcc468014..cf275fc2d9 100644 --- a/crates/effects/src/revolve.rs +++ b/crates/effects/src/revolve.rs @@ -93,6 +93,38 @@ pub fn validate_revolve(n: &Node, p: &Value) -> Result<(), String> { check_surface(&path, b, revolve_options(p), surface_item(&paints), &mut 0) } +/// [`validate_revolve`] for a Revolve with parameters `p` at index `index` of the effects of +/// appearance item `target` of `n`. The surfaces of every fill and stroke that carries a Revolve, +/// `target`'s included, share one budget of paint copies, as in [`art`]. A visible object-level +/// Revolve draws in place of those surfaces. +pub(crate) fn validate_item_revolve(n: &Node, p: &Value, target: usize, index: usize) -> Result<(), String> { + if is_container(n) || visible(&n.appearance.effects) || target >= n.appearance.items.len() { + return validate_revolve(n, p); + } + validate_revolve_params(p)?; + const EMPTY: &str = "Revolve requires a shape with at least two anchors"; + let (path, _) = object_profile(n, "Revolve")?; + if !has_two_anchors(&path) { + return Err(EMPTY.into()); + } + let ctx = GeomContext::of(n); + let path = geometry(&path, &n.appearance.effects, &ctx); + let mut copies = 0; + for (i, item) in n.appearance.items.iter().enumerate() { + let effects = item.effects(); + let at = if i == target { Some(index) } else { effects.iter().position(|e| e.visible && e.id == REVOLVE) }; + let options = match at.and_then(|at| effects.get(at)) { + _ if i == target => revolve_options(p), + Some(fx) if crate::paints(item) => revolve_options(&merged_params(REVOLVE, &fx.params)), + _ => continue, + }; + let path = geometry(&path, at.and_then(|at| effects.get(..at)).unwrap_or(effects), &ctx.item(item)); + let b = path.bounds().ok_or(EMPTY)?; + check_surface(&path, b, options, crate::paints(item).then_some(item), &mut copies)?; + } + Ok(()) +} + fn has_two_anchors(path: &PathData) -> bool { path.subpaths.iter().any(|sp| sp.anchors.len() >= 2) } @@ -250,12 +282,13 @@ fn surface( Some((depths, group)) } -/// A surface of one object: `shape`'s own, built with options derived from `fx`, about `b`. -fn object_surface(n: &Node, path: &PathData, fx: &Effect, item: &AppearanceItem, expand: bool) -> Option { +/// A surface of one object about the bounds of `path`, built with options derived from `fx`. +/// `copies` is the count [`surface`] adds to. +fn object_surface(n: &Node, path: &PathData, fx: &Effect, item: &AppearanceItem, expand: bool, copies: &mut usize) -> Option { let b = path.bounds()?; let (paint, opacity, blend) = paint_of(item); let options = revolve_options(&merged_params(REVOLVE, &fx.params)); - surface(n, path, b, options, fx, paint, opacity, blend, expand, &mut 0).map(|(_, art)| art) + surface(n, path, b, options, fx, paint, opacity, blend, expand, copies).map(|(_, art)| art) } fn shading_paint(g: vectorcraft_three_d::ShadeGradient, color: impl Fn(f64) -> Color, opacity: impl Fn(f64) -> f32) -> Paint { @@ -357,6 +390,7 @@ fn art(n: &Node, expand: bool) -> Option { let (path, _) = profile(n)?; let ctx = GeomContext::of(n); let mut children = vec![]; + let mut copies = 0; let mut remaining = n.appearance.effects.clone(); if let Some(index) = n.appearance.effects.iter().position(|e| e.visible && e.id == REVOLVE) { let fx = n.appearance.effects.get(index)?; @@ -365,7 +399,7 @@ fn art(n: &Node, expand: bool) -> Option { let paints = solid_paints(n); let item = surface_item(&paints)?; let expand = expand && !n.appearance.effects.iter().skip(index + 1).any(|e| e.visible && is_geometry(&e.id)); - children.push(Arc::new(object_surface(n, &path, fx, item, expand)?)); + children.push(Arc::new(object_surface(n, &path, fx, item, expand, &mut copies)?)); // Later geometry acts on the projected art as a whole through container evaluation. remaining = n .appearance @@ -383,7 +417,7 @@ fn art(n: &Node, expand: bool) -> Option { let fx = item.effects().get(index)?; let p = geometry(&path, item.effects().get(..index)?, &ctx.item(item)); let expand = expand && !item.effects().iter().skip(index + 1).any(|e| e.visible && is_geometry(&e.id)); - let mut art = object_surface(n, &p, fx, item, expand)?; + let mut art = object_surface(n, &p, fx, item, expand, &mut copies)?; art.appearance.effects = item.effects().iter().skip(index + 1).filter(|e| e.id != REVOLVE).cloned().collect(); children.push(Arc::new(art)); } else { @@ -721,6 +755,55 @@ mod tests { assert!(validate_revolve(&object, &json!({})).is_err(), "one object past the budget"); } + #[test] + fn the_fills_of_one_object_share_the_budget_of_paint_copies() { + use vectorcraft_doc::color::{Gradient, GradientKind, GradientPaint, GradientStop}; + let source = profile_node(); + let NodeKind::Path { path: profile, .. } = &source.kind else { panic!("a path") }; + let faces = vectorcraft_three_d::revolve(profile, profile.bounds().unwrap(), revolve_options(&json!({}))).unwrap().len(); + let stops = MAX_PAINT_COPIES * 6 / 10 / (faces * 4) / std::mem::size_of::(); + let fill = |stops: usize, revolved: bool| { + let stops = (0..stops).map(|i| GradientStop::new(i as f32 / stops as f32, Color::BLACK)).collect(); + let paint = Paint::Gradient(Box::new(GradientPaint::new(Gradient::new(GradientKind::Linear, stops)))); + let mut item = AppearanceItem::Fill(FillLayer::new(paint)); + if revolved { + item.effects_mut().push(fx()); + } + item + }; + let with = |items: Vec| { + let mut n = source.clone(); + n.appearance = Appearance { items, ..Default::default() }.into(); + n + }; + let one = with(vec![fill(stops, true)]); + assert!(revolve_art(&one).is_some()); + assert_eq!(validate_item_revolve(&one, &json!({}), 0, 0), Ok(())); + let two = with(vec![fill(stops, true), fill(stops, true)]); + assert_eq!(revolve_art(&two), None, "past the budget the source draws"); + let refused = validate_item_revolve(&two, &json!({}), 1, 0).unwrap_err(); + assert!(refused.contains("simplify the gradient"), "{refused}"); + let second = with(vec![fill(stops, true), fill(stops, false)]); + assert!(validate_item_revolve(&second, &json!({}), 1, 0).is_err(), "the item the effect goes on counts"); + assert_eq!(validate_item_revolve(&second, &json!({}), 0, 0), Ok(()), "an item without a Revolve does not"); + let below = with(vec![fill(stops * 2, false), fill(1, false)]); + assert!(validate_item_revolve(&below, &json!({}), 0, 0).is_err(), "the item's own paint, not the top fill"); + assert_eq!(validate_item_revolve(&below, &json!({}), 1, 0), Ok(())); + } + + #[test] + fn an_items_revolve_is_validated_after_only_the_geometry_effects_before_it() { + let roughen = crate::new_effect("distort.roughen", &json!({"size": 1, "detail": 100})).unwrap(); + let mut object = (*star(1)).clone(); + let mut hidden = fx(); + hidden.visible = false; + let item = object.appearance.items.first_mut().unwrap(); + item.effects_mut().push(hidden); + item.effects_mut().push(roughen); + assert_eq!(validate_item_revolve(&object, &json!({}), 0, 0), Ok(()), "the roughen after it is not swept"); + assert!(validate_item_revolve(&object, &json!({}), 0, 2).is_err(), "a Revolve added after the roughen"); + } + #[test] fn transparency_unknown_paints_and_later_distortion_keep_complete_surfaces() { let mut n = profile_node(); diff --git a/crates/effects/src/solids.rs b/crates/effects/src/solids.rs index 3330cde752..2340446f2e 100644 --- a/crates/effects/src/solids.rs +++ b/crates/effects/src/solids.rs @@ -298,6 +298,17 @@ pub fn validate_three_d(n: &Node, id: &str, p: &Value) -> Result<(), String> { if id == crate::REVOLVE { crate::validate_revolve(n, p) } else { validate_solid(n, id, p) } } +/// [`validate_three_d`] for an effect at `(item, index)`, index `index` of the effects of +/// appearance item `item` of `n` (the effects' length for a new effect), or on the object itself +/// when `at` is `None`. A Revolve on a fill or stroke is checked in that item's paint, after the +/// item's geometry effects before it, together with the object's other items that carry a Revolve. +pub fn validate_three_d_at(n: &Node, id: &str, p: &Value, at: Option<(usize, usize)>) -> Result<(), String> { + match at { + Some((item, index)) if id == crate::REVOLVE => crate::revolve::validate_item_revolve(n, p, item, index), + _ => validate_three_d(n, id, p), + } +} + /// Are 3D effect `id`'s parameter values `p` valid? The checks of [`validate_three_d`] that do not /// build the art, for an edit that hides the effect: hidden, it draws nothing, so its object does /// not need to fit. diff --git a/crates/engine/src/cmd/effectcmd.rs b/crates/engine/src/cmd/effectcmd.rs index 028996f615..9d396edc16 100644 --- a/crates/engine/src/cmd/effectcmd.rs +++ b/crates/engine/src/cmd/effectcmd.rs @@ -280,9 +280,13 @@ pub(crate) fn apply(s: &mut Session, p: &Value) -> Result { } let item = item_target(s, p, C)?; if effects::is_three_d(id) { - for target in appearance_targets(s, p)? { + let targets = appearance_targets(s, p)?; + for (target, at) in targets.iter().copied().zip(item.per_target(s, &targets)) { let n = s.doc()?.doc.node(target).ok_or(EngineError::NoNode(target))?; - effects::validate_three_d(n, id, &effect.params).map_err(|e| bad(C, e))?; + // A new effect goes after the item's others. + let on = at.and_then(|at| at.effects_item(&n.appearance, C).ok()).flatten(); + let on = on.and_then(|i| Some((i, n.appearance.items.get(i)?.effects().len()))); + effects::validate_three_d_at(n, id, &effect.params, on).map_err(|e| bad(C, e))?; if id == effects::REVOLVE && effects::has_revolve(n) { return Err(bad(C, "A profile supports one Revolve effect. Edit the existing effect's options instead.")); } @@ -589,7 +593,7 @@ fn set_params(s: &mut Session, p: &Value) -> Result { cur.extend(new); } if visible != Some(false) { - effects::validate_three_d(n, to, &prospective).map_err(|e| bad(C, e))?; + effects::validate_three_d_at(n, to, &prospective, target.map(|item| (item, index))).map_err(|e| bad(C, e))?; } else { // Hidden, the effect draws nothing, so its object need not fit. The values given are // checked, and after a change of 3D type so is every value it keeps. diff --git a/crates/engine/src/cmd/effectcmd/tests.rs b/crates/engine/src/cmd/effectcmd/tests.rs index eaca5d1146..d154c9cfb3 100644 --- a/crates/engine/src/cmd/effectcmd/tests.rs +++ b/crates/engine/src/cmd/effectcmd/tests.rs @@ -260,6 +260,31 @@ fn revolve_refuses_type_and_groups_without_a_shape() { assert!(node(&s, g).appearance.effects.is_empty()); } +/// A Revolve on one fill is checked in that fill's paint, and `effect.setParams` on it as well. +#[test] +fn revolve_on_a_fill_is_checked_in_that_fills_paint() { + use vectorcraft_color::{Color, Gradient, GradientKind, GradientPaint, GradientStop, Paint}; + use vectorcraft_doc::{AppearanceItem, FillLayer}; + let (mut s, id) = session_with_rect(); + // The paint copies of 100 faces reach the 256 MiB budget: 8 segments stay under, 64 pass it. + let count = (256 << 20) / (4 * 100 * std::mem::size_of::()); + let stops = (0..count).map(|i| GradientStop::new(i as f32 / count as f32, Color::rgb(1.0, 0.0, 0.0))).collect(); + let ramp = Paint::Gradient(Box::new(GradientPaint::new(Gradient::new(GradientKind::Linear, stops)))); + s.edit("fills", |d, _| { + let n = d.node_mut(id).unwrap(); + n.appearance.items = + vec![AppearanceItem::Fill(FillLayer::new(ramp)), AppearanceItem::Fill(FillLayer::new(Paint::solid(Color::rgb(0.0, 0.0, 1.0))))]; + Ok(()) + }) + .unwrap(); + let err = s.execute("effect.apply", &json!({"effect": "threeD.revolve", "ids": [id.0], "item": 0})).unwrap_err().to_string(); + assert!(err.contains("simplify the gradient"), "{err}"); + assert!(node(&s, id).appearance.items[0].effects().is_empty()); + s.execute("effect.apply", &json!({"effect": "threeD.revolve", "ids": [id.0], "item": 0, "params": {"segments": 8}})).unwrap(); + let err = s.execute("effect.setParams", &json!({"index": 0, "ids": [id.0], "item": 0, "params": {"segments": 128}})).unwrap_err().to_string(); + assert!(err.contains("simplify the gradient"), "{err}"); +} + /// Revolve on a group sweeps every member about one axis on the left edge of their bounds, keeps /// each member's paint, follows an edited member, expands and undoes as one step. #[test] diff --git a/crates/three-d/src/mesh/tests.rs b/crates/three-d/src/mesh/tests.rs index 27706f0b50..51f7c59e96 100644 --- a/crates/three-d/src/mesh/tests.rs +++ b/crates/three-d/src/mesh/tests.rs @@ -297,13 +297,15 @@ fn the_wall_of_a_counter_faces_into_the_hole() { }; for (clockwise, rule) in [(true, FillRule::NonZero), (true, FillRule::EvenOdd), (false, FillRule::EvenOdd)] { let m = ring(clockwise, rule); - assert!(wall_directions(&m, 110.0).iter().all(|s| *s > 0.0), "outer wall, {clockwise} {rule:?}"); + let outer = wall_directions(&m, 110.0); + assert!(!outer.is_empty() && outer.iter().all(|s| *s > 0.0), "outer wall, {clockwise} {rule:?}"); let hole = wall_directions(&m, 90.0); assert!(!hole.is_empty() && hole.iter().all(|s| *s < 0.0), "the hole's far wall faces the axis, {clockwise} {rule:?}"); } // Wound the same way under the nonzero rule, the inner square is not a hole. let m = ring(false, FillRule::NonZero); - assert!(wall_directions(&m, 90.0).iter().all(|s| *s > 0.0)); + let inner = wall_directions(&m, 90.0); + assert!(!inner.is_empty() && inner.iter().all(|s| *s > 0.0)); } #[test] diff --git a/docs/mcp.md b/docs/mcp.md index 7ab4e07de5..39c746df11 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -2352,7 +2352,7 @@ compound path, type, or a group or layer of them. `effect.dialog` opens compact `effect.expandAppearance` bakes it into shaded vector faces. Native saves keep the editable source; SVG/PDF exports bake the same faces the canvas draws. Only one Revolve is supported per profile. Type sweeps as its glyph outlines and stays type. On a group or layer, every visible member sweeps about one axis on the chosen edge of the members' bounds together, in its own paint, and the members stay editable. A group holding a clipping group, an image, a symbol instance or another object that can't be swept is refused, and so is type with no outlines or a group with no shape in it. -A surface takes at most 16,384 profile points and 163,840 faces, the members of a group together, counted after any geometry effects before the Revolve. A straight segment takes one point, a curve takes at least four, and each contour takes one. Each curved segment takes 16 samples; on a surface over those limits every curve takes as many as fit, down to 4, and the call is refused below that with "reduce Segments or simplify the profile". The call is also refused with "reduce Segments or simplify the gradient" when the faces' copies of the paint would take more than 256 MiB. Mesh export refuses a profile as too complex when its closed contours nest so deeply that finding the holes would take more than 16,000,000 path element tests. +A surface takes at most 16,384 profile points and 163,840 faces, the members of a group together, counted after any geometry effects before the Revolve. A straight segment takes one point, a curve takes at least four, and each contour takes one. Each curved segment takes 16 samples; on a surface over those limits every curve takes as many as fit, down to 4, and the call is refused below that with "reduce Segments or simplify the profile". The call is also refused with "reduce Segments or simplify the gradient" when the faces' copies of the paint would take more than 256 MiB, counted together for the members of a group and for the fills and strokes of one object that carry a Revolve. Mesh export refuses a profile as too complex when its closed contours nest so deeply that finding the holes would take more than 16,000,000 path element tests. Parameters: `angle` (degrees, 0..360, default 360), `offset` (points, 0..100000, default 0), `edge` (`"left"` or `"right"`), `rotationX/Y/Z` (degrees, defaults 0/0/0), `perspectiveAngle` diff --git a/docs/revolve.md b/docs/revolve.md index 836c3faa30..fd0f36f16d 100644 --- a/docs/revolve.md +++ b/docs/revolve.md @@ -3,9 +3,9 @@ This initial implementation follows the scope agreed in [issue #605](https://github.com/storytold/vectorcraft/issues/605#issuecomment-6070543567). Select a path, compound path, type, or a group or layer of them, then choose **Effect > 3D and Materials > Revolve**. -Type stays editable and sweeps as its glyph outlines. On a group or layer, every visible member sweeps about one axis -on the chosen edge of the members' bounds together, in its own paint, as [the help page](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/create-3d-graphics/create-3d-objects.html) -describes for a group. +On a group or layer, every visible member sweeps about one axis, as [the help page](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/create-3d-graphics/create-3d-objects.html) +describes for a group. The following rules are VectorCraft's own: type stays editable and sweeps as its glyph outlines, +the shared axis lies on the chosen edge of the members' bounds together, and each member keeps its own paint. The profile's horizontal distance from a vertical axis is its radius. **Axis side** chooses the left or right edge of the profile's bounds; **Offset** moves the axis outward. For a cylinder made from a vertical line, give it a nonzero offset. A vertical line at zero offset lies on the axis and has no surface. From 2989954301d1dee758b6aa3a813a0a5aef004666 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Mon, 9 Nov 2026 17:24:42 -0800 Subject: [PATCH 15/36] Canvas: a touchpad's End or Cancel ends its wheel turn with Zoom with Mouse Wheel on With Zoom with Mouse Wheel on, a wheel turn that a touchpad began did not end on the End or Cancel phase, so a whole Cmd/Ctrl notch within 150 ms, or in the same frame, still zoomed instead of scrolling sideways. The turn now ends on those phases, as it does in the Scroll Sideways branch. Tests cover End and Cancel in the frame of the next notch, a Start phase, the scroll timeout with no frame in between, a table of fine_steps cases, and exact zoom and scroll amounts. Follows #6360. --- crates/ui-egui/src/canvas/tests.rs | 137 +++++++++++++++++++++++++---- crates/ui-egui/src/canvas/view.rs | 7 +- 2 files changed, 126 insertions(+), 18 deletions(-) diff --git a/crates/ui-egui/src/canvas/tests.rs b/crates/ui-egui/src/canvas/tests.rs index 4ba8ae2739..42778c729f 100644 --- a/crates/ui-egui/src/canvas/tests.rs +++ b/crates/ui-egui/src/canvas/tests.rs @@ -4868,29 +4868,134 @@ fn cmd_with_a_touchpad_zooms_and_a_whole_notch_scrolls_sideways() { }; use egui::{MouseWheelSource as S, MouseWheelUnit as U}; let zooms_in = |(zoom, _, moved): (f64, _, f64)| zoom > 1.0005 && moved < 1e-6; - let sideways = |(zoom, scroll, _): (f64, vectorcraft_geom::Vec2, f64)| (zoom - 1.0).abs() < 1e-9 && scroll.x < -1.0 && scroll.y == 0.0; + let zooms_by = |(zoom, _, moved): (f64, vectorcraft_geom::Vec2, f64), want: f64| (zoom / want - 1.0).abs() < 1e-6 && moved < 1e-6; + // `lines` wheel lines to the left. + let line = f64::from(ctx.options(|o| o.input_options.line_scroll_speed)); + let sideways = |(zoom, scroll, _): (f64, vectorcraft_geom::Vec2, f64), lines: f64| { + (zoom - 1.0).abs() < 1e-9 && (scroll.x + lines * line).abs() < 0.5 && scroll.y == 0.0 + }; assert!(app.session.prefs.ctrl_wheel_scrolls_sideways, "the option is on by default"); - // A wheel message of 30 is a quarter of a 120 notch, and arrives as a quarter line. - assert!(zooms_in(turn(&mut app, vec![ev(U::Line, 0.25, S::Unknown)])), "a line delta smaller than a notch zooms"); - assert!(zooms_in(turn(&mut app, vec![ev(U::Line, 0.25, S::Unknown), ev(U::Line, 0.25, S::Unknown)])), "several small deltas zoom"); - assert!(zooms_in(turn(&mut app, vec![ev(U::Line, 0.25, S::Unknown), ev(U::Line, 1.0, S::Unknown)])), "a whole notch in the same turn zooms"); - assert!(zooms_in(turn(&mut app, vec![ev(U::Point, 6.0, S::Unknown)])), "point deltas of an unnamed source zoom"); - assert!(zooms_in(turn(&mut app, vec![ev(U::Point, 6.0, S::Trackpad)])), "a trackpad scroll zooms"); - assert!(zooms_in(turn(&mut app, vec![ev(U::Point, 6.0, S::Momentum)])), "its momentum zooms"); - assert!(sideways(turn(&mut app, vec![ev(U::Line, 1.0, S::Unknown)])), "a whole notch scrolls sideways"); - assert!(sideways(turn(&mut app, vec![ev(U::Line, 2.0, S::Unknown)])), "a turn of two notches scrolls sideways"); - assert!(sideways(turn(&mut app, vec![ev(U::Line, 0.25, S::Wheel)])), "a part of a line from a named wheel scrolls sideways"); + // A wheel message of 30 is a quarter of a 120 notch, and arrives as a quarter line. The zoom + // is exponential in the wheel's motion, so more motion in a turn multiplies the quarter's. + let small = turn(&mut app, vec![ev(U::Line, 0.25, S::Unknown)]); + assert!(zooms_in(small), "a line delta smaller than a notch zooms"); + let quarter = small.0; + assert!( + zooms_by(turn(&mut app, vec![ev(U::Line, 0.25, S::Unknown), ev(U::Line, 0.25, S::Unknown)]), quarter.powi(2)), + "several small deltas zoom" + ); + assert!( + zooms_by(turn(&mut app, vec![ev(U::Line, 0.25, S::Unknown), ev(U::Line, 1.0, S::Unknown)]), quarter.powi(5)), + "a whole notch in the same turn adds its zoom" + ); + let point = turn(&mut app, vec![ev(U::Point, 6.0, S::Unknown)]); + assert!(zooms_in(point), "point deltas of an unnamed source zoom"); + assert!(zooms_by(turn(&mut app, vec![ev(U::Point, 6.0, S::Trackpad)]), point.0), "a trackpad scroll zooms as far"); + assert!(zooms_by(turn(&mut app, vec![ev(U::Point, 6.0, S::Momentum)]), point.0), "its momentum zooms as far"); + assert!(sideways(turn(&mut app, vec![ev(U::Line, 1.0, S::Unknown)]), 1.0), "a whole notch scrolls sideways"); + assert!(sideways(turn(&mut app, vec![ev(U::Line, 2.0, S::Unknown)]), 2.0), "a turn of two notches scrolls sideways"); + assert!(sideways(turn(&mut app, vec![ev(U::Line, 0.25, S::Wheel)]), 0.25), "a part of a line from a named wheel scrolls sideways"); // A turn ends when the wheel goes idle, so the next whole notch scrolls sideways again. assert!(zooms_in(turn(&mut app, vec![ev(U::Line, 0.25, S::Unknown)])), "a touchpad turn"); - assert!(sideways(turn(&mut app, vec![ev(U::Line, 1.0, S::Unknown)])), "the notch after it scrolls sideways"); + assert!(sideways(turn(&mut app, vec![ev(U::Line, 1.0, S::Unknown)]), 1.0), "the notch after it scrolls sideways"); app.session.execute("prefs.set", &json!({"key": "zoomWithMouseWheel", "value": true})).unwrap(); - assert!(zooms_in(turn(&mut app, vec![ev(U::Line, 0.25, S::Unknown)])), "with Zoom with Mouse Wheel: a small line delta zooms"); + let small = turn(&mut app, vec![ev(U::Line, 0.25, S::Unknown)]); + assert!(zooms_in(small), "with Zoom with Mouse Wheel: a small line delta zooms"); + let quarter = small.0; assert!( - zooms_in(turn(&mut app, vec![ev(U::Line, 0.25, S::Unknown), ev(U::Line, 1.0, S::Unknown)])), - "with Zoom with Mouse Wheel: a whole notch in the same turn zooms" + zooms_by(turn(&mut app, vec![ev(U::Line, 0.25, S::Unknown), ev(U::Line, 1.0, S::Unknown)]), quarter.powi(5)), + "with Zoom with Mouse Wheel: a whole notch in the same turn adds its zoom" ); assert!(zooms_in(turn(&mut app, vec![ev(U::Point, 6.0, S::Trackpad)])), "with Zoom with Mouse Wheel: a trackpad scroll zooms"); - assert!(sideways(turn(&mut app, vec![ev(U::Line, 1.0, S::Unknown)])), "with Zoom with Mouse Wheel: a whole notch scrolls sideways"); + assert!(sideways(turn(&mut app, vec![ev(U::Line, 1.0, S::Unknown)]), 1.0), "with Zoom with Mouse Wheel: a whole notch scrolls sideways"); +} + +/// A wheel turn that a touchpad began ends with an End or Cancel phase, also in the frame the next +/// notch arrives, and after egui's scroll timeout with no frame in between. Under either +/// preference, the whole notch after it scrolls sideways. A notch inside the turn zooms. +#[test] +fn a_touchpad_wheel_turn_ends_with_its_end_phase_or_the_scroll_timeout() { + use egui::{MouseWheelSource as S, MouseWheelUnit as U, TouchPhase as P}; + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + let ctx = egui::Context::default(); + let mut t = 0.0; + let frame_time = 1.0 / 60.0; + let mut run = |app: &mut VectorcraftApp, dt: f64, events: Vec| { + t += dt; + let raw = egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), + time: Some(t), + events, + ..Default::default() + }; + let mut out = ctx.run_ui(raw, |ui| show(app, ui)); + out.textures_delta.clear(); + }; + run(&mut app, frame_time, vec![]); + let rect = app.canvas_rect.unwrap(); + let at = rect.center() + vec2(60.0, 40.0); + let line = f64::from(ctx.options(|o| o.input_options.line_scroll_speed)); + let ev = + |unit, dy: f32, phase, source| egui::Event::MouseWheel { unit, delta: vec2(0.0, dy), phase, modifiers: egui::Modifiers::COMMAND, source }; + // Point deltas zoom at once, so no glide is left when the next event comes. + let small = || ev(U::Point, 6.0, P::Move, S::Unknown); + let notch = || ev(U::Line, 1.0, P::Move, S::Unknown); + // A frame with a small delta, then `events` in one frame `dt` seconds later, and the wheel + // going idle: → (zoom ratio, scroll in points) since the small delta. + let mut turn = |app: &mut VectorcraftApp, dt: f64, events: Vec| { + run(app, frame_time, vec![egui::Event::PointerMoved(at)]); + run(app, frame_time, vec![small()]); + let before = *app.view().unwrap(); + run(app, dt, events); + for _ in 0..40 { + run(app, frame_time, vec![]); + } + let after = *app.view().unwrap(); + (after.zoom / before.zoom, (after.center - before.center) * after.zoom) + }; + let sideways = |(zoom, scroll): (f64, vectorcraft_geom::Vec2)| (zoom - 1.0).abs() < 1e-9 && (scroll.x + line).abs() < 0.5 && scroll.y == 0.0; + for wheel_zooms in [false, true] { + app.session.execute("prefs.set", &json!({"key": "zoomWithMouseWheel", "value": wheel_zooms})).unwrap(); + assert!(turn(&mut app, frame_time, vec![notch()]).0 > 1.01, "a notch inside the turn zooms ({wheel_zooms})"); + assert!( + turn(&mut app, frame_time, vec![ev(U::Point, 0.0, P::Start, S::Trackpad), notch()]).0 > 1.01, + "a Start phase does not end the turn ({wheel_zooms})" + ); + for phase in [P::End, P::Cancel] { + let r = turn(&mut app, frame_time, vec![ev(U::Point, 0.0, phase, S::Trackpad), notch()]); + assert!(sideways(r), "the notch after {phase:?} in the same frame scrolls sideways ({wheel_zooms}, {r:?})"); + } + let (zoom, scroll) = turn(&mut app, 0.2, vec![notch()]); + assert!( + (zoom - 1.0).abs() < 1e-9 && scroll.x < -1.0 && scroll.y == 0.0, + "the notch after the scroll timeout scrolls sideways ({wheel_zooms}, {zoom}, {scroll:?})" + ); + } +} + +/// Which wheel events count as a touchpad: a named trackpad or its momentum, never a named wheel, +/// and for an unnamed source, point deltas or line and page deltas more than 0.001 from a whole +/// number on either axis. +#[test] +fn fine_steps_tells_touchpad_input_from_whole_notches() { + use egui::{MouseWheelSource as S, MouseWheelUnit as U}; + for unit in [U::Point, U::Line, U::Page] { + for delta in [vec2(0.0, 1.0), vec2(0.0, 0.25), vec2(-1.5, 0.0)] { + assert!(view::fine_steps(unit, delta, S::Trackpad), "{unit:?} {delta:?} from a trackpad"); + assert!(view::fine_steps(unit, delta, S::Momentum), "{unit:?} {delta:?} from its momentum"); + assert!(!view::fine_steps(unit, delta, S::Wheel), "{unit:?} {delta:?} from a named wheel"); + } + } + assert!(view::fine_steps(U::Point, vec2(0.0, 1.0), S::Unknown), "whole points from an unnamed source"); + for unit in [U::Line, U::Page] { + for (x, y) in [(0.0, 0.25), (0.0, -0.25), (0.25, 1.0), (1.0, -0.5), (0.0, 0.002), (0.0, 0.998), (0.0, 1.002), (0.0, 1.0 / 120.0)] { + assert!(view::fine_steps(unit, vec2(x, y), S::Unknown), "{unit:?} ({x}, {y}) is finer than a notch"); + } + for (x, y) in [(0.0, 0.0), (0.0, 1.0), (0.0, -1.0), (0.0, 2.0), (-3.0, 1.0), (0.0, 0.9995), (0.0, 1.0005), (0.0, 0.0005)] { + assert!(!view::fine_steps(unit, vec2(x, y), S::Unknown), "{unit:?} ({x}, {y}) is a whole notch"); + } + } } /// With Scroll Sideways with Cmd/Ctrl + Wheel on and Animated Zoom off, Alt + Cmd-wheel zooms diff --git a/crates/ui-egui/src/canvas/view.rs b/crates/ui-egui/src/canvas/view.rs index 3ed6583aea..558bf011d9 100644 --- a/crates/ui-egui/src/canvas/view.rs +++ b/crates/ui-egui/src/canvas/view.rs @@ -358,7 +358,7 @@ struct ThumbDrag { /// the source on the Mac and reports `Unknown` elsewhere. There, point deltas (Wayland) and line /// deltas that are not whole notches (Windows and X11) count as a touchpad. A browser reports a /// mouse wheel in points, so the web build counts only line deltas that are not whole notches. -fn fine_steps(unit: egui::MouseWheelUnit, delta: egui::Vec2, source: egui::MouseWheelSource) -> bool { +pub(super) fn fine_steps(unit: egui::MouseWheelUnit, delta: egui::Vec2, source: egui::MouseWheelSource) -> bool { let notch = |d: f32| (d - d.round()).abs() < 1e-3; match source { egui::MouseWheelSource::Trackpad | egui::MouseWheelSource::Momentum => true, @@ -483,7 +483,7 @@ pub(super) fn wheel( let mut scroll = pan; for e in &i.events { match e { - egui::Event::MouseWheel { unit, delta, modifiers, source, .. } => { + egui::Event::MouseWheel { unit, delta, phase, modifiers, source, .. } => { let d = points(unit, *delta); // The turn ends after egui's scroll timeout, as above. if i.time - turn.last > 0.150 { @@ -502,6 +502,9 @@ pub(super) fn wheel( turn.glide += f64::from(d.y) * WHEEL_ZOOM; scroll.x += d.x; } + if matches!(phase, egui::TouchPhase::End | egui::TouchPhase::Cancel) { + turn.fine = false; + } } egui::Event::Zoom(f) => zoom *= f64::from(*f), _ => {} From 138bdd77e40b12bcb089f3484e7e7f93332122e5 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Mon, 9 Nov 2026 17:07:02 -0800 Subject: [PATCH 16/36] Font menu: a panel scrolled past a closed font field keeps scrolling Since #5331, a closed font family field in Character, Properties or the Control bar took every plain vertical wheel turn while the pointer was over it. When a panel scrolled the field under a still pointer, the field took the rest of the turns: the panel stopped scrolling and each turn changed the selected type's font. The field now takes the wheel only when the pointer was moved onto it after the field last moved, and the wheel stream began there. A stream that began anywhere else, a sideways scroll included, stays there until 0.5 s pass with no wheel input. A stream that leaves its field does not return to it. It leaves when the pointer moves off the field or out of the window, when the field moves or misses a layout pass, and when a turn arrives while an overlay covers the field. Trackpad momentum stays with the stream it follows and never starts one on a field. The rule and its 0.5 s are VectorCraft's own. Wheel turns the field does not take stay in the input in their order. Moving the pointer onto the field and turning the wheel still steps the font, as #5253 asks, in floating panel windows too. When the pointer was moved onto the field while a window or overlay covered it, the pointer has to move again after the cover goes away before the wheel steps the font. --- crates/ui-egui/src/font_menu.rs | 101 ++++++- crates/ui-egui/src/font_menu/tests.rs | 253 ++++++++++++++++++ .../ui-egui/src/panel_docking/native_tests.rs | 75 +++++- crates/ui-egui/src/tests/font_menu.rs | 134 +++++++++- crates/ui-egui/tests/font_wheel.rs | 28 ++ docs/type-parity.md | 2 +- docs/ui-parity.md | 2 +- 7 files changed, 581 insertions(+), 14 deletions(-) diff --git a/crates/ui-egui/src/font_menu.rs b/crates/ui-egui/src/font_menu.rs index 1896b8ed92..d0750d373e 100644 --- a/crates/ui-egui/src/font_menu.rs +++ b/crates/ui-egui/src/font_menu.rs @@ -12,6 +12,8 @@ //! the mouse wheel turned over the closed field applies the next family (or previous one) to the //! selected text, one undo step per notch. The menu's order at the first turn stays fixed until the //! pointer leaves the field or the text selection changes; steps stop at its first and last family. +//! The field takes the wheel only when the pointer was moved onto it after the field last moved and +//! the wheel stream began there. use std::collections::{HashMap, HashSet}; use std::sync::Arc; @@ -441,6 +443,54 @@ struct WheelField { layer: egui::LayerId, } +/// The seconds without wheel input that end a wheel stream. This is VectorCraft's own value. +pub(crate) const WHEEL_STREAM_GAP: f64 = 0.5; + +/// The field the pointer was last moved onto, while that field stays where it was. +#[derive(Clone, Copy)] +struct ArmedWheelField { + id: egui::Id, + rect: egui::Rect, + pass: u64, +} + +#[derive(Clone, Copy, PartialEq)] +enum WheelStreamOwner { + Field(egui::Id), + Elsewhere, +} + +/// One run of wheel input with no gap of [`WHEEL_STREAM_GAP`] in it, trackpad momentum included. +#[derive(Clone, Copy, Default)] +struct WheelStream { + last: Option, + owner: Option, +} + +impl WheelStream { + /// Note wheel input at `now`; a gap since the last input starts a new stream with no owner. + fn note_input(&mut self, now: f64) { + if !self.last.is_some_and(|last| (0.0..WHEEL_STREAM_GAP).contains(&(now - last))) { + self.owner = None; + } + self.last = Some(now); + } + + /// Leave the rest of the stream to the panel when `field` holds it. + fn release(&mut self, field: egui::Id) { + if self.owner == Some(WheelStreamOwner::Field(field)) { + self.owner = Some(WheelStreamOwner::Elsewhere); + } + } + + /// Leave the rest of the stream to the panel when any field holds it. + fn release_any(&mut self) { + if let Some(WheelStreamOwner::Field(field)) = self.owner { + self.release(field); + } + } +} + #[derive(Clone)] struct QueuedWheel { #[cfg(not(target_arch = "wasm32"))] @@ -478,6 +528,8 @@ struct WheelFields { pending: HashMap>, queued: Vec, replay: Vec, + armed: Option, + stream: WheelStream, } #[cfg(not(target_arch = "wasm32"))] @@ -524,6 +576,7 @@ impl egui::Plugin for WheelLayerSnapshot { let id = event.field.with("wheel"); if !active { ctx.data_mut(|d| d.remove::(id)); + fields.stream.release(event.field); if let Some(target) = ctx.layer_id_at(event.position) { fields.replay.push(ReplayWheel { target, position: event.position, event: event.event }); } @@ -636,7 +689,8 @@ pub(crate) fn sync_wheel_target(app: &VectorcraftApp, ctx: &egui::Context, viewp let key = wheel_fields_id(viewport); let (previous, mut fields) = ctx.data(|d| (d.get_temp::(key.with("target")), d.get_temp::(key))); if previous.is_some_and(|old| old != target) { - fields = None; + // A new target keeps the armed field and the running wheel stream. + fields = fields.map(|old| WheelFields { pass: old.pass, armed: old.armed, stream: old.stream, ..Default::default() }); } let mut hidden = Vec::new(); if let Some(fields) = &mut fields { @@ -685,6 +739,16 @@ fn remember_owned_wheel_field(ui: &Ui, resp: &egui::Response, enabled: bool, men fields.fields.clear(); } fields.fields.retain(|f| f.id != resp.id); + if let Some(armed) = &mut fields.armed + && armed.id == resp.id + { + if armed.rect == resp.interact_rect && pass.saturating_sub(armed.pass) <= 1 { + armed.pass = pass; + } else { + fields.armed = None; + fields.stream.release(resp.id); + } + } if enabled { fields.fields.push(WheelField { id: resp.id, menu, owner, rect: resp.interact_rect, layer: resp.layer_id }); } @@ -695,7 +759,10 @@ fn remember_owned_wheel_field(ui: &Ui, resp: &egui::Response, enabled: bool, men } /// Take the closed font fields' wheel events before egui smooths and combines them. Other -/// events stay in the input, so scrolling elsewhere keeps its own delta and event order. +/// events stay in the input, so scrolling elsewhere keeps its own delta and event order. A field +/// takes a wheel stream only when the stream begins with the pointer over it and the pointer was +/// moved onto it after the field last moved. A stream that began anywhere else stays there until +/// it ends, and a stream that leaves its field does not return to it. pub(crate) fn take_raw_wheel(ctx: &egui::Context, raw: &mut egui::RawInput) { let key = wheel_fields_id(raw.viewport_id); let Some(mut fields) = ctx.data(|d| d.get_temp::(key)) else { return }; @@ -712,7 +779,10 @@ pub(crate) fn take_raw_wheel(ctx: &egui::Context, raw: &mut egui::RawInput) { if pass < fields.pass || pass.saturating_sub(fields.pass) > 1 { fields.fields.clear(); fields.pointer = None; + fields.armed = None; + fields.stream.release_any(); } + let now = raw.time.unwrap_or_else(|| ctx.input_for(raw.viewport_id, |i| i.time) + f64::from(raw.predicted_dt)); if fields.pending_frame != frame { fields.pending_frame = frame; fields.pending.clear(); @@ -766,8 +836,20 @@ pub(crate) fn take_raw_wheel(ctx: &egui::Context, raw: &mut egui::RawInput) { { fields.activating.push(id); } + if matches!(event, egui::Event::MouseWheel { .. }) { + fields.stream.note_input(now); + } match event { egui::Event::PointerMoved(p) | egui::Event::PointerButton { pos: p, .. } => { + if pointer != Some(*p) { + let pass = fields.pass; + fields.armed = fields.fields.iter().rev().find(|f| over(f, Some(*p))).map(|f| ArmedWheelField { id: f.id, rect: f.rect, pass }); + if let Some(WheelStreamOwner::Field(id)) = fields.stream.owner + && !fields.fields.iter().any(|f| f.id == id && over(f, Some(*p))) + { + fields.stream.release(id); + } + } mark_queued_wheel_leaves(&mut fields, Some(*p)); for field in &fields.fields { if pointer.is_some_and(|point| field.rect.contains(point)) @@ -782,6 +864,8 @@ pub(crate) fn take_raw_wheel(ctx: &egui::Context, raw: &mut egui::RawInput) { } egui::Event::PointerGone => { pointer = None; + fields.armed = None; + fields.stream.release_any(); mark_queued_wheel_leaves(&mut fields, None); for field in &fields.fields { if !fields.queued.iter().any(|event| event.field == field.id) { @@ -790,8 +874,13 @@ pub(crate) fn take_raw_wheel(ctx: &egui::Context, raw: &mut egui::RawInput) { } } } - egui::Event::MouseWheel { unit, delta, modifiers, .. } if modifiers.is_none() && delta.y != 0.0 => { - if let Some(field) = fields.fields.iter().rev().find(|f| !fields.activating.contains(&f.id) && over(f, pointer)).copied() { + egui::Event::MouseWheel { unit, delta, modifiers, source, .. } if modifiers.is_none() && delta.y != 0.0 => { + let under = fields.fields.iter().rev().find(|f| !fields.activating.contains(&f.id) && over(f, pointer)).copied(); + let starts_on_field = under + .filter(|f| fields.armed.is_some_and(|armed| armed.id == f.id) && !matches!(source, egui::MouseWheelSource::Momentum)) + .map(|f| f.id); + let owner = *fields.stream.owner.get_or_insert(starts_on_field.map_or(WheelStreamOwner::Elsewhere, WheelStreamOwner::Field)); + if let Some(field) = under.filter(|f| owner == WheelStreamOwner::Field(f.id)) { let amount = -match unit { egui::MouseWheelUnit::Point => delta.y / line, egui::MouseWheelUnit::Line | egui::MouseWheelUnit::Page => delta.y, @@ -823,6 +912,10 @@ pub(crate) fn take_raw_wheel(ctx: &egui::Context, raw: &mut egui::RawInput) { captured = true; return false; } + fields.stream.owner = Some(WheelStreamOwner::Elsewhere); + } + egui::Event::MouseWheel { delta, .. } if *delta != egui::Vec2::ZERO => { + fields.stream.owner.get_or_insert(WheelStreamOwner::Elsewhere); } _ => {} } diff --git a/crates/ui-egui/src/font_menu/tests.rs b/crates/ui-egui/src/font_menu/tests.rs index 73db46de30..6e0d3bba69 100644 --- a/crates/ui-egui/src/font_menu/tests.rs +++ b/crates/ui-egui/src/font_menu/tests.rs @@ -355,6 +355,259 @@ fn activating_the_family_popup_leaves_its_wheel_input_available() { } } +/// A family field at `top`, the input's clock at `time`. Returns the wheel events left in the +/// input, the notches the field took and the point in the middle of the field. +fn stream_frame(ctx: &egui::Context, top: Option, time: f64, events: Vec) -> (usize, i32, egui::Pos2) { + let mut raw = egui::RawInput { time: Some(time), events, ..Default::default() }; + super::take_raw_wheel(ctx, &mut raw); + let left = raw.events.iter().filter(|e| matches!(e, egui::Event::MouseWheel { .. })).count(); + let mut notches = 0; + let mut point = egui::Pos2::ZERO; + let mut out = ctx.run_ui(raw, |ui| { + let Some(top) = top else { return }; + let rect = egui::Rect::from_min_size(egui::pos2(10.0, top), egui::vec2(120.0, 24.0)); + let resp = ui.put(rect, egui::Button::new("Family")); + point = resp.rect.center(); + for turn in super::remember_wheel_field(ui, &resp, true) { + if let super::WheelInput::Notches(n) = turn { + notches += n; + } + } + }); + out.textures_delta.clear(); + (left, notches, point) +} + +fn stream_turn(phase: egui::TouchPhase, source: egui::MouseWheelSource, dy: f32) -> egui::Event { + egui::Event::MouseWheel { unit: egui::MouseWheelUnit::Line, delta: egui::vec2(0.0, dy), phase, modifiers: Default::default(), source } +} + +fn plain_turn() -> egui::Event { + stream_turn(egui::TouchPhase::Move, egui::MouseWheelSource::Unknown, -1.0) +} + +/// A context with a family field drawn at `top` for three frames, from clock time 100. +fn stream_context(top: f32) -> (egui::Context, egui::Pos2) { + let ctx = egui::Context::default(); + let mut point = egui::Pos2::ZERO; + for i in 0..3 { + point = stream_frame(&ctx, Some(top), 100.0 + f64::from(i), vec![]).2; + } + (ctx, point) +} + +/// A field that moved under a pointer that stayed put leaves the wheel, including a reported +/// pointer position that did not change, until the pointer really moves. +#[test] +fn a_field_that_slides_under_a_still_pointer_leaves_the_wheel() { + let (ctx, point) = stream_context(40.0); + let below = egui::pos2(point.x, point.y + 200.0); + stream_frame(&ctx, Some(40.0), 110.0, vec![egui::Event::PointerMoved(below)]); + stream_frame(&ctx, Some(40.0 + 200.0), 111.0, vec![]); + let (left, notches, moved) = stream_frame(&ctx, Some(240.0), 120.0, vec![plain_turn()]); + assert_eq!(moved, below); + assert_eq!((left, notches), (1, 0), "the pointer did not move"); + let (left, notches, _) = stream_frame(&ctx, Some(240.0), 130.0, vec![egui::Event::PointerMoved(below), plain_turn()]); + assert_eq!((left, notches), (1, 0), "a report of the same position is not a move"); + let nudged = below + egui::vec2(2.0, 0.0); + let (left, notches, _) = stream_frame(&ctx, Some(240.0), 140.0, vec![egui::Event::PointerMoved(nudged), plain_turn()]); + assert_eq!((left, notches), (0, 1), "the pointer moved onto the field"); +} + +/// A field the pointer was moved onto loses that when the field moves, even back to the same +/// place, or when it is missing from a pass. +#[test] +fn a_field_that_moves_away_and_back_needs_the_pointer_moved_again() { + let (ctx, point) = stream_context(40.0); + stream_frame(&ctx, Some(40.0), 110.0, vec![egui::Event::PointerMoved(point)]); + stream_frame(&ctx, Some(44.0), 111.0, vec![]); + stream_frame(&ctx, Some(40.0), 112.0, vec![]); + let (left, notches, _) = stream_frame(&ctx, Some(40.0), 120.0, vec![plain_turn()]); + assert_eq!((left, notches), (1, 0), "the field moved after the pointer did"); + + let (ctx, point) = stream_context(40.0); + stream_frame(&ctx, Some(40.0), 110.0, vec![egui::Event::PointerMoved(point)]); + stream_frame(&ctx, None, 111.0, vec![]); + stream_frame(&ctx, Some(40.0), 112.0, vec![]); + stream_frame(&ctx, Some(40.0), 113.0, vec![]); + let (left, notches, _) = stream_frame(&ctx, Some(40.0), 120.0, vec![plain_turn()]); + assert_eq!((left, notches), (1, 0), "the field was gone for a pass"); + + let (ctx, point) = stream_context(40.0); + stream_frame(&ctx, Some(40.0), 110.0, vec![egui::Event::PointerMoved(point), egui::Event::PointerGone]); + let (left, notches, _) = stream_frame(&ctx, Some(40.0), 120.0, vec![plain_turn()]); + assert_eq!((left, notches), (1, 0), "the pointer left the window"); +} + +/// A field missing from a pass loses the pointer's placement while another font field keeps +/// drawing in that pass, as when a panel collapses and the Control bar's field stays. +#[test] +fn a_field_gone_for_a_pass_beside_another_field_needs_the_pointer_moved_again() { + let ctx = egui::Context::default(); + let field_at = |ui: &mut egui::Ui, top: f32| { + let rect = egui::Rect::from_min_size(egui::pos2(10.0, top), egui::vec2(120.0, 24.0)); + let resp = ui.put(rect, egui::Button::new("Family")); + super::remember_wheel_field(ui, &resp, true); + }; + let frame = |time: f64, family: bool, events: Vec| { + let mut raw = egui::RawInput { time: Some(time), events, ..Default::default() }; + super::take_raw_wheel(&ctx, &mut raw); + let left = raw.events.iter().filter(|e| matches!(e, egui::Event::MouseWheel { .. })).count(); + let mut out = ctx.run_ui(raw, |ui| { + field_at(ui, 200.0); + if family { + field_at(ui, 40.0); + } + }); + out.textures_delta.clear(); + left + }; + for i in 0..3 { + frame(100.0 + f64::from(i), true, vec![]); + } + let point = egui::pos2(70.0, 52.0); + assert_eq!(frame(110.0, true, vec![egui::Event::PointerMoved(point), plain_turn()]), 0, "the pointer moved onto the field"); + frame(120.0, false, vec![]); + frame(121.0, true, vec![]); + frame(122.0, true, vec![]); + assert_eq!(frame(130.0, true, vec![plain_turn()]), 1, "the field was gone for a pass"); +} + +/// A pointer at rest on a field that stayed put keeps stepping it, stream after stream. +#[test] +fn a_pointer_resting_on_its_field_keeps_the_wheel_across_streams() { + let (ctx, point) = stream_context(40.0); + let (left, notches, _) = stream_frame(&ctx, Some(40.0), 110.0, vec![egui::Event::PointerMoved(point), plain_turn()]); + assert_eq!((left, notches), (0, 1)); + let (left, notches, _) = stream_frame(&ctx, Some(40.0), 110.0 + super::WHEEL_STREAM_GAP / 2.0, vec![plain_turn()]); + assert_eq!((left, notches), (0, 1), "the same stream"); + let (left, notches, _) = stream_frame(&ctx, Some(40.0), 200.0, vec![plain_turn()]); + assert_eq!((left, notches), (0, 1), "a new stream, with the pointer still on the field"); +} + +/// A stream that began elsewhere stays there when the pointer moves onto a field, until a gap +/// with no wheel input ends it. +#[test] +fn a_wheel_stream_that_began_elsewhere_stays_there_until_a_gap() { + let (ctx, point) = stream_context(40.0); + let away = point + egui::vec2(0.0, 200.0); + let step = super::WHEEL_STREAM_GAP * 0.8; + let (left, _, _) = stream_frame(&ctx, Some(40.0), 110.0, vec![egui::Event::PointerMoved(away), plain_turn()]); + assert_eq!(left, 1, "the stream began off the field"); + let (left, notches, _) = stream_frame(&ctx, Some(40.0), 110.0 + step, vec![egui::Event::PointerMoved(point), plain_turn()]); + assert_eq!((left, notches), (1, 0), "a nudge onto the field does not take a running stream"); + let (left, notches, _) = stream_frame(&ctx, Some(40.0), 110.0 + 2.0 * step, vec![plain_turn()]); + assert_eq!((left, notches), (1, 0), "each event renews the stream"); + let (left, notches, _) = stream_frame(&ctx, Some(40.0), 110.0 + 2.0 * step + super::WHEEL_STREAM_GAP, vec![plain_turn()]); + assert_eq!((left, notches), (0, 1), "the stream ended, and the pointer was moved onto the field"); +} + +/// A stream that leaves its field goes to what is under the pointer and does not come back. +#[test] +fn a_wheel_stream_that_leaves_its_field_does_not_return() { + let (ctx, point) = stream_context(40.0); + let away = point + egui::vec2(0.0, 200.0); + let (left, notches, _) = stream_frame(&ctx, Some(40.0), 110.0, vec![egui::Event::PointerMoved(point), plain_turn()]); + assert_eq!((left, notches), (0, 1)); + let (left, notches, _) = stream_frame(&ctx, Some(40.0), 110.1, vec![egui::Event::PointerMoved(away), plain_turn()]); + assert_eq!((left, notches), (1, 0), "the pointer left the field"); + let (left, notches, _) = stream_frame(&ctx, Some(40.0), 110.2, vec![egui::Event::PointerMoved(point), plain_turn()]); + assert_eq!((left, notches), (1, 0), "the stream stays off the field"); +} + +/// A stream loses its field when the pointer leaves without a wheel turn or when the field moves. +#[test] +fn a_wheel_stream_loses_its_field_when_the_pointer_leaves_or_the_field_moves() { + let (ctx, point) = stream_context(40.0); + let away = point + egui::vec2(0.0, 200.0); + let (left, notches, _) = stream_frame(&ctx, Some(40.0), 110.0, vec![egui::Event::PointerMoved(point), plain_turn()]); + assert_eq!((left, notches), (0, 1)); + stream_frame(&ctx, Some(40.0), 110.1, vec![egui::Event::PointerMoved(away)]); + let (left, notches, _) = stream_frame(&ctx, Some(40.0), 110.2, vec![egui::Event::PointerMoved(point), plain_turn()]); + assert_eq!((left, notches), (1, 0), "the pointer left the field during the stream"); + + let (ctx, point) = stream_context(40.0); + let (left, notches, _) = stream_frame(&ctx, Some(40.0), 110.0, vec![egui::Event::PointerMoved(point), plain_turn()]); + assert_eq!((left, notches), (0, 1)); + stream_frame(&ctx, Some(44.0), 110.1, vec![]); + let (left, notches, _) = stream_frame(&ctx, Some(44.0), 110.2, vec![plain_turn()]); + assert_eq!((left, notches), (1, 0), "the field moved during the stream"); + + let (ctx, point) = stream_context(40.0); + let (left, notches, _) = stream_frame(&ctx, Some(40.0), 110.0, vec![egui::Event::PointerMoved(point), plain_turn()]); + assert_eq!((left, notches), (0, 1)); + stream_frame(&ctx, Some(40.0), 110.1, vec![egui::Event::PointerGone]); + let (left, notches, _) = stream_frame(&ctx, Some(40.0), 110.2, vec![egui::Event::PointerMoved(point), plain_turn()]); + assert_eq!((left, notches), (1, 0), "the pointer left the window during the stream"); +} + +/// Trackpad momentum and zero-delta phase events keep their stream going, and momentum alone does +/// not start a stream on a field. +#[test] +fn trackpad_momentum_stays_with_its_stream() { + use egui::{MouseWheelSource::Momentum, MouseWheelSource::Trackpad, TouchPhase}; + let (ctx, point) = stream_context(40.0); + let away = point + egui::vec2(0.0, 200.0); + let step = super::WHEEL_STREAM_GAP * 0.8; + stream_frame(&ctx, Some(40.0), 110.0, vec![egui::Event::PointerMoved(away), stream_turn(TouchPhase::Move, Trackpad, -1.0)]); + // The fingers lift with no delta, the pointer moves onto the field, and momentum follows. + stream_frame(&ctx, Some(40.0), 110.0 + step, vec![stream_turn(TouchPhase::End, Trackpad, 0.0)]); + let (left, notches, _) = + stream_frame(&ctx, Some(40.0), 110.0 + 2.0 * step, vec![egui::Event::PointerMoved(point), stream_turn(TouchPhase::Start, Momentum, -1.0)]); + assert_eq!((left, notches), (1, 0), "momentum continues the stream that began off the field"); + let (left, notches, _) = stream_frame(&ctx, Some(40.0), 200.0, vec![stream_turn(TouchPhase::Move, Momentum, -1.0)]); + assert_eq!((left, notches), (1, 0), "momentum does not start a stream on the field"); + let (left, notches, _) = stream_frame(&ctx, Some(40.0), 300.0, vec![stream_turn(TouchPhase::Move, Trackpad, -1.0)]); + assert_eq!((left, notches), (0, 1), "fingers on the trackpad do"); +} + +/// A stream that began as a sideways scroll stays off the field when it turns vertical. +#[test] +fn a_sideways_turn_that_starts_a_stream_keeps_it_off_the_field() { + let (ctx, point) = stream_context(40.0); + let sideways = egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Line, + delta: egui::vec2(-1.0, 0.0), + phase: egui::TouchPhase::Move, + modifiers: Default::default(), + source: egui::MouseWheelSource::Unknown, + }; + let (left, notches, _) = stream_frame(&ctx, Some(40.0), 110.0, vec![egui::Event::PointerMoved(point), sideways]); + assert_eq!((left, notches), (1, 0)); + let (left, notches, _) = stream_frame(&ctx, Some(40.0), 110.1, vec![plain_turn()]); + assert_eq!((left, notches), (1, 0), "the stream began as a sideways scroll"); +} + +/// A stream loses its field when a layout pass is skipped, even though the field comes back in the +/// same place before the stream ends. +#[test] +fn a_wheel_stream_loses_its_field_when_a_pass_is_skipped() { + let (ctx, point) = stream_context(40.0); + let (left, notches, _) = stream_frame(&ctx, Some(40.0), 110.0, vec![egui::Event::PointerMoved(point), plain_turn()]); + assert_eq!((left, notches), (0, 1)); + stream_frame(&ctx, None, 110.1, vec![]); + stream_frame(&ctx, Some(40.0), 110.2, vec![]); + let (left, notches, _) = stream_frame(&ctx, Some(40.0), 110.3, vec![plain_turn()]); + assert_eq!((left, notches), (1, 0), "the field missed a pass during the stream"); +} + +/// A modified turn is left in the input and keeps the stream away from the field. +#[test] +fn a_modified_turn_that_starts_a_stream_keeps_it_off_the_field() { + let (ctx, point) = stream_context(40.0); + let zoom = egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Line, + delta: egui::vec2(0.0, -1.0), + phase: egui::TouchPhase::Move, + modifiers: egui::Modifiers::COMMAND, + source: egui::MouseWheelSource::Unknown, + }; + let (left, notches, _) = stream_frame(&ctx, Some(40.0), 110.0, vec![egui::Event::PointerMoved(point), zoom]); + assert_eq!((left, notches), (1, 0)); + let (left, notches, _) = stream_frame(&ctx, Some(40.0), 110.1, vec![plain_turn()]); + assert_eq!((left, notches), (1, 0), "the stream began as a modified turn"); +} + /// The Font Technology filter's Variable is the font adjective, not the Variables panel's noun. #[test] fn the_variable_filter_is_named_as_fonts_are() { diff --git a/crates/ui-egui/src/panel_docking/native_tests.rs b/crates/ui-egui/src/panel_docking/native_tests.rs index abb7f2a63f..de0bc1db7e 100644 --- a/crates/ui-egui/src/panel_docking/native_tests.rs +++ b/crates/ui-egui/src/panel_docking/native_tests.rs @@ -1054,8 +1054,9 @@ fn wheel_over_a_floating_character_font_field_changes_the_font() { h.host.borrow_mut().child_overlay = None; h.frame(vec![]); + h.host.borrow_mut().time += crate::font_menu::WHEEL_STREAM_GAP; h.host.borrow_mut().windows.get_mut(&viewport).unwrap().events.extend([ - Event::PointerMoved(pointer), + Event::PointerMoved(pointer + vec2(1.0, 0.0)), Event::MouseWheel { unit: egui::MouseWheelUnit::Line, delta: vec2(0.0, -1.0), @@ -1087,8 +1088,9 @@ fn wheel_over_a_floating_character_font_field_changes_the_font() { h.host.borrow_mut().child_transparent_overlay = None; h.host.borrow_mut().child_pass_through_overlay = Some((viewport, field.expand(10.0))); h.frames(2); + h.host.borrow_mut().time += crate::font_menu::WHEEL_STREAM_GAP; h.host.borrow_mut().windows.get_mut(&viewport).unwrap().events.extend([ - Event::PointerMoved(pointer), + Event::PointerMoved(pointer + vec2(2.0, 0.0)), Event::MouseWheel { unit: egui::MouseWheelUnit::Line, delta: vec2(0.0, -1.0), @@ -1453,6 +1455,75 @@ fn wheel_events_on_both_sides_of_an_overlay_are_checked_separately() { assert_eq!(h.ctx.input_for(viewport, |input| input.pointer.latest_pos()), Some(clear)); } +#[test] +fn a_wheel_stream_that_began_outside_a_floating_font_field_stays_outside() { + let mut h = Harness::new(); + h.app.run("text.create", json!({"x": 30, "y": 50, "text": "Hi", "font": "Source Sans 3"})).unwrap(); + let viewport = h.float("character"); + let field = h + .host + .borrow() + .windows + .get(&viewport) + .and_then(|w| w.texts.iter().find(|(text, _)| text == "Source Sans 3").map(|(_, rect)| *rect)) + .expect("the Character panel's font family field"); + let on = pos2(field.min.x + 4.0, field.center().y); + let off = pos2(field.min.x + 4.0, field.max.y + 60.0); + let turn = Event::MouseWheel { + unit: egui::MouseWheelUnit::Line, + delta: vec2(0.0, -1.0), + phase: egui::TouchPhase::Move, + modifiers: Default::default(), + source: egui::MouseWheelSource::Unknown, + }; + let before = h.app.run("text.fonts", json!({})).unwrap(); + h.host.borrow_mut().windows.get_mut(&viewport).unwrap().events.extend([Event::PointerMoved(off), turn.clone(), Event::PointerMoved(on), turn]); + h.frames(3); + assert_eq!(h.app.run("text.fonts", json!({})).unwrap(), before); +} + +#[test] +fn a_wheel_stream_leaves_a_floating_font_field_that_an_overlay_covered_before_its_queued_turn() { + let mut h = Harness::new(); + h.app.run("text.create", json!({"x": 30, "y": 50, "text": "Hi", "font": "Source Sans 3"})).unwrap(); + let viewport = h.float("character"); + let field = h + .host + .borrow() + .windows + .get(&viewport) + .and_then(|w| w.texts.iter().find(|(text, _)| text == "Source Sans 3").map(|(_, rect)| *rect)) + .expect("the Character panel's font family field"); + let pointer = pos2(field.min.x + 4.0, field.center().y); + let turn = Event::MouseWheel { + unit: egui::MouseWheelUnit::Line, + delta: vec2(0.0, -1.0), + phase: egui::TouchPhase::Move, + modifiers: Default::default(), + source: egui::MouseWheelSource::Unknown, + }; + h.host.borrow_mut().child_raise_overlay = Some((viewport, field.expand(10.0), false)); + h.frames(2); + let field_layer = crate::font_menu::wheel_field_layer_for_test(&h.ctx, viewport, pointer).expect("the font field layer"); + let raise_field = |h: &mut Harness| { + h.host.borrow_mut().child_raise_layer = Some((viewport, field_layer)); + h.frame(vec![]); + h.host.borrow_mut().child_raise_layer = None; + h.frame(vec![]); + }; + raise_field(&mut h); + let before = h.app.run("text.fonts", json!({})).unwrap(); + h.host.borrow_mut().child_raise_overlay = Some((viewport, field.expand(10.0), true)); + h.host.borrow_mut().windows.get_mut(&viewport).unwrap().events.extend([Event::PointerMoved(pointer), turn.clone()]); + h.frames(2); + assert_eq!(h.app.run("text.fonts", json!({})).unwrap(), before, "the overlay covered the field before its queued turn"); + h.host.borrow_mut().child_raise_overlay = Some((viewport, field.expand(10.0), false)); + raise_field(&mut h); + h.host.borrow_mut().windows.get_mut(&viewport).unwrap().events.push(turn); + h.frames(2); + assert_eq!(h.app.run("text.fonts", json!({})).unwrap(), before, "the stream left the field when the overlay covered it"); +} + #[test] fn leaving_a_floating_font_field_resets_queued_fractional_wheel_input() { let mut h = Harness::new(); diff --git a/crates/ui-egui/src/tests/font_menu.rs b/crates/ui-egui/src/tests/font_menu.rs index fa80c359d9..431f2f9829 100644 --- a/crates/ui-egui/src/tests/font_menu.rs +++ b/crates/ui-egui/src/tests/font_menu.rs @@ -238,8 +238,24 @@ fn panel_frame( family: &str, draw: &dyn Fn(&mut VectorcraftApp, &mut egui::Ui), ) -> (Option, bool) { - let mut input = - egui::RawInput { events, screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(600.0, 800.0))), ..Default::default() }; + panel_frame_at(ctx, app, None, events, family, draw) +} + +/// [`panel_frame`] with the input's clock set to `time` seconds. +fn panel_frame_at( + ctx: &egui::Context, + app: &mut VectorcraftApp, + time: Option, + events: Vec, + family: &str, + draw: &dyn Fn(&mut VectorcraftApp, &mut egui::Ui), +) -> (Option, bool) { + let mut input = egui::RawInput { + time, + events, + screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(600.0, 800.0))), + ..Default::default() + }; app.raw_input_hook(ctx, &mut input); let mut out = ctx.run_ui(input, |ui| { crate::font_menu::end_stale_preview(app, ui.ctx()); @@ -394,13 +410,16 @@ fn wheel_events_use_the_pointer_position_when_they_occurred() { assert_eq!(first_style(&app, id).font_family, "Inter", "the wheel was outside the field"); assert_eq!(app.session.active().unwrap().history.undo.len(), before); assert!(ctx.input(|i| i.events.iter().any(|e| matches!(e, egui::Event::MouseWheel { .. }))), "outside wheel input is left for its owner"); - panel_frame(&ctx, &mut app, vec![wheel(line, -1.0, Default::default()), egui::Event::PointerMoved(outside)], "Inter", &draw); + let after_gap = |ctx: &egui::Context| Some(ctx.input(|i| i.time) + crate::font_menu::WHEEL_STREAM_GAP); + let later = after_gap(&ctx); + panel_frame_at(&ctx, &mut app, later, vec![wheel(line, -1.0, Default::default()), egui::Event::PointerMoved(outside)], "Inter", &draw); assert_ne!(first_style(&app, id).font_family, "Inter", "the wheel was over the field before the pointer left"); assert_eq!(app.session.active().unwrap().history.undo.len(), before + 1); app.run("edit.undo", json!({})).unwrap(); assert_eq!(first_style(&app, id).font_family, "Inter"); let button = |pos| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }; - panel_frame(&ctx, &mut app, vec![button(field), wheel(line, -1.0, Default::default()), button(outside)], "Inter", &draw); + let later = after_gap(&ctx); + panel_frame_at(&ctx, &mut app, later, vec![button(field), wheel(line, -1.0, Default::default()), button(outside)], "Inter", &draw); assert_ne!(first_style(&app, id).font_family, "Inter", "button events also carry the pointer's position"); assert_eq!(app.session.active().unwrap().history.undo.len(), before + 1); app.run("edit.undo", json!({})).unwrap(); @@ -412,20 +431,32 @@ fn wheel_events_use_the_pointer_position_when_they_occurred() { source: egui::MouseWheelSource::Unknown, }; panel_frame(&ctx, &mut app, vec![end], "Inter", &draw); - panel_frame( + let later = after_gap(&ctx); + panel_frame_at( &ctx, &mut app, + later, vec![ - wheel(egui::MouseWheelUnit::Point, -2.0, Default::default()), egui::Event::PointerMoved(field), wheel(line, -1.0, Default::default()), egui::Event::PointerMoved(outside), + wheel(egui::MouseWheelUnit::Point, -2.0, Default::default()), ], "Inter", &draw, ); assert_eq!(app.session.active().unwrap().history.undo.len(), before + 1); assert_eq!(ctx.input(|i| i.smooth_scroll_delta.y), -2.0, "only the outside wheel contributes to panel scrolling"); + // A fractional turn made before the pointer reached the field does not count toward its notches. + let speed = ctx.options(|o| o.input_options.line_scroll_speed); + let later = after_gap(&ctx); + let early = wheel(egui::MouseWheelUnit::Point, -0.75 * speed, Default::default()); + panel_frame_at(&ctx, &mut app, later, vec![early, egui::Event::PointerMoved(field)], "Inter", &draw); + let later = after_gap(&ctx); + panel_frame_at(&ctx, &mut app, later, vec![wheel(line, -0.5, Default::default())], "Inter", &draw); + assert_eq!(app.session.active().unwrap().history.undo.len(), before + 1, "half a notch on the field"); + panel_frame(&ctx, &mut app, vec![wheel(line, -0.5, Default::default())], "Inter", &draw); + assert_eq!(app.session.active().unwrap().history.undo.len(), before + 2, "a whole notch on the field"); } /// A turn queued for one document cannot restyle another document opened before its UI pass. @@ -514,6 +545,97 @@ fn the_wheel_steps_the_families_in_properties_and_the_control_bar() { } } +/// The Character panel in a scroll area, its family field near the bottom of the view. +fn scrolling_character_panel(app: &mut VectorcraftApp, ui: &mut egui::Ui) { + egui::ScrollArea::vertical().max_height(300.0).show(ui, |ui| { + ui.add_space(250.0); + crate::panels::character::show(app, ui); + ui.add_space(600.0); + }); +} + +/// Scrolling a panel with a still pointer keeps going when the closed family field slides under +/// the pointer, and the font stays as it was. +#[test] +fn scrolling_a_panel_past_the_family_field_under_a_still_pointer_keeps_the_font() { + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + let id = selected_inter(&mut app); + let draw = scrolling_character_panel; + let mut field = None; + for _ in 0..3 { + field = panel_frame(&ctx, &mut app, vec![], "Inter", &draw).0; + } + let field = field.expect("the panel shows the family field"); + let still = egui::pos2(field.x, field.y - 120.0); + panel_frame(&ctx, &mut app, vec![egui::Event::PointerMoved(still)], "Inter", &draw); + let before = app.session.active().unwrap().history.undo.len(); + let mut passed = false; + for _ in 0..240 { + let at = panel_frame(&ctx, &mut app, vec![wheel(egui::MouseWheelUnit::Point, -6.0, Default::default())], "Inter", &draw).0; + if at.is_some_and(|point| point.y < still.y - 30.0) { + passed = true; + break; + } + } + assert!(passed, "the panel scrolled the field past the pointer"); + assert_eq!(first_style(&app, id).font_family, "Inter"); + assert_eq!(app.session.active().unwrap().history.undo.len(), before); +} + +/// A family field that a panel scrolled under a still pointer leaves later wheel input to the +/// panel, including after the stream ends, until the pointer really moves. +#[test] +fn a_family_field_scrolled_under_a_still_pointer_takes_the_wheel_after_the_pointer_moves() { + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + let id = selected_inter(&mut app); + let draw = scrolling_character_panel; + let mut time = 100.0; + let mut field = None; + for _ in 0..3 { + time += 0.02; + field = panel_frame_at(&ctx, &mut app, Some(time), vec![], "Inter", &draw).0; + } + let field = field.expect("the panel shows the family field"); + let still = egui::pos2(field.x, field.y - 40.0); + time += 0.02; + panel_frame_at(&ctx, &mut app, Some(time), vec![egui::Event::PointerMoved(still)], "Inter", &draw); + let before = app.session.active().unwrap().history.undo.len(); + let mut under = false; + for _ in 0..80 { + time += 0.02; + panel_frame_at(&ctx, &mut app, Some(time), vec![wheel(egui::MouseWheelUnit::Point, -3.0, Default::default())], "Inter", &draw); + let mut at = None; + for _ in 0..12 { + time += 0.02; + at = panel_frame_at(&ctx, &mut app, Some(time), vec![], "Inter", &draw).0; + } + if at.is_some_and(|point| (point.y - still.y).abs() < 4.0) { + under = true; + break; + } + } + assert!(under, "the panel scrolled the field under the pointer"); + assert_eq!(app.session.active().unwrap().history.undo.len(), before); + time += 5.0; + let notch = || wheel(egui::MouseWheelUnit::Line, -1.0, Default::default()); + panel_frame_at(&ctx, &mut app, Some(time), vec![notch()], "Inter", &draw); + assert_eq!(first_style(&app, id).font_family, "Inter", "the still pointer did not arm the field"); + let mut at = None; + for _ in 0..12 { + time += 0.02; + at = panel_frame_at(&ctx, &mut app, Some(time), vec![], "Inter", &draw).0; + } + let at = at.expect("the field is still in view"); + time += 5.0; + panel_frame_at(&ctx, &mut app, Some(time), vec![egui::Event::PointerMoved(at), notch()], "Inter", &draw); + assert_ne!(first_style(&app, id).font_family, "Inter", "the pointer moved onto the field"); + assert_eq!(app.session.active().unwrap().history.undo.len(), before + 1); +} + /// #5253: a family the wheel steps to keeps the type's style when it has that style, else takes the /// closest it has, as a family picked from the menu does. The menus that only pick a font ignore /// the wheel. diff --git a/crates/ui-egui/tests/font_wheel.rs b/crates/ui-egui/tests/font_wheel.rs index 9e658a97b9..3cdb017d77 100644 --- a/crates/ui-egui/tests/font_wheel.rs +++ b/crates/ui-egui/tests/font_wheel.rs @@ -60,6 +60,34 @@ fn assert_queued_wheel_discarded_after_target_change(range: bool) { assert_eq!(app.session.active().unwrap().history.undo.len(), before, "the captured turn cannot restyle the new target"); } +#[test] +fn a_font_field_that_appears_under_a_still_pointer_leaves_the_wheel_until_the_pointer_moves() { + let (ctx, mut app) = selected_type_with_control(); + let mut texts = Vec::new(); + for _ in 0..8 { + texts = frame(&mut app, &ctx, vec![]); + } + let point = texts.iter().find(|(t, p)| t == "Inter" && p.y < 150.0).unwrap().1; + let selected = app.session.active().unwrap().selection.objects.first().unwrap().0; + app.run("select.set", json!({"ids": []})).unwrap(); + frame(&mut app, &ctx, vec![Event::PointerMoved(point)]); + frame(&mut app, &ctx, vec![]); + app.run("select.set", json!({"ids": [selected]})).unwrap(); + for _ in 0..8 { + frame(&mut app, &ctx, vec![]); + } + let mut still = wheel_input(point); + still.events.remove(0); + app.raw_input_hook(&ctx, &mut still); + assert!(still.events.iter().any(|e| matches!(e, Event::MouseWheel { .. })), "the pointer did not move after the field appeared"); + let mut out = ctx.run_ui(still, |ui| app.ui(ui)); + out.textures_delta.clear(); + let mut moved = wheel_input(point + vec2(1.0, 0.0)); + moved.time = Some(ctx.input(|i| i.time) + 5.0); + app.raw_input_hook(&ctx, &mut moved); + assert!(!moved.events.iter().any(|e| matches!(e, Event::MouseWheel { .. })), "the pointer moved onto the field"); +} + #[test] fn artboard_controls_leave_replaced_font_fields_wheel_available() { let (ctx, mut app) = selected_type_with_control(); diff --git a/docs/type-parity.md b/docs/type-parity.md index daec5c927b..756d752ebb 100644 --- a/docs/type-parity.md +++ b/docs/type-parity.md @@ -18,7 +18,7 @@ against Illustrator's public documentation; **estimated** unless noted. | Feature | Status | Notes | |---|---|---| -| Mouse wheel over the closed font-family field | done | #5253: Character, Properties and Control step selected text through the family list using saved font filters (excluding search text), one undo step per notch; the style is matched and the list stops at either end. Escape and leaving the field keep the change. Order, bounds and Undo grouping are VectorCraft's choices; scores unchanged. | +| Mouse wheel over the closed font-family field | done | #5253: Character, Properties and Control step selected text through the family list using saved font filters (excluding search text), one undo step per notch; the style is matched and the list stops at either end. Escape and leaving the field keep the change. The field takes the wheel only when the pointer was moved onto it after the field last moved and the wheel stream began there; a stream that began elsewhere stays there until 0.5 s pass with no wheel input. Order, bounds, Undo grouping, this rule and its 0.5 s are VectorCraft's choices; scores unchanged. | | Soft return (forced line break, Shift+Enter) | done | Shift+Enter inserts a soft return (U+2028), breaking the line without starting a new paragraph; paragraph spacing (space before/after) and first-line indents apply only at paragraph ends; continuation lines in lists do not repeat the marker; in justified text a line a soft return ends is justified as a wrapped line is, only the paragraph's last line keeping its alignment, as Illustrator's own layout in a user's `.ai` sets it (evidence: `tests::typo::soft_return_in_justified_text_spreads_like_a_wrapped_line`; first done the other way in #2754) | | Native font-family labels | partial | With Show Font Names in English off, localized legacy names are used when typographic names contain no native label (#2208); loaded-font and catalog-only regressions. Choosing among multiple native languages is unchanged. | | Point, area and type-on-a-path type; vertical variants | done / partial | vertical point, area and path type have initial support; fullwidth `!` / `?` keep the nominal glyph upright when no vertical alternate exists (Unicode Tu); in a non-rectangular area a word too wide for the top lines moves down to the first line that holds it (#1615); Escape while typing ends the edit, keeps the type selected and switches back to the tool whose double-click on type chose the Type tool (kept until another tool is chosen or another document or window becomes active, so later edits with the Type tool return there too), or to the Selection tool when there is no such tool, as when the Type tool was chosen from the toolbar or after such a switch (#1802), and Cmd+Enter (Ctrl+Enter on Windows and Linux) does the same unless a command is bound to it; `text.create` and `text.createInPath` take `justify` (#1752); MCP `add_text` forwards `vertical` to both commands; Type › Type Orientation sets every frame of a thread, and a thread's frames share one writing direction; the Type and Vertical Type tools make area type when a closed path's outline is clicked and type on a path when an open one is, with pointers that say which, as the public help describes ([Create text](https://web.archive.org/web/20220127090642/https://helpx.adobe.com/illustrator/using/creating-text.html), [Create type on a path](https://web.archive.org/web/20220127090635/https://helpx.adobe.com/illustrator/using/creating-type-path.html)) | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 72f7502ee3..275fa4e333 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -264,7 +264,7 @@ tools, never checked side by side) isn't captured row by row. | Behaviour | Status | Notes | |---|---|---| -| Wheel over a closed font-family field in Character, Properties or Control | done | #5253: applies the next or previous family to selected text, one undo step per notch; uses saved font filters, ignores search text and stops at the list's ends. Escape and pointer departure keep the change. Other font-picking menus and modifier-wheel input keep their existing behavior. | +| Wheel over a closed font-family field in Character, Properties or Control | done | #5253: applies the next or previous family to selected text, one undo step per notch; uses saved font filters, ignores search text and stops at the list's ends. Escape and pointer departure keep the change. The field takes the wheel only when the pointer was moved onto it after the field last moved and the wheel stream began there; a stream that began elsewhere stays there until 0.5 s pass with no wheel input. This rule and its 0.5 s are VectorCraft's choices. Other font-picking menus and modifier-wheel input keep their existing behavior. | | Arrow keys nudge by Keyboard Increment (0.001–1296 pt); Shift ×10; Alt copies | done | | | Fields commit what was typed on a click away or a move to another field, as Enter does; Escape discards it | done | #1787 | | Number fields: Up/Down step, Shift ×10, Cmd/Ctrl ÷10, wheel over a hovered field without a click; steps, the wheel and label scrubs stop at the field's bounds (a stroke weight at 0, a miter limit at 1) | done | Shift+arrow in the Stroke weight field doesn't step by 10 (#991); bounds #3370 | From cddebdf7f67ae206ccc4c78020747ec8d886539f Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Mon, 9 Nov 2026 17:51:22 -0800 Subject: [PATCH 17/36] 3D: a group's fill or stroke takes no 3D effect Revolve, Extrude & Bevel, Inflate and Rotate on a group or layer draw only as the group's own effect. On the group's fill or stroke they drew nothing, yet effect.apply and effect.setParams accepted them. Both now refuse that placement. --- crates/effects/src/revolve.rs | 11 +++++++++++ crates/effects/src/solids.rs | 2 ++ docs/mcp.md | 3 ++- 3 files changed, 15 insertions(+), 1 deletion(-) diff --git a/crates/effects/src/revolve.rs b/crates/effects/src/revolve.rs index cf275fc2d9..58125c6618 100644 --- a/crates/effects/src/revolve.rs +++ b/crates/effects/src/revolve.rs @@ -717,6 +717,17 @@ mod tests { assert_eq!(revolve_art(&both), None, "past the limits the source draws"); } + #[test] + fn a_3d_effect_on_a_groups_fill_is_refused() { + let mut group = Node::group(NodeId(9), vec![member(1, 0.0)]); + group.appearance = Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0).into(); + for id in [REVOLVE, crate::EXTRUDE] { + assert!(crate::validate_three_d(&group, id, &json!({})).is_ok(), "{id} on the group itself"); + let refused = crate::validate_three_d_at(&group, id, &json!({}), Some((0, 0))).unwrap_err(); + assert!(refused.contains("on the group itself"), "{id}: {refused}"); + } + } + #[test] fn validation_measures_the_profile_after_the_geometry_effects_before_revolve() { let roughen = crate::new_effect("distort.roughen", &json!({"size": 1, "detail": 100})).unwrap(); diff --git a/crates/effects/src/solids.rs b/crates/effects/src/solids.rs index 2340446f2e..568bf2be99 100644 --- a/crates/effects/src/solids.rs +++ b/crates/effects/src/solids.rs @@ -302,8 +302,10 @@ pub fn validate_three_d(n: &Node, id: &str, p: &Value) -> Result<(), String> { /// appearance item `item` of `n` (the effects' length for a new effect), or on the object itself /// when `at` is `None`. A Revolve on a fill or stroke is checked in that item's paint, after the /// item's geometry effects before it, together with the object's other items that carry a Revolve. +/// A group's or layer's fills and strokes take no 3D effect: only the group's own effect draws. pub fn validate_three_d_at(n: &Node, id: &str, p: &Value, at: Option<(usize, usize)>) -> Result<(), String> { match at { + Some(_) if is_container(n) => Err("A 3D effect on a group or layer goes on the group itself, not on its fill or stroke".into()), Some((item, index)) if id == crate::REVOLVE => crate::revolve::validate_item_revolve(n, p, item, index), _ => validate_three_d(n, id, p), } diff --git a/docs/mcp.md b/docs/mcp.md index b76f2da5a6..499d02bd44 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -2461,7 +2461,8 @@ checked as that effect's. It is one undo step. Any other `threeDType` value, or `effect.apply {effect: "threeD.extrude" | "threeD.inflate" | "threeD.rotate", params: {...}, ids?, item?}` applies the other 3D types to a path or compound path, live like Revolve (the source stays editable; SVG/PDF exports and -`effect.expandAppearance` use the same shaded faces). An object takes one 3D effect, Revolve included. +`effect.expandAppearance` use the same shaded faces). An object takes one 3D effect, Revolve included. On a group or +layer the 3D effect goes on the group itself: `effect.apply` and `effect.setParams` refuse one on its fill or stroke. `threeD.extrude`: `depth` (points, 0..2000, default 50), `twist` (degrees, 0..360, 0), `taper` (1..100%, 100), `cap` (true solid, false hollow), `bevel` (`none`, `classic`, `rounded`, `cove`, `step`, `ogee`), `bevelWidth` From 86184868a52be1d4e03210fe013516298c2578a4 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Mon, 9 Nov 2026 17:16:43 -0800 Subject: [PATCH 18/36] New Document: More Settings is a pop-up, and Save Preset a mode of Preset Details MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit More Settings no longer replaces the dialog with a form that repeats Preset Details. It opens a pop-up over New Document with only Number of Artboards, the four layout buttons, Change to Right-to-Left Layout, Spacing and Columns, editing the same fields live. Done, Escape and a click outside close it and keep the values; the click outside does not reach the dialog under it. Enter in a field applies it and closes the pop-up. The old form's Templates… button is removed with it; File > New from Template opens templates. Save Preset no longer adds a Preset Name row under the document's name. It replaces the top of Preset Details with a Save Document Preset heading, the preset's name (focused, text selected), Cancel and Save, and hides the document's name, Close and Create. The rows under it stay where they are and editable, and Save stores them. Enter in the name saves; Escape is Cancel and keeps the edits. Nothing creates a document in this mode: a double-clicked preset card, Enter elsewhere and ui.dialog.confirm (which saves). A single click on a card fills the fields, as outside the mode. Save Preset's default name is now the first "Preset N" that no saved preset has, and it is offered again after each save. Before, it was "Preset ", and after a save the field kept the saved name, so pressing Save could replace an existing preset. A name typed by hand still replaces a preset of the same name, as before. Enter creates the document on a new press only, so a held Enter's repeats after a save do not create one. The newDocumentMore dialog kind, presetName and __saving keep working for the control protocol (ui.click on More Settings opens the pop-up); docs/control-protocol.md describes them. The catalog rows of the removed form's strings are removed. --- crates/ui-egui/src/dialogs.rs | 6 +- crates/ui-egui/src/dialogs/flatten.rs | 15 + crates/ui-egui/src/dialogs/new_document.rs | 884 +++++++++++++----- .../src/dialogs/tests_accessibility.rs | 10 +- crates/ui-egui/src/i18n/bg.tsv | 5 +- crates/ui-egui/src/i18n/bs.tsv | 5 +- crates/ui-egui/src/i18n/ca.tsv | 5 +- crates/ui-egui/src/i18n/cs.tsv | 5 +- crates/ui-egui/src/i18n/da.tsv | 5 +- crates/ui-egui/src/i18n/de.tsv | 5 +- crates/ui-egui/src/i18n/el.tsv | 5 +- crates/ui-egui/src/i18n/es.tsv | 5 +- crates/ui-egui/src/i18n/fi.tsv | 5 +- crates/ui-egui/src/i18n/fr.tsv | 5 +- crates/ui-egui/src/i18n/hi.tsv | 5 +- crates/ui-egui/src/i18n/hu.tsv | 5 +- crates/ui-egui/src/i18n/id.tsv | 5 +- crates/ui-egui/src/i18n/it.tsv | 5 +- crates/ui-egui/src/i18n/ja.tsv | 5 +- crates/ui-egui/src/i18n/ka.tsv | 5 +- crates/ui-egui/src/i18n/ko.tsv | 5 +- crates/ui-egui/src/i18n/nl.tsv | 5 +- crates/ui-egui/src/i18n/pl.tsv | 5 +- crates/ui-egui/src/i18n/pt-br.tsv | 5 +- crates/ui-egui/src/i18n/ru.tsv | 5 +- crates/ui-egui/src/i18n/sv.tsv | 5 +- crates/ui-egui/src/i18n/tr.tsv | 5 +- crates/ui-egui/src/i18n/uk.tsv | 5 +- crates/ui-egui/src/i18n/vi.tsv | 5 +- crates/ui-egui/src/i18n/zh-hans.tsv | 5 +- crates/ui-egui/src/i18n/zh-hant.tsv | 5 +- crates/ui-egui/src/shortcuts.rs | 4 +- docs/control-protocol.md | 12 +- docs/target-app-parity.md | 1 + 34 files changed, 719 insertions(+), 348 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 1f59647c13..9137442f04 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -152,8 +152,8 @@ pub(crate) use export_for_screens::{ saved_options as screen_saved_options, saved_rows as screen_saved_rows, }; pub(crate) use form::{humanize, number_list_text, plugin_color_button, read_number_list, unit_suffix}; -pub(crate) use new_document::preset_name; pub use new_document::{create_without_dialog as new_document_without_dialog, open as open_new_document, preset_card}; +pub(crate) use new_document::{popup_open as new_document_popup_open, preset_name}; pub use png_options::open as open_raster_options; pub use save_pdf::{open as open_save_pdf, open_preset as open_pdf_preset}; pub use tools::open_tool_dialog; @@ -560,6 +560,10 @@ pub(crate) fn field_set(app: &mut VectorcraftApp, field: &str, old: Option<&Valu /// opened over (`ui.dialog.cancel`). pub fn cancel(app: &mut VectorcraftApp) { missing_fonts::requeue_replaced(app); + // More Settings' pop-up and Save Preset end on their own; New Document stays. + if app.ui.dialog.as_mut().is_some_and(new_document::close_popup) { + return; + } if let Some(d) = app.ui.dialog.take() { if d.kind == swatch_conflict::KIND { crate::placing::cancel_swatch_paste(app); diff --git a/crates/ui-egui/src/dialogs/flatten.rs b/crates/ui-egui/src/dialogs/flatten.rs index 3cd0a51110..4859348935 100644 --- a/crates/ui-egui/src/dialogs/flatten.rs +++ b/crates/ui-egui/src/dialogs/flatten.rs @@ -316,6 +316,21 @@ mod tests { assert!(!transparent_left(&app)); } + /// Cancel with the Save Preset row showing closes the dialog and rolls the preview back. + #[test] + fn cancel_while_saving_a_preset_closes_the_dialog() { + let mut app = scene(); + let before = app.session.doc().unwrap().doc.clone(); + app.run("ui.flattenTransparencyDialog", json!({})).unwrap(); + app.ui.dialog.as_mut().unwrap().fields.insert("preview".into(), json!(true)); + frame(&mut app); + app.ui.dialog.as_mut().unwrap().fields.insert(SAVING.into(), json!(true)); + frame(&mut app); + super::super::cancel(&mut app); + assert!(app.ui.dialog.is_none() && !app.session.in_interaction()); + assert_eq!(app.session.doc().unwrap().doc, before, "Cancel rolls back"); + } + #[test] fn ok_says_in_the_status_bar_when_an_image_could_not_be_decoded() { const WARNING: &str = "an image couldn't be decoded and is missing from the picture"; diff --git a/crates/ui-egui/src/dialogs/new_document.rs b/crates/ui-egui/src/dialogs/new_document.rs index 982dd7c6fc..8e02009339 100644 --- a/crates/ui-egui/src/dialogs/new_document.rs +++ b/crates/ui-egui/src/dialogs/new_document.rs @@ -1,14 +1,16 @@ //! File → New: category tabs (Recent, Saved and the built-in categories of `file.newPresets`) //! over a grid of preset cards, and a Preset Details column (name and Save Preset, size and //! units, orientation, artboards, bleed, background contents, Advanced Options, More Settings); -//! Close and Create. More Settings is the classic form of the same fields (artboard layout, -//! spacing and columns, profile and size lists), so both share one set of fields and widgets. +//! Close and Create. More Settings opens a pop-up over the dialog with the artboard layout, spacing +//! and columns, editing the same fields. Save Preset turns the top of Preset Details into the +//! preset's name, Cancel and Save, and hides Close and Create until the preset is saved or canceled; +//! the rows under it stay editable and are what the preset stores. //! //! Fields: `file.new`'s params (`preset`, `name`, `width`, `height` (pt), `units`, `artboards`, //! `artboardLayout`, `bleed`, `backgroundContents`, `colorMode`, `rasterEffectsPpi`, -//! `previewMode`), plus `category` (the tab, also More Settings' Profile), `advanced` (Advanced -//! Options open), `bleedLinked` and `presetName` (Save Preset's name). Setting `preset` fills the -//! fields from that preset; changing any of them afterwards makes the settings custom. +//! `previewMode`), plus `category` (the tab), `advanced` (Advanced Options open), `bleedLinked` and +//! `presetName` (Save Preset's name). Setting `preset` fills the fields from that preset; changing +//! any of them afterwards makes the settings custom. use serde_json::{Map, Value, json}; use vectorcraft_doc::Unit; @@ -20,11 +22,11 @@ use crate::theme::{self, Tokens}; use crate::{VectorcraftApp, widgets}; pub(super) const KIND: &str = "newDocument"; -/// More Settings. +/// New Document with its More Settings pop-up open. pub(super) const MORE: &str = "newDocumentMore"; pub(super) const SPEC: DialogSpec = DialogSpec { inputs: |_| &["presetName"], ..DialogSpec::window(show, confirm) }; -pub(super) const MORE_SPEC: DialogSpec = DialogSpec { inputs: |_| &["presetName"], ..DialogSpec::window(show_more, confirm) }; +pub(super) const MORE_SPEC: DialogSpec = SPEC; /// The fields `file.new` takes. const PARAMS: [&str; 12] = [ @@ -44,8 +46,11 @@ const PARAMS: [&str; 12] = [ /// The preset the fields were last filled from, as `apply` set them; a Recent or Saved card keeps /// its own units here, which the units chosen in Preset Details don't change. const APPLIED: &str = "__applied"; -/// Save Preset's name field is showing. +/// Preset Details is saving a preset (Save Preset's name, Cancel and Save are showing). const SAVING: &str = "__saving"; +/// Preset Details has drawn the saving top: set from the mode's second frame, so the first one fills +/// in the default name and focuses it. +const SAVING_SHOWN: &str = "__savingShown"; /// The units New Document offers (all ten). const UNITS: [Unit; 10] = [ Unit::Pixels, @@ -65,6 +70,10 @@ const COLOR_MODES: [(&str, &str); 2] = [("rgb", "RGB Color"), ("cmyk", "CMYK Col const DETAILS: f32 = 292.0; /// Height of the dialog's content. const HEIGHT: f32 = 600.0; +/// Height of the top of Preset Details in either state: the rows under it don't move. +const HEADER: f32 = 100.0; +/// Width of the More Settings pop-up's rows. +const MORE_WIDTH: f32 = 310.0; /// Open New Document on the most recent settings (or Letter), named after the next untitled /// document. @@ -116,8 +125,17 @@ fn sync(app: &VectorcraftApp, d: &mut Dialog) { } } -/// Create the document; the dialog stays open when that fails (bad size, …). +/// Create the document; the dialog stays open when that fails (bad size, …). While a preset is being +/// saved this is its Save, and no document is created. fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { + if d.bool(SAVING) { + let mut d = d.clone(); + let r = save_preset(app, &mut d); + if r.is_ok() { + app.ui.dialog = Some(d); + } + return r; + } let p = params(app, d); let r = app.run("file.new", Value::Object(p)); if r.is_ok() { @@ -175,17 +193,45 @@ fn window(ctx: &egui::Context, id: &str, margin: i8, add: impl FnOnce(&mut egui: super::modal::show(ctx, id, egui::Id::unique(("dialog", id)), -20.0, margin, add); } +/// Whether More Settings' pop-up is open over New Document. +pub(crate) fn popup_open(d: &Dialog) -> bool { + d.kind == MORE +} + +/// Close New Document's More Settings pop-up, or else leave its Save Preset, keeping the fields. +/// False when neither is showing, or `d` is another dialog. +pub(super) fn close_popup(d: &mut Dialog) -> bool { + if d.kind != KIND && d.kind != MORE { + return false; + } + if popup_open(d) { + d.kind = KIND.into(); + return true; + } + let saving = d.bool(SAVING); + if saving { + d.fields.insert(SAVING.into(), json!(false)); + d.fields.remove(SAVING_SHOWN); + } + saving +} + /// What the buttons of a window asked for. #[derive(Default)] struct Buttons { create: bool, close: bool, + /// More Settings' pop-up was open as the frame began: Enter is the pop-up's. + popup: bool, + /// A preset was being saved as the frame began: nothing creates the document this frame. + saving: bool, } impl Buttons { /// Act on the buttons (Enter also creates) once the window is drawn. fn finish(self, app: &mut VectorcraftApp, ctx: &egui::Context, d: Dialog) { - let create = self.create || ctx.input(|i| i.key_pressed(egui::Key::Enter)) && !ctx.memory(|m| m.focused().is_some()); + let enter = !self.popup && crate::shortcuts::pressed_anew(ctx, egui::Key::Enter) && !ctx.memory(|m| m.focused().is_some()); + let create = !self.saving && !d.bool(SAVING) && (self.create || enter); app.ui.dialog = Some(d); if self.close { app.ui.dialog = None; @@ -199,7 +245,7 @@ fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { let Some(mut d) = app.ui.dialog.clone() else { return }; sync(app, &mut d); let t = Tokens::get(ctx); - let mut b = Buttons::default(); + let mut b = Buttons { popup: popup_open(&d), saving: d.bool(SAVING), ..Default::default() }; window(ctx, KIND, 0, |ui| { // No heading: the top margin (14) and the empty part of the category tabs' row (30) move the // window. @@ -220,6 +266,10 @@ fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { }); }); }); + // While More Settings' pop-up is open, a click outside it only closes it. + if b.popup { + ui.interact(ui.min_rect(), ui.scope_id().with("newdoc-popup-shield"), egui::Sense::CLICK | egui::Sense::DRAG); + } }); b.finish(app, ctx, d); } @@ -356,17 +406,24 @@ fn details(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog, b: &mut let t = Tokens::get(ui.ctx()); ui.spacing_mut().item_spacing = egui::vec2(6.0, 4.0); let top = ui.cursor().top(); - ui.label(egui::RichText::new(tl!("PRESET DETAILS")).font(theme::semibold(11.0)).color(t.text_dim)); - ui.add_space(8.0); - let caption = form::caption(ui, tl!("Document Name")); - ui.horizontal(|ui| { - ui.label_inputs_by(caption.id, |ui| form::text(ui, d, "name", DETAILS - 50.0)); - save_button(app, ui, d); - }); - if d.bool(SAVING) { - save_row(app, ui, d); + let saving = d.bool(SAVING); + if saving { + let opening = !d.bool(SAVING_SHOWN); + d.fields.insert(SAVING_SHOWN.into(), json!(true)); + save_header(app, ui, d, opening); + } else { + d.fields.remove(SAVING_SHOWN); + ui.label(egui::RichText::new(tl!("PRESET DETAILS")).font(theme::semibold(11.0)).color(t.text_dim)); + ui.add_space(8.0); + let caption = form::caption(ui, tl!("Document Name")); + ui.horizontal(|ui| { + ui.label_inputs_by(caption.id, |ui| form::text(ui, d, "name", DETAILS - 50.0)); + if widgets::icon_button(ui, "save", tl!("Save Preset"), false, 26.0).clicked() { + d.fields.insert(SAVING.into(), json!(true)); + } + }); } - ui.add_space(8.0); + ui.add_space((HEADER - (ui.cursor().top() - top)).max(0.0)); let unit = unit(d); let caption = form::caption(ui, tl!("Width")); ui.horizontal(|ui| { @@ -387,7 +444,7 @@ fn details(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog, b: &mut ui.add_space(6.0); ui.vertical(|ui| { let caption = form::caption(ui, tl!("Artboards")); - ui.label_inputs_by(caption.id, |ui| artboards(ui, d, 76.0)); + ui.label_inputs_by(caption.id, |ui| artboards(ui, d, "newdoc-artboards", 76.0)); }); }); ui.add_space(8.0); @@ -399,8 +456,9 @@ fn details(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog, b: &mut ui.add_space(8.0); advanced(ui, d, 110.0, DETAILS - 116.0); ui.add_space(12.0); - if widgets::flat_button(ui, tl!("More Settings"), DETAILS).clicked() { - d.kind = MORE.into(); + more_settings(ui, d); + if saving { + return; } // Close and Create at the bottom right. ui.add_space((HEIGHT - (ui.cursor().top() - top) - 28.0).max(12.0)); @@ -411,44 +469,159 @@ fn details(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog, b: &mut }); } -/// The Save Preset button beside the name: it shows or hides [`save_row`]. -fn save_button(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) { - if widgets::icon_button(ui, "save", tl!("Save Preset"), d.bool(SAVING), 26.0).clicked() { - let saving = !d.bool(SAVING); - d.fields.insert(SAVING.into(), json!(saving)); - if saving && d.str("presetName").is_empty() { - let template = "Preset {number}"; - let name = crate::i18n::fmt(tl!(template), &[("number", &(app.session.prefs.new_doc_presets.len() + 1).to_string())]); - d.fields.insert("presetName".into(), json!(name)); - } +/// The key under which a layer remembers that its first field should take the keyboard focus. +fn pending_focus(ui: &egui::Ui) -> egui::Id { + ui.layer_id().id.with("newdoc-focus") +} + +/// Draw `add`; its first enabled field takes the keyboard focus, with its text selected, when +/// `opening` says so or a failed Save asked for it. +fn focus_first(ui: &mut egui::Ui, opening: bool, add: impl FnOnce(&mut egui::Ui) -> R) -> R { + let pending = pending_focus(ui); + if opening { + ui.data_mut(|m| m.insert_temp(pending, true)); + } + // The first frame of a new window only measures it: no field can take the focus until the next. + let focus = !ui.is_sizing_pass() && ui.data(|m| m.get_temp::(pending)).unwrap_or(false); + if focus { + ui.data_mut(|m| m.insert_temp(widgets::dialog_focus_flag(), true)); + } + let r = add(ui); + if focus && ui.data_mut(|m| m.remove_temp::(widgets::dialog_focus_flag())).is_none() { + ui.data_mut(|m| m.remove::(pending)); + } + r +} + +/// The top of Preset Details while a preset is being saved: the heading, the preset's name, Cancel +/// and Save. Enter in the name is Save. `opening` on the mode's first frame. +fn save_header(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog, opening: bool) { + let t = Tokens::get(ui.ctx()); + if opening && d.str("presetName").is_empty() { + d.fields.insert("presetName".into(), json!(new_preset_name(app))); + } + ui.label(egui::RichText::new(tl!("Save Document Preset")).font(theme::semibold(13.0)).color(t.text)); + ui.add_space(5.0); + let name = focus_first(ui, opening, |ui| form::text_edit_named(ui, d, "presetName", tl!("Preset Name"), DETAILS - 14.0)); + let enter = name.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)); + ui.add_space(4.0); + let (mut save, mut cancel) = (enter, false); + ui.allocate_ui_with_layout(egui::vec2(ui.available_width(), 28.0), egui::Layout::right_to_left(egui::Align::Center), |ui| { + save |= widgets::primary_button(ui, tl!("Save")).clicked(); + ui.add_space(8.0); + cancel = widgets::secondary_button(ui, tl!("Cancel")).clicked(); + }); + if cancel { + d.fields.insert(SAVING.into(), json!(false)); + d.fields.remove(SAVING_SHOWN); + } else if save && let Err(e) = save_preset(app, d) { + app.status(e); + let pending = pending_focus(ui); + ui.data_mut(|m| m.insert_temp(pending, true)); } } -/// Save Preset: the preset's name, Save and Cancel. -fn save_row(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) { - ui.add_space(4.0); - let caption = form::caption(ui, tl!("Preset Name")); - ui.horizontal(|ui| { - let name = ui.label_inputs_by(caption.id, |ui| form::text_edit(ui, d, "presetName", DETAILS - 140.0)); - let enter = name.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)); - if widgets::flat_button(ui, tl!("Save"), 54.0).clicked() || enter { - let mut p: Map = PARAMS.iter().filter_map(|k| Some((k.to_string(), d.fields.get(*k)?.clone()))).collect(); - p.insert("name".into(), json!(d.str("presetName"))); - p.remove("preset"); - match app.run("file.newPresets.save", Value::Object(p)) { - Ok(r) => { - d.fields.insert(SAVING.into(), json!(false)); - d.fields.insert("category".into(), json!("Saved")); - d.fields.insert("preset".into(), r["name"].clone()); - d.fields.remove(APPLIED); +/// Save Preset's default name: the first "Preset N" no saved preset has (`file.newPresets.save` +/// replaces a preset of the same name). +fn new_preset_name(app: &VectorcraftApp) -> String { + let saved = &app.session.prefs.new_doc_presets; + let template = "Preset {number}"; + (1..=saved.len() + 1) + .map(|n| crate::i18n::fmt(tl!(template), &[("number", &n.to_string())])) + .find(|name| !saved.iter().any(|p| p.name.trim().eq_ignore_ascii_case(name.trim()))) + .unwrap_or_default() +} + +/// Save the fields as the preset named `presetName` and leave saving, on the saved preset. +fn save_preset(app: &mut VectorcraftApp, d: &mut Dialog) -> Result { + let mut p: Map = PARAMS.iter().filter_map(|k| Some((k.to_string(), d.fields.get(*k)?.clone()))).collect(); + p.insert("name".into(), json!(d.str("presetName"))); + p.remove("preset"); + let r = app.run("file.newPresets.save", Value::Object(p))?; + d.fields.insert(SAVING.into(), json!(false)); + d.fields.remove(SAVING_SHOWN); + // The next Save Preset offers a new default name instead of this preset's. + d.fields.remove("presetName"); + d.fields.insert("category".into(), json!("Saved")); + d.fields.insert("preset".into(), r["name"].clone()); + d.fields.remove(APPLIED); + Ok(r) +} + +/// The More Settings button and its pop-up, which holds what Preset Details doesn't: the artboard +/// layout, spacing and columns. Done, a click outside it and Escape close it; the fields it edited +/// stay as they are. +fn more_settings(ui: &mut egui::Ui, d: &mut Dialog) { + let button = widgets::flat_button(ui, tl!("More Settings"), DETAILS); + let mut open = (d.kind == MORE) != button.clicked(); + let mut done = false; + let opening = button.ctx.read_response(egui::Popup::default_response_id(&button)).is_none(); + egui::Popup::from_response(&button) + .open_bool(&mut open) + .close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside) + .align(egui::RectAlign::TOP_END) + .accessible_name(tl!("More Settings")) + .show(|ui| done = more_settings_popup(ui, opening, d)); + d.kind = if open && !done { MORE } else { KIND }.into(); +} + +/// More Settings' pop-up. True when Done (or Enter) finished with it. +fn more_settings_popup(ui: &mut egui::Ui, opening: bool, d: &mut Dialog) -> bool { + const L: f32 = 150.0; + let t = Tokens::get(ui.ctx()); + let (lay, cols, spacing, rtl) = layout(d); + let n = d.f64("artboards", 1.0); + let several = n > 1.0; + let room = ui.ctx().content_rect().size() - egui::Vec2::splat(4.0 * super::EDGE_GAP) - egui::vec2(0.0, 44.0); + focus_first(ui, opening, |ui| { + egui::ScrollArea::both().id_salt("newdoc-popup-rows").max_width(room.x.max(0.0)).max_height(room.y.max(0.0)).show(ui, |ui| { + ui.set_width(MORE_WIDTH); + ui.label(egui::RichText::new(tl!("More Settings")).font(theme::semibold(13.0)).color(t.text)); + ui.add_space(6.0); + widgets::label_row(ui, tl!("Number of Artboards:"), L, |ui| artboards(ui, d, "newdoc-popup-artboards", 76.0)); + widgets::label_row(ui, "", L, |ui| { + for (l, icon) in [ + (ArtboardLayout::GridByRow, "dc-grid-view"), + (ArtboardLayout::GridByColumn, "grid-3x3"), + (ArtboardLayout::Row, "arrow-left-right"), + (ArtboardLayout::Column, "arrow-up-down"), + ] { + if widgets::icon_button_enabled(ui, icon, l.label(), lay == l, several, 26.0).clicked() { + set_layout(d, "layout", json!(l.id())); + } } - Err(e) => app.status(e), - } - } - if widgets::flat_button(ui, tl!("Cancel"), 60.0).clicked() { - d.fields.insert(SAVING.into(), json!(false)); - } + ui.add_space(6.0); + if widgets::icon_button_enabled(ui, "chevrons-left", tl!("Change to Right-to-Left Layout"), rtl, several, 26.0).clicked() { + set_layout(d, "rightToLeft", json!(!rtl)); + } + }); + ui.add_enabled_ui(several, |ui| { + widgets::label_row(ui, tl!("Spacing:"), L, |ui| { + if let Some(v) = widgets::num_field(ui, "newdoc-spacing", Some(spacing), unit(d), 100.0) { + set_layout(d, "spacing", json!(v.max(0.0))); + } + }); + let grid = matches!(lay, ArtboardLayout::GridByRow | ArtboardLayout::GridByColumn); + ui.add_enabled_ui(grid, |ui| { + widgets::label_row(ui, tl!("Columns:"), L, |ui| { + if let Some(v) = widgets::spin_plain(ui, "newdoc-columns", cols as f64, "", 0, 76.0, 1.0, 1.0, &[]) { + set_layout(d, "columns", json!(v.round().clamp(1.0, n) as u64)); + } + }); + }); + }); + }); }); + ui.add_space(8.0); + // A bad value in a field keeps the pop-up open, as it keeps a dialog open. + let rejected = ui.data_mut(|m| m.remove_temp::(widgets::dialog_reject_commit_flag())).unwrap_or(false); + let enter = !rejected && ui.input(|i| i.key_pressed(egui::Key::Enter)) && ui.memory(|m| m.focused().is_none()); + let row = egui::vec2(ui.min_rect().width(), ui.spacing().interact_size.y); + let mut done = false; + ui.allocate_ui_with_layout(row, egui::Layout::right_to_left(egui::Align::Center), |ui| { + done = widgets::primary_button(ui, tl!("Done")).clicked() || enter; + }); + done } fn units_dropdown(ui: &mut egui::Ui, d: &mut Dialog, width: f32) { @@ -481,10 +654,10 @@ fn orientation(ui: &mut egui::Ui, d: &mut Dialog) { }); } -fn artboards(ui: &mut egui::Ui, d: &mut Dialog, width: f32) { +fn artboards(ui: &mut egui::Ui, d: &mut Dialog, id: &str, width: f32) { let n = d.f64("artboards", 1.0); ui.horizontal(|ui| { - if let Some(v) = widgets::spin_plain(ui, "newdoc-artboards", n, "", 0, width, 1.0, 1.0, &[]) { + if let Some(v) = widgets::spin_plain(ui, id, n, "", 0, width, 1.0, 1.0, &[]) { d.fields.insert("artboards".into(), json!(v.round().clamp(1.0, MAX_ARTBOARDS as f64) as u64)); } }); @@ -543,131 +716,10 @@ fn advanced(ui: &mut egui::Ui, d: &mut Dialog, label: f32, field: f32) { } } -/// More Settings: the classic form of the same fields. -fn show_more(app: &mut VectorcraftApp, ctx: &egui::Context) { - let Some(mut d) = app.ui.dialog.clone() else { return }; - sync(app, &mut d); - let t = Tokens::get(ctx); - let mut b = Buttons::default(); - let mut templates = false; - const L: f32 = 150.0; - window(ctx, MORE, 22, |ui| { - ui.set_width(560.0); - super::modal::heading(ui, tl!("More Settings")); - ui.add_space(12.0); - widgets::label_row(ui, tl!("Name:"), L, |ui| { - form::text(ui, &mut d, "name", 300.0); - save_button(app, ui, &mut d); - }); - if d.bool(SAVING) { - // Under the name, in the fields' column. - widgets::label_row(ui, "", L, |ui| { - ui.vertical(|ui| save_row(app, ui, &mut d)); - }); - } - // Profile: the preset categories (the tabs); Size: the profile's presets. - let profiles: Vec<&str> = newdoc::CATEGORIES.iter().map(|c| c.0).collect(); - let profile = profiles.iter().find(|p| p.eq_ignore_ascii_case(&d.str("category"))).copied().unwrap_or("[Custom]"); - widgets::label_row(ui, tl!("Profile:"), L, |ui| { - if let Some(p) = widgets::dropdown(ui, "newdoc-profile", profile, &profiles, 220.0).and_then(|i| profiles.get(i)) { - d.fields.insert("category".into(), json!(p)); - if let Some(first) = newdoc::category(&app.session, p).and_then(|v| v.into_iter().next()) { - apply(&mut d, &first); - } - } - }); - ui.add_space(6.0); - let (lay, cols, spacing, rtl) = layout(&d); - let n = d.f64("artboards", 1.0); - let several = n > 1.0; - widgets::label_row(ui, tl!("Number of Artboards:"), L, |ui| { - artboards(ui, &mut d, 76.0); - ui.add_space(10.0); - for (l, icon) in [ - (ArtboardLayout::GridByRow, "dc-grid-view"), - (ArtboardLayout::GridByColumn, "grid-3x3"), - (ArtboardLayout::Row, "arrow-left-right"), - (ArtboardLayout::Column, "arrow-up-down"), - ] { - if widgets::icon_button_enabled(ui, icon, l.label(), lay == l, several, 26.0).clicked() { - set_layout(&mut d, "layout", json!(l.id())); - } - } - ui.add_space(6.0); - if widgets::icon_button_enabled(ui, "chevrons-left", tl!("Change to Right-to-Left Layout"), rtl, several, 26.0).clicked() { - set_layout(&mut d, "rightToLeft", json!(!rtl)); - } - }); - ui.add_enabled_ui(several, |ui| { - widgets::label_row(ui, tl!("Spacing:"), L, |ui| { - if let Some(v) = widgets::num_field(ui, "newdoc-spacing", Some(spacing), unit(&d), 100.0) { - set_layout(&mut d, "spacing", json!(v.max(0.0))); - } - ui.add_space(20.0); - widgets::field_label(ui, egui::RichText::new(tl!("Columns:")).color(t.text)); - let grid = matches!(lay, ArtboardLayout::GridByRow | ArtboardLayout::GridByColumn); - ui.add_enabled_ui(grid, |ui| { - if let Some(v) = widgets::spin_plain(ui, "newdoc-columns", cols as f64, "", 0, 76.0, 1.0, 1.0, &[]) { - set_layout(&mut d, "columns", json!(v.round().clamp(1.0, n) as u64)); - } - }); - }); - }); - ui.add_space(6.0); - let sizes = newdoc::category(&app.session, profile).unwrap_or_default(); - let size_names: Vec<&str> = sizes.iter().map(|s| s.name.as_str()).collect(); - let preset = d.str("preset"); - widgets::label_row(ui, tl!("Size:"), L, |ui| { - // The profile's sizes are built in; the preset may be one the user saved. - let shown = if preset.is_empty() { tl!("Custom") } else { preset_name(&preset) }; - if let Some(s) = super::mixed_dropdown(ui, "newdoc-size", shown, &size_names, 220.0, |_| true).and_then(|i| sizes.get(i)) { - apply(&mut d, s); - } - }); - let unit = unit(&d); - widgets::label_row(ui, tl!("Width:"), L, |ui| { - form::length(ui, &mut d, "width", unit, 120.0); - ui.add_space(20.0); - ui.label(egui::RichText::new(tl!("Units:")).color(t.text)); - units_dropdown(ui, &mut d, 120.0); - }); - widgets::label_row(ui, tl!("Height:"), L, |ui| { - form::length(ui, &mut d, "height", unit, 120.0); - ui.add_space(20.0); - ui.label(egui::RichText::new(tl!("Orientation:")).color(t.text)); - orientation(ui, &mut d); - }); - ui.add_space(4.0); - widgets::label_row(ui, tl!("Bleed:"), L, |ui| form::bleed(ui, &mut d, unit, 64.0)); - ui.add_space(4.0); - widgets::label_row(ui, tl!("Background Contents:"), L, |ui| background(ui, &mut d, 220.0)); - ui.add_space(8.0); - advanced(ui, &mut d, L, 220.0); - ui.add_space(16.0); - ui.horizontal(|ui| { - templates = widgets::secondary_button(ui, tl!("Templates…")).on_hover_text(tl!("New from Template")).clicked(); - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - b.create |= widgets::primary_button(ui, tl!("Create Document")).clicked(); - ui.add_space(8.0); - // Cancel goes back to New Document. - if widgets::secondary_button(ui, tl!("Cancel")).clicked() { - d.kind = KIND.into(); - } - }); - }); - }); - if templates { - app.ui.dialog = None; - if let Err(e) = app.run("file.newFromTemplate", json!({})) { - app.status(e); - } - return; - } - b.finish(app, ctx, d); -} - #[cfg(test)] mod tests { + use egui::accesskit::Role; + use egui_kittest::kittest::{NodeT, Queryable}; use serde_json::json; use vectorcraft_engine::Session; @@ -792,6 +844,26 @@ mod tests { run(&mut app, vec![press(false)]); assert!(app.ui.dialog.is_none(), "the double-click created it: {:?}", app.ui.status); assert_eq!(app.session.documents().len(), 1); + + // While a preset is being saved, a click fills the fields and a double-click creates nothing. + app.run("file.newDialog", json!({})).unwrap(); + set(&mut app, "category", json!("Mobile")); + set(&mut app, "preset", json!("Letter")); + set(&mut app, super::SAVING, json!(true)); + // Far enough from the clicks above that the next two count as a click and a double-click. + for _ in 0..12 { + run(&mut app, vec![]); + } + let at = card(&mut app, &mut run); + let press = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + run(&mut app, vec![egui::Event::PointerMoved(at), press(true)]); + run(&mut app, vec![press(false)]); + assert!(field(&app, "preset").as_str().is_some_and(|p| p == "Phone 393×852"), "a click fills the fields: {:?}", field(&app, "preset")); + run(&mut app, vec![press(true)]); + run(&mut app, vec![press(false)]); + run(&mut app, vec![]); + assert_eq!(app.session.documents().len(), 1, "nothing was created while saving"); + assert_eq!(field(&app, super::SAVING), json!(true)); } /// A category tab opens on its first preset, so Orientation matches its (landscape) cards, and @@ -878,14 +950,14 @@ mod tests { assert!(selected(&out), "the turned preset stays chosen: {:?}", field(&app, "preset")); } - /// The document's name field has a caption of its own under the Preset Details heading, and - /// the column stays inside the dialog's height with Save Preset and Advanced Options open. + /// The document's name field has a caption of its own under the Preset Details heading, the + /// column stays inside the dialog's height with Advanced Options open in both states, and saving + /// a preset replaces only the top of the column: the rows under it do not move. #[test] fn the_name_field_is_captioned_and_the_column_fits() { let mut app = VectorcraftApp::new(Session::new(), Default::default()); app.run("file.newDialog", json!({})).unwrap(); set(&mut app, "advanced", json!(true)); - set(&mut app, super::SAVING, json!(true)); let ctx = egui::Context::default(); crate::theme::install_fonts(&ctx); let mut time = 0.0; @@ -897,8 +969,6 @@ mod tests { out.textures_delta.clear(); out }; - run(&mut app); - let out = run(&mut app); fn walk(s: &egui::Shape, out: &mut Vec<(String, egui::Rect)>) { match s { egui::Shape::Text(t) => out.push((t.galley.text().to_string(), t.visual_bounding_rect())), @@ -906,13 +976,33 @@ mod tests { _ => {} } } - let mut texts = vec![]; - out.shapes.iter().for_each(|c| walk(&c.shape, &mut texts)); - let at = |name: &str| texts.iter().find(|(t, _)| t == name).map(|(_, r)| *r).unwrap_or_else(|| panic!("{name} is missing: {texts:?}")); - let (heading, caption, value) = (at("PRESET DETAILS"), at("Document Name"), at("Untitled-1")); + let mut texts_of = |app: &mut VectorcraftApp| { + run(app); + let out = run(app); + let mut texts = vec![]; + out.shapes.iter().for_each(|c| walk(&c.shape, &mut texts)); + texts + }; + let texts = texts_of(&mut app); + let at = |texts: &[(String, egui::Rect)], name: &str| { + texts.iter().find(|(t, _)| t == name).map(|(_, r)| *r).unwrap_or_else(|| panic!("{name} is missing: {texts:?}")) + }; + let (heading, caption, value) = (at(&texts, "PRESET DETAILS"), at(&texts, "Document Name"), at(&texts, "Untitled-1")); assert!(heading.bottom() <= caption.top() && caption.bottom() <= value.top(), "{heading:?} {caption:?} {value:?}"); - assert!(value.bottom() <= at("Preset Name").top(), "the preset's own name field follows"); - let used = at("Create").bottom() - heading.top(); + let used = at(&texts, "Create").bottom() - heading.top(); + assert!(used <= super::HEIGHT, "the column used {used}"); + let width = at(&texts, "Width").top(); + + set(&mut app, super::SAVING, json!(true)); + let saving = texts_of(&mut app); + for gone in ["PRESET DETAILS", "Document Name", "Close", "Create"] { + assert!(saving.iter().all(|(t, _)| t != gone), "{gone} shows while saving"); + } + let (heading, cancel) = (at(&saving, "Save Document Preset"), at(&saving, "Cancel")); + assert!(heading.bottom() <= cancel.top()); + assert!(cancel.bottom() <= at(&saving, "Width").top(), "Cancel and Save are above the rows"); + assert_eq!(at(&saving, "Width").top(), width, "the rows stay where they were"); + let used = at(&saving, "More Settings").bottom() - heading.top(); assert!(used <= super::HEIGHT, "the column used {used}"); } @@ -957,7 +1047,7 @@ mod tests { let out = frame(&mut app); assert!(!out.shapes.is_empty()); } - // More Settings shares the fields; Cancel goes back. + // More Settings edits the same fields. app.ui.dialog.as_mut().unwrap().kind = super::MORE.into(); set(&mut app, "artboards", json!(3)); frame(&mut app); @@ -977,48 +1067,380 @@ mod tests { assert_eq!(app.session.prefs.recent_new_docs[0].name, "Zine"); } - /// Save Preset works in More Settings too (#4893), saving its settings: the artboard layout - /// only More Settings shows among them. - #[test] - fn more_settings_saves_a_preset_of_its_settings() { + type Harness = egui_kittest::Harness<'static, VectorcraftApp>; + + /// New Document open in a harness of `size`, with the app's key handling and the dialogs drawn. + fn harness(size: egui::Vec2) -> Harness { let mut app = VectorcraftApp::new(Session::new(), Default::default()); app.run("file.newDialog", json!({})).unwrap(); - app.ui.dialog.as_mut().unwrap().kind = super::MORE.into(); - set(&mut app, "artboards", json!(4)); - set(&mut app, "artboardLayout", json!({"layout": "column", "spacing": 30})); - set(&mut app, super::SAVING, json!(true)); - set(&mut app, "presetName", json!("Strip")); - let ctx = egui::Context::default(); - crate::theme::install_fonts(&ctx); - let mut time = 0.0; - let mut run = |app: &mut VectorcraftApp, events| { - time += 0.5; - let screen_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1400.0, 1000.0))); - let raw = egui::RawInput { screen_rect, events, time: Some(time), ..Default::default() }; - let mut out = ctx.run_ui(raw, |ui| crate::dialogs::show(app, ui.ctx())); - out.textures_delta.clear(); - out - }; - run(&mut app, vec![]); - let out = run(&mut app, vec![]); - fn walk(s: &egui::Shape, out: &mut Vec<(String, egui::Pos2)>) { - match s { - egui::Shape::Text(t) => out.push((t.galley.text().to_string(), t.visual_bounding_rect().center())), - egui::Shape::Vec(v) => v.iter().for_each(|s| walk(s, out)), - _ => {} + let mut fonts = false; + let mut h = egui_kittest::Harness::builder().with_size(size).build_ui_state( + move |ui, app: &mut VectorcraftApp| { + if !std::mem::replace(&mut fonts, true) { + crate::theme::install_fonts(ui.ctx()); + return; + } + crate::shortcuts::handle(app, ui.ctx()); + crate::dialogs::show(app, ui.ctx()); + }, + app, + ); + h.run_steps(5); + h + } + + fn big() -> Harness { + harness(egui::vec2(1400.0, 1000.0)) + } + + /// Open a pop-up as a control client would, with `artboards` set, and let it draw. + fn open_more(h: &mut Harness, artboards: u64) { + set(h.state_mut(), "artboards", json!(artboards)); + h.state_mut().ui.dialog.as_mut().unwrap().kind = super::MORE.into(); + h.run_steps(4); + } + + fn kind(h: &Harness) -> String { + h.state().ui.dialog.as_ref().unwrap().kind.clone() + } + + /// A primary click at `at`, as a pointer makes it. + fn click_at(h: &mut Harness, at: egui::Pos2) { + h.event(egui::Event::PointerMoved(at)); + h.run_steps(1); + h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() }); + h.run_steps(1); + h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }); + h.run_steps(3); + } + + /// Press Save Preset, leaving the preset's name focused. + fn start_saving(h: &mut Harness) { + h.get_by_label("Save Preset").click(); + h.run_steps(4); + } + + /// Replace the text of field `label` with `text` and press Enter. + fn enter_in(h: &mut Harness, label: &str, text: &str) { + let all = egui::Modifiers { ctrl: true, command: true, ..Default::default() }; + h.get_by_role_and_label(Role::TextInput, label).focus(); + h.run_steps(2); + h.key_press_modifiers(all, egui::Key::A); + h.get_by_role_and_label(Role::TextInput, label).type_text(text); + h.run_steps(2); + h.key_press(egui::Key::Enter); + h.run_steps(3); + } + + /// More Settings opens over New Document with only what Preset Details lacks, its first field + /// takes the keyboard focus, and Done leaves New Document open with what the pop-up set. + #[test] + fn more_settings_is_a_popup_holding_only_the_artboard_layout() { + let mut h = big(); + h.get_by_label("More Settings").click(); + h.run_steps(4); + assert_eq!(kind(&h), super::MORE); + assert!(h.get_by_role_and_label(Role::TextInput, "Number of Artboards:").is_focused(), "the pop-up's first field has the focus"); + for shown in ["Number of Artboards:", "Spacing:", "Columns:", "Create", "Close"] { + let _ = h.get_by_label(shown); + } + for gone in [ + "Name:", + "Profile:", + "Size:", + "Width:", + "Height:", + "Units:", + "Orientation:", + "Bleed:", + "Background Contents:", + "Create Document", + "Templates…", + ] { + assert!(h.query_by_label(gone).is_none(), "{gone} is in the pop-up"); + } + h.get_by_label("Done").click(); + h.run_steps(3); + assert_eq!(kind(&h), super::KIND, "Done goes back to New Document"); + assert!(h.query_by_label("Spacing:").is_none()); + assert!(h.state().session.documents().is_empty()); + } + + /// Escape, a click outside and `ui.dialog.cancel` each close only the pop-up, and the fields it + /// edited stay set. Escape on New Document itself then closes the dialog. + #[test] + fn a_popup_closes_on_its_own_and_keeps_the_values() { + let mut h = big(); + open_more(&mut h, 4); + h.get_by_label(super::ArtboardLayout::Column.label()).click(); + h.run_steps(3); + assert_eq!(kind(&h), super::MORE, "a click inside keeps it open"); + h.key_press(egui::Key::Escape); + h.run_steps(3); + assert_eq!(kind(&h), super::KIND, "Escape closes only the pop-up"); + assert_eq!(field(h.state(), "artboardLayout")["layout"], json!("column")); + h.key_press(egui::Key::Escape); + h.run_steps(3); + assert!(h.state().ui.dialog.is_none(), "the next Escape closes New Document"); + + let mut h = big(); + open_more(&mut h, 4); + let outside = h.get_by_label("PRESET DETAILS").rect().center(); + click_at(&mut h, outside); + assert_eq!(kind(&h), super::KIND, "a click outside closes the pop-up"); + assert_eq!(field(h.state(), "artboards"), json!(4)); + + open_more(&mut h, 4); + crate::dialogs::cancel(h.state_mut()); + assert_eq!(kind(&h), super::KIND, "ui.dialog.cancel closes only the pop-up"); + set(h.state_mut(), super::SAVING, json!(true)); + crate::dialogs::cancel(h.state_mut()); + assert_eq!((kind(&h), field(h.state(), super::SAVING)), (super::KIND.to_string(), json!(false)), "and Save Preset's"); + crate::dialogs::cancel(h.state_mut()); + assert!(h.state().ui.dialog.is_none(), "with no pop-up it cancels New Document"); + } + + /// A click that lands on Create, Close, a preset card or a category tab while the pop-up is open + /// only closes the pop-up: New Document stays open with the values the pop-up set. + #[test] + fn a_click_outside_the_popup_does_not_reach_the_dialog() { + let mut reached = vec![]; + for target in ["Create", "Close", "card", "Web"] { + let mut h = big(); + open_more(&mut h, 4); + let (category, preset) = (field(h.state(), "category"), field(h.state(), "preset")); + let at = if target == "card" { + // The Print tab's second card: the first is the chosen one. + let tab = h.get_by_label("Print").rect(); + egui::pos2(tab.left() + 146.0 + 12.0 + 73.0, tab.bottom() + 120.0) + } else { + h.get_by_label(target).rect().center() + }; + click_at(&mut h, at); + let kept = h.state().ui.dialog.is_some() + && kind(&h) == super::KIND + && h.state().session.documents().is_empty() + && field(h.state(), "artboards") == json!(4) + && (field(h.state(), "category"), field(h.state(), "preset")) == (category, preset); + if !kept { + reached.push(target); } } - let mut texts = vec![]; - out.shapes.iter().for_each(|c| walk(&c.shape, &mut texts)); - assert!(texts.iter().any(|(t, _)| t == "Preset Name"), "the preset's name field shows"); - let at = texts.iter().find(|(t, _)| t == "Save").map(|(_, p)| *p).expect("a Save button"); - let press = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; - run(&mut app, vec![egui::Event::PointerMoved(at), press(true)]); - run(&mut app, vec![press(false)]); - let saved = app.session.prefs.new_doc_presets.iter().find(|p| p.name == "Strip").expect("saved"); + assert!(reached.is_empty(), "the click reached {reached:?}"); + } + + /// Enter applies the field and closes the pop-up. It does not create the document. + #[test] + fn enter_in_a_popup_does_not_create_the_document() { + let mut h = big(); + open_more(&mut h, 3); + enter_in(&mut h, "Spacing:", "25"); + assert_eq!(kind(&h), super::KIND, "Enter closes the pop-up"); + assert_eq!(field(h.state(), "artboardLayout")["spacing"], json!(25.0)); + assert!(h.state().session.documents().is_empty(), "no document was made"); + } + + /// One artboard disables the layout, Spacing and Columns; Columns stays within the artboards + /// and Spacing is not negative. + #[test] + fn the_popup_keeps_the_layout_rules() { + let mut h = big(); + open_more(&mut h, 1); + for name in [super::ArtboardLayout::Row.label(), "Change to Right-to-Left Layout"] { + assert!(h.get_by_label(name).accesskit_node().is_disabled(), "{name}"); + } + for name in ["Spacing:", "Columns:"] { + assert!(h.get_by_role_and_label(Role::TextInput, name).accesskit_node().is_disabled(), "{name}"); + } + open_more(&mut h, 3); + assert!(!h.get_by_role_and_label(Role::TextInput, "Spacing:").accesskit_node().is_disabled()); + enter_in(&mut h, "Columns:", "99"); + assert_eq!(field(h.state(), "artboardLayout")["columns"], json!(3)); + open_more(&mut h, 3); + enter_in(&mut h, "Spacing:", "-5"); + assert_eq!(field(h.state(), "artboardLayout")["spacing"], json!(0.0)); + } + + /// Saving swaps the top of Preset Details for the heading, the preset's name, Cancel and Save, and + /// hides Close and Create; the rows under it stay editable, and Enter in one of them neither saves + /// nor creates. + #[test] + fn save_preset_is_a_mode_of_the_panel() { + let mut h = big(); + start_saving(&mut h); + let _ = (h.get_by_label("Save Document Preset"), h.get_by_label("Cancel"), h.get_by_label("Save")); + for gone in ["PRESET DETAILS", "Create", "Close"] { + assert!(h.query_by_label(gone).is_none(), "{gone} shows while saving"); + } + assert!(h.query_by_role_and_label(Role::TextInput, "Document Name").is_none(), "the document's name is hidden"); + let _ = (h.get_by_label("Advanced Options"), h.get_by_label("More Settings")); + enter_in(&mut h, "Artboards", "2"); + assert_eq!(field(h.state(), "artboards"), json!(2), "the rows stay editable"); + assert_eq!(field(h.state(), super::SAVING), json!(true), "Enter in a row does not save"); + assert!(h.state().session.documents().is_empty() && h.state().session.prefs.new_doc_presets.is_empty()); + h.get_by_label("More Settings").click(); + h.run_steps(4); + assert_eq!(kind(&h), super::MORE, "More Settings opens while saving"); + } + + /// Save Preset focuses the default name with its text selected, Enter in it is Save, and the + /// saved preset keeps the fields. + #[test] + fn enter_in_the_preset_name_saves() { + let mut h = big(); + set(h.state_mut(), "artboards", json!(3)); + h.run_steps(2); + start_saving(&mut h); + assert_eq!(field(h.state(), "presetName"), json!("Preset 1")); + assert!(h.get_by_role_and_label(Role::TextInput, "Preset Name").is_focused()); + h.event(egui::Event::Text("Trio".into())); + h.run_steps(2); + assert_eq!(field(h.state(), "presetName"), json!("Trio"), "typing replaced the selected text"); + h.key_press(egui::Key::Enter); + h.run_steps(3); + let saved = h.state().session.prefs.new_doc_presets.iter().find(|p| p.name == "Trio").expect("Enter saved it"); + assert_eq!(saved.artboards, 3); + assert_eq!((field(h.state(), super::SAVING), field(h.state(), "artboards")), (json!(false), json!(3)), "the fields stay"); + assert_eq!(field(h.state(), "name"), json!("Untitled-1")); + assert!(h.state().session.documents().is_empty() && h.state().ui.dialog.is_some()); + } + + /// Save Preset's default name is the first "Preset N" no saved preset has, after a save as well. + #[test] + fn the_next_save_offers_a_new_name() { + let mut h = big(); + start_saving(&mut h); + assert_eq!(field(h.state(), "presetName"), json!("Preset 1")); + h.get_by_label("Save").click(); + h.run_steps(3); + start_saving(&mut h); + assert_eq!(field(h.state(), "presetName"), json!("Preset 2"), "not the saved preset's name"); + set(h.state_mut(), "presetName", json!("preset 3")); + h.run_steps(2); + h.get_by_label("Save").click(); + h.run_steps(3); + start_saving(&mut h); + assert_eq!(field(h.state(), "presetName"), json!("Preset 2"), "not a name a saved preset has"); + } + + /// A New Document that opens already saving (as a control client can set it) fills in and focuses + /// the default name, also after the last one was closed while saving. + #[test] + fn a_dialog_that_opens_saving_fills_in_the_name() { + let mut h = big(); + start_saving(&mut h); + h.state_mut().ui.dialog = None; + h.run_steps(2); + h.state_mut().run("file.newDialog", json!({})).unwrap(); + set(h.state_mut(), super::SAVING, json!(true)); + h.run_steps(4); + assert_eq!(field(h.state(), "presetName"), json!("Preset 1")); + assert!(h.get_by_role_and_label(Role::TextInput, "Preset Name").is_focused()); + } + + /// Enter that is still held after Enter saved a preset does not create the document. + #[test] + fn a_held_enter_after_a_save_does_not_create() { + let mut h = big(); + start_saving(&mut h); + let enter = |repeat| egui::Event::Key { key: egui::Key::Enter, physical_key: None, pressed: true, repeat, modifiers: Default::default() }; + // Pressed and not released: the next presses are the key's repeats. + h.event(enter(false)); + h.run_steps(1); + assert_eq!(field(h.state(), super::SAVING), json!(false), "Enter saved"); + for _ in 0..3 { + h.event(enter(true)); + h.run_steps(1); + } + assert!(h.state().session.documents().is_empty() && h.state().ui.dialog.is_some()); + } + + /// Escape in the mode is Cancel: it saves nothing, keeps edits made to the fields, and New Document + /// stays open. The Cancel button does the same. + #[test] + fn escape_and_cancel_leave_saving_and_keep_the_edits() { + let mut h = big(); + start_saving(&mut h); + set(h.state_mut(), "artboards", json!(5)); + h.run_steps(2); + h.key_press(egui::Key::Escape); + h.run_steps(3); + assert_eq!((field(h.state(), super::SAVING), field(h.state(), "artboards")), (json!(false), json!(5)), "Escape keeps the edits"); + assert!(h.state().session.prefs.new_doc_presets.is_empty(), "and saves nothing"); + assert!(h.state().ui.dialog.is_some(), "and New Document stays open"); + + start_saving(&mut h); + h.get_by_label("Cancel").click(); + h.run_steps(3); + assert_eq!(field(h.state(), super::SAVING), json!(false)); + let _ = h.get_by_label("Create"); + } + + /// `ui.dialog.confirm` while saving is Save: it stores the preset and creates no document. + #[test] + fn confirm_while_saving_does_not_create_a_document() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.newDialog", json!({})).unwrap(); + frame(&mut app); + set(&mut app, super::SAVING, json!(true)); + set(&mut app, "presetName", json!("Zed")); + crate::dialogs::confirm(&mut app).unwrap(); + assert!(app.session.documents().is_empty() && app.session.prefs.new_doc_presets.iter().any(|p| p.name == "Zed")); + assert_eq!((field(&app, super::SAVING), field(&app, "category")), (json!(false), json!("Saved"))); + set(&mut app, super::SAVING, json!(true)); + set(&mut app, "presetName", json!("")); + assert!(crate::dialogs::confirm(&mut app).is_err(), "an empty name is refused"); + assert_eq!(field(&app, super::SAVING), json!(true)); + assert!(app.session.documents().is_empty()); + } + + /// The preset keeps the artboard layout, spacing and columns set in More Settings' pop-up + /// (#4893), and New Document stays open on it. An empty name stays in the mode. + #[test] + fn save_preset_saves_what_more_settings_set() { + let mut h = big(); + open_more(&mut h, 4); + enter_in(&mut h, "Spacing:", "30"); + open_more(&mut h, 4); + h.get_by_label(super::ArtboardLayout::Column.label()).click(); + h.run_steps(3); + h.get_by_label("Change to Right-to-Left Layout").click(); + h.run_steps(3); + h.get_by_label("Done").click(); + h.run_steps(3); + h.get_by_label("Save Preset").click(); + h.run_steps(4); + assert!(field(h.state(), super::SAVING) == json!(true) && h.get_by_role_and_label(Role::TextInput, "Preset Name").is_focused()); + let _ = (h.get_by_label("Save"), h.get_by_label("Cancel")); + set(h.state_mut(), "presetName", json!(" ")); + h.run_steps(2); + h.get_by_label("Save").click(); + h.run_steps(3); + assert_eq!(field(h.state(), super::SAVING), json!(true), "an empty name stays in the mode"); + set(h.state_mut(), "presetName", json!("Strip")); + h.run_steps(2); + h.get_by_label("Save").click(); + h.run_steps(3); + let saved = h.state().session.prefs.new_doc_presets.iter().find(|p| p.name == "Strip").expect("saved"); assert_eq!(saved.artboards, 4); + assert!(saved.right_to_left); assert_eq!((saved.layout, saved.spacing), (super::ArtboardLayout::Column, 30.0)); - assert_eq!((field(&app, "category"), field(&app, "preset"), field(&app, super::SAVING)), (json!("Saved"), json!("Strip"), json!(false))); - assert_eq!(app.ui.dialog.as_ref().unwrap().kind, super::MORE, "More Settings stays open"); + assert_eq!( + (field(h.state(), "category"), field(h.state(), "preset"), field(h.state(), super::SAVING)), + (json!("Saved"), json!("Strip"), json!(false)) + ); + assert_eq!(kind(&h), super::KIND, "New Document stays open"); + assert!(h.state().session.documents().is_empty()); + } + + /// In a small window the pop-up stays on screen, and its button stays in view under rows that scroll. + #[test] + fn the_popup_stays_inside_a_small_window() { + let mut h = harness(egui::vec2(480.0, 260.0)); + open_more(&mut h, 3); + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(480.0, 260.0)); + for label in ["Number of Artboards:", "Done"] { + assert!(screen.contains_rect(h.get_by_label(label).rect()), "{label} is off screen"); + } } } diff --git a/crates/ui-egui/src/dialogs/tests_accessibility.rs b/crates/ui-egui/src/dialogs/tests_accessibility.rs index 8525fd8bb2..d3ef1ce8ce 100644 --- a/crates/ui-egui/src/dialogs/tests_accessibility.rs +++ b/crates/ui-egui/src/dialogs/tests_accessibility.rs @@ -86,9 +86,8 @@ fn document_setup_names_each_bleed_side() { fn new_document_names_its_fields_by_their_captions() { let mut app = app(); app.run("file.newDialog", json!({})).unwrap(); - app.ui.dialog.as_mut().expect("New Document is open").fields.insert("__saving".into(), json!(true)); let h = strict(app); - for name in ["Document Name", "Preset Name", "Width", "Height", "Artboards"] { + for name in ["Document Name", "Width", "Height", "Artboards"] { let _ = h.get_by_role_and_label(Role::TextInput, name); } for name in ["Units", "Background Contents"] { @@ -96,7 +95,7 @@ fn new_document_names_its_fields_by_their_captions() { } } -/// New Document's More Settings, with Save Preset open, leaves no field unnamed. +/// New Document's More Settings pop-up and Save Preset mode leave no field unnamed. #[test] fn new_document_more_settings_names_its_fields() { let mut app = app(); @@ -104,8 +103,11 @@ fn new_document_more_settings_names_its_fields() { let d = app.ui.dialog.as_mut().expect("New Document is open"); d.kind = super::new_document::MORE.into(); d.fields.insert("__saving".into(), json!(true)); + d.fields.insert("artboards".into(), json!(3)); let h = strict(app); - let _ = h.get_by_role_and_label(Role::TextInput, "Preset Name"); + for name in ["Preset Name", "Number of Artboards:", "Spacing:", "Columns:"] { + let _ = h.get_by_role_and_label(Role::TextInput, name); + } } /// Split Into Grid keeps translated visible labels while naming row and column gutter and total diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 97f8b70064..50868dca69 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -284,7 +284,6 @@ font-technology Variable Променливи Update While Dragging Обновяване при плъзгане Hide Video Rulers Скриване на видеолинийките Background Contents Съдържание на фона - Background Contents: Съдържание на фона: Background: Фон: Backspace Backspace Bar Graph Tool Инструмент „Стълбчеста диаграма“ @@ -654,7 +653,6 @@ font-technology Variable Променливи Couldn't {} {name}: {e} Неуспешно {name} на {}: {e} Create Създаване Create Clipping Mask Създаване на маска за изрязване - Create Document Създаване на документ Create from Guides Създаване от водачи Create from Selection Създаване от селекцията Create From: Създаване от: @@ -2074,7 +2072,6 @@ font-technology Variable Променливи New Color Group… Нова цветова група… New Document Presets Предварителни настройки за нов документ New file Нов файл - New from Template Нов от шаблон New from Template… Нов от шаблон… New Graphic Style Нов графичен стил New Graphic Style (Alt-click to name it) Нов графичен стил (кликнете с Alt, за да го наименувате) @@ -2858,6 +2855,7 @@ digits Malayalam Малаялам Save PDF Preset Запази PDF предварителна настройка Save Perspective Grid Preset Запази предварителна настройка на перспективната мрежа Save Preset Запази предварителна настройка + Save Document Preset Запази предварителна настройка на документа Save Preset… Запази предварителна настройка… Save Print Preset Запази предварителна настройка за печат Save Recovery Data Запази данни за възстановяване @@ -3422,7 +3420,6 @@ digits Malayalam Малаялам Template Шаблон Templates Шаблони Templates Folder Папка с шаблони - Templates… Шаблони… Text Displayed in Cell: Текст, показван в клетката: Text Export Options Опции за експортиране на текст Text Fill & Stroke Color Цвят на запълване и щрих на текста diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index 00859a6552..55af09bc9f 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -2531,15 +2531,12 @@ digits Malayalam Malajalam Selected Swatches Odabrani uzorci boja Advanced Options Napredne opcije Background Contents Sadržaj pozadine - Background Contents: Sadržaj pozadine: BLANK DOCUMENT PRESETS PREDLOŠCI PRAZNIH DOKUMENATA Bleed Napust Change to Right-to-Left Layout Prebaci na raspored s desna nalijevo Columns: Kolone: - Create Document Kreiraj dokument Documents you create appear here. Dokumenti koje kreirate pojavljuju se ovdje. More Settings Više postavki - New from Template Novo iz predloška Number of Artboards: Broj radnih ploča: Orientation: Orijentacija: PRESET DETAILS DETALJI PREDLOŠKA @@ -2550,7 +2547,6 @@ digits Malayalam Malajalam RECENT NEDAVNO RECENT (1) NEDAVNO (1) SAVED SAČUVANO - Templates… Predlošci… Transparent (Default) Prozirno (zadano) Save for Office Documents Sačuvaj za Office dokumente Transparent Background Prozirna pozadina @@ -3486,6 +3482,7 @@ theme Appearance Izgled Arrange by Row Rasporedi po redu Arrange by Column Rasporedi po koloni Save Preset Sačuvaj predložak + Save Document Preset Sačuvaj predložak dokumenta Screen (72 ppi) Ekran (72 ppi) Social Square Post 1080×1080 Kvadratna objava za društvene mreže 1080×1080 Distribute Rasporedi ravnomjerno diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 53ebd517f5..a37133f141 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -423,7 +423,6 @@ antiAlias Sharp Nítid Back Enrere Background Color: Color de fons: Background Contents Contingut del fons - Background Contents: Contingut del fons: Background Level Nivell de fons Background: Fons: Backspace Retrocés @@ -917,7 +916,6 @@ antiAlias Sharp Nítid Create Clipping Mask Crea una màscara de retall Create Compound Path From Ellipses Crea un traçat compost a partir d’el·lipses Create Dimension Crea una dimensió - Create Document Crea un document Create from Guides Crea a partir de guies Create from Selection Crea a partir de la selecció Create From: Crea a partir de: @@ -2597,7 +2595,6 @@ digits Malayalam Malaiàlam New Design Disseny nou New Document Presets Preajustos de document nous New file Fitxer nou - New from Template Nou a partir d’una plantilla New from Template… Nou a partir d’una plantilla… New Graphic Style Estil gràfic nou New Graphic Style (Alt-click to name it) Estil gràfic nou (Alt+clic per posar-hi nom) @@ -3643,6 +3640,7 @@ digits Malayalam Malaiàlam Save PDF Preset Desa el preajust de PDF Save Perspective Grid Preset Desa el preajust de quadrícula en perspectiva Save Preset Desa el preajust + Save Document Preset Desa el preajust de document Save Preset… Desa el preajust… Save Print Preset Desa el preajust d’impressió Save Recovery Data Desa les dades de recuperació @@ -4369,7 +4367,6 @@ digits Malayalam Malaiàlam Template literal not closed El literal de plantilla no està tancat Templates Plantilles Templates Folder Carpeta de plantilles - Templates… Plantilles… Tetrad Tètrada Tetrad 2 Tètrada 2 Tetrad 3 Tètrada 3 diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 3c9090be2a..ece95b1cb9 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -2562,15 +2562,12 @@ digits Malayalam Malajálamské Selected Swatches Vybrané položky vzorníku Advanced Options Rozšířené možnosti Background Contents Obsah pozadí - Background Contents: Obsah pozadí: BLANK DOCUMENT PRESETS PŘEDNASTAVENÍ PRÁZDNÝCH DOKUMENTŮ Bleed Spadávka Change to Right-to-Left Layout Změnit na rozvržení zprava doleva Columns: Sloupce: - Create Document Vytvořit dokument Documents you create appear here. Zde se zobrazí dokumenty, které vytvoříte. More Settings Další nastavení - New from Template Nový ze šablony Number of Artboards: Počet pracovních ploch: Orientation: Orientace: PRESET DETAILS PODROBNOSTI PŘEDNASTAVENÍ @@ -2581,7 +2578,6 @@ digits Malayalam Malajálamské RECENT NEDÁVNÉ RECENT (1) NEDÁVNÉ (1) SAVED ULOŽENÉ - Templates… Šablony… Transparent (Default) Průhledné (výchozí) Save for Office Documents Uložit pro kancelářské dokumenty Transparent Background Průhledné pozadí @@ -3536,6 +3532,7 @@ theme Appearance Motiv Arrange by Row Uspořádat po řádcích Arrange by Column Uspořádat po sloupcích Save Preset Uložit přednastavení + Save Document Preset Uložit přednastavení dokumentu Screen (72 ppi) Obrazovka (72 ppi) Social Square Post 1080×1080 Čtvercový příspěvek pro sociální sítě 1080×1080 Distribute Rozmístit diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index df445718bf..7bc024666a 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -2350,15 +2350,12 @@ digits Malayalam Malayalam Selected Swatches Markerede farveprøver Advanced Options Avancerede indstillinger Background Contents Baggrundens indhold - Background Contents: Baggrundens indhold: BLANK DOCUMENT PRESETS TOMME DOKUMENTFORUDINDSTILLINGER Bleed Beskæring Change to Right-to-Left Layout Skift til højre-til-venstre-layout Columns: Kolonner: - Create Document Opret dokument Documents you create appear here. Dokumenter, du opretter, vises her. More Settings Flere indstillinger - New from Template Nyt fra skabelon Number of Artboards: Antal tegnebrætter: Orientation: Orientering: PRESET DETAILS FORUDINDSTILLINGSDETALJER @@ -2369,7 +2366,6 @@ digits Malayalam Malayalam RECENT SENESTE RECENT (1) SENESTE (1) SAVED GEMT - Templates… Skabeloner… Transparent (Default) Gennemsigtig (standard) Save for Office Documents Gem til Office-dokumenter Transparent Background Gennemsigtig baggrund @@ -3273,6 +3269,7 @@ theme Appearance Udseende Arrange by Row Arranger efter række Arrange by Column Arranger efter kolonne Save Preset Gem forudindstilling + Save Document Preset Gem dokumentforudindstilling Screen (72 ppi) Skærm (72 ppi) Social Square Post 1080×1080 Socialt firkantet opslag 1080×1080 Distribute Fordel diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 6c6c48238f..60ac922fa5 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -272,7 +272,6 @@ font-technology Variable Variabel Update While Dragging Beim Ziehen aktualisieren Hide Video Rulers Videolineale ausblenden Background Contents Hintergrundinhalt - Background Contents: Hintergrundinhalt: Background: Hintergrund: Backspace Rücktaste Bar Graph Tool Balkendiagramm-Werkzeug @@ -647,7 +646,6 @@ font-technology Variable Variabel Couldn't {} {name}: {e} Fehler bei „{}“ für {name}: {e} Create Erstellen Create Clipping Mask Schnittmaske erstellen - Create Document Dokument erstellen Create from Guides Aus Hilfslinien erstellen Create from Selection Aus Auswahl erstellen Create From: Erstellen aus: @@ -2099,7 +2097,6 @@ file-filter Templates Vorlagen New Color Group… Neue Farbgruppe… New Document Presets Vorgaben für neue Dokumente New file Neue Datei - New from Template Neu aus Vorlage New from Template… Neu aus Vorlage… New Graphic Style Neuer Grafikstil New Graphic Style (Alt-click to name it) Neuer Grafikstil (Alt-Klick, um ihn zu benennen) @@ -2891,6 +2888,7 @@ digits Malayalam Malayalam Save PDF Preset PDF-Vorgabe speichern Save Perspective Grid Preset Vorgabe für Perspektivenraster speichern Save Preset Vorgabe speichern + Save Document Preset Dokumentvorgabe speichern Preset {number} Vorgabe {number} Save Preset… Vorgabe speichern… Save Print Preset Druckvorgabe speichern @@ -3454,7 +3452,6 @@ effect Points Punkte Template Vorlage Templates Vorlagen Templates Folder Vorlagenordner - Templates… Vorlagen… Text Displayed in Cell: In der Zelle angezeigter Text: Text Export Options Textexportoptionen Text Fill & Stroke Color Flächen- und Konturfarbe des Texts diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index b2d4f2a6ea..2abc58dee1 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -275,7 +275,6 @@ font-technology Variable Μεταβλητές Update While Dragging Ενημέρωση κατά τη μεταφορά Hide Video Rulers Απόκρυψη χαράκων βίντεο Background Contents Περιεχόμενα φόντου - Background Contents: Περιεχόμενο φόντου: Background: Φόντο: Backspace Backspace Bar Graph Tool Ραβδόγραμμα (Bar Graph Tool) @@ -628,7 +627,6 @@ font-technology Variable Μεταβλητές Couldn't {} {name}: {e} Δεν ήταν δυνατή η {} του {name}: {e} Create Δημιουργία Create Clipping Mask Δημιουργία μάσκας αποκοπής - Create Document Δημιουργία εγγράφου Create from Guides Δημιουργία από οδηγούς Create from Selection Δημιουργία από επιλογή Create From: Δημιουργία από: @@ -2074,7 +2072,6 @@ font-technology Variable Μεταβλητές Temperature Θερμοκρασία Template Πρότυπο Templates Πρότυπα - Templates… Πρότυπα… Threshold Κατώφλι Tile Παράθεση Tint Χρωματική απόχρωση @@ -2902,7 +2899,6 @@ theme Appearance Εμφάνιση New Color Group… Νέα ομάδα χρωμάτων… New Document Presets Νέες προεπιλογές εγγράφου New file Νέο αρχείο - New from Template Νέο από πρότυπο New from Template… Νέο από πρότυπο… New Graphic Style Νέο στυλ γραφικών New Graphic Style (Alt-click to name it) Νέο στυλ γραφικών (Alt-κλικ για ονομασία) @@ -3466,6 +3462,7 @@ digits Malayalam Μαλαγιάλαμ Save PDF Preset Αποθήκευση προεπιλογής PDF Save Perspective Grid Preset Αποθήκευση προεπιλογής πλέγματος προοπτικής Save Preset Αποθήκευση προεπιλογής + Save Document Preset Αποθήκευση προεπιλογής εγγράφου Save Print Preset Αποθήκευση προεπιλογής εκτύπωσης Save Recovery Data Αποθήκευση δεδομένων ανάκτησης Save Save for Web Preset Αποθήκευση προεπιλογής για ιστό diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 9d50594110..f8ce0608a9 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -297,7 +297,6 @@ font-technology Variable Variables Update While Dragging Actualizar al arrastrar Hide Video Rulers Ocultar reglas de video Background Contents Contenido de fondo - Background Contents: Contenido de fondo: Background: Fondo: Backspace Retroceso Bar Graph Tool Herramienta Gráfico de barras @@ -669,7 +668,6 @@ font-technology Variable Variables Couldn't {} {name}: {e} No se pudo {} {name}: {e} Create Crear Create Clipping Mask Crear máscara de recorte - Create Document Crear documento Create from Guides Crear a partir de guías Create from Selection Crear a partir de la selección Create From: Crear a partir de: @@ -2146,7 +2144,6 @@ font-technology Variable Variables New Color Group… Nuevo grupo de colores… New Document Presets Preajustes de documento nuevo New file Archivo nuevo - New from Template Nuevo a partir de plantilla New from Template… Nuevo a partir de plantilla… New Graphic Style Nuevo estilo gráfico New Graphic Style (Alt-click to name it) Nuevo estilo gráfico (Alt+clic para darle nombre) @@ -2933,6 +2930,7 @@ digits Malayalam Malayálam Save PDF Preset Guardar preajuste de PDF Save Perspective Grid Preset Guardar preajuste de cuadrícula de perspectiva Save Preset Guardar preajuste + Save Document Preset Guardar preajuste de documento Save Preset… Guardar preajuste… Save Print Preset Guardar preajuste de impresión Save Recovery Data Guardar datos de recuperación @@ -3510,7 +3508,6 @@ digits Malayalam Malayálam Template Plantilla Templates Plantillas Templates Folder Carpeta de plantillas - Templates… Plantillas… Text Displayed in Cell: Texto mostrado en la celda: Text Export Options Opciones de exportación de texto Text Fill & Stroke Color Color de relleno y trazo del texto diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index dbdf5d111e..e8e3b33fd1 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -2506,15 +2506,12 @@ digits Malayalam Malajalam Selected Swatches Valitut värimallit Advanced Options Lisäasetukset Background Contents Taustan sisältö - Background Contents: Taustan sisältö: BLANK DOCUMENT PRESETS TYHJÄN ASIAKIRJAN ESIASETUKSET Bleed Leikkuuvara Change to Right-to-Left Layout Vaihda oikealta vasemmalle -asetteluun Columns: Sarakkeet: - Create Document Luo asiakirja Documents you create appear here. Luomasi asiakirjat näkyvät tässä. More Settings Lisää asetuksia - New from Template Uusi mallista Number of Artboards: Piirtoalustojen määrä: Orientation: Suunta: PRESET DETAILS ESIASETUKSEN TIEDOT @@ -2525,7 +2522,6 @@ digits Malayalam Malajalam RECENT VIIMEISIMMÄT RECENT (1) VIIMEISIMMÄT (1) SAVED TALLENNETUT - Templates… Mallit… Transparent (Default) Läpinäkyvä (oletus) Save for Office Documents Tallenna Office-asiakirjoja varten Transparent Background Läpinäkyvä tausta @@ -3474,6 +3470,7 @@ theme Appearance Ulkoasu Arrange by Row Järjestä riveittäin Arrange by Column Järjestä sarakkeittain Save Preset Tallenna esiasetus + Save Document Preset Tallenna asiakirjan esiasetus Screen (72 ppi) Näyttö (72 ppi) Social Square Post 1080×1080 Neliömäinen somejulkaisu 1080×1080 Distribute Jaa tasaisesti diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 7c6d1fb0e9..4cafac1f6b 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -298,7 +298,6 @@ font-technology Variable Variables Update While Dragging Mettre à jour pendant le déplacement Hide Video Rulers Masquer les règles vidéo Background Contents Contenu de l’arrière-plan - Background Contents: Contenu de l’arrière-plan : Background: Arrière-plan : Backspace Retour arrière Bar Graph Tool Outil Graphe à barres @@ -670,7 +669,6 @@ font-technology Variable Variables Couldn't {} {name}: {e} Impossible de {} {name} : {e} Create Créer Create Clipping Mask Créer un masque d’écrêtage - Create Document Créer un document Create from Guides Créer à partir des repères Create from Selection Créer à partir de la sélection Create From: Créer à partir de : @@ -2138,7 +2136,6 @@ font-technology Variable Variables New Color Group… Nouveau groupe de couleurs… New Document Presets Paramètres prédéfinis de nouveau document New file Nouveau fichier - New from Template Nouveau d’après un modèle New from Template… Nouveau d’après un modèle… New Graphic Style Nouveau style graphique New Graphic Style (Alt-click to name it) Nouveau style graphique (Alt+clic pour le nommer) @@ -2925,6 +2922,7 @@ digits Malayalam Malayalam Save PDF Preset Enregistrer le paramètre prédéfini PDF Save Perspective Grid Preset Enregistrer le paramètre prédéfini de grille de perspective Save Preset Enregistrer le paramètre prédéfini + Save Document Preset Enregistrer le paramètre prédéfini du document Save Preset… Enregistrer le paramètre prédéfini… Save Print Preset Enregistrer le paramètre prédéfini d’impression Save Recovery Data Enregistrer les données de récupération @@ -3502,7 +3500,6 @@ digits Malayalam Malayalam Template Modèle Templates Modèles Templates Folder Dossier des modèles - Templates… Modèles… Text Displayed in Cell: Texte affiché dans la cellule : Text Export Options Options d’exportation du texte Text Fill & Stroke Color Couleur de fond et de contour du texte diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 1dac03decb..9cbd1121af 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -491,7 +491,6 @@ antiAlias Sharp शार्प Back वापस Background Color: बैकग्राउंड रंग: Background Contents बैकग्राउंड सामग्री - Background Contents: बैकग्राउंड सामग्री: Background Level बैकग्राउंड स्तर Background: बैकग्राउंड: Backspace Backspace @@ -1029,7 +1028,6 @@ antiAlias Sharp शार्प Create Clipping Mask क्लिपिंग मास्क बनाएँ Create Compound Path From Ellipses एलिप्स से कंपाउंड पाथ बनाएँ Create Dimension आयाम बनाएँ - Create Document डॉक्यूमेंट बनाएं Create from Guides गाइड से बनाएँ Create from Selection सिलेक्शन से बनाएँ Create From: से बनाएँ: @@ -2836,7 +2834,6 @@ antiAlias Sharp शार्प New Design नई डिजाइन New Document Presets नया डॉक्यूमेंट प्रीसेट New file नई फ़ाइल - New from Template टेम्पलेट से नया New from Template… टेम्पलेट से नया… New Graphic Style नई ग्राफिक स्टाइल New Graphic Style (Alt-click to name it) नया ग्राफ़िक स्टाइल (नाम देने के लिए Alt-क्लिक करें) @@ -3957,6 +3954,7 @@ antiAlias Sharp शार्प Save PDF Preset PDF प्रीसेट सेव करें Save Perspective Grid Preset सेव पर्सपेक्टिव ग्रिड प्रीसेट Save Preset प्रीसेट सेव करें + Save Document Preset डॉक्यूमेंट प्रीसेट सेव करें Save Preset… प्रीसेट सेव करें… Save Print Preset सेव करें प्रिंट प्रीसेट Save Recovery Data रिकवरी डेटा सेव करें @@ -4736,7 +4734,6 @@ antiAlias Sharp शार्प Template literal not closed टेम्पलेट साक्षर नहीं बंद Templates टेम्पलेट्स Templates Folder टेम्पलेट्स फ़ोल्डर - Templates… टेम्पलेट्स… Tetrad टेट्राड Tetrad 2 टेट्राड 2 Tetrad 3 टेट्राड 3 diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 87842df37e..601bfac2ec 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -393,7 +393,6 @@ font-technology Variable Változó Update While Dragging Frissítés húzás közben Hide Video Rulers Videovonalzók elrejtése Background Contents Háttértartalom - Background Contents: Háttértartalom: Background: Háttér: Backspace Backspace Balance Egyensúly @@ -864,7 +863,6 @@ font-technology Variable Változó Craquelure… Repedezettség… Create Létrehozás Create Clipping Mask Vágómaszk létrehozása - Create Document Dokumentum létrehozása Create from Guides Létrehozás segédvonalakból Create from Selection Létrehozás a kijelölésből Create From: Létrehozás ebből: @@ -2598,7 +2596,6 @@ file-filter Templates Sablonok New Data Set… Új adatkészlet… New Document Presets Új dokumentum előbeállításai New file Új fájl - New from Template Új sablonból New from Template… Új sablonból… New Graphic Style Új grafikai stílus New Graphic Style (Alt-click to name it) Új grafikai stílus (Alt+kattintás: elnevezéssel) @@ -3516,6 +3513,7 @@ mark-style Japanese Japán Save PDF Preset PDF-előbeállítás mentése Save Perspective Grid Preset Perspektivikus rács előbeállításának mentése Save Preset Előbeállítás mentése + Save Document Preset Dokumentum-előbeállítás mentése Preset {number} Előbeállítás {number} Save Preset… Előbeállítás mentése… Save Print Preset Nyomtatási előbeállítás mentése @@ -4164,7 +4162,6 @@ mark-style Japanese Japán Template Sablon Templates Sablonok Templates Folder Sablonok mappája - Templates… Sablonok… Tetrad Tetrád Tetrad 2 Tetrád 2 Tetrad 3 Tetrád 3 diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 476a6988f5..9ed84d0cb2 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -2350,15 +2350,12 @@ digits Malayalam Malayalam Selected Swatches Swatch terpilih Advanced Options Opsi lanjutan Background Contents Konten latar belakang - Background Contents: Konten latar belakang: BLANK DOCUMENT PRESETS PRESET DOKUMEN KOSONG Bleed Bleed Change to Right-to-Left Layout Ubah ke tata letak kanan ke kiri Columns: Kolom: - Create Document Buat dokumen Documents you create appear here. Dokumen yang Anda buat muncul di sini. More Settings Pengaturan lainnya - New from Template Baru dari templat Number of Artboards: Jumlah artboard: Orientation: Orientasi: PRESET DETAILS DETAIL PRESET @@ -2369,7 +2366,6 @@ digits Malayalam Malayalam RECENT TERKINI RECENT (1) TERKINI (1) SAVED TERSIMPAN - Templates… Templat… Transparent (Default) Transparan (default) Save for Office Documents Simpan untuk dokumen Office Transparent Background Latar belakang transparan @@ -3273,6 +3269,7 @@ theme Appearance Tampilan Arrange by Row Susun per baris Arrange by Column Susun per kolom Save Preset Simpan preset + Save Document Preset Simpan preset dokumen Screen (72 ppi) Layar (72 ppi) Social Square Post 1080×1080 Postingan persegi media sosial 1080×1080 Distribute Distribusikan diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index f10e1f272e..c3815cea48 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -297,7 +297,6 @@ font-technology Variable Variabili Update While Dragging Aggiorna durante il trascinamento Hide Video Rulers Nascondi righelli video Background Contents Contenuto sfondo - Background Contents: Contenuto sfondo: Background: Sfondo: Backspace Backspace Bar Graph Tool Strumento Grafico a barre @@ -669,7 +668,6 @@ font-technology Variable Variabili Couldn't {} {name}: {e} Impossibile {} {name}: {e} Create Crea Create Clipping Mask Crea maschera di ritaglio - Create Document Crea documento Create from Guides Crea da guide Create from Selection Crea dalla selezione Create From: Crea da: @@ -2175,7 +2173,6 @@ file-filter Templates Modelli New Color Group… Nuovo gruppo colori… New Document Presets Preset nuovo documento New file Nuovo file - New from Template Nuovo da modello New from Template… Nuovo da modello… New Graphic Style Nuovo stile grafico New Graphic Style (Alt-click to name it) Nuovo stile grafico (Alt+clic per assegnare un nome) @@ -2967,6 +2964,7 @@ digits Malayalam Malayalam Save PDF Preset Salva preset PDF Save Perspective Grid Preset Salva preset griglia prospettica Save Preset Salva preset + Save Document Preset Salva preset documento Preset {number} Preset {number} Save Preset… Salva preset… Save Print Preset Salva preset di stampa @@ -3546,7 +3544,6 @@ digits Malayalam Malayalam Template Modello Templates Modelli Templates Folder Cartella modelli - Templates… Modelli… Text Displayed in Cell: Testo visualizzato nella cella: Text Export Options Opzioni di esportazione testo Text Fill & Stroke Color Colore riempimento e traccia testo diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index b2c2b37a0f..165372bcb9 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -1076,7 +1076,6 @@ antiAlias Sharp シャープ Update While Dragging ドラッグ中に更新 Hide Video Rulers ビデオ定規を隠す Background Contents 背景のカラー - Background Contents: 背景のカラー: Background: 背景: Bar Graph Tool 棒グラフツール Baseline Shift ベースラインシフト @@ -1302,7 +1301,6 @@ antiAlias Sharp シャープ Corners… コーナー… Chamfer 面取り Create Clipping Mask クリッピングマスクを作成 - Create Document ドキュメントを作成 Create From: 作成元: Create Graph グラフを作成 Create New Layer 新規レイヤーを作成 @@ -2362,7 +2360,6 @@ antiAlias Sharp シャープ New Symbol… 新規シンボル… New Workspace 新規ワークスペース New file 新規ファイル - New from Template テンプレートから新規作成 New theme from the current color 現在のカラーから新規テーマ New {kind} Style… 新規{kind}スタイル… Next 次へ @@ -2905,6 +2902,7 @@ mark-style Japanese 日本式 Save PDF Preset PDF プリセットを保存 Save Perspective Grid Preset 遠近グリッドプリセットを保存 Save Preset プリセットを保存 + Save Document Preset ドキュメントプリセットを保存 Preset {number} プリセット {number} Save Preset… 保存… Save Print Preset プリントプリセットを保存 @@ -3253,7 +3251,6 @@ mark-style Japanese 日本式 Target Contents 内容をターゲット Target: ターゲット: Templates Folder テンプレートフォルダー - Templates… テンプレート… Text Displayed in Cell: セルに表示するテキスト: Text Export Options テキスト書き出しオプション Text Import Options テキスト読み込みオプション diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index a625c7bf51..4ba0bef7c3 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -2252,15 +2252,12 @@ digits Malayalam მალაიალამური Selected Swatches მონიშნული ფერის ნიმუშები Advanced Options დამატებითი პარამეტრები Background Contents ფონის შიგთავსი - Background Contents: ფონის შიგთავსი: BLANK DOCUMENT PRESETS ცარიელი დოკუმენტის პრესეტები Bleed ჭრისპირა არე Change to Right-to-Left Layout მარჯვნიდან მარცხნივ განლაგებაზე გადასვლა Columns: სვეტები: - Create Document დოკუმენტის შექმნა Documents you create appear here. თქვენ მიერ შექმნილი დოკუმენტები აქ გამოჩნდება. More Settings მეტი პარამეტრი - New from Template შაბლონიდან შექმნა Number of Artboards: სამუშაო დაფების რაოდენობა: Orientation: ორიენტაცია: PRESET DETAILS პრესეტის დეტალები @@ -2270,7 +2267,6 @@ digits Malayalam მალაიალამური Preview Mode გადახედვის რეჟიმი RECENT ბოლოდროინდელი SAVED შენახული - Templates… შაბლონები… Transparent (Default) გამჭვირვალე (ნაგულისხმევი) Save for Office Documents შენახვა Office-ის დოკუმენტებისთვის Transparent Background გამჭვირვალე ფონი @@ -3095,6 +3091,7 @@ theme Appearance იერსახე Arrange by Row სტრიქონების მიხედვით დალაგება Arrange by Column სვეტების მიხედვით დალაგება Save Preset პრესეტის შენახვა + Save Document Preset დოკუმენტის პრესეტის შენახვა Screen (72 ppi) ეკრანი (72 ppi) Social Square Post 1080×1080 კვადრატული პოსტი 1080×1080 Distribute განაწილება diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index e281e83162..3303149a80 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -1066,7 +1066,6 @@ antiAlias Sharp 날카롭게 Update While Dragging 드래그하는 동안 업데이트 Hide Video Rulers 비디오 눈금자 숨기기 Background Contents 배경 내용 - Background Contents: 배경 내용: Background: 배경: Bar Graph Tool 막대 그래프 도구 Baseline Shift 기준선 이동 @@ -1292,7 +1291,6 @@ antiAlias Sharp 날카롭게 Corners… 모퉁이… Chamfer 모따기 Create Clipping Mask 클리핑 마스크 만들기 - Create Document 문서 만들기 Create From: 만들기 기준: Create Graph 그래프 만들기 Create New Layer 새 레이어 만들기 @@ -2352,7 +2350,6 @@ antiAlias Sharp 날카롭게 New Symbol… 새 심볼… New Workspace 새 작업 영역 New file 새 파일 - New from Template 템플릿에서 새로 만들기 New theme from the current color 현재 색상으로 새 테마 만들기 New {kind} Style… 새 {kind} 스타일… Next 다음 @@ -2894,6 +2891,7 @@ mark-style Japanese 일본식 Save PDF Preset PDF 사전 설정 저장 Save Perspective Grid Preset 원근 격자 사전 설정 저장 Save Preset 사전 설정 저장 + Save Document Preset 문서 사전 설정 저장 Preset {number} 사전 설정 {number} Save Preset… 사전 설정 저장… Save Print Preset 인쇄 사전 설정 저장 @@ -3242,7 +3240,6 @@ mark-style Japanese 일본식 Target Contents 대상 내용 Target: 대상: Templates Folder 템플릿 폴더 - Templates… 템플릿… Text Displayed in Cell: 셀에 표시할 텍스트: Text Export Options 텍스트 내보내기 옵션 Text Import Options 텍스트 가져오기 옵션 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 073203ca3d..ec86634e20 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -2364,15 +2364,12 @@ digits Malayalam Malayalam Selected Swatches Geselecteerde stalen Advanced Options Geavanceerde opties Background Contents Achtergrondinhoud - Background Contents: Achtergrondinhoud: BLANK DOCUMENT PRESETS LEGE DOCUMENTVOORINSTELLINGEN Bleed Afrulling Change to Right-to-Left Layout Omschakelen naar lay-out van rechts naar links Columns: Kolommen: - Create Document Document maken Documents you create appear here. Documenten die je maakt, verschijnen hier. More Settings Meer instellingen - New from Template Nieuw uit sjabloon Number of Artboards: Aantal tekenborden: Orientation: Oriëntatie: PRESET DETAILS VOORINSTELLINGSDETAILS @@ -2383,7 +2380,6 @@ digits Malayalam Malayalam RECENT RECENT RECENT (1) RECENT (1) SAVED OPGESLAGEN - Templates… Sjablonen… Transparent (Default) Transparant (standaard) Save for Office Documents Opslaan voor Office-documenten Transparent Background Transparante achtergrond @@ -3287,6 +3283,7 @@ theme Appearance Vormgeving Arrange by Row Ordenen op rij Arrange by Column Ordenen op kolom Save Preset Voorinstelling opslaan + Save Document Preset Documentvoorinstelling opslaan Screen (72 ppi) Scherm (72 ppi) Social Square Post 1080×1080 Vierkante socialmediapost 1080×1080 Distribute Verdelen diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 8d1be1679e..7b1195fe73 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -2476,15 +2476,12 @@ digits Malayalam Malajalam Selected Swatches Zaznaczone próbki Advanced Options Opcje zaawansowane Background Contents Zawartość tła - Background Contents: Zawartość tła: BLANK DOCUMENT PRESETS USTAWIENIA PUSTEGO DOKUMENTU Bleed Spad Change to Right-to-Left Layout Zmień na układ od prawej do lewej Columns: Kolumny: - Create Document Utwórz dokument Documents you create appear here. Tu pojawią się utworzone dokumenty. More Settings Więcej ustawień - New from Template Nowy z szablonu Number of Artboards: Liczba obszarów kompozycji: Orientation: Orientacja: PRESET DETAILS SZCZEGÓŁY USTAWIENIA @@ -2494,7 +2491,6 @@ digits Malayalam Malajalam Preview Mode Tryb podglądu RECENT OSTATNIE SAVED ZAPISANE - Templates… Szablony… Transparent (Default) Przezroczyste (domyślnie) Save for Office Documents Zapisz dla dokumentów biurowych Transparent Background Przezroczyste tło @@ -3415,6 +3411,7 @@ theme Appearance Wygląd Arrange by Row Ułóż według wierszy Arrange by Column Ułóż według kolumn Save Preset Zapisz ustawienie + Save Document Preset Zapisz ustawienie dokumentu Screen (72 ppi) Ekran (72 ppi) Social Square Post 1080×1080 Kwadratowy post społecznościowy 1080×1080 Distribute Rozmieść diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index ebe44a68cf..b8989eee99 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -283,7 +283,6 @@ antiAlias Sharp Nítido Update While Dragging Atualizar ao Arrastar Hide Video Rulers Ocultar Réguas de Vídeo Background Contents Conteúdo do Plano de Fundo - Background Contents: Conteúdo do Plano de Fundo: Background: Plano de Fundo: Bar Graph Tool Ferramenta Gráfico de Barras Baseline Shift Deslocamento da Linha de Base @@ -596,7 +595,6 @@ antiAlias Sharp Nítido Corners… Cantos… Create Criar Create Clipping Mask Criar Máscara de Recorte - Create Document Criar Documento Create From: Criar De: Create Gradient Mesh… Criar Malha de Gradiente… Create Graph Criar Gráfico @@ -1792,7 +1790,6 @@ antiAlias Sharp Nítido Duplicate Toolbar Duplicar Barra de Ferramentas Delete Toolbar Excluir Barra de Ferramentas New file Novo arquivo - New from Template Novo a partir de Modelo New from Template… Novo a partir de Modelo… New theme from the current color Novo tema a partir da cor atual New {kind} Style… Novo Estilo de {kind}… @@ -2457,6 +2454,7 @@ digits Malayalam Malaiala Save PDF Preset Salvar Predefinido de PDF Save Perspective Grid Preset Salvar Predefinido de Grade de Perspectiva Save Preset Salvar Predefinido + Save Document Preset Salvar Predefinido de Documento Save Preset… Salvar Predefinido… Save Print Preset Salvar Predefinido de Impressão Save Recovery Data Salvar Dados de Recuperação @@ -2880,7 +2878,6 @@ digits Malayalam Malaiala Target: Alvo: Temperature/Tint… Temperatura/Tom… Templates Folder Pasta de Modelos - Templates… Modelos… Text Displayed in Cell: Texto Exibido na Célula: Text Export Options Opções de Exportação de Texto Text Fill & Stroke Color Cor de Preenchimento e Traçado do Texto diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index dc6402e831..453594a196 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -295,7 +295,6 @@ font-technology Variable Вариативные Update While Dragging Обновлять при перетаскивании Hide Video Rulers Скрыть видео-линейки Background Contents Содержимое фона - Background Contents: Содержимое фона: Background: Фон: Backspace Backspace Bar Graph Tool Инструмент «Гистограмма» @@ -667,7 +666,6 @@ font-technology Variable Вариативные Couldn't {} {name}: {e} Не удалось {} {name}: {e} Create Создать Create Clipping Mask Создать маску обрезки - Create Document Создать документ Create from Guides Создать из направляющих Create from Selection Создать из выделения Create From: Создать из: @@ -2170,7 +2168,6 @@ file-filter Templates Шаблоны New Color Group… Новая цветовая группа… New Document Presets Наборы новых документов New file Новый файл - New from Template Создать из шаблона New from Template… Создать из шаблона… New Graphic Style Новый графический стиль New Graphic Style (Alt-click to name it) Новый графический стиль (Alt+щелчок, чтобы задать имя) @@ -2962,6 +2959,7 @@ digits Malayalam Малаялам Save PDF Preset Сохранить набор PDF Save Perspective Grid Preset Сохранить набор сетки перспективы Save Preset Сохранить набор + Save Document Preset Сохранить набор документа Preset {number} Набор {number} Save Preset… Сохранить набор… Save Print Preset Сохранить набор печати @@ -3541,7 +3539,6 @@ digits Malayalam Малаялам Template Шаблон Templates Шаблоны Templates Folder Папка шаблонов - Templates… Шаблоны… Text Displayed in Cell: Текст, отображаемый в ячейке: Text Export Options Параметры экспорта текста Text Fill & Stroke Color Цвет заливки и обводки текста diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index a8ce1904d1..b29bb93899 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -286,7 +286,6 @@ font-technology Variable Variabla Update While Dragging Uppdatera vid dragning Hide Video Rulers Dölj videolinjaler Background Contents Bakgrundsinnehåll - Background Contents: Bakgrundsinnehåll: Background: Bakgrund: Backspace Backspace Bar Graph Tool Stapeldiagramverktyg @@ -656,7 +655,6 @@ font-technology Variable Variabla Couldn't {} {name}: {e} Det gick inte att {} {name}: {e} Create Skapa Create Clipping Mask Skapa klippmask - Create Document Skapa dokument Create from Guides Skapa från stödlinjer Create from Selection Skapa från markering Create From: Skapa från: @@ -2085,7 +2083,6 @@ font-technology Variable Variabla New Color Group… Ny färggrupp… New Document Presets Förinställningar för nytt dokument New file Ny fil - New from Template Ny från mall New from Template… Ny från mall… New Graphic Style Ny grafikstil New Graphic Style (Alt-click to name it) Ny grafikstil (Alt-klicka för att namnge den) @@ -2869,6 +2866,7 @@ digits Malayalam Malayalam Save PDF Preset Spara PDF-förinställning Save Perspective Grid Preset Spara förinställning för perspektivrutnät Save Preset Spara förinställning + Save Document Preset Spara dokumentförinställning Save Preset… Spara förinställning… Save Print Preset Spara utskriftsförinställning Save Recovery Data Spara återställningsdata @@ -3432,7 +3430,6 @@ digits Malayalam Malayalam Template Mall Templates Mallar Templates Folder Mallmapp - Templates… Mallar… Text Displayed in Cell: Text som visas i cellen: Text Export Options Alternativ för textexport Text Fill & Stroke Color Textens fyllnads- och konturfärg diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index c2804006cd..4e7619b7e1 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -282,7 +282,6 @@ font-technology Variable Değişken Update While Dragging Sürüklerken Güncelle Hide Video Rulers Video Cetvellerini Gizle Background Contents Arka Plan İçeriği - Background Contents: Arkaplan İçeriği: Background: Arka plan: Backspace Geri tuşu Bar Graph Tool Çubuk Grafik Aracı @@ -648,7 +647,6 @@ font-technology Variable Değişken Couldn't {} {name}: {e} {} yapılamadı {name}: {e} Create Oluştur Create Clipping Mask Kırpma Maskesi Oluştur - Create Document Belge Oluştur Create from Guides Rehberlerden Oluştur Create from Selection Seçimden Oluştur Create From: Şuradan Oluştur: @@ -1958,7 +1956,6 @@ font-technology Variable Değişken New Color Group… Yeni Renk Grubu… New Document Presets Yeni Belge Ön Ayarları New file Yeni dosya - New from Template Şablondan yeni New from Template… Şablondan yeni… New Graphic Style Yeni Grafik Stili New Graphic Style (Alt-click to name it) Yeni Grafik Stili (Adını vermek için Alt tuşuna basarak tıklayın) @@ -2734,6 +2731,7 @@ digits Malayalam Malayalam Save PDF Preset PDF Hazır Ayarını Kaydet Save Perspective Grid Preset Perspektif Izgarası Ön Ayarını Kaydet Save Preset Ön Ayarı Kaydet + Save Document Preset Belge Ön Ayarını Kaydet Save Preset… Ön Ayarı Kaydet… Save Print Preset Yazdırma Ön Ayarını Kaydet Save Recovery Data Kurtarma Verilerini Kaydet @@ -3323,7 +3321,6 @@ digits Malayalam Malayalam Template Şablon Templates Şablonlar Templates Folder Şablonlar Klasörü - Templates… Şablonlar… Text Displayed in Cell: Hücrede Görüntülenen Metin: Text Export Options Metin Dışa Aktarma Seçenekleri Text Fill & Stroke Color Metin Dolgu ve Kontur Rengi diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 0108c96a50..5729680e69 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -291,7 +291,6 @@ font-technology Variable Варіативні Update While Dragging Оновлювати під час перетягування Hide Video Rulers Приховати відео-лінійки Background Contents Вміст фону - Background Contents: Вміст фону: Background: Фон: Backspace Backspace Bar Graph Tool Інструмент «Гістограма» @@ -663,7 +662,6 @@ font-technology Variable Варіативні Couldn't {} {name}: {e} Не вдалося {} {name}: {e} Create Створити Create Clipping Mask Створити відсічну маску - Create Document Створити документ Create from Guides Створити з напрямних Create from Selection Створити із виділення Create From: Створити з: @@ -2167,7 +2165,6 @@ file-filter Templates Шаблони New Color Group… Нова колірна група… New Document Presets Набір нових документів New file Новий файл - New from Template Створити із шаблону New from Template… Створити із шаблону… New Graphic Style Новий графічний стиль New Graphic Style (Alt-click to name it) Новий графічний стиль (Alt+клацання, щоб задати ім’я) @@ -2959,6 +2956,7 @@ digits Malayalam Малаялам Save PDF Preset Зберегти набір PDF Save Perspective Grid Preset Зберегти набір сітки перспективи Save Preset Зберегти набір + Save Document Preset Зберегти набір документа Preset {number} Набір {number} Save Preset… Зберегти набір… Save Print Preset Зберегти набір друку @@ -3539,7 +3537,6 @@ effect Points Точки Template Шаблон Templates Шаблони Templates Folder Папка шаблонів - Templates… Шаблони… Text Displayed in Cell: Текст, що відображається в комірці: Text Export Options Параметри експорту тексту Text Fill & Stroke Color Колір заливки та обведення тексту diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 5b979b003f..ea1d2ed9ec 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -320,7 +320,6 @@ font-technology Variable Biến thiên Update While Dragging Cập nhật khi kéo Hide Video Rulers Ẩn thước video Background Contents Nội dung nền - Background Contents: Nội dung nền: Background: Nền: Backspace Backspace Balance Cân bằng @@ -765,7 +764,6 @@ font-technology Variable Biến thiên Create Tạo Create Clipping Mask Tạo mặt nạ cắt Create Compound Path From Ellipses Tạo đường dẫn phức từ hình elip - Create Document Tạo tài liệu Create from Guides Tạo từ đường căn Create from Selection Tạo từ vùng chọn Create From: Tạo từ: @@ -2408,7 +2406,6 @@ digits Hindi Hindi New Design Thiết kế mới New Document Presets Thiết lập sẵn tài liệu mới New file Tệp mới - New from Template Mới từ mẫu New from Template… Mới từ mẫu… New Graphic Style Kiểu đồ họa mới New Graphic Style (Alt-click to name it) Kiểu đồ họa mới (Alt-bấm để đặt tên) @@ -3272,6 +3269,7 @@ mark-style Japanese Kiểu Nhật Save PDF Preset Lưu thiết lập sẵn PDF Save Perspective Grid Preset Lưu thiết lập sẵn lưới phối cảnh Save Preset Lưu thiết lập sẵn + Save Document Preset Lưu thiết lập sẵn tài liệu Preset {number} Thiết lập sẵn {number} Save Preset… Lưu thiết lập sẵn… Save Print Preset Lưu thiết lập sẵn in @@ -3873,7 +3871,6 @@ mark-style Japanese Kiểu Nhật Template Mẫu Templates Mẫu Templates Folder Thư mục mẫu - Templates… Mẫu… Text Văn bản Text Displayed in Cell: Văn bản hiển thị trong ô: Text Export Options Tùy chọn xuất văn bản diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index d7b40b95d3..7ce4da2fd9 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -243,7 +243,6 @@ antiAlias Sharp 锐利 Update While Dragging 拖动时更新 Hide Video Rulers 隐藏视频标尺 Background Contents 背景内容 - Background Contents: 背景内容: Background: 背景: Backspace Backspace Bar Graph Tool 条形图工具 @@ -614,7 +613,6 @@ antiAlias Sharp 锐利 Couldn't {} {name}: {e} 无法{} {name}:{e} Create 创建 Create Clipping Mask 创建剪切蒙版 - Create Document 创建文档 Create from Guides 从参考线创建 Create from Selection 从选区创建 Create From: 创建自: @@ -2044,7 +2042,6 @@ file-filter Templates 模板 New Color Group… 新建颜色组… New Document Presets 新建文档预设 New file 新建文件 - New from Template 从模板新建 New from Template… 从模板新建… New Graphic Style 新建图形样式 New Graphic Style (Alt-click to name it) 新建图形样式 (Alt+点击可命名) @@ -2828,6 +2825,7 @@ digits Malayalam 马拉雅拉姆数字 Save PDF Preset 保存 PDF 预设 Save Perspective Grid Preset 存储透视网格预设 Save Preset 保存预设 + Save Document Preset 保存文档预设 Preset {number} 预设 {number} Save Preset… 存储预设… Save Print Preset 保存打印预设 @@ -3374,7 +3372,6 @@ digits Malayalam 马拉雅拉姆数字 Template 模板 Templates 模板 Templates Folder 模板文件夹 - Templates… 模板… Text Displayed in Cell: 单元格中显示的文字: Text Export Options 文本导出选项 Text Fill & Stroke Color 文字填色和描边颜色 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 9e27d9c7b7..961118d556 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -256,7 +256,6 @@ font-technology Variable 可變 Update While Dragging 拖曳時更新 Hide Video Rulers 隱藏視訊尺標 Background Contents 背景內容 - Background Contents: 背景內容: Background: 背景: Backspace Backspace Bar Graph Tool 橫條圖工具 @@ -631,7 +630,6 @@ font-technology Variable 可變 Couldn't {} {name}: {e} 無法{} {name}:{e} Create 建立 Create Clipping Mask 建立剪裁遮色片 - Create Document 建立文件 Create from Guides 從參考線建立 Create from Selection 從選取範圍建立 Create From: 建立來源: @@ -3064,7 +3062,6 @@ file-filter Templates 範本 New Color Group… 新增顏色群組… New Document Presets 新增文件預設集 New file 新增檔案 - New from Template 從範本新增 New from Template… 從範本新增… New Graphic Style 新增繪圖樣式 New Graphic Style (Alt-click to name it) 新增繪圖樣式 (Alt+點擊可命名) @@ -3861,6 +3858,7 @@ digits Malayalam 馬來亞拉姆數字 Save PDF Preset 儲存 PDF 預設集 Save Perspective Grid Preset 儲存透視格點預設集 Save Preset 儲存預設集 + Save Document Preset 儲存文件預設集 Preset {number} 預設集 {number} Save Preset… 儲存預設集… Save Print Preset 儲存列印預設集 @@ -4438,7 +4436,6 @@ digits Malayalam 馬來亞拉姆數字 Template 範本 Templates 範本 Templates Folder 範本資料夾 - Templates… 範本… Text Displayed in Cell: 儲存格中顯示的文字: Text Export Options 文字匯出選項 Text Fill & Stroke Color 文字填色與筆畫顏色 diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index 6d7ac9474f..63e311aeae 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -226,7 +226,9 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context) { } // Escape closes a dropdown list or popover open in the dialog (each closes itself on it) and // leaves the dialog open. - let popup = app.ui.dialog.as_ref().is_some_and(|d| egui::Popup::is_any_open(ctx) || crate::dialogs::brush_options::tips_open(d)); + let popup = app.ui.dialog.as_ref().is_some_and(|d| { + egui::Popup::is_any_open(ctx) || crate::dialogs::brush_options::tips_open(d) || crate::dialogs::new_document_popup_open(d) + }); // Escape in Preferences' search field with text clears the field first. if !popup && pressed_anew(ctx, Key::Escape) && !crate::prefs_dialog::clear_search_on_escape(app, ctx) { crate::dialogs::cancel(app); diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 2cb542c6b2..7a4aa6c6ba 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -572,9 +572,15 @@ New Document: File → New… (`file.newDialog`, Cmd+N) opens the `newDocument` `colorMode`, `rasterEffectsPpi`, `previewMode`) plus `category` (the tab: Recent, Saved, Mobile, Web, Print, Film & Video, Art & Illustration, Branding, Social), `advanced` (Advanced Options open), `bleedLinked` and `presetName` (Save Preset's name). Setting `preset` to a name from `file.newPresets` fills the other fields from that preset; -changing a field afterwards makes the settings custom (`preset` becomes ""). More Settings is the `newDocumentMore` -dialog over the same fields (its Profile is `category`). `ui.dialog.confirm` runs `file.new` with the fields (with -`previewMode: "pixel"` the app turns Pixel Preview on); on an error the dialog stays open. +changing a field afterwards makes the settings custom (`preset` becomes ""). The `newDocumentMore` dialog is New +Document with the More Settings pop-up open over it, which edits `artboards` and `artboardLayout` (layout, right-to-left, +spacing and columns). While `__saving` is true, Preset Details shows Save Document Preset with the `presetName` field, +Cancel and Save in place of the document's name and hides Close and Create. `ui.dialog.cancel` closes the More Settings +pop-up first, then leaves saving, and keeps the fields each time. `ui.dialog.confirm` runs `file.new` with the fields +(with `previewMode: "pixel"` the app turns Pixel Preview on); on an error the dialog stays open. While `__saving` is +true, `ui.dialog.confirm` saves the preset and does not create a document; after a save `presetName` is empty, and the +next Save Preset fills it with the first `Preset N` that no saved preset has. `ui.click` on the More Settings button +opens the `newDocumentMore` pop-up. Swatch Conflict: through the app, when the pasted objects' global or spot swatches conflict with the document's (see `clipboard.conflicts`) and the params have no `swatchConflict`, every Paste command opens the `swatchConflict` diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index a93770b88a..b4bcc9f8ed 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -649,6 +649,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Window › New Window (G7, #4116):** New Window (`window.newWindow`) opens another view of the current document ([public source](https://helpx.adobe.com/illustrator/using/viewing-artwork.html) describes showing one document in several windows at once), with its own zoom, center, rotation, artboard and Outline or Preview, as a tab of its own; an edit in one view shows in every view. Tile, Cascade, Float in Window, Float All in Windows and Consolidate All Windows arrange each view as they arrange a document, and Next and Previous Document step through the views. Closing a view leaves the document open; closing its last view closes the document, asking to save it first. The view's number after the name in its tab ("Name:2", while the document has more than one view) is VectorCraft's own. Tests: `crates/ui-egui/src/doc_windows/tests.rs`. - **Document Setup modal handoff:** Document Setup → Edit Artboards applies valid settings, closes the dialog and activates the Artboard tool. Pointer-click tests cover successful handoff, invalid settings, OK, Cancel and Undo in `dialogs/document_setup.rs`. +- **New Document's More Settings and Save Preset:** More Settings opens a pop-up over New Document with only Number of Artboards, the artboard layout, Change to Right-to-Left Layout, Spacing and Columns; Done, Escape or a click outside closes it and keeps the values. Save Preset turns the top of Preset Details into a Save Document Preset heading with the preset's name, Cancel and Save, and hides the document's name, Close and Create until the preset is saved or canceled; nothing in that mode creates a document. Save Preset's default name is the first "Preset N" no saved preset has. VectorCraft's own layout. Harness tests in `dialogs/new_document.rs` cover both. - **Control bar with the Artboard tool:** while the Artboard tool is in use, the Control bar shows the active artboard's controls in place of the object controls: Preset, Portrait and Landscape, New Artboard, Delete Artboard, the Name field, Artboard Options, Rearrange All Artboards, the Transform link, Move and Scale Artwork with Artboard and, while the bar has room, the reference point with X and Y, and W and H with the Constrain Width and Height Proportions link. Each runs the command its Properties or Transform panel counterpart runs (`artboard.setProps`, `artboard.new`, `artboard.delete`, `tool.options {tool: "artboard"}`), and the reference point is the one the Transform panel uses. In an 800-point window the controls up to the Transform link fit, Move and Scale Artwork with Artboard show as their boxes alone with their names in tooltips, and the link opens the Transform panel with the artboard's position and size. Pointer and key tests in `panels/artboards.rs` cover the controls, W about the reference point, the orientation and the name. Public reference: [Using multiple artboards](https://web.archive.org/web/20210925094818/https://helpx.adobe.com/illustrator/using/using-multiple-artboards.html) (archived copy). - **Names of new and renamed artboards, brushes and patterns:** `artboard.new` and `artboard.setProps` trim the white space around the name they are given, as the Artboards panel's rename does, and a blank name gives the artboard its default name, Artboard N for the Nth artboard. `brush.new`, `brush.duplicate`, `brush.options`, `object.pattern.make`, `object.pattern.saveCopy` and `pattern.options` trim the name as the Brush Options dialog and the Pattern Options panel do, and compare the trimmed name with the names there are; a blank name gives the name each command gives when there is none, and Brush Options and Pattern Options keep the name. Tests in `tests/panelcmds.rs`, `tests/brushsym.rs` and `tests/pattern.rs` cover each command. Public references: [Using multiple artboards](https://web.archive.org/web/20210925094818/https://helpx.adobe.com/illustrator/using/using-multiple-artboards.html), [Brushes](https://web.archive.org/web/20251018101754/https://helpx.adobe.com/illustrator/using/brushes.html) and [Create and edit patterns](https://web.archive.org/web/20211031180358/https://helpx.adobe.com/illustrator/using/create-edit-patterns.html) (archived copies). - **Pattern names and swatch names:** `pattern.options` refuses a new name that another pattern, a swatch or a color group has. It takes a name that only the pattern's own swatches have. The reference page does not say what happens when the new name is taken; the refusal follows VectorCraft's rule that swatches and color groups share one namespace (`Document::swatch_name_taken`), which `swatch.new` and `swatch.edit` also follow. A rename gives the new name only to the pattern's swatches that had the old one; any other swatch with the old name keeps it, and art linked to that swatch stays linked. `object.pattern.make` and `object.pattern.saveCopy` number a color group's name as they number a swatch's. Tests in `tests/pattern.rs` cover each command. Public reference: [Create and edit patterns](https://web.archive.org/web/20211031180358/https://helpx.adobe.com/illustrator/using/create-edit-patterns.html) (archived copy). From 965471d0da3ad01b2db9ba2f9cc423dfe723fe72 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Mon, 9 Nov 2026 17:57:48 -0800 Subject: [PATCH 19/36] Docs: Cut's progress line states the segment rule for mixed selections --- docs/target-app-parity.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index e09a517740..8fb376813e 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -579,7 +579,7 @@ since 2026-10-01. - **Clipping paths on their outline (G1):** the selection tools (Selection, Direct Selection, Group Selection), Object Highlighting and a right-click take a clipping path on its outline before the art it clips; Select Behind lists the same; the eyedropper, the tools that act on what is drawn and the selection tools' double-clicks, loaded text and guide release take the art. Doc and tool regression tests; score unchanged. - **Copy and Cut of anchors picked with a lent Direct Selection (#6123):** with the Pen or the Curvature tool, Cmd/Ctrl held lends Direct Selection for the gesture; the anchors and segments it picks are what Edit › Copy and Cut take once the key is let go, as with Direct Selection chosen, while the last anchor the Pen leaves selected as it draws still makes Copy take the whole path (#2122). A Cmd gesture that leaves the selection as it was (a Shift marquee over nothing) picks nothing. Regression test: `tests::draw2::pen_cmd_selected_anchors_are_what_copy_and_cut_take`. -- **Cut of picked anchors (#6361):** Edit › Cut removes direct-selected anchors from their path and from a compound path's member, with the segments attached to them, as Clear does. Picked segments still lose only those segments. Regression tests: `cut_*_anchors_*` in `crates/engine/tests/it/copy_direct_selection.rs`. +- **Cut of picked anchors (#6361):** Edit › Cut removes direct-selected anchors from their path and from a compound path's member, with the segments attached to them, as Clear does. Picked segments still lose only those segments. When a path also has a picked segment, Cut removes and copies only its picked segments and leaves its picked anchors in place, on every contour, as Clear does. Regression tests: `cut_*_anchors_*` and `cut_of_an_anchor_with_a_segment_on_another_contour_leaves_the_source_as_clear_does` in `crates/engine/tests/it/copy_direct_selection.rs`. - **Escape in Selection tool drags:** Escape during a Selection tool move or Alt-drag copy, a bounding-box scale or rotation, a marquee, or a corner widget, pie widget or bracket drag cancels it: the art returns to its state at the press, no copy is left, no undo step is added, the release does nothing, and the tool stays chosen. This is VectorCraft's own behavior. Regression tests: `escape_cancels_*` in `select/tests.rs` and `pie.rs`, and `escape_cancels_a_selection_tool_move_copy_scale_or_rotation` in `tests/selectescape.rs`. - **Global Edit: Shift-click leaves a match out:** while Global Edit is on, a Shift-click with the Selection tool on a highlighted match, or on an object inside a matched group, leaves that match out (`select.globalEditExclude`): it is no longer highlighted and later edits leave it as it is; the object the edit started from stays, and Global Edit ends when fewer than two matches are left ([Update assets quickly with Global Edit](https://helpx.adobe.com/ph_fil/illustrator/how-to/global-editing-sensei.html)). Regression tests: `a_shift_click_*`, `an_object_left_out_*`, `a_group_left_out_*` and `leaving_out_all_but_one_*` in `crates/engine/src/tests/globaledit.rs`. - **Global Edit Options in the Control bar:** while Select › Start Global Edit can run, the Control bar shows a Global Edit Options dropdown that opens the same options as the Properties panel's (Match: Appearance, Size; On Artboards; Include Objects On Canvas; Result(s)); both set the same options. The dropdown is off while Global Edit is on; it sits after Recolor Artwork, ahead of the X/Y/W/H fields ([Edit similar objects globally](https://helpx.adobe.com/illustrator/desktop/manage-objects/edit-objects/edit-similar-objects.html)). Regression test: `the_control_bar_shows_the_global_edit_options` in `crates/ui-egui/src/chrome/tests.rs`. From d8d73495bb135a0c4bcddf26cccf505eff32ec54 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Mon, 9 Nov 2026 18:05:17 -0800 Subject: [PATCH 20/36] Revolve: a group member's blending mode applies to its surface A member of a group with Revolve drew its surface in Normal mode whatever its own blending mode or that of a group it is in. Each mode now applies once, to the member's surface or to the surfaces of the group that has it, nested as the groups are. Expand Appearance keeps every face of a blended member. --- crates/effects/src/revolve.rs | 74 ++++++++++++++++++++++--- crates/effects/src/solids.rs | 39 +++++++++---- crates/effects/src/solids/tests_mesh.rs | 2 +- 3 files changed, 95 insertions(+), 20 deletions(-) diff --git a/crates/effects/src/revolve.rs b/crates/effects/src/revolve.rs index 58125c6618..0a8479d610 100644 --- a/crates/effects/src/revolve.rs +++ b/crates/effects/src/revolve.rs @@ -2,8 +2,8 @@ use std::sync::Arc; use serde_json::Value; -use vectorcraft_doc::color::{Color, Paint}; -use vectorcraft_doc::{Appearance, AppearanceItem, Effect, FillLayer, LineJoin, Node, NodeKind, StrokeLayer}; +use vectorcraft_doc::color::{BlendMode, Color, Paint}; +use vectorcraft_doc::{Appearance, AppearanceItem, Effect, FillLayer, LineJoin, Node, NodeId, NodeKind, StrokeLayer}; use vectorcraft_geom::{PathData, Rect}; use vectorcraft_three_d::{Edge, Revolve}; @@ -431,6 +431,36 @@ fn art(n: &Node, expand: bool) -> Option { Some(art) } +/// The members' `surfaces` in order, from the blending mode at `level` of each one's +/// `Profile::blends` inward: the surfaces of consecutive members that share a group with a mode +/// other than Normal go in one group with that mode, so each mode applies once, at its own level. +fn blended(id: NodeId, surfaces: Vec<(&[(usize, BlendMode)], Node)>, level: usize) -> Vec> { + fn close(id: NodeId, run: &mut Vec<(&[(usize, BlendMode)], Node)>, key: Option<(usize, BlendMode)>, level: usize, out: &mut Vec>) { + if let Some((_, blend)) = key { + let mut group = Node::group(id, blended(id, std::mem::take(run), level + 1)); + group.blend = blend; + out.push(Arc::new(group)); + } + } + let mut out = vec![]; + let mut run = vec![]; + let mut key = None; + for (blends, s) in surfaces { + let at = blends.get(level).copied(); + if at != key { + close(id, &mut run, key, level, &mut out); + key = at; + } + if key.is_some() { + run.push((blends, s)); + } else { + out.push(Arc::new(s)); + } + } + close(id, &mut run, key, level, &mut out); + out +} + /// [`art`] of group or layer `n`: every member swept about one axis on the bounds of all of them, /// each in its own paint. Opaque members' faces are drawn in one back-to-front order. `None` when /// the effect is only on the group's own fills or strokes or when its members can't be revolved. @@ -449,16 +479,19 @@ fn group_art(n: &Node, expand: bool) -> Option { let Some(item) = surface_item(&s.items) else { continue }; let (paint, opacity, blend) = paint_of(item); let opacity = opacity * s.opacity; - opaque &= opacity >= 1.0 && blend == Default::default(); - surfaces.push(surface(n, &s.path, b, options, fx, paint, opacity, blend, expand, &mut copies)?); + opaque &= opacity >= 1.0 && blend == Default::default() && s.blends.is_empty(); + // A member with non-normal blending keeps every face, as a blended object does. + let expand = expand && s.blends.is_empty(); + let (depths, art) = surface(n, &s.path, b, options, fx, paint, opacity, blend, expand, &mut copies)?; + surfaces.push((depths, art, s.blends.as_slice())); } if surfaces.is_empty() { return None; } - let plain = surfaces.iter().all(|(depths, s)| matches!(&s.kind, NodeKind::Group { children, clip: false } if children.len() == depths.len())); + let plain = surfaces.iter().all(|(depths, s, _)| matches!(&s.kind, NodeKind::Group { children, clip: false } if children.len() == depths.len())); let children = if opaque && plain { let mut faces = vec![]; - for (depths, s) in surfaces { + for (depths, s, _) in surfaces { if let NodeKind::Group { children, .. } = s.kind { faces.extend(depths.into_iter().zip(children)); } @@ -467,7 +500,7 @@ fn group_art(n: &Node, expand: bool) -> Option { faces.sort_by(|a, b| a.0.total_cmp(&b.0)); vec![Arc::new(Node::group(n.id, faces.into_iter().map(|(_, face)| face).collect()))] } else { - surfaces.into_iter().map(|(_, s)| Arc::new(s)).collect() + blended(n.id, surfaces.into_iter().map(|(_, s, blends)| (blends, s)).collect(), 0) }; let remaining = n.appearance.effects.iter().enumerate().filter(|(i, e)| *i != index && (!is_geometry(&e.id) || *i > index)).map(|(_, e)| e.clone()).collect(); @@ -717,6 +750,33 @@ mod tests { assert_eq!(revolve_art(&both), None, "past the limits the source draws"); } + #[test] + fn blending_modes_apply_once_to_the_member_or_group_that_has_them() { + let blend = |n: Arc, mode: BlendMode| { + let mut n = (*n).clone(); + n.blend = mode; + Arc::new(n) + }; + let inner = blend(member(4, 60.0), BlendMode::Multiply); + let screened = blend(Arc::new(Node::group(NodeId(5), vec![member(3, 40.0), inner])), BlendMode::Screen); + let group = group_with(json!({}), vec![blend(member(1, 0.0), BlendMode::Multiply), member(2, 20.0), screened]); + let art = revolve_art(&group).expect("revolved"); + let modes = |n: &Node| match &n.kind { + NodeKind::Group { children, .. } => children.iter().map(|c| c.blend).collect::>(), + _ => panic!("a group"), + }; + assert_eq!(modes(&art), [BlendMode::Multiply, BlendMode::Normal, BlendMode::Screen], "a surface or group for each member"); + let NodeKind::Group { children, .. } = &art.kind else { panic!("a group") }; + let screened = &children[2]; + assert_eq!(modes(screened), [BlendMode::Normal, BlendMode::Multiply], "the screened group's members, together, in their own modes"); + let NodeKind::Group { children: inner, .. } = &screened.kind else { panic!("a group") }; + assert_eq!(modes(&inner[1]).len(), 1, "the multiplied member's surface"); + let plain = group_with(json!({}), vec![member(1, 0.0)]); + assert_ne!(expand_revolve_art(&plain), revolve_art(&plain), "an opaque member's hidden faces are removed"); + let multiplied = group_with(json!({}), vec![blend(member(1, 0.0), BlendMode::Multiply)]); + assert_eq!(expand_revolve_art(&multiplied), revolve_art(&multiplied), "a blended member keeps every face"); + } + #[test] fn a_3d_effect_on_a_groups_fill_is_refused() { let mut group = Node::group(NodeId(9), vec![member(1, 0.0)]); diff --git a/crates/effects/src/solids.rs b/crates/effects/src/solids.rs index 568bf2be99..c4b55fd5e4 100644 --- a/crates/effects/src/solids.rs +++ b/crates/effects/src/solids.rs @@ -225,12 +225,16 @@ pub(crate) fn object_profile(n: &Node, label: &str) -> Result<(PathData, FillRul } /// One shape a solid is built from: its outline and fill rule, the fills and strokes that paint -/// it, and the opacity of the objects it is in (1 for the object with the effect itself). +/// it, the opacity of the objects it is in (1 for the object with the effect itself) and their +/// blending modes other than Normal (none for the object itself). `blends` runs from the outermost +/// group in the effect's group down to the member itself, each with a key that the members of +/// one group share. Group Revolve draws the surfaces in those modes. pub(crate) struct Profile { pub(crate) path: PathData, pub(crate) rule: FillRule, pub(crate) items: Vec, pub(crate) opacity: f32, + pub(crate) blends: Vec<(usize, BlendMode)>, } /// Is `n` a group or layer (a 3D effect on it turns its members about one shared axis)? @@ -256,14 +260,25 @@ const MAX_MESH_TRIANGLES: usize = 2_000_000; /// reference app, they turn about one shared axis (the centre of their bounds together) rather /// than each about its own. An error names what the group holds that can't become a solid. pub(crate) fn members(n: &Node, label: &str) -> Result, String> { - fn walk(n: &Node, opacity: f32, label: &str, out: &mut Vec) -> Result<(), String> { + struct Walk<'a> { + label: &'a str, + out: Vec, + keys: usize, + } + fn walk(n: &Node, opacity: f32, blends: &[(usize, BlendMode)], w: &mut Walk) -> Result<(), String> { + let label = w.label; for c in n.children().into_iter().flatten() { if !c.visible || matches!(c.kind, NodeKind::Path { guide: true, .. }) { continue; } let opacity = opacity * c.opacity; + let mut blends = blends.to_vec(); + if c.blend != BlendMode::Normal { + w.keys += 1; + blends.push((w.keys, c.blend)); + } if is_container(c) { - walk(c, opacity, label, out)?; + walk(c, opacity, &blends, w)?; continue; } let Some((path, rule)) = profile(c) else { @@ -276,19 +291,19 @@ pub(crate) fn members(n: &Node, label: &str) -> Result, String> { }; return Err(format!("{label} on a group needs paths, compound paths and type: release or expand its {what} first.")); }; - if out.len() >= MAX_MEMBERS { + if w.out.len() >= MAX_MEMBERS { return Err(format!("{label} on a group takes at most {MAX_MEMBERS} objects")); } let path = geometry(&path, &c.appearance.effects, &GeomContext::of(c)); if path.bounds().is_some() { - out.push(Profile { path, rule, items: solid_paints(c), opacity }); + w.out.push(Profile { path, rule, items: solid_paints(c), opacity, blends }); } } Ok(()) } - let mut out = vec![]; - walk(n, 1.0, label, &mut out)?; - Ok(out) + let mut w = Walk { label, out: vec![], keys: 0 }; + walk(n, 1.0, &[], &mut w)?; + Ok(w.out) } /// Can 3D effect `id` (Revolve included, [`is_three_d`]) with parameters `p` draw `n`? Checked @@ -524,7 +539,7 @@ pub fn solid_art(n: &Node) -> Option { let remaining: Vec; if let Some((index, fx)) = solid(&n.appearance.effects) { let path = geometry(&path, n.appearance.effects.get(..index)?, &ctx); - children.extend(art_for(n, fx, &[Profile { path, rule, items: paints, opacity: 1.0 }])?); + children.extend(art_for(n, fx, &[Profile { path, rule, items: paints, opacity: 1.0, blends: vec![] }])?); // Later geometry effects act on the projected art as a whole. remaining = n .appearance @@ -540,7 +555,7 @@ pub fn solid_art(n: &Node) -> Option { for item in &paints { if let Some((index, fx)) = solid(item.effects()) { let p = geometry(&path, item.effects().get(..index)?, &ctx.item(item)); - for mut art in art_for(n, fx, &[Profile { path: p, rule, items: vec![item.clone()], opacity: 1.0 }])? { + for mut art in art_for(n, fx, &[Profile { path: p, rule, items: vec![item.clone()], opacity: 1.0, blends: vec![] }])? { Arc::make_mut(&mut art).appearance.effects = item.effects().iter().skip(index + 1).filter(|e| !is_three_d(&e.id)).cloned().collect(); children.push(art); @@ -703,14 +718,14 @@ fn meshes_within(n: &Node, total: &mut usize, limit: usize) -> Result Date: Tue, 10 Nov 2026 02:07:37 +0000 Subject: [PATCH 21/36] .ai import: images in a spot ink's Separation space keep their ink (#6350) An image whose color space XC sets to one ink (n [c m y k /DeviceCMYK (name) /Separation] XC) opened as a gray RGB image: its samples, the ink's tints (255 at 100 %), were read as gray levels, so the tints drew inverted and the ink was lost. It now opens as a grayscale image of the tints, black at 100 %, colorized by the ink at 100 %: its spot swatch, a process plate's CMYK color, or Registration. Its alpha channel becomes its opacity mask, a reduced large image's too (a colorized image reduced to fit the limits no longer keeps its alpha in its pixels). 8-bit and 1-bit samples, binary or in hex comment lines. A device space and the ink None change nothing. Co-Authored-By: Claude Opus 5.5 --- crates/eps/src/import/ai/mesh.rs | 2 +- crates/eps/src/import/ai/paint.rs | 31 ++++++ crates/eps/src/import/ai/read/art.rs | 61 +++++++++--- crates/eps/src/import/ai/read/directives.rs | 8 +- crates/eps/src/import/ai/read/ops.rs | 8 ++ crates/eps/src/import/ai/read/types.rs | 5 +- crates/eps/src/import/ai/tests.rs | 102 ++++++++++++++++++++ docs/file-format-parity.md | 6 +- 8 files changed, 206 insertions(+), 17 deletions(-) diff --git a/crates/eps/src/import/ai/mesh.rs b/crates/eps/src/import/ai/mesh.rs index 3937045dbe..4f667fbe45 100644 --- a/crates/eps/src/import/ai/mesh.rs +++ b/crates/eps/src/import/ai/mesh.rs @@ -133,7 +133,7 @@ impl Space { } /// A named colour or ink's full colour: `[c m y k /DeviceCMYK (name) /kind]` or `[r g b /DeviceRGB …]`. -fn ink(a: &[V]) -> Option { +pub(super) fn ink(a: &[V]) -> Option { let at = a.iter().position(|v| matches!(v, V::Name(_)))?; let nums: Vec = a.get(..at)?.iter().map(|v| v.num().filter(|v| v.is_finite()).map(|v| v.clamp(0.0, 1.0) as f32)).collect::>()?; match (a.get(at).and_then(V::text).as_deref(), nums.as_slice()) { diff --git a/crates/eps/src/import/ai/paint.rs b/crates/eps/src/import/ai/paint.rs index a25f2b82af..5f39c3b977 100644 --- a/crates/eps/src/import/ai/paint.rs +++ b/crates/eps/src/import/ai/paint.rs @@ -72,6 +72,37 @@ pub(super) fn color_op_in(op: &str, vals: &[V], names: &mut Names) -> Option<(Pa } } +/// `n [c m y k /DeviceCMYK (name) /Separation] XC` (or `[r g b /DeviceRGB …]`) before an image: its +/// samples are one ink's tints. The ink at 100 % as a paint, with the named colour that makes its +/// spot swatch (none for Registration). `None` for a device space (`/DeviceGray XC`), the ink +/// `None`, which paints nothing, or another form. +pub(super) fn separation_ink(vals: &[V], names: &mut Names) -> Option<(Paint, Option)> { + let a = vals.iter().rev().find_map(|v| if let V::Arr(a) = v { Some(a) } else { None })?; + let [.., name @ V::Str(_), V::Name(kind)] = a.as_slice() else { return None }; + if kind.trim_start_matches('/') != "Separation" { + return None; + } + let color = super::mesh::ink(a)?; + let name = names.text(name)?; + if name == "None" { + return None; + } + if is_registration(&name) { + let paint = Paint::Solid { color: Color::cmyk(1.0, 1.0, 1.0, 1.0), swatch: Some(vectorcraft_color::swatch::REGISTRATION.into()), tint: 1.0 }; + return Some((paint, None)); + } + // A process plate is its CMYK colour, not a spot ink: no swatch of its name. + if let Some(plate) = ["Cyan", "Magenta", "Yellow", "Black"].iter().position(|p| *p == name) { + let mut cmyk = [0.0; 4]; + if let Some(v) = cmyk.get_mut(plate) { + *v = 1.0; + } + return Some((Paint::solid(Color::cmyk(cmyk[0], cmyk[1], cmyk[2], cmyk[3])), None)); + } + let paint = Paint::Solid { color, swatch: Some(name.clone()), tint: 1.0 }; + Some((paint, Some(Named { name, color, tint: 1.0, spot: true }))) +} + fn is_registration(name: &str) -> bool { matches!(name, vectorcraft_color::swatch::REGISTRATION | "Registration" | "/All" | "All" | "/[Registration]") } diff --git a/crates/eps/src/import/ai/read/art.rs b/crates/eps/src/import/ai/read/art.rs index 7d2565172d..3d859bf74e 100644 --- a/crates/eps/src/import/ai/read/art.rs +++ b/crates/eps/src/import/ai/read/art.rs @@ -472,10 +472,34 @@ impl<'a> Reader<'a> { return Ok(()); }; let opacity = opacity.filter(|_| alpha_len > 0); + // An image in one ink's Separation space (`XC`): its samples are the ink's tints, the most + // at 100 % (255, or a set bit). It comes in as a grayscale image of them, black at 100 %, + // that the ink colorizes, as a Separation image opens from a PDF; its alpha channel + // becomes its opacity mask. + let ink = if channels == 1 && !image_mask { self.raster.as_mut().and_then(|r| r.ink.take()) } else { None }; + let inverted; + let samples = if ink.is_some() { + inverted = samples.iter().map(|b| !b).collect::>(); + inverted.as_slice() + } else { + samples + }; + let ink = ink.map(|(paint, named)| { + // Its colour counts toward the document's colour model, as a fill's does (Registration + // says nothing of it). + let registration = matches!(&paint, Paint::Solid { swatch: Some(s), .. } if s == vectorcraft_color::swatch::REGISTRATION); + match paint.color().filter(|_| !registration) { + Some(vectorcraft_color::Color::Rgb { .. }) => self.rgb_colors += 1, + Some(vectorcraft_color::Color::Cmyk { .. }) => self.cmyk_colors += 1, + _ => {} + } + self.spots.extend(named); + paint + }); let gray1 = *bits == 1.0 && channels == 1 && opacity.is_none(); if k > 1 || w * h > raster::SMALL || (gray1 && !image_mask) { - // An image mask keeps the color that colorizes it. - let colorize = (image_mask && channels == 1 && *bits == 1.0).then(|| self.mask_paint()); + // An image mask keeps the color that colorizes it, an ink image its ink. + let colorize = if image_mask && channels == 1 && *bits == 1.0 { Some(self.mask_paint()) } else { ink }; return self.large_image(samples, opacity, shape, k, &m, colorize); } // CMYK samples keep their ink amounts as a CMYK TIFF, as the PDF importer keeps a CMYK @@ -487,7 +511,11 @@ impl<'a> Reader<'a> { return self.add_raster(blob, opacity, &m, (w as u32, h as u32), None); } if image_mask && channels == 1 && *bits == 1.0 { - return self.colorized_raster(samples, row, opacity, &m, (w as u32, h as u32)); + let paint = self.mask_paint(); + return self.colorized_raster(samples, (row, 1), opacity, &m, (w as u32, h as u32), paint); + } + if let Some(paint) = ink { + return self.colorized_raster(samples, (row, *bits as u8), opacity, &m, (w as u32, h as u32), paint); } let mut rgba = Vec::with_capacity((w * h * 4) as usize); for y in 0..h as usize { @@ -526,15 +554,24 @@ impl<'a> Reader<'a> { self.add_raster(ImageBlob::new("image/png", png), None, &m, (w as u32, h as u32), None) } - /// A 1-bit gray image whose ImageMask is 1 (`samples`, `row` bytes a row): a grayscale image - /// colorized with the fill color. Its 0 bits, which open black with ImageMask 0, take that color, - /// and its 1 bits are transparent. A fill that isn't a solid color colorizes it black. - fn colorized_raster(&mut self, samples: &[u8], row: usize, alpha: Option<&[u8]>, m: &[f64], (w, h): (u32, u32)) -> Result<(), String> { + /// A one-channel image (`samples` of `bits` bits, `row` bytes a row) as a grayscale image + /// colorized with `paint`: a 1-bit gray image whose ImageMask is 1, with the fill color (its 0 + /// bits, which open black with ImageMask 0, take that color, and its 1 bits are transparent; a + /// fill that isn't a solid color colorizes it black), or an ink's tints, inverted, with the ink. + fn colorized_raster( + &mut self, + samples: &[u8], + (row, bits): (usize, u8), + alpha: Option<&[u8]>, + m: &[f64], + (w, h): (u32, u32), + paint: Paint, + ) -> Result<(), String> { let mut gray = Vec::with_capacity(w as usize * h as usize); for y in 0..h as usize { let line = samples.get(y * row..(y + 1) * row).unwrap_or_default(); for x in 0..w as usize { - let v = line.get(x / 8).map_or(0, |b| ((b >> (7 - x % 8)) & 1) * 255); + let v = if bits == 1 { line.get(x / 8).map_or(0, |b| ((b >> (7 - x % 8)) & 1) * 255) } else { line.get(x).copied().unwrap_or(0) }; gray.push(v); } } @@ -544,7 +581,6 @@ impl<'a> Reader<'a> { self.image_left_out("an image VectorCraft can't read was left out"); return Ok(()); } - let paint = self.mask_paint(); self.add_raster(ImageBlob::new("image/png", png), alpha, m, (w, h), Some(paint)) } @@ -625,12 +661,15 @@ impl<'a> Reader<'a> { return self.add_raster(ImageBlob::new("image/png", png), None, m, (ow, oh), colorize); } let cmyk = s.channels == 4; - let (pixels, mask) = raster::reduce(samples, opacity, s, k, !cmyk); + // A colorized image's alpha channel becomes its opacity mask, as at its full size: a + // grayscale image whose pixels have alpha takes only a spot color, black or white. + let inline = !cmyk && colorize.is_none(); + let (pixels, mask) = raster::reduce(samples, opacity, s, k, inline); let blob = if cmyk { Inks::new(ow, oh, pixels).and_then(|i| ImageBlob::cmyk_tiff(&i)) } else { use image::ImageEncoder as _; - let colour = match (s.channels, opacity.is_some()) { + let colour = match (s.channels, inline && opacity.is_some()) { (1, false) => image::ExtendedColorType::L8, (1, true) => image::ExtendedColorType::La8, (_, false) => image::ExtendedColorType::Rgb8, diff --git a/crates/eps/src/import/ai/read/directives.rs b/crates/eps/src/import/ai/read/directives.rs index e1fbe0d4d5..f7df4f35d6 100644 --- a/crates/eps/src/import/ai/read/directives.rs +++ b/crates/eps/src/import/ai/read/directives.rs @@ -181,9 +181,13 @@ impl<'a> Reader<'a> { } else if line.starts_with("AI5_EndRaster") { // A file `XG` named in this raster object is no later one's. self.image_link = None; - if let Some(Raster { operands: Some(vals), mut samples, half, max, .. }) = self.raster.take() { + if let Some(Raster { operands: Some(vals), mut samples, half, max, ink, .. }) = self.raster.take() { end_hex(&mut samples, half, max); - return self.image_from(vals, &samples, true); + // The image's ink (`XC`) stays for `image_from` to read. + self.raster = Some(Raster { ink, ..Raster::default() }); + let read = self.image_from(vals, &samples, true); + self.raster = None; + return read; } } else if line.starts_with("AI5_BeginPlace") { // One that is still open (no `~` ended it) isn't read. diff --git a/crates/eps/src/import/ai/read/ops.rs b/crates/eps/src/import/ai/read/ops.rs index f584f26c27..6c84eff236 100644 --- a/crates/eps/src/import/ai/read/ops.rs +++ b/crates/eps/src/import/ai/read/ops.rs @@ -361,6 +361,14 @@ impl<'a> Reader<'a> { r.space = vals.iter().rev().find_map(V::text); } } + // `n [… (name) /Separation] XC`: the image's samples are that ink's tints. A device + // space (`/DeviceGray XC` after a linked image's file name) changes nothing read here. + "XC" => { + let ink = paint::separation_ink(&vals, &mut self.names); + if let Some(r) = self.raster.as_mut() { + r.ink = ink; + } + } // Placed files. // `[a b c d tx ty] llx lly urx ury (path) `` ` ``, three more numbers before the path in // newer files: the version 7 form has no `%AI5_BeginPlace` before it. Before the art, diff --git a/crates/eps/src/import/ai/read/types.rs b/crates/eps/src/import/ai/read/types.rs index b07270ef82..473a070794 100644 --- a/crates/eps/src/import/ai/read/types.rs +++ b/crates/eps/src/import/ai/read/types.rs @@ -279,6 +279,9 @@ impl BrushArtColors { #[derive(Debug, Default)] pub(super) struct Raster { pub(super) space: Option, + /// The ink of an image in a Separation space (`XC`): its paint at 100 % and the named colour + /// for its swatch (`paint::separation_ink`). + pub(super) ink: Option<(Paint, Option)>, /// `XI`'s operands when its samples follow as hex in comment lines (`%FF00…`) up to /// `%AI5_EndRaster`, the form the version 7 specification documents (§8). pub(super) operands: Option>, @@ -370,8 +373,6 @@ pub(super) const IGNORED: &[&str] = &[ "As", "Xd", "Xr", - // `/DeviceGray XC` after a linked image's file name (`XG`): its colour space. - "XC", "Xh", "XH", "Bc", diff --git a/crates/eps/src/import/ai/tests.rs b/crates/eps/src/import/ai/tests.rs index 2d6aa99678..19cd6fa279 100644 --- a/crates/eps/src/import/ai/tests.rs +++ b/crates/eps/src/import/ai/tests.rs @@ -2755,6 +2755,108 @@ fn image_masks_are_colorized_with_the_fill() { assert_eq!(pixels(&doc).0.get_pixel(0, 0).0, [255, 0, 0, 255]); } +/// A gray image `w` × 1 (`bits` bits a pixel, `alpha` channels, data `data`) whose colour space is +/// set by `xc` (the operands of `XC`) instead of `/DeviceGray XN`. +fn ink_image(xc: &str, bits: u32, alpha: u32, w: u32, data: &[u8]) -> Vec { + let mut out = image("[ 1 0 0 1 0 100 ]", (w, 1), ("DeviceGray", 1, alpha), data, data.len() + 3); + // In place, as bytes: the samples aren't text. + let mut swap = |from: &[u8], to: &[u8]| { + let at = out.windows(from.len()).position(|s| s == from).unwrap(); + out.splice(at..at + from.len(), to.iter().copied()); + }; + swap(b"/DeviceGray XN\n", format!("{xc} XC\n").as_bytes()); + swap(format!(" 8 1 {alpha} 0 1 0 ").as_bytes(), format!(" {bits} 1 {alpha} 0 1 0 ").as_bytes()); + out +} + +/// An image in one ink's Separation space (`n [… (name) /Separation] XC`): its samples are the +/// ink's tints (255 at 100 %), so it opens as a grayscale image of them, black at 100 %, colorized +/// by the ink's spot swatch; its alpha channel becomes its opacity mask. +#[test] +fn separation_images_are_colorized_with_their_ink() { + let black = "1 [0 0 0 1 /DeviceCMYK (Black Ink) /Separation]"; + let r = read(&ink_image(black, 8, 0, 3, &[0, 128, 255])).unwrap(); + assert!(r.warnings.is_empty(), "{:?}", r.warnings); + let a = art(&r.doc); + let Some(Paint::Solid { color, swatch: Some(swatch), tint }) = a[0].extras().colorize.as_deref() else { panic!("{:?}", a[0].extras().colorize) }; + assert_eq!((swatch.as_str(), *tint), ("Black Ink", 1.0)); + assert_eq!(color.to_rgba8(1.0), Color::cmyk(0.0, 0.0, 0.0, 1.0).to_rgba8(1.0)); + assert!(r.doc.swatch("Black Ink").is_some_and(|s| s.spot), "a spot swatch for the ink"); + let NodeKind::Image(im) = &a[0].kind else { panic!("{:?}", a[0].kind) }; + assert!(r.doc.images[&im.key].gray_source().is_some_and(|g| !g.alpha)); + let img = pixels(&r.doc).0; + assert_eq!((0..3).map(|x| img.get_pixel(x, 0).0[0]).collect::>(), [255, 127, 0], "no ink is white, 100 % black"); + + // An RGB alternate gives the ink's colour; an alpha channel becomes the opacity mask. + let white = "1 [1 1 1 /DeviceRGB (White Ink) /Separation]"; + let r = read(&ink_image(white, 8, 1, 2, &[255, 255, 255, 0])).unwrap(); + let a = art(&r.doc); + let Some(Paint::Solid { color, swatch: Some(swatch), .. }) = a[0].extras().colorize.as_deref() else { panic!("{:?}", a[0].extras().colorize) }; + assert_eq!((swatch.as_str(), color.to_rgba8(1.0)), ("White Ink", Color::rgb(1.0, 1.0, 1.0).to_rgba8(1.0))); + assert!(a[0].extras().mask.is_some(), "the alpha channel as an opacity mask"); + + // 1 bit a pixel: a set bit is the ink at 100 % (101). + let r = read(&ink_image(black, 1, 0, 3, b"\xa0")).unwrap(); + let a = art(&r.doc); + assert!(a[0].extras().colorize.is_some()); + let img = pixels(&r.doc).0; + assert_eq!((0..3).map(|x| img.get_pixel(x, 0).0[0]).collect::>(), [0, 255, 0]); + + // A device space, and the ink None (which paints nothing), leave the image as it was. + for xc in ["/DeviceGray", "1 [0 0 0 1 /DeviceCMYK (None) /Separation]"] { + let doc = read(&ink_image(xc, 8, 0, 2, &[0, 200])).unwrap().doc; + assert!(art(&doc)[0].extras().colorize.is_none(), "{xc}"); + assert_eq!(pixels(&doc).0.get_pixel(1, 0).0, [200, 200, 200, 255], "{xc}"); + } +} + +/// A process plate's Separation colorizes in the plate's CMYK colour, with no swatch of its name; +/// Registration in the Registration colour. +#[test] +fn separation_images_of_process_plates_and_registration() { + let r = read(&ink_image("1 [0 0 0 1 /DeviceCMYK (Black) /Separation]", 8, 0, 2, &[0, 255])).unwrap(); + assert_eq!(art(&r.doc)[0].extras().colorize.as_deref(), Some(&Paint::solid(Color::cmyk(0.0, 0.0, 0.0, 1.0)))); + assert!(r.doc.swatches.iter().all(|s| !s.spot), "no spot swatch for a process plate"); + let r = read(&ink_image("1 [1 1 1 1 /DeviceCMYK (All) /Separation]", 8, 0, 2, &[0, 255])).unwrap(); + let swatch = art(&r.doc)[0].extras().colorize.as_deref().and_then(|p| if let Paint::Solid { swatch, .. } = p { swatch.clone() } else { None }); + assert_eq!(swatch.as_deref(), Some(vectorcraft_color::swatch::REGISTRATION)); +} + +/// The version 7 form, with samples in hex comment lines after `XI`, keeps the image's ink. +#[test] +fn a_separation_image_with_hex_comment_lines_keeps_its_ink() { + let s = "%AI5_BeginLayer\n1 1 1 1 0 0 1 0 79 128 255 0 50 0 Lb\n(L) Ln\n%AI5_File:\n%AI5_BeginRaster\n() 1 XG\n\ + 1 [0 0 0 1 /DeviceCMYK (Black Ink) /Separation] XC\n[ 1 0 0 1 0 100 ] 3 1 0 Xh\n\ + [ 1 0 0 1 0 100 ] 0 0 3 1 3 1 8 1 0 0 0 0 XI\n%0080FF\n%AI5_EndRaster\nN\nLB\n%AI5_EndLayer--\n"; + let mut data = stream("").into_bytes(); + let at = data.windows(14).position(|w| w == b"%%PageTrailer\n").unwrap(); + data.splice(at..at, s.bytes()); + let r = read(&data).unwrap(); + let a = art(&r.doc); + assert!( + matches!(a[0].extras().colorize.as_deref(), Some(Paint::Solid { swatch: Some(s), .. }) if s == "Black Ink"), + "{:?}", + a[0].extras().colorize + ); + let img = pixels(&r.doc).0; + assert_eq!((0..3).map(|x| img.get_pixel(x, 0).0[0]).collect::>(), [255, 127, 0]); +} + +/// A Separation image reduced to fit the limits keeps its ink, and its alpha channel becomes its +/// opacity mask, as at its full size. +#[test] +fn a_reduced_separation_image_keeps_its_ink_and_its_alpha_as_a_mask() { + let samples = [255u8; 16].iter().chain([0u8; 16].iter()).copied().collect::>(); + let data = ink_image("1 [0 0 0 1 /DeviceCMYK (Black Ink) /Separation]", 8, 1, 16, &samples); + let r = super::with_image_limits(4, 1 << 31, 512 << 20, || read(&data).unwrap()); + assert!(r.warnings.iter().any(|w| w.contains("was reduced")), "{:?}", r.warnings); + let a = art(&r.doc); + assert!(a[0].extras().colorize.is_some()); + assert!(a[0].extras().mask.is_some(), "the alpha channel as an opacity mask"); + let NodeKind::Image(im) = &a[0].kind else { panic!("{:?}", a[0].kind) }; + assert!(r.doc.images[&im.key].gray_source().is_some_and(|g| !g.alpha), "no alpha in the pixels"); +} + #[test] fn images_with_alpha_channels() { // RGB samples, then the alpha channel (not counted in the data's size). diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index a04c8926ee..1f911f6e50 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-09 · **Change:** minor (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-09 · **Change:** minor (`.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 @@ -223,6 +223,9 @@ private editing data. VectorCraft: A 1-bit gray image whose `XI` or `XF` has ImageMask 1 comes in colorized by the fill color set before it, or by black when that fill is not a solid color. VectorCraft's own rule is that the pixels that open black with ImageMask 0 take the color and the others are transparent. + An image whose color space `XC` sets to one ink (`n [… (name) /Separation] XC`) comes in as a grayscale image of + its tints (black at 100 %) colorized by the ink (its spot swatch, or a process plate's CMYK color), its alpha channel + as its opacity mask (#6350). - Estimate: **~65%** for real exchange with Illustrator users (open: ~75%; save: PDF-compatible, or legacy version 7 with layers and groups; not yet tested by users who hand files back). Closing the open-side issues: 10–20 h with users' files. @@ -324,6 +327,7 @@ Names in the editing data that aren't UTF-8 (layers, objects, artboards, colors, | Date | Change | Summary | |---|---|---| +| 2026-11-09 | minor | `.ai` import: an image whose color space `XC` sets to one ink (`n [c m y k /DeviceCMYK (name) /Separation] XC`, or an RGB alternate) opened as a gray RGB image: its samples, the ink's tints (255 at 100 %), were read as gray levels, so the tints drew inverted and the ink was lost. It now opens as a grayscale image of the tints, black at 100 %, colorized by the ink at 100 %: a spot swatch, a process plate's CMYK color (no swatch of its name), or Registration; the ink's color counts toward the document's color mode as a fill's does. Its alpha channel becomes its opacity mask, a reduced large image's too: a colorized image reduced to fit the limits (an image mask included) now keeps its alpha as an opacity mask rather than in its pixels, as at its full size. 8-bit and 1-bit samples, binary or in hex comment lines. A device space (`/DeviceGray XC` after a linked image's file name) and the ink `None` change nothing, as before; linked images (`XF`) keep their placeholder. Tests `import::ai::tests::separation_images_are_colorized_with_their_ink`, `separation_images_of_process_plates_and_registration`, `a_separation_image_with_hex_comment_lines_keeps_its_ink`, `a_reduced_separation_image_keeps_its_ink_and_its_alpha_as_a_mask` (#6350) | | 2026-11-09 | minor | PDF import: an image in a Separation color space (one ink, ISO 32000-1 §8.6.6.4) opens as a grayscale image of its tints (white where it lays no ink, black at 100 %, its Decode array applied; 1, 2, 4, 8 and 16 bits a sample, or one gray channel from its JPEG, CCITT, JBIG2 or JPEG 2000 decoder) colorized by its ink at 100 %: a spot ink's swatch, a process plate's CMYK color, or Registration. It used to open as an RGB image, losing the ink, and Fill and Recolor Artwork didn't reach its color. Its soft mask or mask becomes its opacity mask, as a CMYK image's does. The ink is found by the image's color space (kind, colorant name and tint transform), once per image. As with any colorized image, where it lays no ink the art under it shows, where the RGB image was opaque white. The ink `None`, which paints nothing, an ink whose color can't be told, an image inside an alpha soft mask, inline images and DeviceN images open as before; samples that can't be read (data cut short, a decoder's output of another layout) open in RGB with a warning, and the ink gets no swatch. Tests `colorize::a_separation_image_opens_colorized_by_its_ink`, `colorize::a_separation_image_reads_its_samples_and_decode_array`, `colorize::a_separation_image_of_a_process_plate_or_of_no_ink`, `colorize::a_separation_image_keeps_its_soft_mask`, `colorize::a_separation_image_drawn_twice_is_colorized_twice`, `colorize::a_separation_jpeg_opens_colorized_by_its_ink`, `colorize::a_separation_image_that_cant_be_read_stays_rgb` (#6314) | | 2026-11-09 | minor | PDF import: an image mask (`/ImageMask true`, ISO 32000-1 §8.9.6.2) painted in a solid color opens as a grayscale image of what it paints (black, the rest white) colorized by that color, as the `.ai` reader opens a 1-bit image mask (#5790). It used to open as an RGB image with the color in its pixels: Fill and Recolor Artwork didn't reach the color, a spot color lost its ink, and the same mask painted again in another color took the first color, in an uncolored pattern's cell too. Its color now counts toward the document's color mode, as a fill's does. A spot color is found by its color space, as a fill's is when the page's walk names no ink: two spaces of other inks with the same alternate and tint transform can't be told apart, and the color comes in as RGB. Inside an alpha soft mask, or painted with a pattern, a mask opens as before. Tests `colorize::an_image_mask_opens_colorized_by_its_spot_color`, `colorize::an_image_mask_drawn_in_two_colors_keeps_both`, `colorize::an_uncolored_pattern_drawing_an_image_mask_keeps_each_color` (#6313) | | 2026-11-09 | 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. Synthetic evidence: `crates/pdf/src/tests/actual_text.rs`. | From ecb2fbe6096747124c6221ebe671ef5287a41a56 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Mon, 9 Nov 2026 18:12:27 -0800 Subject: [PATCH 22/36] Revolve: the search for holes looks only at contours near each point Mesh export tested the middle of every segment against every closed contour's bounds, so a profile of thousands of separate contours took hundreds of millions of comparisons before the work limit counted any. The closed contours now go in a grid over their bounds, a point is tested only against the contours of its cell, and placing a contour in a cell and each candidate looked at count toward the limit. --- crates/three-d/src/lib.rs | 52 ++++++++++++++++++---- crates/three-d/src/tests/profile_limits.rs | 15 +++++++ docs/mcp.md | 2 +- 3 files changed, 60 insertions(+), 9 deletions(-) diff --git a/crates/three-d/src/lib.rs b/crates/three-d/src/lib.rs index 54bc0c5a6f..88843cb4bb 100644 --- a/crates/three-d/src/lib.rs +++ b/crates/three-d/src/lib.rs @@ -508,14 +508,23 @@ pub fn revolve_curve_samples<'a>(paths: impl IntoIterator, if samples < MIN_CURVE_SAMPLES { Err(TOO_COMPLEX_SURFACE.into()) } else { Ok(samples) } } -/// Most path elements the search for holes tests a point against, each winding test counting its -/// contour's elements. +/// Most work the search for holes does: each winding test counts its contour's elements, and each +/// contour placed in a cell of the grid or looked at as a candidate for a point counts one. const MAX_HOLE_WORK: usize = 16_000_000; +/// Most cells along each side of the grid the search for holes sorts contours into. +const MAX_HOLE_GRID: usize = 256; + /// For each segment of each subpath of `path`, whether it walls a hole under `rule`: the fill is /// outside its closed contour at the segment's middle and not inside. An error past /// [`MAX_HOLE_WORK`]. fn hole_segments(path: &PathData, rule: FillRule) -> Result>, String> { + hole_segments_counted(path, rule).map(|(holes, _)| holes) +} + +/// [`hole_segments`] and the work it did. A point is tested only against the closed contours whose +/// bounds reach into its cell of a grid over the bounds of them all. +fn hole_segments_counted(path: &PathData, rule: FillRule) -> Result<(Vec>, usize), String> { let contours: Vec<(BezPath, Rect)> = path .subpaths .iter() @@ -527,6 +536,33 @@ fn hole_segments(path: &PathData, rule: FillRule) -> Result>, Stri .collect(); let closed = |j: usize| path.subpaths.get(j).is_some_and(|sp| sp.closed); let mut work = 0usize; + let spend = |n: usize, work: &mut usize| { + *work = work.saturating_add(n); + if *work > MAX_HOLE_WORK { Err(TOO_COMPLEX_PROFILE.to_string()) } else { Ok(()) } + }; + let area = contours.iter().enumerate().filter(|(j, _)| closed(*j)).map(|(_, (_, b))| *b).reduce(|a, b| a.union(b)); + let count = (0..contours.len()).filter(|j| closed(*j)).count(); + let side = ((count as f64).sqrt().ceil() as usize).clamp(1, MAX_HOLE_GRID); + let at = |v: f64, lo: f64, hi: f64| { + let span = hi - lo; + if span > 0.0 { (((v - lo) / span * side as f64).floor().max(0.0) as usize).min(side - 1) } else { 0 } + }; + let cell = |p: Point| area.map(|a| at(p.y, a.y0, a.y1) * side + at(p.x, a.x0, a.x1)); + let mut grid: Vec> = vec![vec![]; side * side]; + if let Some(a) = area { + for (j, (_, b)) in contours.iter().enumerate().filter(|(j, _)| closed(*j)) { + let (x0, x1) = (at(b.x0, a.x0, a.x1), at(b.x1, a.x0, a.x1)); + let (y0, y1) = (at(b.y0, a.y0, a.y1), at(b.y1, a.y0, a.y1)); + spend((x1 - x0 + 1).saturating_mul(y1 - y0 + 1), &mut work)?; + for y in y0..=y1 { + for x in x0..=x1 { + if let Some(c) = grid.get_mut(y * side + x) { + c.push(j); + } + } + } + } + } let mut holes = Vec::with_capacity(contours.len()); for (i, (sp, (own, _))) in path.subpaths.iter().zip(&contours).enumerate() { if !sp.closed { @@ -538,11 +574,11 @@ fn hole_segments(path: &PathData, rule: FillRule) -> Result>, Stri for segment in 0..sp.anchors.len() { let p = sp.segment(segment).eval(0.5); let mut others = 0i32; - for (_, (contour, _)) in contours.iter().enumerate().filter(|(j, (_, bounds))| *j != i && closed(*j) && bounds.contains(p)) { - work = work.saturating_add(contour.elements().len()); - if work > MAX_HOLE_WORK { - return Err(TOO_COMPLEX_PROFILE.into()); - } + let candidates = cell(p).and_then(|c| grid.get(c)).map_or(&[][..], Vec::as_slice); + spend(candidates.len(), &mut work)?; + for &j in candidates { + let Some((contour, _)) = contours.get(j).filter(|(_, bounds)| j != i && bounds.contains(p)) else { continue }; + spend(contour.elements().len(), &mut work)?; others += contour.winding(p); } segments.push(match rule { @@ -552,7 +588,7 @@ fn hole_segments(path: &PathData, rule: FillRule) -> Result>, Stri } holes.push(segments); } - Ok(holes) + Ok((holes, work)) } /// [`revolve`], or with `mesh` the surface's faces before projection, kept there instead of drawn, diff --git a/crates/three-d/src/tests/profile_limits.rs b/crates/three-d/src/tests/profile_limits.rs index 025f2d9dfa..5eeb10f2a1 100644 --- a/crates/three-d/src/tests/profile_limits.rs +++ b/crates/three-d/src/tests/profile_limits.rs @@ -105,6 +105,21 @@ fn deeply_nested_contours_stop_the_search_for_holes() { assert_eq!(hole_segments(&few, FillRule::EvenOdd).map(|h| h.len()), Ok(4)); } +#[test] +fn a_point_is_tested_only_against_the_contours_near_it() { + // 3,000 separate squares: each segment's middle lies in the bounds of its own square only. + let squares = (0..3000) + .map(|i| { + let (x, y) = ((i % 60) as f64 * 20.0, (i / 60) as f64 * 20.0); + let corners = [(x, y), (x + 10.0, y), (x + 10.0, y + 10.0), (x, y + 10.0)]; + SubPath::new(corners.iter().map(|(x, y)| Anchor::corner(Point::new(*x, *y))).collect(), true) + }) + .collect(); + let (holes, work) = hole_segments_counted(&PathData::new(squares), FillRule::NonZero).unwrap(); + assert!(holes.iter().flatten().all(|hole| !hole)); + assert!(work < 200_000, "{work}"); +} + #[test] fn overlapping_contours_are_holes_only_where_they_overlap() { let square = |x: f64, y: f64, side: f64| { diff --git a/docs/mcp.md b/docs/mcp.md index 499d02bd44..0e73231dc4 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -2388,7 +2388,7 @@ compound path, type, or a group or layer of them. `effect.dialog` opens compact `effect.expandAppearance` bakes it into shaded vector faces. Native saves keep the editable source; SVG/PDF exports bake the same faces the canvas draws. Only one Revolve is supported per profile. Type sweeps as its glyph outlines and stays type. On a group or layer, every visible member sweeps about one axis on the chosen edge of the members' bounds together, in its own paint, and the members stay editable. A group holding a clipping group, an image, a symbol instance or another object that can't be swept is refused, and so is type with no outlines or a group with no shape in it. -A surface takes at most 16,384 profile points and 163,840 faces, the members of a group together, counted after any geometry effects before the Revolve. A straight segment takes one point, a curve takes at least four, and each contour takes one. Each curved segment takes 16 samples; on a surface over those limits every curve takes as many as fit, down to 4, and the call is refused below that with "reduce Segments or simplify the profile". The call is also refused with "reduce Segments or simplify the gradient" when the faces' copies of the paint would take more than 256 MiB, counted together for the members of a group and for the fills and strokes of one object that carry a Revolve. Mesh export refuses a profile as too complex when its closed contours nest so deeply that finding the holes would take more than 16,000,000 path element tests. +A surface takes at most 16,384 profile points and 163,840 faces, the members of a group together, counted after any geometry effects before the Revolve. A straight segment takes one point, a curve takes at least four, and each contour takes one. Each curved segment takes 16 samples; on a surface over those limits every curve takes as many as fit, down to 4, and the call is refused below that with "reduce Segments or simplify the profile". The call is also refused with "reduce Segments or simplify the gradient" when the faces' copies of the paint would take more than 256 MiB, counted together for the members of a group and for the fills and strokes of one object that carry a Revolve. To find the holes, mesh export tests each segment's middle against the closed contours whose bounds share its cell of a grid over the profile. It refuses the profile as too complex when its contours overlap so much that the search would take more than 16,000,000 steps, where each path element tested, each contour looked at and each contour placed in a grid cell counts one. Parameters: `angle` (degrees, 0..360, default 360), `offset` (points, 0..100000, default 0), `edge` (`"left"` or `"right"`), `rotationX/Y/Z` (degrees, defaults 0/0/0), `perspectiveAngle` From bed632cc651586368e75329944d24fafdbee3bb7 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Mon, 9 Nov 2026 17:01:10 -0800 Subject: [PATCH 23/36] Transform: butt-capped lines measure to their end points with Use Preview Bounds With Use Preview Bounds on, the visual box of a butt-capped stroke on an open path grew by half the stroke weight past each end of the path, where a butt cap paints nothing. A 2 pt vertical line set to H: 10 mm in the Transform panel drew about 9.3 mm long. The box of an open subpath with butt caps no longer reaches past its ends. A straight segment's box comes from the four corners of its stroked rectangle, and a curve's box comes from the stroke at its ends and where its tangent is square to an axis. Bevel joins, and miter joins the limit bevels, are measured by the stroked corners, and round joins by the arc they paint outside the turn, instead of half the weight all round. Round and projecting caps, arrowheads, closed paths and miter tips measure as before. Two cases can still measure larger than the stroke paints: a curve that bends more tightly than half the weight, and a butt-dashed path whose first or last dash is a gap, which is measured to the path's ends. A horizontal or vertical butt line now measures one stroke weight less along its length in W or H, and Align places it by its end points. A slanted line's box shrinks by less: the stroke's corners still reach past the end points across the line. object.setBounds measures as drawn, after each scale it tries, a butt-capped open path with a segment at a slant, symbol instances' art included. --- crates/doc/src/reach.rs | 364 ++++++++++++++++++++--- crates/effects/src/stroke/tests_reach.rs | 22 ++ crates/engine/src/cmd/object/bounds.rs | 13 +- crates/engine/src/tests/previewbounds.rs | 71 +++++ crates/engine/tests/plugin_import.rs | 2 +- docs/mcp.md | 6 +- docs/target-app-parity.md | 1 + 7 files changed, 434 insertions(+), 45 deletions(-) diff --git a/crates/doc/src/reach.rs b/crates/doc/src/reach.rs index 0c4ef497f3..4b5f1d435a 100644 --- a/crates/doc/src/reach.rs +++ b/crates/doc/src/reach.rs @@ -1,10 +1,10 @@ //! Where a path's strokes paint, from the path's geometry without building their outlines: the //! visual bounds and the clicks of a stroke take in its alignment, its width profile, miter spikes -//! up to the miter limit, projecting caps and arrowheads. +//! up to the miter limit, projecting caps and arrowheads. Butt caps end at the path's end points. use vectorcraft_geom::bez::{CORNER_COS, has_length, kink, segments, subpaths, tangent, unit}; use vectorcraft_geom::hit::fill_contains; -use vectorcraft_geom::kurbo::{ParamCurveArclen, ParamCurveNearest}; +use vectorcraft_geom::kurbo::{ParamCurveArclen, ParamCurveExtrema, ParamCurveNearest}; use vectorcraft_geom::{BezPath, FillRule, ParamCurve, PathSeg, Point, Rect, Shape, Vec2}; use crate::appearance::HEAD_REACH; @@ -101,6 +101,58 @@ fn profile_at(profile: &WidthProfile, segs: &[PathSeg], closed: bool, i: usize, w } +/// Does the curvature of `seg` stay below one over `side` all along it? A curve's first derivative +/// is a mix of its three control differences, and its second a mix of their two differences, which +/// bound the curvature from above. Then the edges of a stroke reaching `side` from it do not fold +/// back, and those edges reach farthest at the ends of `seg` or where its tangent is square to an +/// axis. +fn gentle(seg: &PathSeg, side: f64) -> bool { + let c = seg.to_cubic(); + let d = [c.p1 - c.p0, c.p2 - c.p1, c.p3 - c.p2].map(|v| v * 3.0); + let along = unit(d[0] + d[1] + d[2]); + let slowest = d.iter().map(|v| v.dot(along)).fold(f64::INFINITY, f64::min); + let bend = 2.0 * (d[1] - d[0]).hypot().max((d[2] - d[1]).hypot()); + slowest > 0.0 && side * bend < slowest * slowest +} + +/// The box a stroke reaching `side` from `seg` paints when it stops square at both ends of `seg`: +/// the corners at the ends and the points across the stroke where the tangent is square to an axis. +/// A curve that bends tighter than `side` is halved up to `depth` times, and a piece still bending +/// that much is grown by `side` on every side. +fn swept_box(seg: &PathSeg, side: f64, depth: u32) -> Rect { + if !matches!(seg, PathSeg::Line(_)) && !gentle(seg, side) { + if depth == 0 { + return ParamCurveExtrema::bounding_box(seg).inflate(side, side); + } + return swept_box(&seg.subsegment(0.0..0.5), side, depth - 1).union(swept_box(&seg.subsegment(0.5..1.0), side, depth - 1)); + } + let across = |t: f64| { + let (p, tn) = (seg.eval(t), tangent(seg, t)); + let n = Vec2::new(-tn.y, tn.x) * side; + [p + n, p - n] + }; + let [a, b] = across(0.0); + seg.extrema().into_iter().chain([1.0]).flat_map(across).fold(Rect::from_points(a, b), |r, p| r.union_pt(p)) +} + +/// The box of the arc a round join paints `side` from `at`, where the path turns from tangent +/// `t_in` to `t_out`: the arc runs on the outer side of the turn, and its box comes from its ends +/// and the points on it square to an axis. A joint where the path turns back on itself is given +/// the whole disc's box, and one where it does not turn paints no arc (`None`). +fn round_join_box(at: Point, t_in: Vec2, t_out: Vec2, side: f64) -> Option { + let (t_in, t_out) = (unit(t_in), unit(t_out)); + let turn = t_in.cross(t_out); + if turn.abs() < 1e-12 { + return (t_in.dot(t_out) < 0.0).then(|| Rect::from_center_size(at, (2.0 * side, 2.0 * side))); + } + let s = turn.signum(); + let outer = |t: Vec2| Vec2::new(t.y, -t.x) * s; + let (a, b) = (outer(t_in), outer(t_out)); + let axes = [Vec2::new(1.0, 0.0), Vec2::new(-1.0, 0.0), Vec2::new(0.0, 1.0), Vec2::new(0.0, -1.0)]; + let arc = Rect::from_points(at + a * side, at + b * side); + Some(axes.into_iter().filter(|d| a.cross(*d) * s >= 0.0 && d.cross(b) * s >= 0.0).fold(arc, |r, d| r.union_pt(at + d * side))) +} + /// Is `p` inside the convex polygon `pts` (either winding), or within `tol` of it? fn in_convex(pts: &[Point], p: Point, tol: f64) -> bool { let n = pts.len(); @@ -115,39 +167,81 @@ fn in_convex(pts: &[Point], p: Point, tol: f64) -> bool { impl StrokeLayer { /// Does bounding this stroke need its path's shape: miter spikes, projecting caps, arrowheads, - /// or an alignment that depends on whether the path is closed? Else it reaches evenly round it. + /// an alignment that depends on whether the path is closed, or butt caps, which stop at the + /// ends of an open path? Else it reaches evenly round it. fn needs_shape(&self) -> bool { self.join == LineJoin::Miter - || self.cap == LineCap::Square + || self.cap != LineCap::Round || self.start_arrow.is_some() || self.end_arrow.is_some() || self.align != StrokeAlign::Center } - /// The box this stroke paints along `bp`, whose geometric bounds are `bounds`: the bounds grown - /// by the body's reach, with the tips of the miter spikes the limit keeps, the corners of + /// The box this stroke paints along `segs`, the segments with length of an open subpath, when + /// its caps are butt caps: each segment's [`swept_box`] and, with round joins, the arc each + /// joint between segments paints ([`round_join_box`]), a shallow one included. + /// `None` when `segs` is empty. Butt dashes stop inside the same box. The heads and miter tips + /// are measured apart. A segment in the middle is measured only when its own box grown by + /// `side`, which holds everything it paints, is not inside the box so far. + fn butt_body(&self, segs: &[PathSeg], side: f64) -> Option { + let (first, last) = (segs.first()?, segs.last()?); + let ends = swept_box(first, side, 3).union(swept_box(last, side, 3)); + let body = segs.iter().skip(1).take(segs.len().saturating_sub(2)).fold(ends, |b, s| { + if b.contains_rect(ParamCurveExtrema::bounding_box(s).inflate(side, side)) { b } else { b.union(swept_box(s, side, 3)) } + }); + if self.join != LineJoin::Round { + return Some(body); + } + Some(joints(segs, false).fold(body, |b, (at, t_in, t_out)| round_join_box(at, t_in, t_out, side).map_or(b, |j| b.union(j)))) + } + + /// The box this stroke paints along `bp` (geometric bounds `bounds`): an open subpath with butt + /// caps by [`Self::butt_body`], every other subpath by the box of its segments grown by its + /// reach, with `tips` and, when `measure_tips`, the miter spikes the limit keeps, the corners of /// projecting caps and the reach of the arrowheads round their end points. - pub fn bounds_on(&self, bp: &BezPath, bounds: Rect) -> Rect { + fn reach_along(&self, bp: &BezPath, bounds: Rect, tips: Option, measure_tips: bool) -> Rect { let els = bp.elements(); - let mut out = bounds; + let subs = subpaths(bp); + let mut out = tips.map_or(bounds, |t| bounds.union(t)); let mut side_max: f64 = 0.0; + // Every subpath reaches evenly unless one is open and butt-capped; only then is each + // subpath's own box needed. + let even_only = self.cap != LineCap::Butt || subs.iter().all(|s| s.1); + let mut even = even_only.then_some(bounds); // Dashes put a cap on every dash: projecting ones reach a corner's diagonal anywhere. let capped_dashes = self.cap == LineCap::Square && self.dash.as_ref().is_some_and(Dash::is_dashed); - for (r, closed) in subpaths(bp) { + for (r, closed) in subs { let side = self.side_reach(closed); - side_max = side_max.max(if capped_dashes { side * std::f64::consts::SQRT_2 } else { side }); + let butt = self.cap == LineCap::Butt && !closed; + if !butt { + side_max = side_max.max(if capped_dashes { side * std::f64::consts::SQRT_2 } else { side }); + } if side <= 0.0 { continue; } + let square = self.cap == LineCap::Square && !closed; + let miters = measure_tips && self.join == LineJoin::Miter; + if !(butt || square || miters || !even_only) { + continue; + } let segs = drawn(&els[r]); - if self.join == LineJoin::Miter { + if miters { for (at, t_in, t_out) in joints(&segs, closed) { if let Some([_, tip, _]) = miter(at, t_in, t_out, side, self.miter_limit) { out = out.union_pt(tip); } } } - if self.cap == LineCap::Square && !closed { + if butt { + if let Some(body) = self.butt_body(&segs, side) { + out = out.union(body); + } + continue; + } + if !even_only { + even = vectorcraft_geom::union_opt(even, segs.iter().map(ParamCurveExtrema::bounding_box).reduce(|a, b| a.union(b))); + } + if square { for (at, dir) in ends(&segs).into_iter().flatten() { let n = Vec2::new(-dir.y, dir.x) * side; out = out.union_pt(at + dir * side + n).union_pt(at + dir * side - n); @@ -158,7 +252,15 @@ impl StrokeLayer { let r = self.head_reach(end); out = out.union(Rect::from_center_size(at, (2.0 * r, 2.0 * r))); } - out.union(bounds.inflate(side_max, side_max)) + even.map_or(out, |e| out.union(e.inflate(side_max, side_max))) + } + + /// The box this stroke paints along `bp`, whose geometric bounds are `bounds`: the bounds grown + /// by the body's reach, with the tips of the miter spikes the limit keeps, the corners of + /// projecting caps and the reach of the arrowheads round their end points. An open subpath + /// with butt caps does not reach past its ends ([`Self::butt_body`]). + pub fn bounds_on(&self, bp: &BezPath, bounds: Rect) -> Rect { + self.reach_along(bp, bounds, None, true) } /// The tip of the miter spike this stroke paints where segment `b` follows segment `a` in a @@ -176,30 +278,7 @@ impl StrokeLayer { /// every joint of `bp` ([`Self::miter_tip`]) or `None` when there are none, instead of measured /// along `bp`. It measures the projecting caps and arrowheads as [`Self::bounds_on`] does. pub fn bounds_with_tips(&self, bp: &BezPath, bounds: Rect, tips: Option) -> Rect { - let els = bp.elements(); - let mut out = tips.map_or(bounds, |t| bounds.union(t)); - let mut side_max: f64 = 0.0; - // Dashes put a cap on every dash: projecting ones reach a corner's diagonal anywhere. - let capped_dashes = self.cap == LineCap::Square && self.dash.as_ref().is_some_and(Dash::is_dashed); - for (r, closed) in subpaths(bp) { - let side = self.side_reach(closed); - side_max = side_max.max(if capped_dashes { side * std::f64::consts::SQRT_2 } else { side }); - if side <= 0.0 { - continue; - } - if self.cap == LineCap::Square && !closed { - let segs = drawn(&els[r]); - for (at, dir) in ends(&segs).into_iter().flatten() { - let n = Vec2::new(-dir.y, dir.x) * side; - out = out.union_pt(at + dir * side + n).union_pt(at + dir * side - n); - } - } - } - for (at, _, end) in head_ends(self, bp) { - let r = self.head_reach(end); - out = out.union(Rect::from_center_size(at, (2.0 * r, 2.0 * r))); - } - out.union(bounds.inflate(side_max, side_max)) + self.reach_along(bp, bounds, tips, false) } /// Does this stroke along `bp` (filled with `rule`) paint within `tol` of `p`? Inside and @@ -314,7 +393,7 @@ impl Appearance { } /// [`Self::stroked_bounds`] with the box of each visible stroke that needs the path's shape - /// (miter spikes, projecting caps, arrowheads or an alignment) given by `shaped`. + /// (miter spikes, projecting or butt caps, arrowheads or an alignment) given by `shaped`. pub fn stroked_bounds_by(&self, bounds: Rect, mut shaped: impl FnMut(&StrokeLayer) -> Rect) -> Rect { self.painted_strokes().fold(bounds, |acc, s| { if !s.needs_shape() { @@ -325,6 +404,30 @@ impl Appearance { }) } + /// Does this appearance have a visible butt-capped stroke? + pub fn has_butt_stroke(&self) -> bool { + self.painted_strokes().any(|s| s.cap == LineCap::Butt) + } + + /// Does a visible butt-capped stroke of this appearance run along an open subpath of `bp` with + /// a segment that starts or ends at a slant, not square to the axes, once placed by `a`? The + /// visual box's margin at the stroke's corners there depends on their direction, which a scale + /// out of proportion changes. + pub fn has_slanted_butt_stroke(&self, bp: &BezPath, a: vectorcraft_geom::Affine) -> bool { + if !self.has_butt_stroke() { + return false; + } + let slanted = |v: Vec2| { + let d = unit(a * v.to_point() - a * Point::ZERO); + (d.x * d.y).abs() > 1e-9 + }; + let els = bp.elements(); + subpaths(bp) + .into_iter() + .filter(|(_, closed)| !closed) + .any(|(r, _)| drawn(&els[r]).iter().any(|s| slanted(tangent(s, 0.0)) || slanted(tangent(s, 1.0)))) + } + /// Does a visible stroke of this appearance along `bp` (filled with `rule`) paint within /// `tol` of `p` ([`StrokeLayer::hits`])? pub fn stroke_hit(&self, bp: &BezPath, rule: FillRule, p: Point, tol: f64) -> bool { @@ -332,6 +435,31 @@ impl Appearance { } } +/// How many symbol instances [`crate::Node::has_slanted_butt_stroke`] looks into for one object +/// before it answers yes without looking further. +pub const SLANT_INSTANCES: usize = 4096; + +impl crate::Node { + /// Does a visible butt-capped stroke of this object, of an object in it or of an object in a + /// symbol instance's art from `symbols`, placed by `a`, run along an open path with a segment + /// at a slant ([`Appearance::has_slanted_butt_stroke`])? Past [`SLANT_INSTANCES`] instances it + /// answers yes. + pub fn has_slanted_butt_stroke(&self, symbols: &crate::SymbolBoxes, a: vectorcraft_geom::Affine) -> bool { + self.slanted_butt(symbols, a, &std::cell::Cell::new(SLANT_INSTANCES)) + } + + /// [`Self::has_slanted_butt_stroke`] with `left` instances still to look into. + fn slanted_butt(&self, symbols: &crate::SymbolBoxes, a: vectorcraft_geom::Affine, left: &std::cell::Cell) -> bool { + if let crate::NodeKind::SymbolInstance { symbol, xf } = &self.kind { + let Some(l) = left.get().checked_sub(1) else { return true }; + left.set(l); + return symbols.measure_art(symbol, |art| art.slanted_butt(symbols, a * *xf, left)).unwrap_or(false); + } + (self.appearance.has_butt_stroke() && self.stroke_path().is_some_and(|bp| self.appearance.has_slanted_butt_stroke(&bp, a))) + || self.children().is_some_and(|c| c.iter().any(|c| c.slanted_butt(symbols, a, left))) + } +} + #[cfg(test)] mod tests { use vectorcraft_color::{Color, Paint}; @@ -378,7 +506,7 @@ mod tests { let (small, big) = (at(100.0).bounds_on(&l, b), at(300.0).bounds_on(&l, b)); assert!(big.x1 > small.x1 + 15.0 && big.y1 > small.y1 + 7.0, "{small:?} → {big:?}"); // A 300% head on a 2 pt stroke is 24 pt long and wide: its tip, past the end, is inside. - assert!(big.x1 >= 100.0 + 24.0 && big.y1 >= 12.0 && big.x0 == -1.0, "{big:?}"); + assert!(big.x1 >= 100.0 + 24.0 && big.y1 >= 12.0 && big.x0 == 0.0, "{big:?}"); // The start has no head. assert_eq!(at(300.0).head_reach(false), 0.0); } @@ -405,6 +533,164 @@ mod tests { assert_eq!(stroke(4.0, |s| s.align = StrokeAlign::Outside).bounds_on(&sq, b), b.inflate(4.0, 4.0)); } + /// An open path through `points`. + fn polyline(points: &[(f64, f64)]) -> BezPath { + let mut bp = BezPath::new(); + for (i, p) in points.iter().enumerate() { + if i == 0 { bp.move_to(*p) } else { bp.line_to(*p) } + } + bp + } + + #[test] + fn butt_caps_stop_at_the_ends_of_an_open_path() { + let butt = |f: fn(&mut StrokeLayer)| { + stroke(2.0, |s| { + s.cap = LineCap::Butt; + f(s); + }) + }; + // A vertical line reaches sideways only. + let v = polyline(&[(50.0, 10.0), (50.0, 60.0)]); + assert_eq!(butt(|_| {}).bounds_on(&v, v.bounding_box()), Rect::new(49.0, 10.0, 51.0, 60.0)); + // A 3-4-5 diagonal: its corners reach 0.8 pt across x and 0.6 pt across y. + let d = polyline(&[(0.0, 0.0), (30.0, 40.0)]); + let b = butt(|_| {}).bounds_on(&d, d.bounding_box()); + for (got, want) in [(b.x0, -0.8), (b.y0, -0.6), (b.x1, 30.8), (b.y1, 40.6)] { + assert!((got - want).abs() < 1e-9, "{b:?}"); + } + // The dashes of a butt stroke stop inside the line as well, and an open path ignores the + // alignment. + let dashed = butt(|s| s.dash = Some(Dash { pattern: vec![4.0, 2.0], offset: 0.0, align_corners: false })); + assert_eq!(dashed.bounds_on(&v, v.bounding_box()), Rect::new(49.0, 10.0, 51.0, 60.0)); + assert_eq!(butt(|s| s.align = StrokeAlign::Inside).bounds_on(&v, v.bounding_box()), Rect::new(49.0, 10.0, 51.0, 60.0)); + // A round cap reaches half the weight past each end, a square one the corners. + let round = butt(|s| s.cap = LineCap::Round).bounds_on(&v, v.bounding_box()); + assert_eq!(round, Rect::new(49.0, 9.0, 51.0, 61.0)); + let square = butt(|s| s.cap = LineCap::Square).bounds_on(&v, v.bounding_box()); + assert_eq!(square, Rect::new(49.0, 9.0, 51.0, 61.0)); + // Two strokes union. + let mut ap = Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 2.0); + ap.stroke_mut().unwrap().cap = LineCap::Butt; + let wide = stroke(10.0, |s| s.cap = LineCap::Round); + ap.items.push(crate::AppearanceItem::Stroke(wide)); + let vb = v.bounding_box(); + assert_eq!(ap.stroked_bounds(vb, || v.clone()), Rect::new(45.0, 5.0, 55.0, 65.0)); + } + + #[test] + fn a_butt_paths_joins_and_curves_stay_inside_the_box() { + // A corner in the middle: the round join reaches a full half weight straight down, outside + // the turn, the ends do not, and a miter reaches its tip. + let zig = polyline(&[(0.0, 0.0), (20.0, 20.0), (40.0, 0.0)]); + let round = stroke(4.0, |s| { + s.cap = LineCap::Butt; + s.join = LineJoin::Round; + }) + .bounds_on(&zig, zig.bounding_box()); + assert!((round.y1 - 22.0).abs() < 1e-9, "the round join's reach: {round:?}"); + assert!((round.x0 + 2.0_f64.sqrt()).abs() < 1e-9 && (round.x1 - 40.0 - 2.0_f64.sqrt()).abs() < 1e-9, "the ends' corners: {round:?}"); + // A round join paints its arc on the outer side of a turn too shallow to count as a + // corner, and nothing where the path goes straight on. + let round_butt = stroke(4.0, |s| { + s.cap = LineCap::Butt; + s.join = LineJoin::Round; + }); + let shallow = polyline(&[(0.0, 0.25), (50.0, 0.0), (100.0, 0.25)]); + let b = round_butt.bounds_on(&shallow, shallow.bounding_box()); + assert!((b.y0 + 2.0).abs() < 1e-12, "{b:?}"); + let straight = polyline(&[(0.0, 0.0), (0.1, 0.0), (100.0, 0.0)]); + assert_eq!(round_butt.bounds_on(&straight, straight.bounding_box()), Rect::new(0.0, -2.0, 100.0, 2.0)); + let miter = stroke(4.0, |s| s.cap = LineCap::Butt).bounds_on(&zig, zig.bounding_box()); + assert!((miter.y1 - (20.0 + 2.0 * 2.0_f64.sqrt())).abs() < 1e-9, "the miter tip: {miter:?}"); + // An arc over the top whose ends leave the page straight down: the top reaches half the + // weight past the path, and the bottom stops at the ends. + let mut arc = BezPath::new(); + arc.move_to((0.0, 50.0)); + arc.curve_to((0.0, 22.4), (22.4, 0.0), (50.0, 0.0)); + arc.curve_to((77.6, 0.0), (100.0, 22.4), (100.0, 50.0)); + let b = stroke(4.0, |s| s.cap = LineCap::Butt).bounds_on(&arc, arc.bounding_box()); + assert!((b.y0 + 2.0).abs() < 1e-9 && (b.x0 + 2.0).abs() < 1e-9 && (b.x1 - 102.0).abs() < 1e-9, "{b:?}"); + assert!((b.y1 - 50.0).abs() < 1e-9, "{b:?}"); + } + + #[test] + fn a_butt_stroke_is_slanted_where_a_segment_starts_or_ends_at_a_slant() { + let mut ap = Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 2.0); + ap.stroke_mut().unwrap().cap = LineCap::Butt; + let id = vectorcraft_geom::Affine::IDENTITY; + assert!(!ap.has_slanted_butt_stroke(&polyline(&[(0.0, 0.0), (0.0, 50.0), (30.0, 50.0)]), id)); + // Square ends, slanted segments in the middle. + let apex = polyline(&[(0.0, 50.0), (0.0, 40.0), (10.0, 0.0), (20.0, 40.0), (20.0, 50.0)]); + assert!(ap.has_slanted_butt_stroke(&apex, id)); + // A rotation slants a vertical line; a round cap is never slanted. + let v = polyline(&[(0.0, 0.0), (0.0, 50.0)]); + assert!(ap.has_slanted_butt_stroke(&v, vectorcraft_geom::Affine::rotate(0.3))); + ap.stroke_mut().unwrap().cap = LineCap::Round; + assert!(!ap.has_slanted_butt_stroke(&apex, id)); + } + + #[test] + fn the_slant_check_looks_into_at_most_4096_symbol_instances() { + // S0 is a vertical butt line. Each of S1 to S12 holds two instances of the symbol before + // it, so an instance of S12 holds 8,191 instances and none of them is slanted. Past 4,096 + // the check answers yes. + let mut d = Document::new(400.0, 400.0); + let mut ap = Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 2.0); + ap.stroke_mut().unwrap().cap = LineCap::Butt; + let line = Node::path(d.alloc_id(), PathData::from_bezpath(&polyline(&[(0.0, 0.0), (0.0, 10.0)])), ap); + d.symbols.push(crate::Symbol { dynamic: false, name: "S0".into(), art: std::sync::Arc::new(line) }); + for k in 1..=12 { + let mut instance = |dx: f64| { + let xf = vectorcraft_geom::Affine::translate((dx, 0.0)); + std::sync::Arc::new(Node::new(d.alloc_id(), crate::NodeKind::SymbolInstance { symbol: format!("S{}", k - 1), xf })) + }; + let pair = vec![instance(0.0), instance(20.0)]; + let art = Node::group(d.alloc_id(), pair); + d.symbols.push(crate::Symbol { dynamic: false, name: format!("S{k}"), art: std::sync::Arc::new(art) }); + } + let top = Node::new(d.alloc_id(), crate::NodeKind::SymbolInstance { symbol: "S12".into(), xf: vectorcraft_geom::Affine::IDENTITY }); + let symbols = d.symbol_boxes(); + assert!(top.has_slanted_butt_stroke(&symbols, vectorcraft_geom::Affine::IDENTITY)); + assert!(symbols.art_measured() <= SLANT_INSTANCES, "{}", symbols.art_measured()); + // One instance of S1 holds three instances, all looked into. + let one = Node::new(d.alloc_id(), crate::NodeKind::SymbolInstance { symbol: "S1".into(), xf: vectorcraft_geom::Affine::IDENTITY }); + assert!(!one.has_slanted_butt_stroke(&d.symbol_boxes(), vectorcraft_geom::Affine::IDENTITY)); + } + + #[test] + fn a_compound_path_measures_each_of_its_subpaths_by_its_own_ends() { + // A closed square at the top and an open line below it: only the square reaches past its + // own edges. + let mut bp = BezPath::new(); + bp.extend(square().elements().iter().copied()); + bp.move_to((50.0, 150.0)); + bp.line_to((50.0, 200.0)); + let st = stroke(4.0, |s| s.cap = LineCap::Butt); + assert_eq!(st.bounds_on(&bp, bp.bounding_box()), Rect::new(-2.0, -2.0, 102.0, 200.0)); + } + + #[test] + fn a_middle_segment_can_set_the_box_of_a_butt_path() { + // The flat middle segment reaches a full half weight above the path, and the ends' corners + // reach 10/51 of it below. + let hat = polyline(&[(0.0, 50.0), (10.0, 0.0), (20.0, 0.0), (30.0, 50.0)]); + let b = stroke(2.0, |s| { + s.cap = LineCap::Butt; + s.join = LineJoin::Bevel; + }) + .bounds_on(&hat, hat.bounding_box()); + let corner = 10.0 / (10.0_f64 * 10.0 + 50.0 * 50.0).sqrt(); + assert!((b.y0 + 1.0).abs() < 1e-9 && (b.y1 - 50.0 - corner).abs() < 1e-9, "{b:?}"); + } + + #[test] + fn bounds_with_tips_measures_a_butt_line_as_bounds_on_does() { + let v = polyline(&[(50.0, 10.0), (50.0, 60.0)]); + let st = stroke(2.0, |s| s.cap = LineCap::Butt); + assert_eq!(st.bounds_with_tips(&v, v.bounding_box(), None), Rect::new(49.0, 10.0, 51.0, 60.0)); + } + #[test] fn clicks_hit_outside_strokes_on_their_side_only() { let sq = square(); diff --git a/crates/effects/src/stroke/tests_reach.rs b/crates/effects/src/stroke/tests_reach.rs index 859d8b1c0e..29a48343ed 100644 --- a/crates/effects/src/stroke/tests_reach.rs +++ b/crates/effects/src/stroke/tests_reach.rs @@ -56,6 +56,28 @@ fn check(bp: &BezPath, st: &StrokeLayer, what: &str) { assert!(checked > 0, "{what}: no samples"); } +#[test] +fn a_butt_capped_open_path_is_measured_where_it_paints() { + let mut vertical = BezPath::new(); + vertical.move_to((50.0, 10.0)); + vertical.line_to((50.0, 90.0)); + let mut diagonal = BezPath::new(); + diagonal.move_to((10.0, 10.0)); + diagonal.line_to((40.0, 50.0)); + let mut arc = BezPath::new(); + arc.move_to((10.0, 50.0)); + arc.curve_to((10.0, 22.4), (32.4, 0.0), (60.0, 0.0)); + let mut stroke = StrokeLayer::new(Paint::solid(Color::BLACK), 4.0); + stroke.cap = LineCap::Butt; + for (name, bp) in [("vertical", vertical), ("diagonal", diagonal), ("arc", arc)] { + let painted = outline_region(&PathData::from_bezpath(&bp), FillRule::NonZero, &stroke).to_bezpath().bounding_box(); + let bounds = stroke.bounds_on(&bp, bp.bounding_box()); + for (got, want) in [(bounds.x0, painted.x0), (bounds.y0, painted.y0), (bounds.x1, painted.x1), (bounds.y1, painted.y1)] { + assert!((got - want).abs() < 0.05, "{name}: measured {bounds:?}, paints {painted:?}"); + } + } +} + #[test] fn bounds_and_clicks_cover_what_every_stroke_paints() { let profiles = [None, Some(WidthProfile::lens()), Some(WidthProfile { points: vec![(0.0, 2.0, 0.5), (0.6, 0.8, 1.6), (1.0, 1.2, 0.3)] })]; diff --git a/crates/engine/src/cmd/object/bounds.rs b/crates/engine/src/cmd/object/bounds.rs index 9d4795235f..df2c1ffd1f 100644 --- a/crates/engine/src/cmd/object/bounds.rs +++ b/crates/engine/src/cmd/object/bounds.rs @@ -125,15 +125,20 @@ fn bounds_transform(s: &Session, p: &Value, ids: Vec, least: f64, anchor // The scales and the point above can miss the size or the reference point when the margins of // the objects differ, the members of groups and the objects in symbols' art included, and with // strokes that scale, when the sizes are out of proportion. The objects are then measured one - // by one ([`Spans::measure`]), and the scales solved for. Objects with geometry effects are - // measured as drawn after each scale tried instead ([`refit_drawn`]). + // by one ([`Spans::measure`]), and the scales solved for. Objects with geometry effects, and + // objects with a butt-capped stroke along a segment at a slant, whose margins change with the + // segment's direction, are measured as drawn after each scale tried instead ([`refit_drawn`]). let symbols = d.symbol_boxes(); - let drawn = || ids.iter().filter_map(|id| d.node(*id)).any(|n| effects::preview::draws_geometry(&symbols, n)); + let to_local = frame.to_doc().inverse(); + let drawn = || { + ids.iter() + .filter_map(|id| d.node(*id)) + .any(|n| effects::preview::draws_geometry(&symbols, n) || n.has_slanted_butt_stroke(&symbols, to_local)) + }; let solved = if !(s.prefs.use_preview_bounds && w >= 0.0 && h >= 0.0) { None } else if transforms_objects(p) && !bool_or(p, "typeAreas", false) && drawn() { let options = scaling_options(s, p); - let to_local = frame.to_doc().inverse(); let measure = |sx: f64, sy: f64| { let xf = frame.conjugate(about(rp, Affine::scale_non_uniform(sx, sy))); let sc = if Scaling::factor(xf).is_some() { options } else { Scaling::default() }; diff --git a/crates/engine/src/tests/previewbounds.rs b/crates/engine/src/tests/previewbounds.rs index c9bad6e60d..773b2b7842 100644 --- a/crates/engine/src/tests/previewbounds.rs +++ b/crates/engine/src/tests/previewbounds.rs @@ -618,6 +618,77 @@ fn set_bounds_leaves_the_height_of_lines_with_no_height() { assert!(close(geometric(&s, ids[1]), Rect::new(8.3, 100.0, 30.0, 100.0)), "{:?}", geometric(&s, ids[1])); } +/// A line with a 2 pt stroke and butt caps, selected, with Use Preview Bounds on. +fn butt_line(s: &mut Session, from: (f64, f64), to: (f64, f64)) -> NodeId { + let id = NodeId(run(s, "shape.line", json!({"x1": from.0, "y1": from.1, "x2": to.0, "y2": to.1}))["id"].as_u64().unwrap()); + run(s, "stroke.set", json!({"weight": 2, "cap": "butt"})); + preview(s, true); + run(s, "select.set", json!({"ids": [id.0]})); + id +} + +#[test] +fn set_bounds_gives_a_butt_line_the_whole_height() { + // A butt cap ends at the anchor, so 10 mm (28.3465 pt) is the length of the line itself. + let mut s = session(); + let id = butt_line(&mut s, (100.0, 100.0), (100.0, 140.0)); + assert!(close(s.transform_bounds(&[id]).unwrap(), Rect::new(99.0, 100.0, 101.0, 140.0)), "{:?}", s.transform_bounds(&[id])); + run(&mut s, "object.setBounds", json!({"height": 28.3465, "reference": 0, "strokes": false})); + assert!(close(geometric(&s, id), Rect::new(100.0, 100.0, 100.0, 128.3465)), "{:?}", geometric(&s, id)); + assert!(close(s.transform_bounds(&[id]).unwrap(), Rect::new(99.0, 100.0, 101.0, 128.3465)), "{:?}", s.transform_bounds(&[id])); +} + +#[test] +fn set_bounds_gives_a_diagonal_butt_line_the_size_out_of_proportion() { + // The stroke's corners follow the line's slope, which a scale out of proportion changes. + let mut s = session(); + let id = butt_line(&mut s, (100.0, 100.0), (130.0, 140.0)); + let before = s.transform_bounds(&[id]).unwrap(); + // The stroke's corners reach 0.8 pt across x and 0.6 pt across y at this 3-4-5 slope. + assert!(close(before, Rect::new(99.2, 99.4, 130.8, 140.6)), "{before:?}"); + run(&mut s, "object.setBounds", json!({"height": 100, "reference": 0, "strokes": false})); + let v = s.transform_bounds(&[id]).unwrap(); + assert!((v.height() - 100.0).abs() < 1e-6, "{v:?}"); +} + +#[test] +fn set_bounds_gives_a_butt_path_with_slanted_corners_the_size_out_of_proportion() { + // Square ends, but the bevel join at the apex sets the top edge by the slanted segments' + // corners, which a scale out of proportion moves. + let mut s = session(); + let anchors = json!([{"x": 100, "y": 150}, {"x": 100, "y": 140}, {"x": 110, "y": 100}, {"x": 120, "y": 140}, {"x": 120, "y": 150}]); + let id = NodeId(run(&mut s, "path.create", json!({"anchors": anchors}))["id"].as_u64().unwrap()); + run(&mut s, "stroke.set", json!({"weight": 2, "cap": "butt", "join": "bevel"})); + preview(&mut s, true); + run(&mut s, "select.set", json!({"ids": [id.0]})); + run(&mut s, "object.setBounds", json!({"width": 60, "height": 20, "reference": 0, "strokes": false})); + let v = s.transform_bounds(&[id]).unwrap(); + assert!((v.width() - 60.0).abs() < 1e-6 && (v.height() - 20.0).abs() < 1e-6, "{v:?}"); +} + +#[test] +fn set_bounds_gives_a_symbol_instance_of_a_diagonal_butt_line_the_size_out_of_proportion() { + let mut s = session(); + butt_line(&mut s, (100.0, 100.0), (130.0, 140.0)); + let id = NodeId(run(&mut s, "symbol.new", json!({"name": "Line"}))["id"].as_u64().unwrap()); + run(&mut s, "select.set", json!({"ids": [id.0]})); + run(&mut s, "object.setBounds", json!({"width": 20, "height": 100, "reference": 0, "strokes": false})); + let v = s.transform_bounds(&[id]).unwrap(); + assert!((v.width() - 20.0).abs() < 1e-6 && (v.height() - 100.0).abs() < 1e-6, "{v:?}"); +} + +#[test] +fn align_places_a_vertical_butt_line_by_its_end_points() { + // With Use Preview Bounds on, a butt line's ends line up with the top of a rectangle without + // a stroke. + let mut s = session(); + rect(&mut s, 20.0, 50.0, 0.0); + let id = butt_line(&mut s, (200.0, 160.0), (200.0, 190.0)); + run(&mut s, "select.all", json!({})); + run(&mut s, "object.align", json!({"vertical": "top"})); + assert!(close(geometric(&s, id), Rect::new(200.0, 100.0, 200.0, 130.0)), "{:?}", geometric(&s, id)); +} + /// The rectangles of [`thin_and_thick`] in a group, the thick one in a group of its own inside it /// when `nested`, selected with an unstroked rectangle from x 100 to 110 when `plain`. Returns the /// selected objects. diff --git a/crates/engine/tests/plugin_import.rs b/crates/engine/tests/plugin_import.rs index 71f21e10d2..b780c115c3 100644 --- a/crates/engine/tests/plugin_import.rs +++ b/crates/engine/tests/plugin_import.rs @@ -145,7 +145,7 @@ fn a_file_an_importer_reads_opens_as_editable_art() { let stroked = r#"{"objects":[{"type":"path","path":{"subpaths":[{"closed":false,"anchors":[{"p":[0,-4000000]},{"p":[50,-4000000]}]}]},"strokes":[{"paint":{"type":"solid","color":{"model":"rgb","r":0,"g":0,"b":0}},"width":10}]}]}"#; open(&mut s, "Stroked.tso", stroked.as_bytes(), json!({})).unwrap(); let (x0, y0, x1, y1) = artboard(&s); - assert!(x0 < 0.0 && x1 > 50.0 && y0 == -4_000_000.0 && y1 > y0 + 1.0, "{:?}", artboard(&s)); + assert!(x0 == 0.0 && x1 == 50.0 && y0 == -4_000_000.0 && y1 > y0 + 1.0, "{:?}", artboard(&s)); s.execute("select.all", &json!({})).unwrap(); s.execute("object.move", &json!({"dx": 0, "dy": 5})).unwrap(); diff --git a/docs/mcp.md b/docs/mcp.md index 8f605eee0f..d4a26e9955 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -3188,7 +3188,11 @@ the `…Open` rings…) and marks (`Bar`, `DoubleBar`, `DotOnBar`, `Slash`, `Dou Visual bounds (Fit to Selected Art, Rasterize, exporting all art) and clicks on the canvas take in the whole stroke as drawn: arrowheads, inside/outside alignment (an outside stroke is hit outside the path, an inside one inside it), the width profile's width where you click, projecting caps and miter spikes up to the miter -limit. A rectangle's right-angle miters stay within half the weight of its edges. +limit. A rectangle's right-angle miters stay within half the weight of its edges. A butt-capped stroke on an open +path does not reach past the path's ends: a straight segment's box comes from the four corners of its stroked +rectangle. Past an end, only a round or projecting cap or an arrowhead adds to the box. A curve that bends more +tightly than half the stroke weight can measure a little larger than it paints, and a butt-dashed path whose first or +last dash is a gap is measured to the path's ends. A symbol instance is measured by its symbol's art, placed by the instance's transform. The bounds in `document.inspect` and `document.node`, the Align commands, `object.setBounds`, the bounding box, clicks, marquees, diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 6346f0020f..721ed42aff 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1044,6 +1044,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Pathfinder › Trap ([trapping](https://helpx.adobe.com/illustrator/using/trapping.html), [Pathfinder panel](https://helpx.adobe.com/illustrator/using/pathfinder-panel-overview.html)): the Pathfinder panel menu's Trap… (`object.pathfinder.trap`) and Effect › Pathfinder › Trap… (`pathfinder.trap`, live on groups) add, where two objects' visible areas of different solid colours meet, a strip of the darker one's area along the edge, filled with the lighter colour at the Tint Reduction percentage and set to overprint (Reverse Traps: the darker colour spreads instead, but not a rich black); Thickness 0.01–5000 pt across vertical edges, Height/Width scales it across horizontal ones, Traps with Process Color paints a swatch colour's trap with its CMYK equivalent; the effect also has Precision and Remove Redundant Points. The panel's Trap is one undo step and groups the traps above the topmost selected object, selected; with no adjoining edges of different colours it says so and changes nothing. Tests: `crates/pathops/src/trap.rs`, `crates/effects/src/trap.rs`, `group::tests::trap_effect_adds_an_overprinting_strip_above_the_members`, `crates/engine/src/tests/trap.rs`, `panels::pathfinder::tests::trap_opens_its_options_and_ok_traps_the_selection`. - Compound shapes ([Create compound shapes with Pathfinder](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/create-compound-shapes-with-pathfinder.html), [Pathfinder panel overview](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/pathfinder-panel-overview.html)): the Pathfinder panel menu's Make Compound Shape (every member in Add) and Alt-clicking a Shape Mode (every member in that mode) make the two or more selected paths, compound paths, groups, compound shapes or type a live compound shape (`object.compoundShape.make`): a group whose members each keep a shape mode and stay editable, drawn, printed and exported (SVG, PDF and the rest through the baked document) as the area the modes make from the backmost member up, open paths closed, painted with its own appearance, taken from the topmost member not subtracting. With members selected (Direct Selection, Layers panel, isolation mode) a Shape Mode button sets theirs (`object.compoundShape.setMode`) and is lit for the mode they share; paint given to a member goes to the compound shape. Release Compound Shape gives the members back as they were (`object.compoundShape.release`); Expand Compound Shape and the panel's Expand button turn it into the path or compound path of its area (`object.compoundShape.expand`). Shape Modes and Pathfinders take a compound shape as its area. Saved files keep it (`compound_shape`, `shape_mode`; an unknown mode reads as Add). Blends, envelopes and objects with a warp or another geometry effect are members as they draw (a blend's steps, an envelope's distorted art, the warped outline), and the area follows an edit of the blend, envelope or warp; Release gives them back as they were. Tests: `crates/engine/src/tests/compound_shape.rs`, `group::tests::compound_shapes_draw_the_area_their_shape_modes_make`, `group::tests::nested_compound_shapes_take_part_as_their_area`, `panels::pathfinder::tests::compound_shape_items_follow_the_selection`, the `CompoundShape` op of the property-test strategies. Not yet: the question marks on mixed member modes, hit-testing by the area (a click hits the members). - Effect › Path › Outline Object has no options. Type that carries it is drawn, exported (PDF and SVG as paths, with no font) and expanded as its glyph outlines while it stays editable, and the effects after it reshape those outlines. Paths and images draw as before. With Use Preview Bounds on, type is measured by its glyph outlines. Public reference: [Summary of effects](https://web.archive.org/web/20210901011839/https://helpx.adobe.com/illustrator/using/summary-effects.html) (archived copy). + - A butt cap ends a stroke square at the end of an open path, and a projecting cap extends half the weight past it. VectorCraft's visual box of a butt-capped stroke on an open path does not grow past the path's ends: a straight segment's box comes from the corners of its stroked rectangle, and a curve's box comes from the stroke at its ends and where its tangent is square to an axis. Round and projecting caps and arrowheads still reach past an end. A curve that bends more tightly than half the weight can measure a little larger than it paints, and a butt-dashed path whose first or last dash is a gap is measured to the path's ends. Public reference: [Apply stroke on an object](https://helpx.adobe.com/illustrator/using/stroke-object.html). - With Use Preview Bounds on, the bounding box, the Transform panel, Align, Distribute, Distribute Spacing and `object.setBounds` measure each object after its geometry effects. A path is measured by its effected shape, and a fill or stroke with geometry effects of its own is measured by the shape it paints. Type, images, live objects and symbol instances are measured by their reshaped art, and a symbol instance is also measured by its reshaped art when its symbol's art has geometry effects. A group or layer with geometry or Pathfinder effects, or with a fill or stroke of its own that has geometry effects or paints members with geometry effects, is measured by its evaluated art. A blend, envelope or repeat whose objects have geometry effects is measured by its evaluated art, with those objects measured after their effects. A 100 pt square without a stroke, with Offset Path 10, measures 120 pt, and type with Outline Object aligns and sizes by its glyphs. Raster effects are not measured. Public reference: [Create text](https://web.archive.org/web/20220127090642/https://helpx.adobe.com/illustrator/using/creating-text.html) (archived copy). - Initial live Revolve (#605, #846): a portable geometry and shading crate (`three-d`), editable path and compound profiles, compact options with artboard rotation and light gizmos, SVG/PDF vector output and Expand Appearance that can trim covered faces. Cap is on by default with continuous curved vector rims; shading uses vector gradients automatically, with sharp profile corners, uniform caps and round seam joins. One light and approximate visibility remain ([revolve.md](revolve.md)). - Effect › Path › Outline Object has no options. Type that carries it is drawn, exported (PDF and SVG as paths, with no font) and expanded as its glyph outlines while it stays editable, and the effects after it reshape those outlines. Paths and images draw as before. With Use Preview Bounds on, type is still measured by its layout box. Public reference: [Summary of effects](https://web.archive.org/web/20210901011839/https://helpx.adobe.com/illustrator/using/summary-effects.html) (archived copy). From c770e3bfeac4003922a5a950fc72075b164271dc Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Mon, 9 Nov 2026 18:19:22 -0800 Subject: [PATCH 24/36] Revolve: mesh export cuts an edge where it crosses another contour Mesh export decided whether an edge walls a hole at the edge's middle alone, so an edge that crosses another closed contour turned its whole length toward the hole, or none of it. Each sampled piece of a closed contour is now cut where it crosses another closed contour, and each part faces a hole only where it walls one. --- crates/three-d/src/lib.rs | 214 +++++++++++++++++++++---------- crates/three-d/src/mesh/tests.rs | 17 +++ docs/mcp.md | 2 +- 3 files changed, 167 insertions(+), 66 deletions(-) diff --git a/crates/three-d/src/lib.rs b/crates/three-d/src/lib.rs index 88843cb4bb..479481c2a6 100644 --- a/crates/three-d/src/lib.rs +++ b/crates/three-d/src/lib.rs @@ -518,77 +518,152 @@ const MAX_HOLE_GRID: usize = 256; /// For each segment of each subpath of `path`, whether it walls a hole under `rule`: the fill is /// outside its closed contour at the segment's middle and not inside. An error past /// [`MAX_HOLE_WORK`]. +#[cfg(test)] fn hole_segments(path: &PathData, rule: FillRule) -> Result>, String> { hole_segments_counted(path, rule).map(|(holes, _)| holes) } -/// [`hole_segments`] and the work it did. A point is tested only against the closed contours whose -/// bounds reach into its cell of a grid over the bounds of them all. +/// [`hole_segments`] and the work it did. +#[cfg(test)] fn hole_segments_counted(path: &PathData, rule: FillRule) -> Result<(Vec>, usize), String> { - let contours: Vec<(BezPath, Rect)> = path - .subpaths - .iter() - .map(|sp| { - let contour = PathData::new(vec![sp.clone()]).to_bezpath(); - let bounds = contour.bounding_box(); - (contour, bounds) - }) - .collect(); - let closed = |j: usize| path.subpaths.get(j).is_some_and(|sp| sp.closed); - let mut work = 0usize; - let spend = |n: usize, work: &mut usize| { - *work = work.saturating_add(n); - if *work > MAX_HOLE_WORK { Err(TOO_COMPLEX_PROFILE.to_string()) } else { Ok(()) } - }; - let area = contours.iter().enumerate().filter(|(j, _)| closed(*j)).map(|(_, (_, b))| *b).reduce(|a, b| a.union(b)); - let count = (0..contours.len()).filter(|j| closed(*j)).count(); - let side = ((count as f64).sqrt().ceil() as usize).clamp(1, MAX_HOLE_GRID); - let at = |v: f64, lo: f64, hi: f64| { - let span = hi - lo; - if span > 0.0 { (((v - lo) / span * side as f64).floor().max(0.0) as usize).min(side - 1) } else { 0 } - }; - let cell = |p: Point| area.map(|a| at(p.y, a.y0, a.y1) * side + at(p.x, a.x0, a.x1)); - let mut grid: Vec> = vec![vec![]; side * side]; - if let Some(a) = area { - for (j, (_, b)) in contours.iter().enumerate().filter(|(j, _)| closed(*j)) { - let (x0, x1) = (at(b.x0, a.x0, a.x1), at(b.x1, a.x0, a.x1)); - let (y0, y1) = (at(b.y0, a.y0, a.y1), at(b.y1, a.y0, a.y1)); - spend((x1 - x0 + 1).saturating_mul(y1 - y0 + 1), &mut work)?; - for y in y0..=y1 { - for x in x0..=x1 { - if let Some(c) = grid.get_mut(y * side + x) { - c.push(j); - } + let mut search = HoleSearch::new(path, rule)?; + let mut holes = Vec::with_capacity(path.subpaths.len()); + for (i, sp) in path.subpaths.iter().enumerate() { + let segments = if sp.closed { sp.anchors.len() } else { 0 }; + holes.push((0..segments).map(|segment| search.is_hole(i, sp.segment(segment).eval(0.5))).collect::, _>>()?); + } + Ok((holes, search.work)) +} + +/// The closed contours of a profile sorted into a grid over the bounds of them all, each listed in +/// the cells its bounds reach into, to find which parts of the profile's edges wall a hole under +/// its fill rule. A point is tested only against the contours of its cell. Every test counts +/// toward [`MAX_HOLE_WORK`]. +struct HoleSearch { + /// Each subpath's outline, its bounds, and for a closed one which way it winds. + contours: Vec<(BezPath, Rect, Option)>, + rule: FillRule, + area: Option, + side: usize, + grid: Vec>, + work: usize, +} + +impl HoleSearch { + fn new(path: &PathData, rule: FillRule) -> Result { + let contours: Vec<(BezPath, Rect, Option)> = path + .subpaths + .iter() + .map(|sp| { + let contour = PathData::new(vec![sp.clone()]).to_bezpath(); + let bounds = contour.bounding_box(); + let direction = sp.closed.then(|| if contour.area() < 0.0 { -1 } else { 1 }); + (contour, bounds, direction) + }) + .collect(); + let closed = || contours.iter().enumerate().filter(|(_, (_, _, d))| d.is_some()); + let area = closed().map(|(_, (_, b, _))| *b).reduce(|a, b| a.union(b)); + let side = ((closed().count() as f64).sqrt().ceil() as usize).clamp(1, MAX_HOLE_GRID); + let mut search = HoleSearch { contours: vec![], rule, area, side, grid: vec![vec![]; side * side], work: 0 }; + for (j, (_, b, _)) in closed() { + let cells = search.cells(*b); + search.spend(cells.len())?; + for c in cells { + if let Some(c) = search.grid.get_mut(c) { + c.push(j); } } } + search.contours = contours; + Ok(search) } - let mut holes = Vec::with_capacity(contours.len()); - for (i, (sp, (own, _))) in path.subpaths.iter().zip(&contours).enumerate() { - if !sp.closed { - holes.push(vec![]); - continue; + + fn spend(&mut self, n: usize) -> Result<(), String> { + self.work = self.work.saturating_add(n); + if self.work > MAX_HOLE_WORK { Err(TOO_COMPLEX_PROFILE.to_string()) } else { Ok(()) } + } + + /// The grid cells `r` reaches into. + fn cells(&self, r: Rect) -> Vec { + let Some(a) = self.area else { return vec![] }; + let side = self.side; + let at = |v: f64, lo: f64, hi: f64| { + let span = hi - lo; + if span > 0.0 { (((v - lo) / span * side as f64).floor().max(0.0) as usize).min(side - 1) } else { 0 } + }; + let (x0, x1) = (at(r.x0, a.x0, a.x1), at(r.x1, a.x0, a.x1)); + let (y0, y1) = (at(r.y0, a.y0, a.y1), at(r.y1, a.y0, a.y1)); + (y0..=y1).flat_map(|y| (x0..=x1).map(move |x| y * side + x)).collect() + } + + /// The closed contours other than `own` whose bounds may meet `r`, each once. + fn candidates(&mut self, own: usize, r: Rect) -> Result, String> { + let mut out = vec![]; + for c in self.cells(r) { + let listed = self.grid.get(c).map_or(&[][..], Vec::as_slice); + out.extend(listed.iter().copied().filter(|j| *j != own)); } - let direction = if own.area() < 0.0 { -1 } else { 1 }; - let mut segments = Vec::with_capacity(sp.anchors.len()); - for segment in 0..sp.anchors.len() { - let p = sp.segment(segment).eval(0.5); - let mut others = 0i32; - let candidates = cell(p).and_then(|c| grid.get(c)).map_or(&[][..], Vec::as_slice); - spend(candidates.len(), &mut work)?; - for &j in candidates { - let Some((contour, _)) = contours.get(j).filter(|(_, bounds)| j != i && bounds.contains(p)) else { continue }; - spend(contour.elements().len(), &mut work)?; - others += contour.winding(p); + self.spend(out.len())?; + out.sort_unstable(); + out.dedup(); + Ok(out) + } + + /// Whether the edge of closed subpath `own` at `p` walls a hole: the fill is outside its + /// contour there and not inside. + fn is_hole(&mut self, own: usize, p: Point) -> Result { + let Some(direction) = self.contours.get(own).and_then(|c| c.2) else { return Ok(false) }; + let mut others = 0i32; + for j in self.candidates(own, Rect::from_points(p, p))? { + let Some((contour, bounds, Some(_))) = self.contours.get(j) else { continue }; + if !bounds.contains(p) { + continue; } - segments.push(match rule { - FillRule::EvenOdd => others % 2 != 0, - FillRule::NonZero => others != 0 && others + direction == 0, - }); + let (elements, winding) = (contour.elements().len(), contour.winding(p)); + self.spend(elements)?; + others += winding; } - holes.push(segments); + Ok(match self.rule { + FillRule::EvenOdd => others % 2 != 0, + FillRule::NonZero => others != 0 && others + direction == 0, + }) + } + + /// The line from `a` to `b` on subpath `own`, cut where it crosses another closed contour: each + /// part's end, the last one `b`, and whether that part walls a hole at its middle. + fn parts(&mut self, own: usize, a: Point, b: Point) -> Result, String> { + let mut out = vec![]; + let mut from = 0.0; + for t in self.crossings(own, a, b)?.into_iter().chain([1.0]) { + let hole = self.is_hole(own, a.lerp(b, (from + t) * 0.5))?; + out.push((if t < 1.0 { a.lerp(b, t) } else { b }, hole)); + from = t; + } + Ok(out) + } + + /// Where the line from `a` to `b` on closed subpath `own` crosses another closed contour, as + /// sorted fractions of the way from `a` to `b`, strictly between them. + fn crossings(&mut self, own: usize, a: Point, b: Point) -> Result, String> { + if self.contours.get(own).and_then(|c| c.2).is_none() { + return Ok(vec![]); + } + let line = vectorcraft_geom::kurbo::Line::new(a, b); + let reach = Rect::from_points(a, b); + let mut out = vec![]; + for j in self.candidates(own, reach)? { + let Some((contour, bounds, Some(_))) = self.contours.get(j) else { continue }; + if bounds.x0 > reach.x1 || bounds.x1 < reach.x0 || bounds.y0 > reach.y1 || bounds.y1 < reach.y0 { + continue; + } + let found: Vec = contour.segments().flat_map(|s| s.intersect_line(line)).map(|i| i.line_t).collect(); + self.spend(contour.elements().len())?; + out.extend(found.into_iter().filter(|t| *t > 1e-9 && *t < 1.0 - 1e-9)); + } + out.sort_by(f64::total_cmp); + out.dedup_by(|x, y| (*x - *y).abs() < 1e-9); + Ok(out) } - Ok((holes, work)) } /// [`revolve`], or with `mesh` the surface's faces before projection, kept there instead of drawn, @@ -605,7 +680,9 @@ fn revolve_faces( Some(samples) => samples.clamp(1, CURVE_SAMPLES), None => revolve_curve_samples([path], o)?, }; - let holes = if mesh.is_some() { hole_segments(path, rule)? } else { vec![] }; + // For a mesh, each piece of a closed contour's edge is cut where it crosses another contour, so + // each part's normal turns toward a hole only where it walls one. + let mut search = if mesh.is_some() { Some(HoleSearch::new(path, rule)?) } else { None }; // A face kept for a mesh: its normal follows from its corners. let keep = |mesh: &mut Vec, face: Vec, normal: V3| { mesh.push((vec![face], normal)); @@ -688,12 +765,19 @@ fn revolve_faces( let v = d.eval(t.clamp(1e-7, 1.0 - 1e-7)).to_vec2(); if v.hypot2() > 1e-20 { v } else { b - a } }; - tangents.push([tangent(t0), tangent(t1)]); - hole_edges.push(holes.get(contour).and_then(|h| h.get(i)).copied().unwrap_or(false)); - profile.push(b); - count += 1; - if count > MAX_PROFILE_POINTS { - return Err(TOO_COMPLEX_PROFILE.into()); + let a = profile.last().copied().unwrap_or(b); + let parts = match search.as_mut() { + Some(search) => search.parts(contour, a, b)?, + None => vec![(b, false)], + }; + for (end, hole) in parts { + tangents.push([tangent(t0), tangent(t1)]); + hole_edges.push(hole); + profile.push(end); + count += 1; + if count > MAX_PROFILE_POINTS { + return Err(TOO_COMPLEX_PROFILE.into()); + } } } } diff --git a/crates/three-d/src/mesh/tests.rs b/crates/three-d/src/mesh/tests.rs index 51f7c59e96..2011d6689c 100644 --- a/crates/three-d/src/mesh/tests.rs +++ b/crates/three-d/src/mesh/tests.rs @@ -308,6 +308,23 @@ fn the_wall_of_a_counter_faces_into_the_hole() { assert!(!inner.is_empty() && inner.iter().all(|s| *s > 0.0)); } +#[test] +fn an_edge_that_crosses_another_contour_faces_the_hole_only_where_it_walls_it() { + // Two squares overlapping under the even-odd rule leave their overlap empty. The first square's + // right edge (110 pt from the axis) and the second's left edge (50 pt from it) each wall the + // overlap along part of their length and the outside along the rest. + let mut b = BezPath::new(); + square(&mut b, 0.0, 0.0, 100.0, false); + square(&mut b, 40.0, 40.0, 100.0, false); + let (p, r) = shape(&b); + let m = revolve_mesh(&p, FillRule::EvenOdd, r, Revolve { offset: 10.0, segments: 32, ..Revolve::default() }).unwrap(); + for radius in [110.0, 50.0] { + let walls = wall_directions(&m, radius); + assert!(walls.iter().any(|s| *s > 0.0) && walls.iter().any(|s| *s < 0.0), "both ways at {radius}"); + } + assert!(wall_directions(&m, 150.0).iter().all(|s| *s > 0.0), "the second square's far edge faces out"); +} + #[test] fn inflate_puffs_the_front_toward_the_viewer() { let mut b = BezPath::new(); diff --git a/docs/mcp.md b/docs/mcp.md index 0e73231dc4..056b823241 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -2388,7 +2388,7 @@ compound path, type, or a group or layer of them. `effect.dialog` opens compact `effect.expandAppearance` bakes it into shaded vector faces. Native saves keep the editable source; SVG/PDF exports bake the same faces the canvas draws. Only one Revolve is supported per profile. Type sweeps as its glyph outlines and stays type. On a group or layer, every visible member sweeps about one axis on the chosen edge of the members' bounds together, in its own paint, and the members stay editable. A group holding a clipping group, an image, a symbol instance or another object that can't be swept is refused, and so is type with no outlines or a group with no shape in it. -A surface takes at most 16,384 profile points and 163,840 faces, the members of a group together, counted after any geometry effects before the Revolve. A straight segment takes one point, a curve takes at least four, and each contour takes one. Each curved segment takes 16 samples; on a surface over those limits every curve takes as many as fit, down to 4, and the call is refused below that with "reduce Segments or simplify the profile". The call is also refused with "reduce Segments or simplify the gradient" when the faces' copies of the paint would take more than 256 MiB, counted together for the members of a group and for the fills and strokes of one object that carry a Revolve. To find the holes, mesh export tests each segment's middle against the closed contours whose bounds share its cell of a grid over the profile. It refuses the profile as too complex when its contours overlap so much that the search would take more than 16,000,000 steps, where each path element tested, each contour looked at and each contour placed in a grid cell counts one. +A surface takes at most 16,384 profile points and 163,840 faces, the members of a group together, counted after any geometry effects before the Revolve. A straight segment takes one point, a curve takes at least four, and each contour takes one. Each curved segment takes 16 samples; on a surface over those limits every curve takes as many as fit, down to 4, and the call is refused below that with "reduce Segments or simplify the profile". The call is also refused with "reduce Segments or simplify the gradient" when the faces' copies of the paint would take more than 256 MiB, counted together for the members of a group and for the fills and strokes of one object that carry a Revolve. To find the holes, mesh export cuts each sampled piece of a closed contour where it crosses another closed contour, adding a profile point at each cut, and tests the middle of each part against the closed contours whose bounds share its cell of a grid over the profile. It refuses the profile as too complex when its contours overlap so much that the search would take more than 16,000,000 steps, where each path element tested, each contour looked at and each contour placed in a grid cell counts one. Parameters: `angle` (degrees, 0..360, default 360), `offset` (points, 0..100000, default 0), `edge` (`"left"` or `"right"`), `rotationX/Y/Z` (degrees, defaults 0/0/0), `perspectiveAngle` From db56690f481d48a667ba06492f56ec3a87511186 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Mon, 9 Nov 2026 18:19:24 -0800 Subject: [PATCH 25/36] MCP: docs list every JSON Schema keyword in a command's schema The Parameter schemas section of docs/mcp.md now names every keyword ParamSchema::to_json writes: $schema, description and deprecated at the root, items for lists, prefixItems, items: false and minItems for a point, anyOf and minItems for a color, and description, default and deprecated on a parameter. --- docs/mcp.md | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/docs/mcp.md b/docs/mcp.md index 66459255ad..b12a003656 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1387,9 +1387,13 @@ vectorcraft-cli run --cmd file.new --params '{"width":600,"height":200}' --cmd Some commands have a machine-readable parameter schema: at present `view.capsLock`, `view.saved.new`, `view.saved.edit`, `view.saved.list`, `view.transparencyGrid` and `view.snapToGlyph.options`. The engine's `engine.commands` list gives such a command a `schema` field: an object schema in the shape of JSON Schema 2020-12 -(https://json-schema.org/draft/2020-12), with `type`, `properties`, `required`, `additionalProperties: false`, -`minimum`, `maximum`, `exclusiveMinimum` and `exclusiveMaximum` for ranges, `enum`, and `default` for a default the engine fills in. VectorCraft's own -fields are `x-since` (the API level that added the command or the parameter), `x-deprecatedSince`, `x-lenient`, +(https://json-schema.org/draft/2020-12). Its root has `$schema` (the 2020-12 dialect), `description` when the +command has a summary, and `deprecated: true` when the command is deprecated. The schema uses `type`, `properties`, `required` and +`additionalProperties: false` for objects; `minimum`, `maximum`, `exclusiveMinimum` and `exclusiveMaximum` for ranges; +`enum` for a list of names; `items` for the values of a list; `prefixItems`, `items: false` and `minItems: 2` for a +point; `anyOf` and `minItems: 3` for a color, which is a string, a list of at least three numbers or an object; +`description` for a parameter's doc text; `default` for a default the engine fills in; and `deprecated: true` for a +deprecated parameter. VectorCraft's own fields are `x-since` (the API level that added the command or the parameter), `x-deprecatedSince`, `x-lenient`, `x-unit`, `x-kind` (`id`, `color` or `point`), `x-default` (a default the command works out, such as `toggle`), `x-cost`, `x-writes` (`nothing`, `session`, `preferences` or `document`) and `x-returns`. A parameter that reads null as left out is an `anyOf` with `{"type": "null"}`. Null params read as `{}`, so a schema with no required parameter has From 1847f40fbea14f06e79e6184b0c0dd9f92bef335 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Mon, 9 Nov 2026 18:28:16 -0800 Subject: [PATCH 26/36] Docs: Cut's progress line says anchors left without a segment go too --- docs/target-app-parity.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 8fb376813e..fc73244082 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -579,7 +579,7 @@ since 2026-10-01. - **Clipping paths on their outline (G1):** the selection tools (Selection, Direct Selection, Group Selection), Object Highlighting and a right-click take a clipping path on its outline before the art it clips; Select Behind lists the same; the eyedropper, the tools that act on what is drawn and the selection tools' double-clicks, loaded text and guide release take the art. Doc and tool regression tests; score unchanged. - **Copy and Cut of anchors picked with a lent Direct Selection (#6123):** with the Pen or the Curvature tool, Cmd/Ctrl held lends Direct Selection for the gesture; the anchors and segments it picks are what Edit › Copy and Cut take once the key is let go, as with Direct Selection chosen, while the last anchor the Pen leaves selected as it draws still makes Copy take the whole path (#2122). A Cmd gesture that leaves the selection as it was (a Shift marquee over nothing) picks nothing. Regression test: `tests::draw2::pen_cmd_selected_anchors_are_what_copy_and_cut_take`. -- **Cut of picked anchors (#6361):** Edit › Cut removes direct-selected anchors from their path and from a compound path's member, with the segments attached to them, as Clear does. Picked segments still lose only those segments. When a path also has a picked segment, Cut removes and copies only its picked segments and leaves its picked anchors in place, on every contour, as Clear does. Regression tests: `cut_*_anchors_*` and `cut_of_an_anchor_with_a_segment_on_another_contour_leaves_the_source_as_clear_does` in `crates/engine/tests/it/copy_direct_selection.rs`. +- **Cut of picked anchors (#6361):** Edit › Cut removes direct-selected anchors from their path and from a compound path's member, with the segments attached to them, as Clear does. Picked segments still lose only those segments. When a path also has a picked segment, Cut removes and copies only its picked segments and ignores its picked anchors, on every contour, as Clear does; an anchor left without a surviving segment is removed with them. Regression tests: `cut_*_anchors_*` and `cut_of_an_anchor_with_a_segment_on_another_contour_leaves_the_source_as_clear_does` in `crates/engine/tests/it/copy_direct_selection.rs`. - **Escape in Selection tool drags:** Escape during a Selection tool move or Alt-drag copy, a bounding-box scale or rotation, a marquee, or a corner widget, pie widget or bracket drag cancels it: the art returns to its state at the press, no copy is left, no undo step is added, the release does nothing, and the tool stays chosen. This is VectorCraft's own behavior. Regression tests: `escape_cancels_*` in `select/tests.rs` and `pie.rs`, and `escape_cancels_a_selection_tool_move_copy_scale_or_rotation` in `tests/selectescape.rs`. - **Global Edit: Shift-click leaves a match out:** while Global Edit is on, a Shift-click with the Selection tool on a highlighted match, or on an object inside a matched group, leaves that match out (`select.globalEditExclude`): it is no longer highlighted and later edits leave it as it is; the object the edit started from stays, and Global Edit ends when fewer than two matches are left ([Update assets quickly with Global Edit](https://helpx.adobe.com/ph_fil/illustrator/how-to/global-editing-sensei.html)). Regression tests: `a_shift_click_*`, `an_object_left_out_*`, `a_group_left_out_*` and `leaving_out_all_but_one_*` in `crates/engine/src/tests/globaledit.rs`. - **Global Edit Options in the Control bar:** while Select › Start Global Edit can run, the Control bar shows a Global Edit Options dropdown that opens the same options as the Properties panel's (Match: Appearance, Size; On Artboards; Include Objects On Canvas; Result(s)); both set the same options. The dropdown is off while Global Edit is on; it sits after Recolor Artwork, ahead of the X/Y/W/H fields ([Edit similar objects globally](https://helpx.adobe.com/illustrator/desktop/manage-objects/edit-objects/edit-similar-objects.html)). Regression test: `the_control_bar_shows_the_global_edit_options` in `crates/ui-egui/src/chrome/tests.rs`. From 2e81086c384ecfc5ed53244ad76e092576f8d5d3 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Mon, 9 Nov 2026 18:30:41 -0800 Subject: [PATCH 27/36] Revolve: a nested group's opacity applies once to its members together The opacity of a group inside a revolved group was multiplied into each member's surface, so where members overlap they drew darker than the group does without the effect. A member or group with opacity below 100% or a blending mode other than Normal now holds its members' surfaces in one group with that opacity and mode. --- crates/effects/src/revolve.rs | 60 ++++++++++++++----------- crates/effects/src/solids.rs | 39 +++++++++------- crates/effects/src/solids/tests_mesh.rs | 2 +- 3 files changed, 60 insertions(+), 41 deletions(-) diff --git a/crates/effects/src/revolve.rs b/crates/effects/src/revolve.rs index 0a8479d610..99a6e053e8 100644 --- a/crates/effects/src/revolve.rs +++ b/crates/effects/src/revolve.rs @@ -2,12 +2,12 @@ use std::sync::Arc; use serde_json::Value; -use vectorcraft_doc::color::{BlendMode, Color, Paint}; +use vectorcraft_doc::color::{Color, Paint}; use vectorcraft_doc::{Appearance, AppearanceItem, Effect, FillLayer, LineJoin, Node, NodeId, NodeKind, StrokeLayer}; use vectorcraft_geom::{PathData, Rect}; use vectorcraft_three_d::{Edge, Revolve}; -use crate::solids::{Profile, is_container, members, object_profile, profile, reshape_members, solid_paints, union_bounds}; +use crate::solids::{Profile, Scope, is_container, members, object_profile, profile, reshape_members, solid_paints, union_bounds}; use crate::util::{flag, num, text}; use crate::{GeomContext, apply_geometry_with, is_geometry, merged_params}; @@ -431,33 +431,34 @@ fn art(n: &Node, expand: bool) -> Option { Some(art) } -/// The members' `surfaces` in order, from the blending mode at `level` of each one's -/// `Profile::blends` inward: the surfaces of consecutive members that share a group with a mode -/// other than Normal go in one group with that mode, so each mode applies once, at its own level. -fn blended(id: NodeId, surfaces: Vec<(&[(usize, BlendMode)], Node)>, level: usize) -> Vec> { - fn close(id: NodeId, run: &mut Vec<(&[(usize, BlendMode)], Node)>, key: Option<(usize, BlendMode)>, level: usize, out: &mut Vec>) { - if let Some((_, blend)) = key { - let mut group = Node::group(id, blended(id, std::mem::take(run), level + 1)); - group.blend = blend; +/// The members' `surfaces` in order, from the [`Scope`] at `level` of each one's `Profile::scopes` +/// inward: the surfaces of consecutive members that share a scope go in one group with its opacity +/// and blending mode, so each applies once, to the members it holds together. +fn scoped(id: NodeId, surfaces: Vec<(&[Scope], Node)>, level: usize) -> Vec> { + fn close(id: NodeId, run: &mut Vec<(&[Scope], Node)>, scope: Option, level: usize, out: &mut Vec>) { + if let Some(scope) = scope { + let mut group = Node::group(id, scoped(id, std::mem::take(run), level + 1)); + group.opacity = scope.opacity; + group.blend = scope.blend; out.push(Arc::new(group)); } } let mut out = vec![]; let mut run = vec![]; - let mut key = None; - for (blends, s) in surfaces { - let at = blends.get(level).copied(); - if at != key { - close(id, &mut run, key, level, &mut out); - key = at; + let mut scope = None; + for (scopes, s) in surfaces { + let at = scopes.get(level).copied(); + if at.map(|a| a.key) != scope.map(|s: Scope| s.key) { + close(id, &mut run, scope, level, &mut out); + scope = at; } - if key.is_some() { - run.push((blends, s)); + if scope.is_some() { + run.push((scopes, s)); } else { out.push(Arc::new(s)); } } - close(id, &mut run, key, level, &mut out); + close(id, &mut run, scope, level, &mut out); out } @@ -477,13 +478,13 @@ fn group_art(n: &Node, expand: bool) -> Option { let mut opaque = true; for s in &shapes { let Some(item) = surface_item(&s.items) else { continue }; + // The opacity and blending of the member and the groups it is in apply through its scopes. let (paint, opacity, blend) = paint_of(item); - let opacity = opacity * s.opacity; - opaque &= opacity >= 1.0 && blend == Default::default() && s.blends.is_empty(); - // A member with non-normal blending keeps every face, as a blended object does. - let expand = expand && s.blends.is_empty(); + opaque &= opacity >= 1.0 && blend == Default::default() && s.scopes.is_empty(); + // A member with transparency of its own keeps every face, as a transparent object does. + let expand = expand && s.scopes.is_empty(); let (depths, art) = surface(n, &s.path, b, options, fx, paint, opacity, blend, expand, &mut copies)?; - surfaces.push((depths, art, s.blends.as_slice())); + surfaces.push((depths, art, s.scopes.as_slice())); } if surfaces.is_empty() { return None; @@ -500,7 +501,7 @@ fn group_art(n: &Node, expand: bool) -> Option { faces.sort_by(|a, b| a.0.total_cmp(&b.0)); vec![Arc::new(Node::group(n.id, faces.into_iter().map(|(_, face)| face).collect()))] } else { - blended(n.id, surfaces.into_iter().map(|(_, s, blends)| (blends, s)).collect(), 0) + scoped(n.id, surfaces.into_iter().map(|(_, s, scopes)| (scopes, s)).collect(), 0) }; let remaining = n.appearance.effects.iter().enumerate().filter(|(i, e)| *i != index && (!is_geometry(&e.id) || *i > index)).map(|(_, e)| e.clone()).collect(); @@ -752,6 +753,7 @@ mod tests { #[test] fn blending_modes_apply_once_to_the_member_or_group_that_has_them() { + use vectorcraft_doc::color::BlendMode; let blend = |n: Arc, mode: BlendMode| { let mut n = (*n).clone(); n.blend = mode; @@ -775,6 +777,14 @@ mod tests { assert_ne!(expand_revolve_art(&plain), revolve_art(&plain), "an opaque member's hidden faces are removed"); let multiplied = group_with(json!({}), vec![blend(member(1, 0.0), BlendMode::Multiply)]); assert_eq!(expand_revolve_art(&multiplied), revolve_art(&multiplied), "a blended member keeps every face"); + // A nested group's opacity applies once, to its members' surfaces together. + let mut faded = Node::group(NodeId(6), vec![member(3, 40.0), member(4, 45.0)]); + faded.opacity = 0.5; + let art = revolve_art(&group_with(json!({}), vec![member(1, 0.0), Arc::new(faded)])).expect("revolved"); + let NodeKind::Group { children, .. } = &art.kind else { panic!("a group") }; + let opacities = |n: &Node| n.children().unwrap().iter().map(|c| c.opacity).collect::>(); + assert_eq!(children.iter().map(|c| c.opacity).collect::>(), [1.0, 0.5]); + assert_eq!(opacities(&children[1]), [1.0, 1.0], "the faded group's members, together"); } #[test] diff --git a/crates/effects/src/solids.rs b/crates/effects/src/solids.rs index c4b55fd5e4..ab119192c4 100644 --- a/crates/effects/src/solids.rs +++ b/crates/effects/src/solids.rs @@ -224,17 +224,26 @@ pub(crate) fn object_profile(n: &Node, label: &str) -> Result<(PathData, FillRul }) } +/// A group in a 3D effect's group, or a member of it, whose opacity is below 100% or whose blending +/// mode is not Normal. The members of one group share its `key`. +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) struct Scope { + pub(crate) key: usize, + pub(crate) opacity: f32, + pub(crate) blend: BlendMode, +} + /// One shape a solid is built from: its outline and fill rule, the fills and strokes that paint -/// it, the opacity of the objects it is in (1 for the object with the effect itself) and their -/// blending modes other than Normal (none for the object itself). `blends` runs from the outermost -/// group in the effect's group down to the member itself, each with a key that the members of -/// one group share. Group Revolve draws the surfaces in those modes. +/// it, and the opacity of the objects it is in multiplied together (1 for the object with the +/// effect itself). `scopes` lists those objects with transparency of their own ([`Scope`]), from +/// the outermost group down to the member itself (none for the object itself). Group Revolve +/// applies each once, to the surfaces of the members it holds together. pub(crate) struct Profile { pub(crate) path: PathData, pub(crate) rule: FillRule, pub(crate) items: Vec, pub(crate) opacity: f32, - pub(crate) blends: Vec<(usize, BlendMode)>, + pub(crate) scopes: Vec, } /// Is `n` a group or layer (a 3D effect on it turns its members about one shared axis)? @@ -265,20 +274,20 @@ pub(crate) fn members(n: &Node, label: &str) -> Result, String> { out: Vec, keys: usize, } - fn walk(n: &Node, opacity: f32, blends: &[(usize, BlendMode)], w: &mut Walk) -> Result<(), String> { + fn walk(n: &Node, opacity: f32, scopes: &[Scope], w: &mut Walk) -> Result<(), String> { let label = w.label; for c in n.children().into_iter().flatten() { if !c.visible || matches!(c.kind, NodeKind::Path { guide: true, .. }) { continue; } let opacity = opacity * c.opacity; - let mut blends = blends.to_vec(); - if c.blend != BlendMode::Normal { + let mut scopes = scopes.to_vec(); + if c.blend != BlendMode::Normal || c.opacity < 1.0 { w.keys += 1; - blends.push((w.keys, c.blend)); + scopes.push(Scope { key: w.keys, opacity: c.opacity, blend: c.blend }); } if is_container(c) { - walk(c, opacity, &blends, w)?; + walk(c, opacity, &scopes, w)?; continue; } let Some((path, rule)) = profile(c) else { @@ -296,7 +305,7 @@ pub(crate) fn members(n: &Node, label: &str) -> Result, String> { } let path = geometry(&path, &c.appearance.effects, &GeomContext::of(c)); if path.bounds().is_some() { - w.out.push(Profile { path, rule, items: solid_paints(c), opacity, blends }); + w.out.push(Profile { path, rule, items: solid_paints(c), opacity, scopes }); } } Ok(()) @@ -539,7 +548,7 @@ pub fn solid_art(n: &Node) -> Option { let remaining: Vec; if let Some((index, fx)) = solid(&n.appearance.effects) { let path = geometry(&path, n.appearance.effects.get(..index)?, &ctx); - children.extend(art_for(n, fx, &[Profile { path, rule, items: paints, opacity: 1.0, blends: vec![] }])?); + children.extend(art_for(n, fx, &[Profile { path, rule, items: paints, opacity: 1.0, scopes: vec![] }])?); // Later geometry effects act on the projected art as a whole. remaining = n .appearance @@ -555,7 +564,7 @@ pub fn solid_art(n: &Node) -> Option { for item in &paints { if let Some((index, fx)) = solid(item.effects()) { let p = geometry(&path, item.effects().get(..index)?, &ctx.item(item)); - for mut art in art_for(n, fx, &[Profile { path: p, rule, items: vec![item.clone()], opacity: 1.0, blends: vec![] }])? { + for mut art in art_for(n, fx, &[Profile { path: p, rule, items: vec![item.clone()], opacity: 1.0, scopes: vec![] }])? { Arc::make_mut(&mut art).appearance.effects = item.effects().iter().skip(index + 1).filter(|e| !is_three_d(&e.id)).cloned().collect(); children.push(art); @@ -718,14 +727,14 @@ fn meshes_within(n: &Node, total: &mut usize, limit: usize) -> Result Date: Tue, 10 Nov 2026 07:46:05 +0500 Subject: [PATCH 28/36] Spelling in the canvas context menu: suggestions, Ignore All and Add to Dictionary on a word Auto Spell Check underlined (part of #4936) --- crates/ui-egui/src/canvas/context.rs | 3 + crates/ui-egui/src/canvas/tool_overlays.rs | 73 ++++++++++-- crates/ui-egui/src/i18n/ca.tsv | 3 + crates/ui-egui/src/i18n/cs.tsv | 3 + crates/ui-egui/src/i18n/de.tsv | 3 + crates/ui-egui/src/i18n/el.tsv | 3 + crates/ui-egui/src/i18n/es.tsv | 3 + crates/ui-egui/src/i18n/fi.tsv | 3 + crates/ui-egui/src/i18n/fr.tsv | 3 + crates/ui-egui/src/i18n/hi.tsv | 3 + crates/ui-egui/src/i18n/hu.tsv | 3 + crates/ui-egui/src/i18n/it.tsv | 3 + crates/ui-egui/src/i18n/ja.tsv | 3 + crates/ui-egui/src/i18n/ka.tsv | 3 + crates/ui-egui/src/i18n/ko.tsv | 3 + crates/ui-egui/src/i18n/pl.tsv | 3 + crates/ui-egui/src/i18n/pt-br.tsv | 3 + crates/ui-egui/src/i18n/ru.tsv | 3 + crates/ui-egui/src/i18n/sv.tsv | 3 + crates/ui-egui/src/i18n/tr.tsv | 3 + crates/ui-egui/src/i18n/uk.tsv | 3 + crates/ui-egui/src/i18n/vi.tsv | 3 + crates/ui-egui/src/menus/context.rs | 36 +++++- crates/ui-egui/src/menus/entries.rs | 7 +- crates/ui-egui/src/menus/labels.rs | 9 ++ crates/ui-egui/src/state.rs | 20 ++++ crates/ui-egui/src/tests/contextmenu.rs | 122 +++++++++++++++++++++ docs/gaps.md | 5 +- docs/mcp.md | 4 + docs/roadmap.md | 9 +- docs/target-app-parity.md | 19 ++-- docs/type-parity.md | 5 +- 32 files changed, 346 insertions(+), 26 deletions(-) diff --git a/crates/ui-egui/src/canvas/context.rs b/crates/ui-egui/src/canvas/context.rs index 5af09ab3bf..c5bbe90882 100644 --- a/crates/ui-egui/src/canvas/context.rs +++ b/crates/ui-egui/src/canvas/context.rs @@ -20,6 +20,8 @@ pub(super) fn context_menu(app: &mut VectorcraftApp, resp: &egui::Response, xf: .active() .filter(|_| app.session.tool_id() == "intertwine") .and_then(|st| vectorcraft_tools::intertwine::menu_target(&st.doc, &st.selection.objects, p).map(|(g, m)| (st.uid, g, p, m))); + // Auto Spell Check: the underlined word right-clicked, for its suggestions. + app.ui.context_spelling = if app.ui.view.auto_spell_check { super::tool_overlays::misspelled_word_at(app, p) } else { None }; let on_artboards = app.session.tool_id() == "artboard"; if on_artboards { crate::panels::artboards::right_click(app, p); @@ -39,6 +41,7 @@ pub(super) fn context_menu(app: &mut VectorcraftApp, resp: &egui::Response, xf: app.ui.context_locked = None; app.ui.context_intertwine = None; app.ui.context_artboards = None; + app.ui.context_spelling = None; } if let Some((id, p)) = clicked { crate::menus::invoke(app, &id, p); diff --git a/crates/ui-egui/src/canvas/tool_overlays.rs b/crates/ui-egui/src/canvas/tool_overlays.rs index b90af09a4b..5e23522f7e 100644 --- a/crates/ui-egui/src/canvas/tool_overlays.rs +++ b/crates/ui-egui/src/canvas/tool_overlays.rs @@ -144,13 +144,7 @@ pub(super) fn spelling_overlay(app: &mut VectorcraftApp, p: &egui::Painter, xf: } walk(&st.doc.layers, &mut |n| { let NodeKind::Text(tx) = &n.kind else { return }; - // An effect on the type or on one of its fills or strokes, shown (the Appearance panel's - // eye): the helpx page's "Auto spell check isn't available for text with effects applied". - let shown = |fx: &[vectorcraft_doc::Effect]| fx.iter().any(|e| e.visible); - if shown(&n.appearance.effects) - || n.appearance.items.iter().any(|i| shown(i.effects())) - || n.geometric_bounds().is_none_or(|b| !egui::Rect::from_points(&xf.quad(b)).intersects(clip)) - { + if !auto_spell_checks(n) || n.geometric_bounds().is_none_or(|b| !egui::Rect::from_points(&xf.quad(b)).intersects(clip)) { return; } let lines = match last.remove(&n.id) { @@ -164,6 +158,71 @@ pub(super) fn spelling_overlay(app: &mut VectorcraftApp, p: &egui::Painter, xf: }); } +/// Whether Auto Spell Check marks `n`'s words: not with an effect shown (the Appearance panel's +/// eye) on the type or on one of its fills or strokes, as the helpx page says "Auto spell check +/// isn't available for text with effects applied". +fn auto_spell_checks(n: &Node) -> bool { + let shown = |fx: &[vectorcraft_doc::Effect]| fx.iter().any(|e| e.visible); + !shown(&n.appearance.effects) && !n.appearance.items.iter().any(|i| shown(i.effects())) +} + +/// The word with Auto Spell Check's red line at `p` (document space), on top of the others: in +/// the topmost visible, unlocked type object whose unknown word's glyphs `p` is on, with what its +/// language's dictionary suggests instead. For the context menu's suggestions, Ignore All and Add +/// to Dictionary ([Check and correct spelling](https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/check-and-correct-spelling.html)). +pub(crate) fn misspelled_word_at(app: &mut VectorcraftApp, p: Point) -> Option { + let st = app.session.active()?; + let mut hit = None; + fn walk(nodes: &[std::sync::Arc], f: &mut impl FnMut(&Node)) { + for n in nodes.iter().filter(|n| n.visible && !n.locked) { + f(n); + if let Some(children) = n.children() { + walk(children, f); + } + } + } + // Paint order: a later hit is on top. + walk(&st.doc.layers, &mut |n| { + let NodeKind::Text(tx) = &n.kind else { return }; + if !auto_spell_checks(n) || n.geometric_bounds().is_none_or(|b| !b.contains(p)) { + return; + } + let (words, _) = vectorcraft_engine::cmd::spelling::misspellings(tx, &st.spelling_ignored, MAX_UNDERLINED); + if words.is_empty() { + return; + } + let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), tx); + for w in words { + let on = vectorcraft_text::selection_quads(&lay, w.range.start, w.range.end).iter().any(|q| in_quad(q.map(|c| tx.xf * c), p)); + if on { + hit = Some((n.id, w)); + } + } + }); + let (id, w) = hit?; + let Some(NodeKind::Text(tx)) = st.doc.node(id).map(|n| &n.kind) else { return None }; + let word = tx.plain_text().get(w.range.clone())?.to_string(); + let uid = st.uid; + let suggestions = app + .session + .execute("text.spelling.suggest", &serde_json::json!({ "word": word, "lang": w.lang.tag() })) + .ok() + .and_then(|r| r.get("suggestions").and_then(|s| s.as_array()).map(|a| a.iter().filter_map(|s| s.as_str().map(str::to_string)).collect())) + .unwrap_or_default(); + Some(crate::state::ContextSpelling { uid, id, start: w.range.start, end: w.range.end, word, suggestions }) +} + +/// Whether `p` is inside the convex quad `q` (its corners in order, either way round) or on its +/// edge. +fn in_quad(q: [Point; 4], p: Point) -> bool { + let sides = (0..4).map(|i| { + let (a, b) = (q[i], q[(i + 1) % 4]); + (b.x - a.x) * (p.y - a.y) - (b.y - a.y) * (p.x - a.x) + }); + let sides: Vec = sides.collect(); + sides.iter().all(|s| *s >= 0.0) || sides.iter().all(|s| *s <= 0.0) +} + /// The most words underlined in one text object: past it the rest of the object isn't checked, /// so a long story of unknown words costs one check's worth. const MAX_UNDERLINED: usize = 1_000; diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index bc984eec5e..6583bfde62 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -6648,3 +6648,6 @@ breakText Glyphs Glifs Align to Pixel Grid Alinea a la quadrícula de píxels @msg only paths and compound paths can be aligned to the pixel grid només els traçats i els traçats compostos es poden alinear a la quadrícula de píxels @msg side is left or right, not `{s}` side ha de ser left o right, no `{s}` + Ignore All Ignora-ho tot + Add to Dictionary Afegeix al diccionari + No Suggestions No hi ha suggeriments diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index fc357028f9..173a09ea0e 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -6667,3 +6667,6 @@ breakText Glyphs Glyfy Align to Pixel Grid Zarovnat na mřížku obrazových bodů @msg only paths and compound paths can be aligned to the pixel grid na mřížku obrazových bodů lze zarovnat jen cesty a složené cesty @msg side is left or right, not `{s}` side je left nebo right, ne `{s}` + Ignore All Ignorovat vše + Add to Dictionary Přidat do slovníku + No Suggestions Žádné návrhy diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index b66afbe451..af42a42d76 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -6254,3 +6254,6 @@ breakText Glyphs Glyphen Align to Pixel Grid Am Pixelraster ausrichten @msg only paths and compound paths can be aligned to the pixel grid nur Pfade und zusammengesetzte Pfade können am Pixelraster ausgerichtet werden @msg side is left or right, not `{s}` side ist left oder right, nicht `{s}` + Ignore All Alle ignorieren + Add to Dictionary Zum Wörterbuch hinzufügen + No Suggestions Keine Vorschläge diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 632a6feec9..4483549673 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -6619,3 +6619,6 @@ breakText Glyphs Γλύφοι Align to Pixel Grid Στοίχιση στο πλέγμα εικονοστοιχείων @msg only paths and compound paths can be aligned to the pixel grid μόνο διαδρομές και σύνθετες διαδρομές μπορούν να στοιχιστούν στο πλέγμα εικονοστοιχείων @msg side is left or right, not `{s}` το side είναι left ή right, όχι `{s}` + Ignore All Παράβλεψη όλων + Add to Dictionary Προσθήκη στο λεξικό + No Suggestions Καμία πρόταση diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 3369528a18..02161d827a 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -6610,3 +6610,6 @@ breakText Glyphs Glifos Align to Pixel Grid Alinear con cuadrícula de píxeles @msg only paths and compound paths can be aligned to the pixel grid solo los trazados y los trazados compuestos se pueden alinear con la cuadrícula de píxeles @msg side is left or right, not `{s}` side es left o right, no `{s}` + Ignore All Omitir todo + Add to Dictionary Añadir al diccionario + No Suggestions No hay sugerencias diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index 1ba0cff27b..9eb0230ca5 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -6702,3 +6702,6 @@ breakText Glyphs Merkit Align to Pixel Grid Tasaa pikseliruudukkoon @msg only paths and compound paths can be aligned to the pixel grid vain polut ja yhdistelmäpolut voi tasata pikseliruudukkoon @msg side is left or right, not `{s}` side on left tai right, ei `{s}` + Ignore All Ohita kaikki + Add to Dictionary Lisää sanastoon + No Suggestions Ei ehdotuksia diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 6a7817aea4..d02ff3bde6 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -6662,3 +6662,6 @@ breakText Glyphs Glyphes Align to Pixel Grid Aligner sur la grille de pixels @msg only paths and compound paths can be aligned to the pixel grid seuls les tracés et les tracés transparents peuvent être alignés sur la grille de pixels @msg side is left or right, not `{s}` side vaut left ou right, pas `{s}` + Ignore All Tout ignorer + Add to Dictionary Ajouter au dictionnaire + No Suggestions Aucune suggestion diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 1d5571d2dd..2445493468 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -8109,3 +8109,6 @@ theme Appearance अपीयरेंस @msg an importer declares 1 to {MAX_EXTENSIONS} `extensions` इंपोर्टर 1 से {MAX_EXTENSIONS} `extensions` घोषित करता है @msg an importer always gets an import's budget: leave `budget` out इंपोर्टर को हमेशा इंपोर्ट का बजट मिलता है: `budget` छोड़ दें @msg side is left or right, not `{s}` side left या right होना चाहिए, `{s}` नहीं + Ignore All सभी को अनदेखा करें + Add to Dictionary शब्दकोश में जोड़ें + No Suggestions कोई सुझाव नहीं diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 92d7822e46..9e65a755a9 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -6651,3 +6651,6 @@ breakText Glyphs Glifák Align to Pixel Grid Igazítás a pixelrácshoz @msg only paths and compound paths can be aligned to the pixel grid csak útvonalak és összetett útvonalak igazíthatók a pixelrácshoz @msg side is left or right, not `{s}` a side értéke left vagy right lehet, nem `{s}` + Ignore All Az összes kihagyása + Add to Dictionary Hozzáadás a szótárhoz + No Suggestions Nincs javaslat diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 8f710a566b..904ca4c9a3 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -6288,3 +6288,6 @@ breakText Glyphs Glifi Align to Pixel Grid Allinea alla griglia pixel @msg only paths and compound paths can be aligned to the pixel grid solo i tracciati e i tracciati composti possono essere allineati alla griglia pixel @msg side is left or right, not `{s}` side è left o right, non `{s}` + Ignore All Ignora tutto + Add to Dictionary Aggiungi al dizionario + No Suggestions Nessun suggerimento diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 6403b1e8ac..0c5383e91d 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -6353,3 +6353,6 @@ breakText Glyphs 字形 Align to Pixel Grid ピクセルグリッドに整合 @msg only paths and compound paths can be aligned to the pixel grid ピクセルグリッドに整合できるのはパスと複合パスのみです @msg side is left or right, not `{s}` side は left または right です(`{s}` ではありません) + Ignore All すべて無視 + Add to Dictionary 辞書に追加 + No Suggestions 候補なし diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 0b6755253c..d373590792 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -6728,3 +6728,6 @@ font-script Japanese იაპონური Align to Pixel Grid პიქსელურ ბადეზე გასწორება @msg only paths and compound paths can be aligned to the pixel grid პიქსელურ ბადეზე მხოლოდ ტრაექტორიების და შედგენილი ტრაექტორიების გასწორებაა შესაძლებელი @msg side is left or right, not `{s}` side არის left ან right და არა `{s}` + Ignore All ყველას იგნორირება + Add to Dictionary ლექსიკონში დამატება + No Suggestions შემოთავაზებები არ არის diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 64844bf7c6..18121f268e 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -6249,3 +6249,6 @@ breakText Glyphs 글리프 Align to Pixel Grid 픽셀 격자에 정렬 @msg only paths and compound paths can be aligned to the pixel grid 패스와 컴파운드 패스만 픽셀 격자에 정렬할 수 있습니다 @msg side is left or right, not `{s}` side는 left 또는 right입니다(`{s}` 아님) + Ignore All 모두 무시 + Add to Dictionary 사전에 추가 + No Suggestions 제안 없음 diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index dd33ebd052..ab94114151 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -6523,3 +6523,6 @@ breakText Glyphs Glify Align to Pixel Grid Wyrównaj do siatki pikseli @msg only paths and compound paths can be aligned to the pixel grid do siatki pikseli można wyrównać tylko ścieżki i ścieżki złożone @msg side is left or right, not `{s}` side to left lub right, a nie `{s}` + Ignore All Ignoruj wszystko + Add to Dictionary Dodaj do słownika + No Suggestions Brak sugestii diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index a80de1e0ca..c28eb87987 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -6740,3 +6740,6 @@ swatch Yellow Green Verde-amarelado Align to Pixel Grid Alinhar à Grade de Pixels @msg only paths and compound paths can be aligned to the pixel grid somente caminhos e caminhos compostos podem ser alinhados à grade de pixels @msg side is left or right, not `{s}` side é left ou right, não `{s}` + Ignore All Ignorar Tudo + Add to Dictionary Adicionar ao Dicionário + No Suggestions Nenhuma Sugestão diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 2ddc692a37..de217605b4 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -6756,3 +6756,6 @@ shape Open Открытый Align to Pixel Grid Выровнять по пиксельной сетке @msg only paths and compound paths can be aligned to the pixel grid по пиксельной сетке можно выровнять только контуры и составные контуры @msg side is left or right, not `{s}` side — это left или right, а не `{s}` + Ignore All Пропустить все + Add to Dictionary Добавить в словарь + No Suggestions Нет вариантов diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 7a0e3f39b6..b712cc51b4 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -8152,3 +8152,6 @@ digits Hindi Hindi Align to Pixel Grid Justera mot pixelrutnät @msg only paths and compound paths can be aligned to the pixel grid bara banor och sammansatta banor kan justeras mot pixelrutnätet @msg side is left or right, not `{s}` side är left eller right, inte `{s}` + Ignore All Ignorera alla + Add to Dictionary Lägg till i ordlistan + No Suggestions Inga förslag diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 67b958ebde..7c80c3568e 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -6623,3 +6623,6 @@ breakText Glyphs Glifler Align to Pixel Grid Piksel Izgarasına Hizala @msg only paths and compound paths can be aligned to the pixel grid piksel ızgarasına yalnızca yollar ve bileşik yollar hizalanabilir @msg side is left or right, not `{s}` side left veya right olmalıdır, `{s}` değil + Ignore All Tümünü Yoksay + Add to Dictionary Sözlüğe Ekle + No Suggestions Öneri Yok diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 67050653e9..c835a9998e 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -6268,3 +6268,6 @@ breakText Glyphs Гліфи Align to Pixel Grid Вирівняти за піксельною сіткою @msg only paths and compound paths can be aligned to the pixel grid за піксельною сіткою можна вирівняти лише контури і складені контури @msg side is left or right, not `{s}` side — це left або right, а не `{s}` + Ignore All Пропустити все + Add to Dictionary Додати до словника + No Suggestions Немає варіантів diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 7c02797921..92392744a2 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -6269,3 +6269,6 @@ breakText Glyphs Glyph Align to Pixel Grid Căn theo lưới pixel @msg only paths and compound paths can be aligned to the pixel grid chỉ đường dẫn và đường dẫn phức mới căn theo lưới pixel được @msg side is left or right, not `{s}` side là left hoặc right, không phải `{s}` + Ignore All Bỏ qua tất cả + Add to Dictionary Thêm vào từ điển + No Suggestions Không có gợi ý diff --git a/crates/ui-egui/src/menus/context.rs b/crates/ui-egui/src/menus/context.rs index bc191593a8..387cd099cd 100644 --- a/crates/ui-egui/src/menus/context.rs +++ b/crates/ui-egui/src/menus/context.rs @@ -14,7 +14,8 @@ pub fn context_items(app: &VectorcraftApp) -> Vec { let roots: Vec<&vectorcraft_doc::Node> = st.selection.objects.iter().filter_map(|id| st.doc.node(*id)).collect(); let any = |f: fn(&NodeKind) -> bool| roots.iter().any(|n| f(&n.kind)); let several = roots.len() >= 2; - let mut v = vec![c("Undo", "edit.undo"), c("Redo", "edit.redo"), Sep]; + let mut v = spelling_items(app); + v.extend([c("Undo", "edit.undo"), c("Redo", "edit.redo"), Sep]); if st.isolation.is_some() { v.extend([c("Exit Isolation Mode", "object.exitIsolation"), Sep]); } @@ -108,6 +109,39 @@ pub fn context_items(app: &VectorcraftApp) -> Vec { prune(app, v) } +/// A right-click on a word Auto Spell Check underlined ([`crate::state::UiState::context_spelling`]): +/// the words its dictionary suggests, each replacing it (`text.editRange`, one undo step; +/// `spelling_item_label` shows the word), or No Suggestions; then Ignore All +/// (`text.spelling.ignoreAll`) and Add to Dictionary (`text.spelling.addWord`), as the public +/// [Check and correct spelling](https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/check-and-correct-spelling.html) +/// page lists them. Nothing for another right-click, or after the document or the word changed. +fn spelling_items(app: &VectorcraftApp) -> Vec { + let Some(st) = app.session.active() else { return vec![] }; + let Some(w) = app.ui.context_spelling.as_ref().filter(|w| w.uid == st.uid) else { return vec![] }; + let still = match st.doc.node(w.id).map(|n| &n.kind) { + Some(vectorcraft_doc::NodeKind::Text(t)) => t.plain_text().get(w.start..w.end) == Some(w.word.as_str()), + _ => false, + }; + if !still { + return vec![]; + } + let mut v: Vec = w + .suggestions + .iter() + .map(|s| cp("Suggestion", "text.editRange", json!({ "id": w.id.0, "start": w.start, "end": w.end, "insert": s }))) + .collect(); + if v.is_empty() { + v.push(Item::Header("No Suggestions")); + } + v.extend([ + Sep, + cp("Ignore All", "text.spelling.ignoreAll", json!({ "word": w.word })), + cp("Add to Dictionary", "text.spelling.addWord", json!({ "word": w.word })), + Sep, + ]); + v +} + /// The locked objects under the pointer at the right-click, each unlocked as the Layers panel's /// lock column does. `unlock_item_label` shows each object's name. fn unlock_items(app: &VectorcraftApp) -> Vec { diff --git a/crates/ui-egui/src/menus/entries.rs b/crates/ui-egui/src/menus/entries.rs index 36811ba4ca..5d203fe875 100644 --- a/crates/ui-egui/src/menus/entries.rs +++ b/crates/ui-egui/src/menus/entries.rs @@ -1,6 +1,6 @@ use super::context::context_items; use super::invoke::click_target; -use super::labels::{display_label, dynamic_label, effect_item_label, item_shortcut, unlock_item_label}; +use super::labels::{display_label, dynamic_label, effect_item_label, item_shortcut, spelling_item_label, unlock_item_label}; use super::state::{shortcut_of, shown_state}; use super::tree::menu_tree_named; use super::{Entry, Item, Serialize, Value, VectorcraftApp}; @@ -51,6 +51,7 @@ pub fn entry<'a>(app: &VectorcraftApp, it: &'a Item) -> Option> { source: label, label: effect_item_label(id, p) .or_else(|| unlock_item_label(app, id, p, crate::i18n::current())) + .or_else(|| spelling_item_label(id, p)) .unwrap_or_else(|| display_label(app, id, label)) .into(), shortcut: item_shortcut(id, p), @@ -129,7 +130,9 @@ fn flatten(app: &VectorcraftApp, path: Vec, items: &[Item], out: &mut Ve let Some((enabled, _)) = shown_state(app, id, p) else { continue }; out.push(MenuEntry { path: path.clone(), - label: unlock_item_label(app, id, p, crate::i18n::Lang::EN).unwrap_or_else(|| dynamic_label(app, id, l)), + label: unlock_item_label(app, id, p, crate::i18n::Lang::EN) + .or_else(|| spelling_item_label(id, p)) + .unwrap_or_else(|| dynamic_label(app, id, l)), command: Some(id.to_string()), params: p.clone(), enabled, diff --git a/crates/ui-egui/src/menus/labels.rs b/crates/ui-egui/src/menus/labels.rs index e26d0bdec8..148368a279 100644 --- a/crates/ui-egui/src/menus/labels.rs +++ b/crates/ui-egui/src/menus/labels.rs @@ -214,6 +214,12 @@ pub(super) fn unlock_item_label(app: &VectorcraftApp, id: &str, p: &Value, lang: Some(crate::panels::layers::shown_name(app, n, lang)) } +/// A context menu suggestion's label: the word it puts in (`text.editRange {insert}`), as it is, +/// untranslated ([`super::context`]'s `spelling_items`). +pub(super) fn spelling_item_label(id: &str, p: &Value) -> Option { + (id == "text.editRange").then(|| p.get("insert")?.as_str().map(str::to_string)).flatten() +} + /// The shortcut a menu item shows: its command's (items without params), a panel's for the /// Window menu's `window.panel {panel}` items, an effect's for the Effect menu's items, or a filter /// plug-in's for Object › Plug-ins' `plugin.dialog {id}` items. @@ -235,6 +241,9 @@ pub fn item_shortcut(id: &str, p: &Value) -> Option<&'static str> { /// Labels only the canvas context menu ([`context_items`]) shows, so the catalog tests can insist /// every one of them is translated (a test checks the list against the menu). pub const CONTEXT_LABELS: &[&str] = &[ + "Ignore All", + "Add to Dictionary", + "No Suggestions", "Isolate Selected Group", "Exit Isolation Mode", "Lock Artboards", diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 64574e52d2..ce03daca56 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -348,6 +348,21 @@ pub struct ReplaceFontWarning { pub styles: Vec<(String, String)>, } +/// A misspelled word right-clicked on the canvas ([`UiState::context_spelling`]). +#[derive(Clone, Debug, PartialEq)] +pub struct ContextSpelling { + /// The document's `uid`. + pub uid: u64, + /// The type object. + pub id: vectorcraft_doc::NodeId, + /// The word's bytes in the object's plain text, which `text.editRange` replaces. + pub start: usize, + pub end: usize, + pub word: String, + /// What the dictionary of the word's language suggests instead, best first. + pub suggestions: Vec, +} + #[derive(Clone, Debug, Serialize, Deserialize)] #[serde(default)] pub struct UiState { @@ -415,6 +430,10 @@ pub struct UiState { /// while it is open ([`crate::panels::artboards::MenuFacts`]). Not saved. #[serde(skip)] pub context_artboards: Option, + /// The word with Auto Spell Check's red line under the last right-click on the canvas, for the + /// context menu's suggestions, Ignore All and Add to Dictionary while it is open. Not saved. + #[serde(skip)] + pub context_spelling: Option, /// Which link statuses the active document's images and placed documents have, for Select › /// Linked, Embedded, Missing and Modified to be on only when some do (refreshed by /// [`crate::addons::select::link_status::refresh`]). Not saved. @@ -1002,6 +1021,7 @@ impl Default for UiState { context_locked: None, context_intertwine: None, context_artboards: None, + context_spelling: None, link_statuses: Default::default(), slot_tool: Default::default(), floating_flyouts: vec![], diff --git a/crates/ui-egui/src/tests/contextmenu.rs b/crates/ui-egui/src/tests/contextmenu.rs index 6dadd856cd..0812f410e5 100644 --- a/crates/ui-egui/src/tests/contextmenu.rs +++ b/crates/ui-egui/src/tests/contextmenu.rs @@ -204,7 +204,10 @@ fn labels(items: &[Item]) -> Vec<&'static str> { items .iter() .flat_map(|it| match it { + // A spelling suggestion shows the word it puts in, not its label. + Item::Cmd(_, "text.editRange", _) => vec![], Item::Cmd(l, ..) => vec![*l], + Item::Header(l) => vec![*l], Item::Sub(l, ch) => std::iter::once(*l).chain(labels(ch)).collect(), _ => vec![], }) @@ -370,6 +373,18 @@ fn every_context_label_is_a_menu_string() { app.run("layer.setProps", json!({"ids": [a.0], "locked": true})).unwrap(); app.ui.context_locked = Some((app.session.active().unwrap().uid, vec![a])); shown.extend(labels(&menus::context_items(&app))); + // A word Auto Spell Check underlined, with no suggestions: Ignore All, Add to Dictionary. + let text = NodeId(app.run("text.create", json!({"x": 10, "y": 200, "text": "Zqxvbtkw"})).unwrap()["id"].as_u64().unwrap()); + app.ui.context_spelling = Some(crate::state::ContextSpelling { + uid: app.session.active().unwrap().uid, + id: text, + start: 0, + end: 8, + word: "Zqxvbtkw".into(), + suggestions: vec![], + }); + shown.extend(labels(&menus::context_items(&app))); + app.ui.context_spelling = None; // The Lock Artboards and Unlock Artboards submenus of the Artboard tool. app.run("artboard.new", json!({"x": 400, "y": 0, "width": 100, "height": 80})).unwrap(); app.run("artboard.setProps", json!({"index": 0, "locked": true})).unwrap(); @@ -384,3 +399,110 @@ fn every_context_label_is_a_menu_string() { assert!(shown.contains(label), "`{label}` is listed but the context menu never shows it"); } } + +/// Point type holding `t` at (20, 60), with Auto Spell Check on. +fn spelled(app: &mut VectorcraftApp, t: &str) -> NodeId { + let id = NodeId(app.run("text.create", json!({"x": 20, "y": 60, "text": t, "size": 24})).unwrap()["id"].as_u64().unwrap()); + app.run("select.none", json!({})).unwrap(); + app.run("edit.autoSpellCheck", json!({})).unwrap(); + id +} + +/// Where on screen the middle of bytes `a..b` of type object `id` is. +fn word_at(app: &VectorcraftApp, id: NodeId, a: usize, b: usize) -> Pos2 { + let st = app.session.active().unwrap(); + let Some(NodeKind::Text(t)) = st.doc.node(id).map(|n| &n.kind) else { panic!("type") }; + let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t); + let q = vectorcraft_text::selection_quads(&lay, a, b)[0].map(|c| t.xf * c); + let c = Point::new(q.iter().map(|p| p.x).sum::() / 4.0, q.iter().map(|p| p.y).sum::() / 4.0); + screen(app, c.x, c.y) +} + +fn plain(app: &VectorcraftApp, id: NodeId) -> String { + match app.session.active().unwrap().doc.node(id).map(|n| &n.kind) { + Some(NodeKind::Text(t)) => t.plain_text(), + _ => panic!("type"), + } +} + +/// With Auto Spell Check on, right-clicking an underlined word lists what the dictionary suggests +/// instead, then Ignore All and Add to Dictionary; a suggestion replaces the word in one undo step +/// (helpx: Check and correct spelling, "Correct spelling errors during Auto Spell Check"). +#[test] +fn right_click_on_an_underlined_word_offers_its_suggestions() { + let mut app = app(); + let id = spelled(&mut app, "the speling cat"); + let want: Vec = app.session.execute("text.spelling.suggest", &json!({"word": "speling"})).unwrap()["suggestions"] + .as_array() + .unwrap() + .iter() + .map(|s| s.as_str().unwrap().to_string()) + .collect(); + assert!(!want.is_empty(), "the dictionary has suggestions for `speling`"); + let ctx = egui::Context::default(); + frame(&mut app, &ctx, vec![]); + let p = word_at(&app, id, 4, 11); + let texts = click(&mut app, &ctx, p, PointerButton::Secondary); + for w in &want { + assert!(has(&texts, w), "`{w}` in {texts:?}"); + } + assert!(has(&texts, "Ignore All") && has(&texts, "Add to Dictionary"), "{texts:?}"); + // Agents see them at the top of the menu, a suggestion by its word. + let entries = menus::context_entries(&app); + assert_eq!((entries[0].label.as_str(), entries[0].command.as_deref()), (want[0].as_str(), Some("text.editRange"))); + let steps = app.session.active().unwrap().history.undo.len(); + let texts = click(&mut app, &ctx, at(&texts, &want[0]), PointerButton::Primary); + assert_eq!(plain(&app, id), format!("the {} cat", want[0]), "the word is replaced"); + assert_eq!(app.session.active().unwrap().history.undo.len(), steps + 1, "in one undo step"); + assert!(!has(&texts, "Ignore All"), "the menu closed: {texts:?}"); +} + +/// Ignore All and Add to Dictionary from the menu: the word's line goes, in this document for +/// Ignore All, in every check for Add. A word with nothing to suggest says so. +#[test] +fn ignore_all_and_add_to_dictionary_from_the_context_menu() { + let mut app = app(); + let id = spelled(&mut app, "Qzvxtrwk and Kwqzvxtr"); + let ctx = egui::Context::default(); + frame(&mut app, &ctx, vec![]); + let p = word_at(&app, id, 0, 8); + let texts = click(&mut app, &ctx, p, PointerButton::Secondary); + assert!(has(&texts, "No Suggestions"), "{texts:?}"); + click(&mut app, &ctx, at(&texts, "Ignore All"), PointerButton::Primary); + assert!(app.session.active().unwrap().spelling_ignored.contains("qzvxtrwk")); + let p = word_at(&app, id, 13, 21); + let texts = click(&mut app, &ctx, p, PointerButton::Secondary); + click(&mut app, &ctx, at(&texts, "Add to Dictionary"), PointerButton::Primary); + let added = craft_proof_has(&mut app, "kwqzvxtr"); + app.run("text.spelling.removeWord", json!({"word": "kwqzvxtr"})).unwrap(); + assert!(added, "added to the custom dictionary"); + // Nothing left underlined there: a right-click on it offers no spelling items. + let p = word_at(&app, id, 0, 8); + let texts = click(&mut app, &ctx, p, PointerButton::Secondary); + assert!(!has(&texts, "Ignore All"), "{texts:?}"); +} + +fn craft_proof_has(app: &mut VectorcraftApp, w: &str) -> bool { + app.run("text.spelling.dictionary", json!({})).unwrap()["words"].as_array().unwrap().iter().any(|x| x == w) +} + +/// With Auto Spell Check off, or on a word that is right, a right-click lists no spelling items. +#[test] +fn no_spelling_items_off_a_misspelled_word_or_with_auto_spell_check_off() { + let mut app = app(); + let id = spelled(&mut app, "the Vqzxtrwk"); + let ctx = egui::Context::default(); + frame(&mut app, &ctx, vec![]); + let p = word_at(&app, id, 0, 3); + let texts = click(&mut app, &ctx, p, PointerButton::Secondary); + assert!(!has(&texts, "Ignore All"), "a right word: {texts:?}"); + app.run("edit.autoSpellCheck", json!({})).unwrap(); + frame( + &mut app, + &ctx, + vec![Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() }], + ); + let p = word_at(&app, id, 4, 12); + let texts = click(&mut app, &ctx, p, PointerButton::Secondary); + assert!(!has(&texts, "Ignore All"), "off: {texts:?}"); +} diff --git a/docs/gaps.md b/docs/gaps.md index 0939dbf3d9..cd3a77db6f 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,10 +1,11 @@ # Gaps -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-10 · **Change:** minor (G2: Effect Gallery thumbnails and in-dialog preview; estimate 1–2 h → 0; 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: built-in UI Frames symbol library with default 9-slice; estimate 11–21 h; previously minor: G5: dynamic symbol part on-canvas takes opacity, blend, gradient and pattern; docs Overlap/auto-corners sync; estimate 12–22 h; previously minor: interim macOS cursor delivery, #2292; native validation pending; 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, Pattern brush auto corners and Global Edit Shift-click leave-out moved from Missing to Done (code already shipped); 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; G9: EPS gradients drawn as abutting strips import as one gradient, without seams; Tools stays beside the canvas with left-side panels; new gradients follow document color mode; Selection QoL: marquee modes and visible feedback, #5313; #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; Indian-language hyphenation review and license allowlist, #4947; opening a slow file shows a progress window, part of #5930; Hindi catalog and contributor loanword preferences; agent wording pass complete; wider native proofreading welcome; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); scores and estimates unchanged; a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window; scores and estimates unchanged; large images in CMYK documents are shrunk before they are separated into ink planes (#4155); scores and estimates unchanged; `.ai` paths with a Calligraphic brush open brushed, with the 6D Art Pen's channels, and Windows reads the pen's tilt and barrel rotation, #372; scores and estimates unchanged; Compound Shapes add-on, beyond Illustrator; scores and estimates unchanged; Contour add-on, beyond Illustrator; scores and estimates unchanged; a slow first frame is waited for instead of restarting without the graphics adapter, and the menu bar no longer waits for the installed fonts' scan; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-10 · **Change:** minor (right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: G2: Effect Gallery thumbnails and in-dialog preview; estimate 1–2 h → 0; 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: built-in UI Frames symbol library with default 9-slice; estimate 11–21 h; previously minor: G5: dynamic symbol part on-canvas takes opacity, blend, gradient and pattern; docs Overlap/auto-corners sync; estimate 12–22 h; previously minor: interim macOS cursor delivery, #2292; native validation pending; 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, Pattern brush auto corners and Global Edit Shift-click leave-out moved from Missing to Done (code already shipped); 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; G9: EPS gradients drawn as abutting strips import as one gradient, without seams; Tools stays beside the canvas with left-side panels; new gradients follow document color mode; Selection QoL: marquee modes and visible feedback, #5313; #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; Indian-language hyphenation review and license allowlist, #4947; opening a slow file shows a progress window, part of #5930; Hindi catalog and contributor loanword preferences; agent wording pass complete; wider native proofreading welcome; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); scores and estimates unchanged; a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window; scores and estimates unchanged; large images in CMYK documents are shrunk before they are separated into ink planes (#4155); scores and estimates unchanged; `.ai` paths with a Calligraphic brush open brushed, with the 6D Art Pen's channels, and Windows reads the pen's tilt and barrel rotation, #372; scores and estimates unchanged; Compound Shapes add-on, beyond Illustrator; scores and estimates unchanged; Contour add-on, beyond Illustrator; scores and estimates unchanged; a slow first frame is waited for instead of restarting without the graphics adapter, and the menu bar no longer waits for the installed fonts' scan; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) ## Feature submission evidence **Feature evidence last updated:** 2026-11-02. +| 2026-11-10 | minor | G4 type: a right-click on a word Auto Spell Check underlined lists its suggestions, Ignore All and Add to Dictionary, #4936; the Check Spelling and Edit Custom Dictionary dialogs remain; scores and estimates unchanged. | | 2026-11-06 | minor | Opening a file that takes longer than 0.4 s shows a progress window: the file name, a moving bar (reading reports no steps), how many more files wait, Cancel (Esc; the file doesn't open) and Continue in Background (`file.cancelOpen`, `file.openInBackground`); it never opens over another dialog or progress window (part of #5930); scores and estimates unchanged | Raster rendering fits and preflattens curved stroke outlines more tightly only below four times the raster tolerance, retaining thin-band coverage without changing stroke weight or vector export geometry. Current cached character/object outlines, cropped long strokes, gradient clips and effect rasters share the helper; cropped-region reuse and guarded flattening remain. The unchanged `fractional_curved_strokes_retain_coverage_between_notches` regression covers fractional weights, rotation, phase, opacity and f32-rounded endpoints. This is a quality/cost tradeoff, not a speed claim. Scores and estimates unchanged. @@ -449,7 +450,7 @@ macOS cursor delivery (#2292) is narrowed to a native host adapter using the exi - **Missing:** CJK vertical composition (mojikumi sets and dialog, kinsoku settings #633, manual tate-chu-yoko), Middle Eastern features beyond bidi, digit types, Character Direction and Kashidas (Middle Eastern composers, a split caret), spell check (its engine is done, #4936: `text.spelling.check` and - `text.spelling.suggest` on craft-proof; the custom dictionary and Ignore All commands; Auto Spell Check, `edit.autoSpellCheck`; the Check Spelling and Edit Custom Dictionary dialogs and the right-click Spelling menu remain), + `text.spelling.suggest` on craft-proof; the custom dictionary and Ignore All commands; Auto Spell Check, `edit.autoSpellCheck`, with a right-click on an underlined word offering its suggestions, Ignore All and Add to Dictionary; the Check Spelling and Edit Custom Dictionary dialogs remain), Touch Type, Retype, Snap to Glyph; optional preservation of font sizes during group scaling (#1034 enhancement). Itemized in [type-parity.md](type-parity.md). - **Impact:** professional typography and Japanese/Chinese publishing. diff --git a/docs/mcp.md b/docs/mcp.md index 66459255ad..e3b7487dcd 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -5976,6 +5976,10 @@ any case, while the document is open. It is not saved and not an undo step. In the app, Edit › Spelling › Auto Spell Check (the UI command `edit.autoSpellCheck`, which returns whether it is now on) underlines on the canvas the words `text.spelling.check` lists, in the visible type without effects, and keeps the lines up to date as the type, the custom dictionary and the Ignore All words change. +A right-click on an underlined word puts at the top of the canvas context menu the words `text.spelling.suggest` gives +for it (each a `text.editRange {id, start, end, insert}` item labelled with its word), or No Suggestions, then Ignore +All (`text.spelling.ignoreAll {word}`) and Add to Dictionary (`text.spelling.addWord {word}`); the context menu's +entries list them with their params. ## Justification options diff --git a/docs/roadmap.md b/docs/roadmap.md index 19369b9fa9..62b7dc7d4a 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # Roadmap: milestones and what's next -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-10 · **Change:** minor (G2: Effect Gallery thumbnails and in-dialog preview are in; 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; interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; 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; M4/M7: `.ai`/EPS type keeps its tab stops, and right-aligned lines with tabs lay out as the file's page, #6147; M4/M7: `.ai`/EPS type from the text document lays its lines out closer to the file's page, within limits, #6137; numeric fields step on wheel hover without taking keyboard focus, #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; 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; Indian-language hyphenation review and license allowlist, #4947; Hindi catalog and contributor loanword preferences; full wording review in progress; Effect dialogs: a colour swatch beside a hex colour opens the Color Picker, #5365; shape tools move the selection from its centre point and turn it from the zone outside a box corner, #5477; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-10 · **Change:** minor (right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: G2: Effect Gallery thumbnails and in-dialog preview are in; 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; interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; 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; M4/M7: `.ai`/EPS type keeps its tab stops, and right-aligned lines with tabs lay out as the file's page, #6147; M4/M7: `.ai`/EPS type from the text document lays its lines out closer to the file's page, within limits, #6137; numeric fields step on wheel hover without taking keyboard focus, #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; 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; Indian-language hyphenation review and license allowlist, #4947; Hindi catalog and contributor loanword preferences; full wording review in progress; Effect dialogs: a colour swatch beside a hex colour opens the Color Picker, #5365; shape tools move the selection from its centre point and turn it from the zone outside a box corner, #5477; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) ## Feature submission evidence @@ -40,6 +40,7 @@ M7 type: Touch Type moves a right-to-left character along its line and up from i | Date | Change | Summary | |---|---|---| +| 2026-11-10 | minor | M7: right-click suggestions, Ignore All and Add to Dictionary on a word Auto Spell Check underlined (#4936); the dialogs remain; scores and estimates unchanged. | | 2026-11-07 | minor | M4/M7: `.ai`/EPS type from the text document keeps its paragraphs' tab stops, and right-aligned area type sets the text after a line's last tab against the right edge, the text before it at its stops from the left edge (#6147). Scores and estimates unchanged. | | 2026-11-07 | minor | M4/M7: `.ai`/EPS type from the text document keeps auto leading at the paragraph's share, the paragraph's hyphenation options and may show more of the last lines the file sets in an area frame of its own, when growing the frame by its lines' deepest descent plus 0.05 pt shows them, and a line a soft return ends is justified as a wrapped line is (#6137); a user's `.ai` that opened from its PDF part now opens from its layers. Limits: frames grow only for area type of one frame, horizontal, by its lines' deepest descent plus 0.05 pt; type in a font that isn't installed, and hyphenation in a language that isn't imported, still lay out differently. Scores and estimates unchanged. | | 2026-11-08 | minor | Character Styles panel: a double-click on a user style's row opens its Character Style Options (`ui.charStyleOptions`); the dialog keeps a fixed width and scrolls its sections in a shorter window, so OK and Cancel stay on screen. Tests `panels::text_styles::tests::double_clicking_a_character_style_row_opens_its_options` and `dialogs::char_style_options::tests::the_sections_scroll_in_a_window_shorter_than_the_general_list`. Scores and estimates unchanged. | @@ -137,9 +138,9 @@ native wording review, layout checks and a web Devanagari font remain. [Notes](l | M3 | Paint & appearance (swatches, color, gradient, stroke, appearance, transparency, styles) | ✅ done (M3.7–M3.98): swatches, groups, libraries, tints, Lab spots and Registration; Eyedropper styles highlighted text only (atomic transparency rejection); Eyedropper reads visible locked and outside-isolation sources (#1251); Color Picker, Color panel, Color Guide, Color Themes, Edit Colors and Recolor Artwork (screen-bounded Edit chip list, #3416); gradients that follow every transform, the annotator, linked stops, freeform gradients, focal points, gradients on strokes and Expand; visible strokes when paint is added to inherited None/zero-width art (#1253); stroke geometry, arrowheads, dashes fitted to corners, stroke on type, width profiles and the Width tool (Shift-double-click enables adjoining adjustment in Width Point Edit), Scale Strokes and preview bounds; Appearance targeting, container appearance, target circles, Expand Appearance; knockout, isolation, blend accuracy, CMYK blending, overprint, Attributes, masks and clipping, Flatten Transparency; graphic styles, links and libraries; CSS library export preserves fractional linear and radial gradient stop positions. Left: freeform gradients in SVG/PDF, mixed spot/process gradients in SVG and PDF gradients mixing a spot color with RGB or Lab stops export as stops or process colors; PDF export writes dithered gradient fills with spot stops, and Perceptual spot gradients with RGB or Lab stops other than white or more than 4,096 stops on their curve, as images in process colors; confirm the Scale Strokes & Effects default | 4–8 | | M5 | Performance | 🟡 background render + caches + MT done; `vectorcraft-cli bench` and `vectorcraft-cli perf` (budget suite); opaque solid-fill knockout paths draw once in hard-edge/supersampled raster export, with analytical-AA compositing unchanged; file format v2 opens 3× faster (50k paths: 722 → 244 ms); raster effects (glows, shadows, blur, feather) no longer force the whole frame single-threaded (filtered offscreen per effect, verified equal to the single-threaded reference); Craquelure/Mosaic Tiles/Stained Glass distance searches optimized with pixel-invariance checks and the `texture_drag_perf` probe (#1257); effect-heavy demos need a clean-machine benchmark; dirty-region rendering, GPU backend spike pending | 15–25 | | M6 | Path operations (Pathfinder, Shape Builder, offset…) | ✅ mostly done (live compound shapes: Make, Alt-click a Shape Mode, member modes, Release, Expand (#4522); single-result Pathfinder names and SVG IDs retained (#1293); Divide open-cutting-path geometry, appearance and history verified (#1307; existing segment cap remains); Simplify preview/counts and rewritten-path component selection verified (#1272; the slider opens at the Auto-Simplify level; a Direct Selection part of a path simplifies on its own); Compound appearance and member fill-rule edits verified (#1197); Boolean precision on almost-horizontal edges fixed and the property tests made deterministic; Shape Builder gap detection, large-offset bug open); Shape Builder Gap Detection shares Live Paint’s spatial gap finder and temporary merge bridges (#2526); Custom is capped at 72 pt in the existing options dialog | 3–6 | -| M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | Spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof and the custom dictionary and Ignore All commands and Auto Spell Check (#4936; the dialogs and the right-click Spelling menu remain); Right-aligned area type sets the text after a line's last tab against the right edge, the text before it at its stops (#6147); A line a soft return ends is justified as a wrapped line is (#6137); Indian-language pattern hyphenation preserves syllables in both composers (#4947); 🟡 Hanging punctuation keeps its published amounts under Glyph Scaling, and Standard burasagari counts a hung line start as room; Fullwidth `!` / `?` keep an upright fallback in vertical type; Document-point character controls after object/group scaling (#1034; optional font-size preservation remains separate), Character/Paragraph Styles (explicit Auto-leading resets and local leading overrides), per-paragraph paragraph attributes, mixed paragraph alignment indication (headless regressions, full integration CI and visual review pass), Area Type Options (with Width/Height, Auto Size and Shrink Text to Fit; overflow reported to agents and vertical Align), type areas resized by their handles and reshaped with Direct Selection, threaded text, Fit Headline, Glyphs, OpenType panel (including font-defined Stylistic Sets), Find Font (Document, Recent and System replacement sources, #5217), installed system fonts in every font menu (fonts registered with Windows outside its font folders, the XDG and Flatpak host font folders on Linux, and fonts installed while the app runs, listed when it comes back to the front; searchable; the Character and Properties menus show each family's sample of the selected text, preview it live under the pointer or ↑/↓, show recently applied families first (#3515), filter by script and kind, list a family's styles, and star favourites; the Glyphs panel and Find Font's Replace With use the same menu without the live preview) and found by name on open/import (full-name aliases and Missing Fonts searches verified, #1258; Windows font-service QA pending), variable fonts' named instances as styles, CJK names in the UI, input methods (marked text in place, candidate window at the caret; Japanese IME), Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects and Options with draggable start/centre/end brackets (slide, flip), tab stops + Tabs panel (Above Text floats it over the selected text, its ruler at the canvas zoom so stops line up with the columns, #729), inline symbols in text (beyond parity), Hebrew/Arabic bidirectional text, Paragraph Direction, Every-line/Single-line Composer per object (ragged text too) done; paint-only Arabic harakat keep joining with GDEF-mark ownership and Insert Kashidas tatweels on the base letter's paint (`tests::harakat`); tab leaders, spell check, vertical type pending | 45–60 | -| M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | Spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof and the custom dictionary and Ignore All commands and Auto Spell Check (#4936; the dialogs and the right-click Spelling menu remain); Right-aligned area type sets the text after a line's last tab against the right edge, the text before it at its stops (#6147); A line a soft return ends is justified as a wrapped line is (#6137); Indian-language pattern hyphenation preserves syllables in both composers (#4947); 🟡 Fullwidth `!` / `?` keep an upright fallback in vertical type; Hyphenation… and Justification… per paragraph (#2310, #2755), Auto Leading also driving the Character panel's Leading, the Alt+↑/↓ leading nudge and Fill with Placeholder Text; Document-point character controls after object/group scaling (#1034; optional font-size preservation remains separate), Character/Paragraph Styles (explicit Auto-leading resets and local leading overrides), per-paragraph paragraph attributes, mixed paragraph alignment indication (headless regressions, full integration CI and visual review pass), Area Type Options (with Width/Height, Auto Size and Shrink Text to Fit; overflow reported to agents and vertical Align), type areas resized by their handles and reshaped with Direct Selection, threaded text, Fit Headline, Glyphs, OpenType panel, Find Font (Document, Recent and System replacement sources, #5217), installed system fonts in every font menu (fonts registered with Windows outside its font folders, the XDG and Flatpak host font folders on Linux, and fonts installed while the app runs, listed when it comes back to the front; searchable; the Character and Properties menus show each family's sample of the selected text, preview it live under the pointer or ↑/↓, filter by script and kind, list a family's styles, and star favourites; the Glyphs panel and Find Font's Replace With use the same menu without the live preview) and found by name on open/import (full-name aliases and Missing Fonts searches verified, #1258; Windows font-service QA pending), variable fonts' named instances as styles, CJK names in the UI, input methods (marked text in place, candidate window at the caret; Japanese IME), Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects and Options with draggable start/centre/end brackets (slide, flip), tab stops + Tabs panel (Above Text floats it over the selected text, its ruler at the canvas zoom so stops line up with the columns, #729), inline symbols in text (beyond parity), Hebrew/Arabic bidirectional text, Paragraph Direction, Every-line/Single-line Composer per object (ragged text too) done; paint-only Arabic harakat keep joining with GDEF-mark ownership and Insert Kashidas tatweels on the base letter's paint (`tests::harakat`); tab leaders, spell check, vertical type pending | 45–60 | -| M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | Spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof and the custom dictionary and Ignore All commands and Auto Spell Check (#4936; the dialogs and the right-click Spelling menu remain); Right-aligned area type sets the text after a line's last tab against the right edge, the text before it at its stops (#6147); A line a soft return ends is justified as a wrapped line is (#6137); Indian-language pattern hyphenation preserves syllables in both composers (#4947); 🟡 Fullwidth `!` / `?` keep an upright fallback in vertical type; Document-point character controls after object/group scaling (#1034; optional font-size preservation remains separate), Character/Paragraph Styles (explicit Auto-leading resets and local leading overrides), per-paragraph paragraph attributes, mixed paragraph alignment indication (headless regressions, full integration CI and visual review pass), Area Type Options (with Width/Height, Auto Size and Shrink Text to Fit; overflow reported to agents and vertical Align), type areas resized by their handles and reshaped with Direct Selection, threaded text, Fit Headline, Glyphs, OpenType panel, Find Font (Document, Recent and System replacement sources, #5217), installed system fonts in every font menu (fonts registered with Windows outside its font folders, the XDG and Flatpak host font folders on Linux, and fonts installed while the app runs, listed when it comes back to the front; searchable; the Character and Properties menus show each family's sample of the selected text, preview it live under the pointer or ↑/↓, show recently applied families first (#3515), filter by script and kind, list a family's styles, and star favourites; the Glyphs panel and Find Font's Replace With use the same menu without the live preview) and found by name on open/import (full-name aliases and Missing Fonts searches verified, #1258; Windows font-service QA pending), variable fonts' named instances as styles, CJK names in the UI, input methods (marked text in place, candidate window at the caret; Japanese IME), Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects and Options with draggable start/centre/end brackets (slide, flip), tab stops + Tabs panel (Above Text floats it over the selected text, its ruler at the canvas zoom so stops line up with the columns, #729), inline symbols in text (beyond parity), Hebrew/Arabic bidirectional text, Paragraph Direction, Every-line/Single-line Composer per object (ragged text too) done; paint-only Arabic harakat keep joining with GDEF-mark ownership and Insert Kashidas tatweels on the base letter's paint (`tests::harakat`); tab leaders, spell check, vertical type pending | 45–60 | +| M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | Spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof and the custom dictionary and Ignore All commands and Auto Spell Check with its right-click suggestions, Ignore All and Add to Dictionary (#4936; the dialogs remain); Right-aligned area type sets the text after a line's last tab against the right edge, the text before it at its stops (#6147); A line a soft return ends is justified as a wrapped line is (#6137); Indian-language pattern hyphenation preserves syllables in both composers (#4947); 🟡 Hanging punctuation keeps its published amounts under Glyph Scaling, and Standard burasagari counts a hung line start as room; Fullwidth `!` / `?` keep an upright fallback in vertical type; Document-point character controls after object/group scaling (#1034; optional font-size preservation remains separate), Character/Paragraph Styles (explicit Auto-leading resets and local leading overrides), per-paragraph paragraph attributes, mixed paragraph alignment indication (headless regressions, full integration CI and visual review pass), Area Type Options (with Width/Height, Auto Size and Shrink Text to Fit; overflow reported to agents and vertical Align), type areas resized by their handles and reshaped with Direct Selection, threaded text, Fit Headline, Glyphs, OpenType panel (including font-defined Stylistic Sets), Find Font (Document, Recent and System replacement sources, #5217), installed system fonts in every font menu (fonts registered with Windows outside its font folders, the XDG and Flatpak host font folders on Linux, and fonts installed while the app runs, listed when it comes back to the front; searchable; the Character and Properties menus show each family's sample of the selected text, preview it live under the pointer or ↑/↓, show recently applied families first (#3515), filter by script and kind, list a family's styles, and star favourites; the Glyphs panel and Find Font's Replace With use the same menu without the live preview) and found by name on open/import (full-name aliases and Missing Fonts searches verified, #1258; Windows font-service QA pending), variable fonts' named instances as styles, CJK names in the UI, input methods (marked text in place, candidate window at the caret; Japanese IME), Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects and Options with draggable start/centre/end brackets (slide, flip), tab stops + Tabs panel (Above Text floats it over the selected text, its ruler at the canvas zoom so stops line up with the columns, #729), inline symbols in text (beyond parity), Hebrew/Arabic bidirectional text, Paragraph Direction, Every-line/Single-line Composer per object (ragged text too) done; paint-only Arabic harakat keep joining with GDEF-mark ownership and Insert Kashidas tatweels on the base letter's paint (`tests::harakat`); tab leaders, spell check, vertical type pending | 45–60 | +| M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | Spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof and the custom dictionary and Ignore All commands and Auto Spell Check with its right-click suggestions, Ignore All and Add to Dictionary (#4936; the dialogs remain); Right-aligned area type sets the text after a line's last tab against the right edge, the text before it at its stops (#6147); A line a soft return ends is justified as a wrapped line is (#6137); Indian-language pattern hyphenation preserves syllables in both composers (#4947); 🟡 Fullwidth `!` / `?` keep an upright fallback in vertical type; Hyphenation… and Justification… per paragraph (#2310, #2755), Auto Leading also driving the Character panel's Leading, the Alt+↑/↓ leading nudge and Fill with Placeholder Text; Document-point character controls after object/group scaling (#1034; optional font-size preservation remains separate), Character/Paragraph Styles (explicit Auto-leading resets and local leading overrides), per-paragraph paragraph attributes, mixed paragraph alignment indication (headless regressions, full integration CI and visual review pass), Area Type Options (with Width/Height, Auto Size and Shrink Text to Fit; overflow reported to agents and vertical Align), type areas resized by their handles and reshaped with Direct Selection, threaded text, Fit Headline, Glyphs, OpenType panel, Find Font (Document, Recent and System replacement sources, #5217), installed system fonts in every font menu (fonts registered with Windows outside its font folders, the XDG and Flatpak host font folders on Linux, and fonts installed while the app runs, listed when it comes back to the front; searchable; the Character and Properties menus show each family's sample of the selected text, preview it live under the pointer or ↑/↓, filter by script and kind, list a family's styles, and star favourites; the Glyphs panel and Find Font's Replace With use the same menu without the live preview) and found by name on open/import (full-name aliases and Missing Fonts searches verified, #1258; Windows font-service QA pending), variable fonts' named instances as styles, CJK names in the UI, input methods (marked text in place, candidate window at the caret; Japanese IME), Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects and Options with draggable start/centre/end brackets (slide, flip), tab stops + Tabs panel (Above Text floats it over the selected text, its ruler at the canvas zoom so stops line up with the columns, #729), inline symbols in text (beyond parity), Hebrew/Arabic bidirectional text, Paragraph Direction, Every-line/Single-line Composer per object (ragged text too) done; paint-only Arabic harakat keep joining with GDEF-mark ownership and Insert Kashidas tatweels on the base letter's paint (`tests::harakat`); tab leaders, spell check, vertical type pending | 45–60 | +| M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | Spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof and the custom dictionary and Ignore All commands and Auto Spell Check with its right-click suggestions, Ignore All and Add to Dictionary (#4936; the dialogs remain); Right-aligned area type sets the text after a line's last tab against the right edge, the text before it at its stops (#6147); A line a soft return ends is justified as a wrapped line is (#6137); Indian-language pattern hyphenation preserves syllables in both composers (#4947); 🟡 Fullwidth `!` / `?` keep an upright fallback in vertical type; Document-point character controls after object/group scaling (#1034; optional font-size preservation remains separate), Character/Paragraph Styles (explicit Auto-leading resets and local leading overrides), per-paragraph paragraph attributes, mixed paragraph alignment indication (headless regressions, full integration CI and visual review pass), Area Type Options (with Width/Height, Auto Size and Shrink Text to Fit; overflow reported to agents and vertical Align), type areas resized by their handles and reshaped with Direct Selection, threaded text, Fit Headline, Glyphs, OpenType panel, Find Font (Document, Recent and System replacement sources, #5217), installed system fonts in every font menu (fonts registered with Windows outside its font folders, the XDG and Flatpak host font folders on Linux, and fonts installed while the app runs, listed when it comes back to the front; searchable; the Character and Properties menus show each family's sample of the selected text, preview it live under the pointer or ↑/↓, show recently applied families first (#3515), filter by script and kind, list a family's styles, and star favourites; the Glyphs panel and Find Font's Replace With use the same menu without the live preview) and found by name on open/import (full-name aliases and Missing Fonts searches verified, #1258; Windows font-service QA pending), variable fonts' named instances as styles, CJK names in the UI, input methods (marked text in place, candidate window at the caret; Japanese IME), Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects and Options with draggable start/centre/end brackets (slide, flip), tab stops + Tabs panel (Above Text floats it over the selected text, its ruler at the canvas zoom so stops line up with the columns, #729), inline symbols in text (beyond parity), Hebrew/Arabic bidirectional text, Paragraph Direction, Every-line/Single-line Composer per object (ragged text too) done; paint-only Arabic harakat keep joining with GDEF-mark ownership and Insert Kashidas tatweels on the base letter's paint (`tests::harakat`); tab leaders, spell check, vertical type pending | 45–60 | | M8 | Transform & distort (Puppet Warp, Liquify tools, Envelopes, Blends, Perspective Grid) | ✅ done (M8.1–M8.20 on 2026-10-06, fidelity pass): Liquify Tool Options with one shared brush, pen pressure, hold-to-apply, a safe scope and incremental strokes; tool options that last; Puppet Warp rotation, multi-select, Control bar and a rest-shape session that follows Undo; Width tool on compound paths; Envelope dialogs with preview, Reset with Warp/Mesh, full Envelope Options, type/images/symbols/appearance/gradients/patterns distorted on canvas and in every export, warps that keep their frame, Edit Contents, mesh handles and Control bar; Blend Options, anchor-targeted Blend tool, editable spine, keys picked on the canvas, faithful interpolation and knockout; Anchor Point spine conversion/handle editing and selection priority (#2544), with #2425 Shift reshape preserved; Perspective Grid with Define Grid, presets, lock/snap/rulers, widgets, the 1–4 plane switch, movable planes, Perspective Selection scale/copy/perpendicular/Transform Again, and type and symbols truly projected with Edit Text. Left: Anti-Alias and Preserve Shape output, warp-envelope point editing, brush interpolation in blends, Free Transform/Scale/Reflect along a rotated box | 2–4 | | M9 | Live effects (+ 3D & Materials) | 🟡 2D effects done incl. Effect → Pathfinder, Color Adjustments and Document Raster Effects Settings; PDF export warns when the pixel cap lowers raster-effect resolution; Artistic › Colored Pencil, Cutout, Dry Brush, Film Grain, Fresco, Neon Glow, Paint Daubs, Palette Knife, Poster Edges, Rough Pastels, Smudge Stick, Sponge, Underpainting and Watercolor and Sketch › Halftone Pattern, Bas Relief, Charcoal, Conté Crayon, Photocopy, Stamp, Torn Edges and Water Paper done (57/57 Photoshop filters); Revolve with automatic smooth vector shading, default-on circular caps and continuous rims, Extrude & Bevel, Inflate and Rotate with one to ten colored directional lights and four original lighting presets; SVG Filters, remaining raster effects and 3D materials and shadows pending | 81–130 | | M10 | Brushes, symbols, patterns, Repeat | 🟡 pattern swatches (5 tile types, Pattern Options, editing mode, SVG ``/PDF export) and live Repeat (radial/grid/mirror) done; interactive selected mirror axis implemented (focused and native checks passed; full CI blocked by an unrelated text test); symbol libraries done (Window › Symbol Libraries, a generated Basic Shapes library, User Defined, Other Library…, Save Symbols); brush libraries done (Window › Brush Libraries, four generated libraries, User Defined, Other Library…, Save Brushes); symbol editing shows draggable 9-slice guides (#5280), with guides outside the artwork still unsupported; brushes/symbols in progress | 23–37 | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 7610075f65..c99d61e838 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-10 · **Change:** 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-10 · **Change:** 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) ## Feature submission evidence @@ -41,6 +41,7 @@ Artboard Options forwards the active Artboard tool's Scale Artwork with Artboard | Date | Change | Summary | |---|---|---| +| 2026-11-10 | minor | Type advanced: right-click suggestions, Ignore All and Add to Dictionary on a word Auto Spell Check underlined, #4936; evidence added; the dialogs remain; scores and hours unchanged. | | 2026-11-09 | minor | Canvas: a 3D effect (Extrude & Bevel, Inflate, Rotate, Revolve) changed on type or a group draws again where its art was and is, measured by that art; the canvas measures through its renderer and draws the part with the art it made to measure it. Exports and Object › Rasterize take the solid of 3D type. Fit to Artwork Bounds and per-artboard SVG export measure every artboard through one renderer, building each solid once. A 3D effect on a fill or stroke that builds nothing (Revolve after Roughen) no longer overflows the stack on the canvas, in Object › Rasterize and in raster and EPS exports: the fill or stroke draws as without it. Tests `tests::changed::a_3d_effect_changed_on_type_or_a_group_changes_where_its_solid_was`, `fx::tests::a_renderer_measures_the_art_on_each_artboard_building_each_solid_once`, `canvas::tests::an_edit_to_a_3d_effect_redraws_with_the_art_measured`, `fx::tests::a_3d_effect_that_builds_nothing_draws_the_item_as_without_it`. Scores and estimates unchanged. | | 2026-11-07 | minor | File formats and Type core (#6147): `.ai`/EPS type keeps its paragraphs' tab stops (positions and alignments; leaders aren't read; at most 1,024 a paragraph and 2^20 a story), and right-aligned area type sets the text after a line's last tab against the right edge, the text before it at its stops; centred and justified lines are aligned as a whole as before; evidence added. Scores and estimates unchanged. | | 2026-11-07 | minor | File formats and Type core (#6137): `.ai`/EPS type from the text document keeps auto leading per line, its hyphenation options, may show more of the last lines the file sets in an area frame of its own, when growing the frame by its lines' deepest descent plus 0.05 pt shows them, and a line a soft return ends is justified; evidence added. Limits: frames grow only for area type of one frame, horizontal, by its lines' deepest descent plus 0.05 pt; type in a font that isn't installed, and hyphenation in a language that isn't imported, still lay out differently. Scores and estimates unchanged. | @@ -213,15 +214,15 @@ dominate; 3D, graphs and automation are specialist). They sum to 105. Feature de | Appearance, transparency, graphic styles, masks | 5 | 97% | raster effect reach in preview bounds | 1–2 | estimated; Transform typing, sliders, preview, and undo are covered in `crates/ui-egui/src/panels/tests_appearance.rs` | | 3D and Materials (and Turntable) | 4 | ~25% | live Revolve with automatic smooth vector shading and default-on continuous circular caps, Extrude & Bevel (twist, taper, caps, five bevel shapes), Inflate and Rotate with Position presets (colored Inflate uses smooth membrane lighting; white-light Inflate retains its existing shading; Extrude & Bevel and Rotate retain planar shading; one to ten colored lights, shared neutral ambient, painter visibility); materials, light presets, shadows, ray tracing, the rest of 3D Classic (Revolve, Rotate, Map Art), Turntable views, textures, map art and shading in 3D export | 30–55 | estimated (Extrude & Bevel, Inflate and Rotate added 2026-10-12) | | Type core | 9 | 80% | Auto-leading style resets are implemented; Hyphenation… and Justification… options are done (#2310, #2755), and a paragraph's Auto Leading also sets the Character panel's Leading, the Alt+↑/↓ leading nudge and Fill with Placeholder Text; remaining: Optical Margin Alignment, Middle Eastern features beyond bidi layout, optional group font-size preservation; transformed character controls fixed (#1034), see [type-parity.md](type-parity.md) | 14–19 | estimated; eleven-language pattern hyphenation in both composers, with grapheme and shaping-cluster protection (#4947, `hyphen::tests` and `layout::wrapping_tests`); Sanskrit patterns pending an allowlisted source; recent-font groups in font pickers (#3515); full-name font matches regression-tested (#1258); mixed paragraph alignment indication implemented; headless regressions, full integration CI and visual review pass; localized legacy family-name fallback with loaded/catalog regressions (#2208); Fractional Widths per type object regression-tested; color emoji fonts with bitmap glyphs (`sbix`, `CBDT`) draw and export; threading ports: every selected area type and type on a path shows its in and out ports (empty, arrow, red "+"), and the Selection tool threads from either, the in port into a frame before it, and a double-click on a threaded port breaks the thread there (`threads::tests::break_splits_the_thread_and_the_story_flows_into_the_first_part`, `select::tests::a_double_click_on_a_threaded_port_breaks_the_thread`, `canvas::tests::a_double_click_on_a_threaded_out_port_breaks_the_thread`) (#2572; `select::tests::a_click_on_the_in_port_threads_into_a_frame_before_it`, `text::tests::selected_type_shows_its_ports_in_each_state`, `threads::tests::add_before_threads_the_story_from_the_new_frame_into_the_clicked_one`, live screenshot); the Glyphs panel highlights and scrolls to the character selected with the Type tool (#4908, `panels::glyphs` tests); the OpenType panel lists only `ss01`–`ss20` sets present in the selected font and uses its custom names when supplied; Type-range desktop Copy publishes styled art and keeps font family, size and color on canvas Paste after editing (#5636); paint-only Arabic harakat keep joining with conditional GDEF-mark ownership and Insert Kashidas base-letter paint (`tests::harakat`) | -| Type advanced (CJK, spelling, Touch Type, Retype) | 4 | 50% | CJK vertical composition (ruby, mojikumi), variable font axes, tab leaders, spell check (engine, custom dictionary and Auto Spell Check done, #4936; the dialogs and the right-click Spelling menu remain), Touch Type, Retype | 20–28 | estimated; upright fallback for fullwidth `!` / `?` regression-tested; score and hours unchanged | +| Type advanced (CJK, spelling, Touch Type, Retype) | 4 | 50% | CJK vertical composition (ruby, mojikumi), variable font axes, tab leaders, spell check (engine, custom dictionary and Auto Spell Check with its right-click suggestions done, #4936; the dialogs remain), Touch Type, Retype | 20–28 | estimated; upright fallback for fullwidth `!` / `?` regression-tested; score and hours unchanged | | Symbols, blends, envelopes, Repeat, perspective | 5 | 88% | remaining: dynamic symbols' `.ai` round trip, further built-in symbol libraries (Basic Shapes and UI Frames with default 9-slice are done), envelope Anti-Alias/Preserve Shape, 9-slice guides outside the art; Symbol Options › Registration, Export Type (Graphic / Movie Clip) and 9-slice guides (drawn and draggable during symbol editing, #5280) and Symbol Type (dynamic on-canvas fill/stroke including gradient/pattern, opacity and blend, #4938) are done | 4–7 | estimated; Blend panel Type and Ramp control step easing with a separate color Shift (#2539); Blend Options retains spacing and orientation; polynomial curves are original approximations; interactive selected mirror axis implemented; bounding-box handles take priority over the axis line (focused regression and native checks passed; full CI blocked by an unrelated text test); #2544 spine Anchor Point conversion and picking regression-tested | -| Type advanced (CJK, spelling, Touch Type, Retype) | 4 | 50% | CJK vertical composition (ruby, mojikumi), tab leaders, spell check (engine, custom dictionary and Auto Spell Check done, #4936; the dialogs and the right-click Spelling menu remain), Touch Type, Retype | 20–28 | estimated; upright fallback for fullwidth `!` / `?` regression-tested; score and hours unchanged | +| Type advanced (CJK, spelling, Touch Type, Retype) | 4 | 50% | CJK vertical composition (ruby, mojikumi), tab leaders, spell check (engine, custom dictionary and Auto Spell Check with its right-click suggestions done, #4936; the dialogs remain), Touch Type, Retype | 20–28 | estimated; upright fallback for fullwidth `!` / `?` regression-tested; score and hours unchanged | | Symbols, blends, envelopes, Repeat, perspective | 5 | 88% | remaining: dynamic symbols' `.ai` round trip, further built-in symbol libraries (Basic Shapes and UI Frames with default 9-slice are done), envelope Anti-Alias/Preserve Shape, 9-slice guides outside the art; Symbol Options › Registration, Export Type (Graphic / Movie Clip) and 9-slice guides (drawn and draggable during symbol editing, #5280) and Symbol Type (dynamic on-canvas fill/stroke including gradient/pattern, opacity and blend, #4938) are done | 4–7 | estimated; Blend panel Type and Ramp control step easing with a separate color Shift (#2539); Blend Options retains spacing and orientation; polynomial curves are original approximations; interactive selected mirror axis implemented; bounding-box handles take priority over the axis line (focused regression and native checks passed; full CI blocked by an unrelated text test); #2544 spine Anchor Point conversion and picking regression-tested | | Image Trace, graphs, image tools | 3 | 75% | graph Column designs, Image Trace Create Strokes depth (Create Object Mosaic is done; junction-returning piece survival has source-generated regressions in `image-trace-quality.md`; simpler contour walk, identical output; an edge's sub-pixel shift is the same from either side; notches read only their own piece; collapsed loops keep few segments, so Paths is monotone on the code-drawn sweep) | 5–8 | estimated (raised from 72%); quick presets reveal adjustment controls, Properties reopens them; Live trace result bounds and selection highlights exclude the retained bitmap except in source-image views (scores/hours unchanged) | | Live vector effects | 5 | 85% | Outline Object, SVG Filters | 4–8 | measured count 49/50 (Pathfinder 13/13 with Trap), depth estimated; Warp Options controls verified by focused checks and original-image review | | 3D and Materials (and Turntable) | 4 | ~25% | live Revolve with automatic smooth vector shading and default-on continuous circular caps, Extrude & Bevel (twist, taper, caps, five bevel shapes), Inflate and Rotate with Position presets (colored Inflate uses smooth membrane lighting; white-light Inflate retains its existing shading; Extrude & Bevel and Rotate retain planar shading; one to ten colored lights, shared neutral ambient, original lighting presets, painter visibility); materials, shadows, ray tracing, the rest of 3D Classic (Revolve, Rotate, Map Art), Turntable views, textures, map art and shading in 3D export | 30–55 | estimated (Extrude & Bevel, Inflate and Rotate added 2026-10-12) | -| Type advanced (CJK, spelling, Touch Type, Retype) | 4 | 50% | CJK vertical composition (mojikumi), variable font axes, tab leaders, spell check (engine, custom dictionary and Auto Spell Check done, #4936; the dialogs and the right-click Spelling menu remain), Touch Type, Retype | 20–28 | estimated; No Break keeps marked characters on one line; upright fallback for fullwidth `!` / `?` regression-tested; score and hours unchanged | -| Type advanced (CJK, spelling, Touch Type, Retype) | 4 | 50% | CJK vertical composition (mojikumi), tab leaders, spell check (engine, custom dictionary and Auto Spell Check done, #4936; the dialogs and the right-click Spelling menu remain), Touch Type, Retype | 20–28 | estimated; No Break keeps marked characters on one line; upright fallback for fullwidth `!` / `?` regression-tested; score and hours unchanged | +| Type advanced (CJK, spelling, Touch Type, Retype) | 4 | 50% | CJK vertical composition (mojikumi), variable font axes, tab leaders, spell check (engine, custom dictionary and Auto Spell Check with its right-click suggestions done, #4936; the dialogs remain), Touch Type, Retype | 20–28 | estimated; No Break keeps marked characters on one line; upright fallback for fullwidth `!` / `?` regression-tested; score and hours unchanged | +| Type advanced (CJK, spelling, Touch Type, Retype) | 4 | 50% | CJK vertical composition (mojikumi), tab leaders, spell check (engine, custom dictionary and Auto Spell Check with its right-click suggestions done, #4936; the dialogs remain), Touch Type, Retype | 20–28 | estimated; No Break keeps marked characters on one line; upright fallback for fullwidth `!` / `?` regression-tested; score and hours unchanged | | Raster effects (Effect Gallery) | 4 | 55% | — | — | estimated (raised from 52%: the Effect Gallery's filter thumbnails and in-dialog preview, an original landscape drawing passed through each filter, [Filter Gallery overview](https://helpx.adobe.com/photoshop/desktop/effects-filters/get-started-with-filters/filter-gallery.html), `dialogs::effect_gallery::tests::gallery_shows_thumbnails_and_an_in_dialog_preview`; the gallery itself shipped in #2883, six categories, per-filter settings, ordered stack, live canvas preview, one undo step, `stack_ok_is_one_undo_step`, `stack_preview_is_one_interaction`); measured count 57/57 (Artistic complete with Colored Pencil, Fresco, Paint Daubs, Palette Knife, Rough Pastels and Underpainting; Sketch complete with Torn Edges and Conté Crayon, `pixel::tests_sketch::conte_crayon_*`); texture redraw distances optimized (#1257), pixels verified; the score stays under 100% because the filters are original approximations; depth estimated | | Symbols, blends, envelopes, Repeat, perspective | 5 | 88% | remaining: dynamic symbols' `.ai` round trip, further built-in symbol libraries (Basic Shapes and UI Frames with default 9-slice are done), envelope Anti-Alias/Preserve Shape, 9-slice guides outside the art; Symbol Options › Registration, Export Type and Symbol Type (dynamic on-canvas fill/stroke including gradient/pattern, opacity and blend, #4938) and on-canvas 9-slice guides (#5280) are done | 4–7 | estimated | | Image Trace, graphs, image tools | 3 | 75% | graph Column designs, Image Trace Create Strokes depth (Create Object Mosaic is done; junction-returning piece survival has source-generated regressions in `image-trace-quality.md`; large images trace with a warning; Auto Color keeps rare distinct colours) | 5–8 | estimated (raised from 72%); quick presets reveal adjustment controls, Properties reopens them | @@ -482,10 +483,10 @@ about 1–1.5 agent-hours each, so the other rows (estimated on the older scale) | Appearance, transparency, graphic styles, masks | 5 | 98% | raster effect reach in preview bounds (flattener presets apply in PDF, EPS and print) | 1–2 | | Live vector effects | 5 | 85% | Outline Object, Pathfinder Hard/Soft Mix and Trap (since done: #3061, #3063, #3785), SVG Filters, Transform Patterns and Scale Strokes & Effects on a fill's or stroke's own Transform effect | 6–10 | | 3D and Materials | 4 | ~25% | Initial Revolve, Extrude & Bevel, Inflate and Rotate, with simple lighting; materials, light presets, shadows, ray tracing, Revolve (Classic), Rotate (Classic), Map Art and more complete visibility remain | 25–45 | -| Type advanced | 4 | 50% | CJK composition for vertical type (ruby, mojikumi beyond Line-end Punctuation Half Width — the other sets (and their paragraph-start bracket and indent rules), custom kinsoku sets (beyond Hard, Soft and None) and the Mojikumi Settings dialog —, manual tate-chu-yoko), tab leaders, spell check (its engine, custom dictionary commands and Auto Spell Check are done, #4936: the Check Spelling and Edit Custom Dictionary dialogs and the right-click Spelling menu remain), Touch Type, Retype; vertical point/area/path type and Type Orientation have initial support (beyond parity, not scored: inline symbols in text) | 20–28 | +| Type advanced | 4 | 50% | CJK composition for vertical type (ruby, mojikumi beyond Line-end Punctuation Half Width — the other sets (and their paragraph-start bracket and indent rules), custom kinsoku sets (beyond Hard, Soft and None) and the Mojikumi Settings dialog —, manual tate-chu-yoko), tab leaders, spell check (its engine, custom dictionary commands and Auto Spell Check are done, #4936: the Check Spelling and Edit Custom Dictionary dialogs remain; a right-click on an underlined word offers its suggestions, Ignore All and Add to Dictionary), Touch Type, Retype; vertical point/area/path type and Type Orientation have initial support (beyond parity, not scored: inline symbols in text) | 20–28 | | Raster effects (Effect Gallery, Document Raster Effects Settings) | 4 | 35% | ~36 Photoshop-style filters (Artistic, Brush Strokes, Sketch, Stylize) and Texturizer's Load Texture (since done, see the area table above, as is the Effect Gallery, #2883); Document Raster Effects Settings, raster effects in PDF, the pixel-filter pipeline, Radial Blur, Smart Blur (Normal, Edge Only and Overlay Edge), Unsharp Mask, Glowing Edges, Distort (Diffuse Glow, Glass, Ocean Ripple), Pixelate (Color Halftone, Crystallize, Mezzotint, Pointillize), Texture (Craquelure, Grain, Mosaic Tiles, Patchwork, Stained Glass, Texturizer) and Video (De-Interlace, NTSC Colors) are done | 8–14 | | 3D and Materials | 4 | ~25% | Initial Revolve, Extrude & Bevel, Inflate and Rotate, with simple lighting and four original lighting presets; materials, shadows, ray tracing, Revolve (Classic), Rotate (Classic), Map Art and more complete visibility remain | 25–45 | -| Type advanced | 4 | 50% | CJK composition for vertical type (mojikumi beyond Line-end Punctuation Half Width — the other sets (and their paragraph-start bracket and indent rules), custom kinsoku sets (beyond Hard, Soft and None) and the Mojikumi Settings dialog —, manual tate-chu-yoko is done, its up/down settings remain; No Break is done), tab leaders, spell check (its engine, custom dictionary commands and Auto Spell Check are done, #4936: the Check Spelling and Edit Custom Dictionary dialogs and the right-click Spelling menu remain), Touch Type, Retype; vertical point/area/path type and Type Orientation have initial support (beyond parity, not scored: inline symbols in text) | 20–28 | +| Type advanced | 4 | 50% | CJK composition for vertical type (mojikumi beyond Line-end Punctuation Half Width — the other sets (and their paragraph-start bracket and indent rules), custom kinsoku sets (beyond Hard, Soft and None) and the Mojikumi Settings dialog —, manual tate-chu-yoko is done, its up/down settings remain; No Break is done), tab leaders, spell check (its engine, custom dictionary commands and Auto Spell Check are done, #4936: the Check Spelling and Edit Custom Dictionary dialogs remain; a right-click on an underlined word offers its suggestions, Ignore All and Add to Dictionary), Touch Type, Retype; vertical point/area/path type and Type Orientation have initial support (beyond parity, not scored: inline symbols in text) | 20–28 | | Type core | 9 | 80% | Middle Eastern features beyond bidi layout, Paragraph Direction, digit types and Kashidas (the Middle Eastern composers, editable bidirectional SVG text, a split caret where directions meet); paint-only harakat keep joining with conditional GDEF-mark colour ownership, but fonts without GDEF mark classes and some AAT/`morx` ligatures can still leave marks painted as their letters; Every-line Composer fidelity for justified and ragged text (available per object; mixed type sizes in a paragraph still break single-line in non-rectangular area type and with text wrap), Optical Margin Alignment, paragraph spacing limit for vertically justified area type, hidden characters, paragraph attributes in the Type tool's copy and paste (the characters keep their styles, the paragraphs take the destination's), mixed-value display in the Paragraph panel for a selection across differing paragraphs, partial selection of type-area anchors by marquee, optional font-size preservation during group scaling (scaled character controls fixed in #1034). Hyphenation and Justification options, Fractional Widths, System Layout, styled Type-range canvas Paste (#5636), and pattern hyphenation for eleven Indian languages with grapheme/cluster protection (#4947) are done; Sanskrit automatic patterns remain pending an allowlisted source | 14–19 | | Symbols, blends, envelopes, Repeat, perspective | 5 | 90% | further built-in symbol libraries (Basic Shapes and UI Frames with default 9-slice are done), dynamic symbols' `.ai` round trip, envelope Anti-Alias/Preserve Shape output and warp point editing, brush interpolation in blends (M8 fidelity pass done); Symbol Options › Registration and Export Type and on-canvas dynamic part paint (gradient/pattern, opacity, blend) are done; symbol editing shows draggable 9-slice guides (#5280), while guides outside the artwork remain unsupported | 4–7 | | Image Trace, graphs, image tools | 3 | 72% | graph Column designs (Graph Design, Marker designs and series colours set with Group Selection are done), Create Object Mosaic, per-feature stroke/fill choice and varying widths in Image Trace's Create Strokes (Vector Halftone and the Image Trace View modes, Palette and Create Strokes are done) | 6–10 | @@ -1896,7 +1897,9 @@ Edit › Spelling has the commands its Check Spelling dialog will use, as the pu The custom dictionary and Ignore All followed (2026-11-09), as the same page describes them: Add puts a word in the custom dictionary, which Edit Custom Dictionary adds to, deletes from and changes, and Ignore All skips a word in the current document (#4936). `text.spelling.addWord`, `removeWord` and `changeWord` keep the dictionary in craft-proof, which every language's check reads, and the desktop app saves it to `Custom Dictionary.txt` beside its preferences (none in runs without preferences); `text.spelling.dictionary` lists it. `text.spelling.ignoreAll` keeps a word for the active document while it is open, not saved and not undoable (`tests/spelling.rs`). The dialogs remain. -Auto Spell Check followed (2026-11-09): the same page has Edit › Spelling › Auto Spell Check underline potential spelling errors with a red line as you type, and says it isn't available for text with effects applied (#4936). `edit.autoSpellCheck` turns it on and off (off at first, saved with the view settings); the canvas draws a red wavy line under each word `text.spelling.check` would list in the visible type, leaves out type with an effect shown on it or on its fills and strokes, and checks a type object again when it is edited, when the custom dictionary changes and when Ignore All adds a word (`ui-egui/src/canvas/tests.rs`, `auto_spell_check_*`; `tests/spelling.rs`). The right-click Spelling menu on an underlined word (suggestions, Ignore All, Add to Dictionary) and the dialogs remain. +Auto Spell Check followed (2026-11-09): the same page has Edit › Spelling › Auto Spell Check underline potential spelling errors with a red line as you type, and says it isn't available for text with effects applied (#4936). `edit.autoSpellCheck` turns it on and off (off at first, saved with the view settings); the canvas draws a red wavy line under each word `text.spelling.check` would list in the visible type, leaves out type with an effect shown on it or on its fills and strokes, and checks a type object again when it is edited, when the custom dictionary changes and when Ignore All adds a word (`ui-egui/src/canvas/tests.rs`, `auto_spell_check_*`; `tests/spelling.rs`). The dialogs remain. + +The right-click followed (2026-11-10), as the same page's "Correct spelling errors during Auto Spell Check" describes it: right-click a word with a red underline, then choose a suggested word, Ignore All to skip it in the current document, or Add to Dictionary (#4936). The canvas context menu lists the words `text.spelling.suggest` gives for it in its run's language, each replacing the word with `text.editRange` as one undo step, or No Suggestions; then Ignore All (`text.spelling.ignoreAll`) and Add to Dictionary (`text.spelling.addWord`). It lists nothing for a word that is right, with Auto Spell Check off, or once the word under the right-click has changed (`ui-egui/src/tests/contextmenu.rs`). The dialogs and the context menu's Spelling › Auto Spell Check remain. ## Control bar: Paragraph section while type is selected (2026-11-06) diff --git a/docs/type-parity.md b/docs/type-parity.md index ea09866b42..fbfe1b2a69 100644 --- a/docs/type-parity.md +++ b/docs/type-parity.md @@ -1,6 +1,6 @@ # Type parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-09 · **Change:** minor (Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; paint-only Arabic harakat keep joining with GDEF-mark ownership and Insert Kashidas base-letter paint; scores and estimates unchanged; custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: right-aligned area type sets the text after a line's last tab against the right edge; a soft return in justified type no longer keeps its line from being justified; 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) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-10 · **Change:** minor (right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; paint-only Arabic harakat keep joining with GDEF-mark ownership and Insert Kashidas base-letter paint; scores and estimates unchanged; custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: right-aligned area type sets the text after a line's last tab against the right edge; a soft return in justified type no longer keeps its line from being justified; 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) · **Target:** Adobe Illustrator 2026 (30.x) Type is Illustrator's largest single area by use (weight 13 of 105 across "Type core" and "Type advanced" in [target-app-parity.md](target-app-parity.md)). This checklist lists the features one by one. Status is from the @@ -47,7 +47,7 @@ against Illustrator's public documentation; **estimated** unless noted. | Hyphenation… and Justification… options | done | Eleven Indian languages use attributed patterns and preserve syllables in both composers (#4947). Hyphenation… (`ui.hyphenation`, `text.setFormat`) sets each paragraph's Words Longer Than, After First, Before Last, Hyphen Limit, Hyphenation Zone (Single-line Composer only) and Hyphenate Capitalized Words, and paragraph styles carry them (#2310). Justification… (`ui.justification`, `text.setFormat`, #2755) sets Word Spacing, Letter Spacing and Glyph Scaling (Minimum, Desired, Maximum), Auto Leading and Single Word Justification; Desired applies to every line, justified lines and the Every-line Composer use the ranges. Vertical type isn't glyph scaled; type on a path doesn't use them. Auto Leading also sets the Leading the Character panel shows for Auto type, the leading Alt+↑/↓ (`type.step`) steps from, per paragraph, and the lines Fill with Placeholder Text counts. Evidence: `crates/text/src/tests/justify.rs`, `dialogs::justification::tests`, `tests_prefs::auto_leading_steps_and_fills_from_the_paragraph_auto_leading`, `panels::character` test `leading_field_shows_the_paragraph_auto_leading` | | Tab stops and leaders; Tabs panel | partial | Tabs panel and stops with leaders in the model (`text.tabs.set`); a stop drags from where the press landed (#1458); leaders fill the tab's gap, repeated on a grid so they line up from line to line (#1877); Ctrl-dragging a stop (Command on macOS) moves all the stops together; in right-aligned area type the text before a line's last tab sits at its stops, from the left edge, and the text after it against the right edge, as Illustrator's own layout in a user's `.ai` sets a price list (evidence: `tests::right_aligned_area_type_sets_the_text_after_its_last_tab_against_the_edge`; centred and justified lines are still aligned as a whole) | | Hidden characters (Show Hidden Characters) | done | `type.hiddenCharacters` | -| Spell check (Check Spelling, Auto Spell Check, custom dictionary) | partial | the engine (#4936): `text.spelling.check` lists the unknown words of the document's type, each run in its Language, at the bytes `text.editRange` changes, and `text.spelling.suggest` gives suggestions, both from craft-proof, each paragraph on its own (English from the first dictionary it finds: a Hunspell dictionary in the user's dictionaries folder, the SCOWL dictionary built in from craft-i18n, a system Hunspell dictionary, and last the public-domain Moby word list built into it; other languages from the dictionaries craft-proof finds for them). The custom dictionary commands are done: `text.spelling.addWord`, `removeWord` and `changeWord` (Add, and Edit Custom Dictionary's Add, Delete and Change), and `text.spelling.dictionary` lists the words, saved to `Custom Dictionary.txt` beside the preferences; Ignore All: `text.spelling.ignoreAll` skips a word in the active document while it is open. Auto Spell Check is done: Edit › Spelling › Auto Spell Check (`edit.autoSpellCheck`, off at first, saved with the view settings) draws a red wavy line under the unknown words of the visible type, leaves out type with effects, and follows edits, the custom dictionary and Ignore All. The Check Spelling and Edit Custom Dictionary dialogs and the right-click Spelling menu (suggestions, Ignore All, Add to Dictionary) remain. Evidence: `crates/engine/src/tests/spelling.rs`, `crates/ui-egui/src/canvas/tests.rs` (`auto_spell_check_*`). | +| Spell check (Check Spelling, Auto Spell Check, custom dictionary) | partial | the engine (#4936): `text.spelling.check` lists the unknown words of the document's type, each run in its Language, at the bytes `text.editRange` changes, and `text.spelling.suggest` gives suggestions, both from craft-proof, each paragraph on its own (English from the first dictionary it finds: a Hunspell dictionary in the user's dictionaries folder, the SCOWL dictionary built in from craft-i18n, a system Hunspell dictionary, and last the public-domain Moby word list built into it; other languages from the dictionaries craft-proof finds for them). The custom dictionary commands are done: `text.spelling.addWord`, `removeWord` and `changeWord` (Add, and Edit Custom Dictionary's Add, Delete and Change), and `text.spelling.dictionary` lists the words, saved to `Custom Dictionary.txt` beside the preferences; Ignore All: `text.spelling.ignoreAll` skips a word in the active document while it is open. Auto Spell Check is done: Edit › Spelling › Auto Spell Check (`edit.autoSpellCheck`, off at first, saved with the view settings) draws a red wavy line under the unknown words of the visible type, leaves out type with effects, and follows edits, the custom dictionary and Ignore All. A right-click on an underlined word lists the words its dictionary suggests (each replaces it in one undo step, `text.editRange`), or No Suggestions, then Ignore All and Add to Dictionary. The Check Spelling and Edit Custom Dictionary dialogs and the context menu's Spelling › Auto Spell Check remain. Evidence: `crates/engine/src/tests/spelling.rs`, `crates/ui-egui/src/canvas/tests.rs` (`auto_spell_check_*`), `crates/ui-egui/src/tests/contextmenu.rs` (the right-click). | | Smart Punctuation, Change Case, Find & Replace | done | | | Alt + numeric-keypad codes while typing (Windows: `Alt+0176` → °) | partial | #5108: on Windows the character Windows posts when Alt comes up after keypad digits is typed at the caret like any other, in the typing's undo step, and the keypad digits typed with Alt run no shortcut. With NumLock off the keypad keys (Insert, End, arrows, Clear, Home, Page Up/Down without the extended-key flag) count as digits too and don't move the caret (`windows_alt_codes` unit tests; neither NumLock on nor off has been tried on a Windows machine yet). `type.insert {code}` (Windows Alt+0 code, Windows-1252 for 128–159) and `{unicode}` (hex code point) insert a character by its code (`menucmds::insert_a_character_by_its_code`). macOS and Linux have no such system convention and egui reports a keypad digit as the top-row digit, so there it does nothing; a floating panel window doesn't receive the character | | Caret: the arrows and Delete step over whole clusters (Thai consonant with its marks, combining accents, emoji); Backspace removes one character | done | in text with right-to-left characters the arrows move in visual order and stop on both sides of a character drawn with no width, such as a zero width non-joiner | @@ -81,6 +81,7 @@ Character Styles menu New/Redefine and footer New capture the first selected cha | Date | Change | Summary | |---|---|---| +| 2026-11-10 | minor | Spelling in the canvas context menu (#4936): a right-click on a word Auto Spell Check underlined lists its suggestions, Ignore All and Add to Dictionary; the Check Spelling and Edit Custom Dictionary dialogs remain. Scores and estimates unchanged. | | 2026-11-09 | minor | Auto Spell Check (#4936): Edit › Spelling › Auto Spell Check (`edit.autoSpellCheck`) underlines the unknown words of the visible type, without type with effects; the Check Spelling and Edit Custom Dictionary dialogs and the right-click Spelling menu remain. Scores and estimates unchanged. | | 2026-11-09 | minor | PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged. Decoded stream and placement regressions; reader ActualText (#6165), styled clusters and external viewer validation remain separate. | | 2026-11-09 | minor | Custom dictionary and Ignore All (#4936): `text.spelling.addWord`, `removeWord`, `changeWord` and `dictionary`, saved beside the preferences, and `text.spelling.ignoreAll` for the active document; the Check Spelling and Edit Custom Dictionary dialogs and Auto Spell Check remain. Scores and estimates unchanged. | From 4f4a447b05d072f594aad61188942e778413eb22 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Mon, 9 Nov 2026 18:47:38 -0800 Subject: [PATCH 29/36] Font menu: a font field covered by an overlay needs the pointer moved again A closed font family field stayed ready for the wheel as long as it kept its place and drew in each pass. Coverage was checked only when a pointer move or wheel turn arrived. When a window, modal or popup covered a still pointer and went away with no input in between, the next plain wheel turn changed the selected type's font. The field now stops being ready, and a wheel stream it held ends, in any pass where the last pointer position is over the field but on another layer. The pointer has to move onto the field again before the wheel steps the font. --- crates/ui-egui/src/font_menu.rs | 15 ++++++----- crates/ui-egui/src/font_menu/tests.rs | 37 +++++++++++++++++++++++++++ docs/type-parity.md | 2 +- docs/ui-parity.md | 2 +- 4 files changed, 48 insertions(+), 8 deletions(-) diff --git a/crates/ui-egui/src/font_menu.rs b/crates/ui-egui/src/font_menu.rs index d0750d373e..19d103a748 100644 --- a/crates/ui-egui/src/font_menu.rs +++ b/crates/ui-egui/src/font_menu.rs @@ -12,8 +12,8 @@ //! the mouse wheel turned over the closed field applies the next family (or previous one) to the //! selected text, one undo step per notch. The menu's order at the first turn stays fixed until the //! pointer leaves the field or the text selection changes; steps stop at its first and last family. -//! The field takes the wheel only when the pointer was moved onto it after the field last moved and -//! the wheel stream began there. +//! The field takes the wheel only when the pointer was moved onto it after the field last moved or +//! was covered by a window or overlay, and the wheel stream began there. use std::collections::{HashMap, HashSet}; use std::sync::Arc; @@ -446,7 +446,7 @@ struct WheelField { /// The seconds without wheel input that end a wheel stream. This is VectorCraft's own value. pub(crate) const WHEEL_STREAM_GAP: f64 = 0.5; -/// The field the pointer was last moved onto, while that field stays where it was. +/// The field the pointer was last moved onto, while that field stays where it was and uncovered. #[derive(Clone, Copy)] struct ArmedWheelField { id: egui::Id, @@ -732,6 +732,9 @@ fn remember_wheel_field(ui: &Ui, resp: &egui::Response, enabled: bool) -> Vec) -> Vec { let key = wheel_fields_id(ui.ctx().viewport_id()); let pass = ui.ctx().cumulative_pass_nr(); + // A window or overlay over the pointer covers the field even when no input arrives. + let pointer = ui.data_mut(|d| d.get_temp_mut_or_default::(key).pointer).or_else(|| ui.ctx().input(|i| i.pointer.latest_pos())); + let covered = pointer.is_some_and(|p| resp.interact_rect.contains(p) && ui.ctx().layer_id_at(p) != Some(resp.layer_id)); ui.data_mut(|d| { let mut fields: WheelFields = d.get_temp(key).unwrap_or_default(); if fields.pass != pass { @@ -742,7 +745,7 @@ fn remember_owned_wheel_field(ui: &Ui, resp: &egui::Response, enabled: bool, men if let Some(armed) = &mut fields.armed && armed.id == resp.id { - if armed.rect == resp.interact_rect && pass.saturating_sub(armed.pass) <= 1 { + if armed.rect == resp.interact_rect && pass.saturating_sub(armed.pass) <= 1 && !covered { armed.pass = pass; } else { fields.armed = None; @@ -761,8 +764,8 @@ fn remember_owned_wheel_field(ui: &Ui, resp: &egui::Response, enabled: bool, men /// Take the closed font fields' wheel events before egui smooths and combines them. Other /// events stay in the input, so scrolling elsewhere keeps its own delta and event order. A field /// takes a wheel stream only when the stream begins with the pointer over it and the pointer was -/// moved onto it after the field last moved. A stream that began anywhere else stays there until -/// it ends, and a stream that leaves its field does not return to it. +/// moved onto it after the field last moved or was covered. A stream that began anywhere else +/// stays there until it ends, and a stream that leaves its field does not return to it. pub(crate) fn take_raw_wheel(ctx: &egui::Context, raw: &mut egui::RawInput) { let key = wheel_fields_id(raw.viewport_id); let Some(mut fields) = ctx.data(|d| d.get_temp::(key)) else { return }; diff --git a/crates/ui-egui/src/font_menu/tests.rs b/crates/ui-egui/src/font_menu/tests.rs index 6e0d3bba69..87c3feb060 100644 --- a/crates/ui-egui/src/font_menu/tests.rs +++ b/crates/ui-egui/src/font_menu/tests.rs @@ -358,6 +358,11 @@ fn activating_the_family_popup_leaves_its_wheel_input_available() { /// A family field at `top`, the input's clock at `time`. Returns the wheel events left in the /// input, the notches the field took and the point in the middle of the field. fn stream_frame(ctx: &egui::Context, top: Option, time: f64, events: Vec) -> (usize, i32, egui::Pos2) { + covered_stream_frame(ctx, top, false, time, events) +} + +/// [`stream_frame`], with an overlay over the whole field when `covered` is set. +fn covered_stream_frame(ctx: &egui::Context, top: Option, covered: bool, time: f64, events: Vec) -> (usize, i32, egui::Pos2) { let mut raw = egui::RawInput { time: Some(time), events, ..Default::default() }; super::take_raw_wheel(ctx, &mut raw); let left = raw.events.iter().filter(|e| matches!(e, egui::Event::MouseWheel { .. })).count(); @@ -373,6 +378,12 @@ fn stream_frame(ctx: &egui::Context, top: Option, time: f64, events: Vec Date: Tue, 10 Nov 2026 03:09:31 +0000 Subject: [PATCH 30/36] PDF import: DeviceN images of one ink or of process plates keep their inks (#6348) An image in a DeviceN color space opened as an RGB image, losing its inks. One of one colorant now opens as a Separation image does since #6347: a grayscale image of its tints colorized by its ink. One of two to 32 colorants that are all process plates (the ink None may be among them) opens as a CMYK image, each colorant's tint in its plate, its Decode array applied (8 or 16 bits a sample). A DeviceN image that mixes a spot ink with other colorants, such as a duotone, still opens as RGB. Co-Authored-By: Claude Opus 5.5 --- crates/pdf/src/import/builder.rs | 4 +- crates/pdf/src/import/device.rs | 2 +- crates/pdf/src/import_image.rs | 94 ++++++++++++++++++++++++++++---- crates/pdf/src/tests/colorize.rs | 61 +++++++++++++++++++++ docs/file-format-parity.md | 5 +- 5 files changed, 149 insertions(+), 17 deletions(-) diff --git a/crates/pdf/src/import/builder.rs b/crates/pdf/src/import/builder.rs index 5fb504fd9a..b052ff7c32 100644 --- a/crates/pdf/src/import/builder.rs +++ b/crates/pdf/src/import/builder.rs @@ -585,13 +585,13 @@ impl<'p> Builder<'p> { Some(Paint::Pattern { pattern: name, xf }) } - /// Separation image `st` (`w` × `h` pixels) as the paint of its ink at 100 % (a spot swatch, a + /// Image `st` of one ink (`w` × `h` pixels) as the paint of its ink at 100 % (a spot swatch, a /// process plate or Registration, found by its colour space) and its tints as gray pixels /// ([`crate::import_image::tint_gray`]). `None` for another image, an ink whose colour can't /// be told, or tints that can't be read (with a warning): the image opens in RGB. The ink is /// only marked used once the tints are read. pub(super) fn separation_image(&mut self, st: &craft_pdf::syntax::object::Stream<'_>, w: u32, h: u32) -> Option<(Paint, Vec)> { - let space = self.colors.array_space(&crate::import_image::separation_space(st)?)?; + let space = self.colors.array_space(&crate::import_image::one_ink_space(st)?)?; let gray = crate::import_image::tint_gray(st, w, h).map_err(|why| self.warn(why)).ok()?; Some((self.colors.native(space, &[1.0])?.paint(), gray)) } diff --git a/crates/pdf/src/import/device.rs b/crates/pdf/src/import/device.rs index 731a74d07a..2fb6bb9c80 100644 --- a/crates/pdf/src/import/device.rs +++ b/crates/pdf/src/import/device.rs @@ -250,7 +250,7 @@ impl<'a> Device<'a> for Builder<'_> { let mask = if crate::import_image::has_mask(st) { self.alpha_mask(key, &r, w, h, transform) } else { None }; return self.add_image_masked(key, || cmyk.map(|blob| (blob, w, h)), transform, mask, None); } - // A Separation image keeps its ink: its tints as a grayscale image that the ink at + // An image of one ink keeps it: its tints as a grayscale image that the ink at // 100 % colorizes, as a mask painted in the ink comes in. Its mask becomes an // opacity mask, as a CMYK image's does. Read once per image: the decision is kept, // so one that opens in RGB stays RGB, and the gray pixels have their own key. diff --git a/crates/pdf/src/import_image.rs b/crates/pdf/src/import_image.rs index b5875e9bd3..d376c2b31e 100644 --- a/crates/pdf/src/import_image.rs +++ b/crates/pdf/src/import_image.rs @@ -3,9 +3,10 @@ //! convention JPEG readers and the PDF writer take a CMYK JPEG to follow) is kept as it is; any //! other is decoded, its Decode array applied, and stored as a CMYK TIFF //! ([`ImageBlob::cmyk_tiff`]). A mask (`/SMask`, `/Mask`) doesn't change that: the importer -//! carries it as the image's opacity mask ([`has_mask`]). A Separation image keeps its ink: its -//! tints are read as a grayscale image ([`tint_gray`]) that the ink colorizes. Every other image is -//! read as RGB by the interpreter. +//! carries it as the image's opacity mask ([`has_mask`]). A DeviceN image of process plates only +//! keeps them the same way, as a CMYK image ([`process_devicen`]). An image of one ink (a +//! Separation, or a DeviceN of one colorant) keeps it: its tints are read as a grayscale image +//! ([`tint_gray`]) that the ink colorizes. Every other image is read as RGB by the interpreter. use craft_pdf::syntax::Filter; use craft_pdf::syntax::object::stream::{ImageColorSpace, ImageDecodeParams}; @@ -13,6 +14,8 @@ use craft_pdf::syntax::object::{Array, Dict, Name, Stream}; use vectorcraft_doc::ImageBlob; use vectorcraft_doc::cmyk::{Inks, MAX_CMYK_PIXELS, jpeg_components}; +use crate::import_color::ink_names; + /// A CMYK image read as RGB: why. pub(crate) const UNREAD: &str = "CMYK images whose samples couldn't be read in CMYK were imported in RGB"; @@ -57,7 +60,7 @@ fn decode_ranges(d: &Dict<'_>) -> [(f32, f32); 4] { pub(crate) fn cmyk(st: &Stream<'_>, width: u32, height: u32) -> Result, &'static str> { let d = st.dict(); if !cmyk_space(d) { - return Ok(None); + return process_devicen(st, width, height); } let ranges = decode_ranges(d); let filters = st.filters(); @@ -94,19 +97,86 @@ pub(crate) fn cmyk(st: &Stream<'_>, width: u32, height: u32) -> Result(st: &Stream<'a>) -> Option> { +/// The image's colour space when it lays one ink: a Separation (`[/Separation name alt tint]`), or +/// a DeviceN of one colorant (`[/DeviceN [name] alt tint …]`). `None` for the ink `None`, which +/// paints nothing (ISO 32000-1 §8.6.6.4): the interpreter draws it. +pub(crate) fn one_ink_space<'a>(st: &Stream<'a>) -> Option> { let d = st.dict(); let a = d.get::>(key(d, b"ColorSpace", b"CS"))?; - let one_ink = { - let mut it = a.flex_iter(); - it.next::>()?.as_ref() == b"Separation" && it.next::>()?.as_ref() != b"None" - }; + let one_ink = matches!(ink_names(&mut a.flex_iter()).as_ref().map(|(_, names)| names.as_slice()), Some([name]) if name != "None"); one_ink.then_some(a) } -/// The tints of the one-component image `st` (`width` × `height` pixels, a Separation image's) +/// The CMYK channel of each colorant of the image's DeviceN space (`None` for the ink `None`) when +/// it has two or more colorants and all are process plates, or `None`. One colorant is an image of +/// one ink ([`one_ink_space`]). +fn process_plates(d: &Dict<'_>) -> Option>> { + let a = d.get::>(key(d, b"ColorSpace", b"CS"))?; + let (device_n, names) = ink_names(&mut a.flex_iter())?; + // PDF's own limit is 32 colorants. + if !device_n || names.len() < 2 || names.len() > 32 { + return None; + } + let plates = names + .iter() + .map(|n| match n.as_str() { + "None" => Some(None), + n => ["Cyan", "Magenta", "Yellow", "Black"].iter().position(|p| *p == n).map(Some), + }) + .collect::>>()?; + plates.iter().any(Option::is_some).then_some(plates) +} + +/// A DeviceN image of process plates only ([`process_plates`]) in CMYK: each colorant's tint in +/// its plate, its Decode array applied, as a CMYK TIFF. `Ok(None)` for any other image, `Err` (a +/// warning) when its samples can't be read. +fn process_devicen(st: &Stream<'_>, width: u32, height: u32) -> Result, &'static str> { + let d = st.dict(); + let Some(plates) = process_plates(d) else { return Ok(None) }; + let bpc = d.get::(key(d, b"BitsPerComponent", b"BPC")).unwrap_or(8); + if !matches!(bpc, 8 | 16) { + return Err(UNREAD); + } + let n = u64::from(width) * u64::from(height); + if n == 0 || n > MAX_CMYK_PIXELS { + return Err(UNREAD); + } + let comps = plates.len(); + let ranges: Vec<(f32, f32)> = match d.get::>(key(d, b"Decode", b"D")).map(|a| a.iter::().collect::>()) { + Some(v) if v.len() == 2 * comps && v.iter().all(|x| x.is_finite()) => v.as_chunks::<2>().0.iter().map(|p| (p[0], p[1])).collect(), + _ => vec![(0.0, 1.0); comps], + }; + let size = usize::from(bpc / 8); + let params = + ImageDecodeParams { is_indexed: false, bpc: Some(bpc), num_components: u8::try_from(comps).ok(), target_dimension: None, width, height }; + let decoded = st.decoded_image(¶ms).map_err(|_| UNREAD)?; + // An image codec lays its samples out its own way. + if decoded.image_data.is_some() { + return Err(UNREAD); + } + // Within `MAX_CMYK_PIXELS` and 32 colorants of 2 bytes this fits a 64-bit `usize`, not always a + // 32-bit one (the web build): checked. + let len = usize::try_from(n).ok().and_then(|n| n.checked_mul(comps)?.checked_mul(size)).ok_or(UNREAD)?; + let raw = decoded.data.get(..len).ok_or(UNREAD)?; + let value = |s: &[u8]| match *s { + [hi, lo] => u16::from_be_bytes([hi, lo]) as f32 / 65535.0, + [v] => v as f32 / 255.0, + _ => 0.0, + }; + let mut inks = Vec::with_capacity(n as usize * 4); + for px in raw.chunks_exact(size * comps) { + let mut cmyk = [0.0f32; 4]; + for ((s, plate), (lo, hi)) in px.chunks_exact(size).zip(&plates).zip(&ranges) { + if let Some(c) = plate.and_then(|p| cmyk.get_mut(p)) { + *c = c.max((lo + value(s) * (hi - lo)).clamp(0.0, 1.0)); + } + } + inks.extend(cmyk.map(|v| (v * 255.0).round() as u8)); + } + Inks::new(width, height, inks).and_then(|i| ImageBlob::cmyk_tiff(&i)).map(Some).ok_or(UNREAD) +} + +/// The tints of the one-component image `st` (`width` × `height` pixels, an image of one ink) /// as a grayscale image: white where it lays no ink, black at 100 %, its Decode array applied. /// `Err` (a warning) when its samples can't be read. pub(crate) fn tint_gray(st: &Stream<'_>, width: u32, height: u32) -> Result, &'static str> { diff --git a/crates/pdf/src/tests/colorize.rs b/crates/pdf/src/tests/colorize.rs index 2e4469dbb0..97388ea313 100644 --- a/crates/pdf/src/tests/colorize.rs +++ b/crates/pdf/src/tests/colorize.rs @@ -432,3 +432,64 @@ fn a_separation_image_that_cant_be_read_stays_rgb() { assert!(r.document.swatches.iter().all(|s| !s.spot), "no swatch for an ink nothing uses"); assert_eq!(r.warnings.iter().filter(|w| w.contains("spot ink")).count(), 1, "{:?}", r.warnings); } + +/// A Type 4 tint transform as an object, `code` its program. +fn type4(code: &str, inputs: usize) -> String { + let domain = "0 1 ".repeat(inputs); + format!("<< /FunctionType 4 /Domain [{}] /Range [0 1 0 1 0 1 0 1] /Length {} >>\nstream\n{code}\nendstream", domain.trim_end(), code.len()) +} + +/// The imported images' blobs, in paint order. +fn image_blobs(d: &Document) -> Vec<(ImageBlob, bool)> { + let mut out = vec![]; + for l in &d.layers { + l.walk(&mut |n| { + if let NodeKind::Image(im) = &n.kind { + out.push((d.images[&im.key].clone(), n.extras().colorize.is_some())); + } + }); + } + out +} + +/// A DeviceN image of one colorant opens as a Separation image does: its tints, colorized by the ink. +#[test] +fn a_devicen_image_of_one_ink_opens_colorized_by_it() { + let space = "[/DeviceN [/SpotRed] /DeviceCMYK << /FunctionType 2 /Domain [0 1] /C0 [0 0 0 0] /C1 [0 1 1 0] /N 1 >>]"; + let d = separation_page(space, 4, 8, "", "0055AAFF", &[]); + let images = imported_images(&d); + assert_eq!(images.len(), 1); + assert_eq!(images[0].1, [255, 170, 85, 0]); + assert!(matches!(&images[0].0, Some(Paint::Solid { swatch: Some(s), .. }) if s == "SpotRed"), "{:?}", images[0].0); +} + +/// A DeviceN image of process plates only opens in CMYK, each colorant's tint in its plate. +#[test] +fn a_devicen_image_of_process_plates_opens_in_cmyk() { + let space = format!("[/DeviceN [/Cyan /Magenta] /DeviceCMYK {} 0 R]", first_extra(1) + 1); + // Cyan and Magenta → (c, m, 0, 0); pixels: none, cyan, magenta, both. + let d = separation_page(&space, 4, 8, "", "0000FF0000FFFFFF", &[&type4("{ 0 0 }", 2)]); + let blobs = image_blobs(&d); + assert_eq!(blobs.len(), 1); + let (blob, colorized) = &blobs[0]; + assert!(blob.is_cmyk() && !colorized, "a CMYK image"); + let inks = blob.cmyk().unwrap().data; + assert_eq!(inks, [0, 0, 0, 0, 255, 0, 0, 0, 0, 255, 0, 0, 255, 255, 0, 0]); + + // 16 bits a sample, the ink None among the plates, and a Decode array that inverts Cyan. + let space = format!("[/DeviceN [/Cyan /None /Black] /DeviceCMYK {} 0 R]", first_extra(1) + 1); + let d = separation_page(&space, 2, 16, "/Decode [1 0 0 1 0 1]", "FFFF0000000000000000FFFF", &[&type4("{ exch pop 0 0 3 -1 roll }", 3)]); + let blobs = image_blobs(&d); + assert_eq!(blobs[0].0.cmyk().unwrap().data, [0, 0, 0, 0, 255, 0, 0, 255]); +} + +/// A DeviceN image of a spot ink and a process plate (a duotone) still opens as RGB: an image can +/// hold one ink or the process plates, not both. +#[test] +fn a_duotone_devicen_image_still_opens_as_rgb() { + let space = format!("[/DeviceN [/Black /SpotRed] /DeviceCMYK {} 0 R]", first_extra(1) + 1); + let d = separation_page(&space, 2, 8, "", "FF0000FF", &[&type4("{ dup 3 -1 roll 0 4 1 roll }", 2)]); + let blobs = image_blobs(&d); + assert_eq!(blobs.len(), 1); + assert!(!blobs[0].0.is_cmyk() && !blobs[0].1, "RGB, not colorized"); +} diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 1f911f6e50..10dded6237 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-09 · **Change:** minor (`.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-09 · **Change:** minor (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 @@ -283,7 +283,7 @@ Names in the editing data that aren't UTF-8 (layers, objects, artboards, colors, | VectorCraft `.vectorcraft` / `.vctemplate` | — | yes | yes | lossless JSON, versioned, save down to v1; property-tested round trips (`format/tests/prop_format.rs`); documents with a lot of path data are written as compact JSON, about a fifth of the size (#1492); a save falls back to a plain fsync and to writing elsewhere and copying in when the folder refuses the usual steps (#1625, #1649, part of #1620; not confirmed on a real network share); the size limits on embedded data also apply to files read from memory (#1783), and saving over a private file on Unix leaves no readable temporary copy beside it (#1778); a dynamic symbol's type (`dynamic` on the symbol) and an instance's appearance changes to its art (`symbol_overrides`, each path at most 64 deep, counted against the object budget) are written only when set, so documents without them save as before (#4938) | | Illustrator template `.ait` | open, save | yes | PDF-compatible only, from Save as Template (`.vctemplate` stays its default) | New from Template opens a saved `.ait` untitled with its layers and art (`tests_pdfedit.rs`) | | Illustrator `.ai` | open, save | yes (PDF-compatible + editing-data structure with template layers and ruler guides, File Info) | PDF-compatible, or legacy version 7 (layers and groups) | see above; no real-file corpus | -| PDF | open, place, save | yes (authored ActualText in editable import, #6165; layers, masks, editable text, security, overprint, File Info from the XMP packet and the document information dictionary; Crop To › Trim takes the bleed box as the document's bleed) | yes (presets, PDF/X-1a/3/4, layers, marks and bleed, ICC, fast web view, 1.3+; objects' URLs as link annotations, not in PDF/X) | fuzzed import; shared guarded writer and windowed reader ([pdf-backend.md](pdf-backend.md)), metadata, incomplete-content and dependency-source regressions; PDF text opens where the file draws it, glyph by glyph (#722: kerning or none, spacing per character, text rise as baseline shift, vertical writing, justified text boxes); alpha soft masks drawn as images import with the mask (#1551); a clip only counts as covering the page when its shape does (#1542); a PDF another app copied pastes as editable vector art (#1499); letter-spaced type on a path keeps its length and its glyph-by-glyph stroke comes in as one text object (#1586, #1585); a crafted name tree with shared nodes no longer takes exponential time on open (#1781); fonts named with hyphens (`Noto-Sans-Bold`) or with a composite font's CMap (`Name-Identity-H`) find the installed family; invisible text (rendering mode 3: a scanned page's recognized text) opens as type with no fill or stroke and saves back as invisible, searchable text (#2675); PDF/X-1a and X-3 flattening in a CMYK document keeps CMYK inks where it rasterizes (#2160); an image mask (`/ImageMask true`) painted in a solid color opens as a grayscale image colorized by that color, a spot color keeping its swatch and tint, so Fill and Recolor Artwork reach it, and the same mask painted again in another color keeps that color (#6313); an image in a Separation space opens as a grayscale image of its tints colorized by its ink at 100 % (a spot swatch, a process plate or Registration), its soft mask as its opacity mask (#6314) PDF export keeps harakat and zero-advance Noto Arabic dot glyphs in one compatible batch per layout line, with per-glyph vertical offsets (#6309). `crates/pdf/src/tests/realtext.rs` (`mark_text_*`) checks decoded cluster mappings, mixed Arabic/Latin/digits, contour placement against outline export, vertical columns, differing path rotations and PDF/X-1a/3/4. Editable-text import preserves authored ActualText separately (#6165), with cross-line/style layout limits; separately styled or differently rotated clusters and external viewer search/copy remain outside this export-only evidence. | +| PDF | open, place, save | yes (authored ActualText in editable import, #6165; layers, masks, editable text, security, overprint, File Info from the XMP packet and the document information dictionary; Crop To › Trim takes the bleed box as the document's bleed) | yes (presets, PDF/X-1a/3/4, layers, marks and bleed, ICC, fast web view, 1.3+; objects' URLs as link annotations, not in PDF/X) | fuzzed import; shared guarded writer and windowed reader ([pdf-backend.md](pdf-backend.md)), metadata, incomplete-content and dependency-source regressions; PDF text opens where the file draws it, glyph by glyph (#722: kerning or none, spacing per character, text rise as baseline shift, vertical writing, justified text boxes); alpha soft masks drawn as images import with the mask (#1551); a clip only counts as covering the page when its shape does (#1542); a PDF another app copied pastes as editable vector art (#1499); letter-spaced type on a path keeps its length and its glyph-by-glyph stroke comes in as one text object (#1586, #1585); a crafted name tree with shared nodes no longer takes exponential time on open (#1781); fonts named with hyphens (`Noto-Sans-Bold`) or with a composite font's CMap (`Name-Identity-H`) find the installed family; invisible text (rendering mode 3: a scanned page's recognized text) opens as type with no fill or stroke and saves back as invisible, searchable text (#2675); PDF/X-1a and X-3 flattening in a CMYK document keeps CMYK inks where it rasterizes (#2160); an image mask (`/ImageMask true`) painted in a solid color opens as a grayscale image colorized by that color, a spot color keeping its swatch and tint, so Fill and Recolor Artwork reach it, and the same mask painted again in another color keeps that color (#6313); an image in a Separation space opens as a grayscale image of its tints colorized by its ink at 100 % (a spot swatch, a process plate or Registration), its soft mask as its opacity mask (#6314); a DeviceN image of one colorant opens the same way, and one of process plates only (the ink `None` may be among them) opens in CMYK with each colorant's tint in its plate (#6348) PDF export keeps harakat and zero-advance Noto Arabic dot glyphs in one compatible batch per layout line, with per-glyph vertical offsets (#6309). `crates/pdf/src/tests/realtext.rs` (`mark_text_*`) checks decoded cluster mappings, mixed Arabic/Latin/digits, contour placement against outline export, vertical columns, differing path rotations and PDF/X-1a/3/4. Editable-text import preserves authored ActualText separately (#6165), with cross-line/style layout limits; separately styled or differently rotated clusters and external viewer search/copy remain outside this export-only evidence. | | EPS | open, place, save | yes (own PostScript L3 interpreter, Illustrator EPS editing data structure) | yes (L2/L3, TIFF previews) | CIDFonts the file doesn't carry not drawn, but for those over a predefined Unicode CMap (`UniJIS-UCS2-H`), set as type in the font of the CIDFont's name; name-keyed fonts in a FontSet set as type by name; #505; images encoded with `CCITTFaxDecode` (ITU-T T.4 Group 3 and T.6 Group 4, with `K`, `Columns`, `Rows`, `EndOfBlock`, `EndOfLine`, `EncodedByteAlign` and `BlackIs1`) draw, and damaged data keeps the rows decoded before its first damaged row; Type 42 (TrueType) fonts set type at its size (#1553); an embedded Type 1 font's encrypted part is read for its glyphs, which space its type, outline it and draw glyphs without a character, in plain and composite fonts; a Type 42 font that carries its TrueType font gives each code its character by its glyph name or, for names that say nothing (`/g12`), by the glyph's entry in the font's own character map, spaces its type by its own widths and draws glyphs that stand for no character from its outlines; the show operators' spacing along the baseline (`ashow`, `widthshow`, `xshow`, `yshow`, `xyshow`, and the moves of `kshow`'s procedure, run between each two characters) is kept on live type as tracking, so type in a font that isn't installed keeps the file's line lengths (spacing that moves characters off the baseline, as `yshow`, `xyshow` or a `kshow` procedure can, places each character as its own text object); composite (Type 0) fonts with the 8/8 mapping, as PDF-to-PostScript converters write two-byte fonts, draw a Type 42 descendant's glyphs from the TrueType outlines it carries and an embedded Type 1 descendant's from its charstrings, and set other descendants' characters as type at the composite font's size (other mappings are still set as type in the font of their name); CID-keyed fonts (`composefont`, `FMapType` 9 over a CMap built with the CIDInit operators or `Identity-H`/`Identity-V`; `bfchar` and `bfrange` mappings give a base-font descendant its codes or glyph names) draw the glyphs of the Type 0 CIDFonts (Type 1 charstrings in their `StartData` binary data or `GlyphDirectory`) and Type 2 CIDFonts (TrueType glyphs through `CIDMap`) they carry, and the CID-keyed fonts of a FontSet's Compact Font Format data (Type 2 charstrings); a clip only counts as covering the page when its shape does (#1542); an EPS another app copied pastes as editable vector art (`clipboard.importEps`, #1499); a stitched shading keeps its hard colour changes on import (#1654); sampled functions (type 0) interpolate between their samples, multilinearly or by cubics with `Order 3`, and a sampled axial or radial gradient has a stop at each sample; a gradient the page paints as a row of abutting one-colour strips (as Level 2 writers draw smooth shades) imports as one linear gradient, without the seams the strips leave when each is drawn alone; CMYK images, CMYK JPEGs included, keep their ink amounts on import, as from PDF, and the pixels a `MaskColor` or `ImageType 3` mask hides become a grayscale opacity mask; ruzstd 0.9.1 for compressed editing data (#1807, RUSTSEC-2026-0344) | | PostScript `.ps` | open, place | yes (the EPS reader reads its first page; `.epsf` files open and place as EPS) | via Print to PostScript | `tests_epsimport.rs` | | SVG / SVGZ | open, place, save | yes | yes (SVG Options, Preserve Editing, symbols, filters, rich text) | strongest format; tests in `crates/svg/tests`; text on a path follows basic shapes as their SVG 2 equivalent paths (#1527); CSS transforms and the `font` shorthand (#1565), percentage lengths in symbols (#1596), Inkscape flowed text as area type (#1568) and Inkscape pages as artboards (#1601) import; an unsupported filter is named in its warning (#1598); an artboard exported on its own (Export As with Use Artboards, Export for Screens, Asset Export) has the artboard's name as ``, Save the document's (#3544); attributes in other applications' XML namespaces (`plotter:pen="2"`) kept on their objects from import to export, as `data-*` attributes are (`crates/svg/tests/it/foreign_attributes.rs`); simple shapes export as `<rect>`, `<circle>`, `<ellipse>`, `<line>`, `<polygon>` and `<polyline>` where they draw the same as the path (#4652, `crates/svg/tests/it/basic_shapes.rs`) | @@ -327,6 +327,7 @@ Names in the editing data that aren't UTF-8 (layers, objects, artboards, colors, | Date | Change | Summary | |---|---|---| +| 2026-11-10 | minor | PDF import: an image in a DeviceN space of one colorant opens as a Separation image does (#6314): a grayscale image of its tints colorized by its ink. One of two or more colorants that are all process plates (Cyan, Magenta, Yellow, Black, and the ink `None`) opens as a CMYK image, each colorant's tint in its plate and its Decode array applied (8 or 16 bits a sample; one that can't be read, such as a JPEG, opens in RGB with the CMYK images' warning). Both used to open as RGB images, losing their inks. A DeviceN image that mixes a spot ink with other colorants, such as a duotone, still opens as RGB: an image holds one ink or the process plates, not both (#6348). Tests `colorize::a_devicen_image_of_one_ink_opens_colorized_by_it`, `colorize::a_devicen_image_of_process_plates_opens_in_cmyk`, `colorize::a_duotone_devicen_image_still_opens_as_rgb` | | 2026-11-09 | minor | `.ai` import: an image whose color space `XC` sets to one ink (`n [c m y k /DeviceCMYK (name) /Separation] XC`, or an RGB alternate) opened as a gray RGB image: its samples, the ink's tints (255 at 100 %), were read as gray levels, so the tints drew inverted and the ink was lost. It now opens as a grayscale image of the tints, black at 100 %, colorized by the ink at 100 %: a spot swatch, a process plate's CMYK color (no swatch of its name), or Registration; the ink's color counts toward the document's color mode as a fill's does. Its alpha channel becomes its opacity mask, a reduced large image's too: a colorized image reduced to fit the limits (an image mask included) now keeps its alpha as an opacity mask rather than in its pixels, as at its full size. 8-bit and 1-bit samples, binary or in hex comment lines. A device space (`/DeviceGray XC` after a linked image's file name) and the ink `None` change nothing, as before; linked images (`XF`) keep their placeholder. Tests `import::ai::tests::separation_images_are_colorized_with_their_ink`, `separation_images_of_process_plates_and_registration`, `a_separation_image_with_hex_comment_lines_keeps_its_ink`, `a_reduced_separation_image_keeps_its_ink_and_its_alpha_as_a_mask` (#6350) | | 2026-11-09 | minor | PDF import: an image in a Separation color space (one ink, ISO 32000-1 §8.6.6.4) opens as a grayscale image of its tints (white where it lays no ink, black at 100 %, its Decode array applied; 1, 2, 4, 8 and 16 bits a sample, or one gray channel from its JPEG, CCITT, JBIG2 or JPEG 2000 decoder) colorized by its ink at 100 %: a spot ink's swatch, a process plate's CMYK color, or Registration. It used to open as an RGB image, losing the ink, and Fill and Recolor Artwork didn't reach its color. Its soft mask or mask becomes its opacity mask, as a CMYK image's does. The ink is found by the image's color space (kind, colorant name and tint transform), once per image. As with any colorized image, where it lays no ink the art under it shows, where the RGB image was opaque white. The ink `None`, which paints nothing, an ink whose color can't be told, an image inside an alpha soft mask, inline images and DeviceN images open as before; samples that can't be read (data cut short, a decoder's output of another layout) open in RGB with a warning, and the ink gets no swatch. Tests `colorize::a_separation_image_opens_colorized_by_its_ink`, `colorize::a_separation_image_reads_its_samples_and_decode_array`, `colorize::a_separation_image_of_a_process_plate_or_of_no_ink`, `colorize::a_separation_image_keeps_its_soft_mask`, `colorize::a_separation_image_drawn_twice_is_colorized_twice`, `colorize::a_separation_jpeg_opens_colorized_by_its_ink`, `colorize::a_separation_image_that_cant_be_read_stays_rgb` (#6314) | | 2026-11-09 | minor | PDF import: an image mask (`/ImageMask true`, ISO 32000-1 §8.9.6.2) painted in a solid color opens as a grayscale image of what it paints (black, the rest white) colorized by that color, as the `.ai` reader opens a 1-bit image mask (#5790). It used to open as an RGB image with the color in its pixels: Fill and Recolor Artwork didn't reach the color, a spot color lost its ink, and the same mask painted again in another color took the first color, in an uncolored pattern's cell too. Its color now counts toward the document's color mode, as a fill's does. A spot color is found by its color space, as a fill's is when the page's walk names no ink: two spaces of other inks with the same alternate and tint transform can't be told apart, and the color comes in as RGB. Inside an alpha soft mask, or painted with a pattern, a mask opens as before. Tests `colorize::an_image_mask_opens_colorized_by_its_spot_color`, `colorize::an_image_mask_drawn_in_two_colors_keeps_both`, `colorize::an_uncolored_pattern_drawing_an_image_mask_keeps_each_color` (#6313) | From 9172950ad50349b186f23dba6d4d18ba429ad5c6 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Mon, 9 Nov 2026 19:34:24 -0800 Subject: [PATCH 31/36] MCP: command listings give each command with a schema an inputSchema engine.commands, command_list and list_commands now list an inputSchema next to schema for each command with a parameter schema. It is the same JSON Schema 2020-12 schema with the root type "object", the form an MCP tool's inputSchema takes (https://modelcontextprotocol.io/specification/2026-07-28/schema); schema keeps the root type ["object", "null"] when no parameter is required. A command without a schema lists neither field. tools/list is unchanged, and the commands are still run with command_run or run_command. brief now leaves out schema and inputSchema as well as the params text; a listing filtered to the command's id gives them. Docs: docs/mcp.md, docs/target-app-parity.md. --- crates/engine/src/cmd.rs | 18 ++++++++- crates/engine/src/schemas.rs | 60 +++++++++++++++++++++++++++++ crates/mcp/src/tests/conventions.rs | 49 ++++++++++++++++++++++- crates/mcp/src/tools/definitions.rs | 4 +- crates/mcp/src/tools/dispatch.rs | 9 +++-- docs/mcp.md | 15 ++++++-- docs/target-app-parity.md | 1 + 7 files changed, 144 insertions(+), 12 deletions(-) diff --git a/crates/engine/src/cmd.rs b/crates/engine/src/cmd.rs index 4042f0a13e..a68703f11b 100644 --- a/crates/engine/src/cmd.rs +++ b/crates/engine/src/cmd.rs @@ -149,11 +149,26 @@ pub struct CommandInfo { /// The parameters' schema as a JSON Schema 2020-12 object schema, for a command that has one. #[serde(skip_serializing_if = "Option::is_none")] pub schema: Option<Value>, + /// The same schema in the form an MCP tool's `inputSchema` takes ([`input_schema`]). + #[serde(rename = "inputSchema", skip_serializing_if = "Option::is_none")] + pub input_schema: Option<Value>, +} + +/// `schema` as an MCP tool input schema: the root `type` is `"object"`, since the arguments are +/// always an object, and every other keyword is kept. The root of [`CommandInfo::schema`] also +/// takes null when no parameter is required. +pub(crate) fn input_schema(schema: &Value) -> Value { + let mut o = schema.clone(); + if let Some(map) = o.as_object_mut() { + map.insert("type".into(), "object".into()); + } + o } impl CommandSpec { pub fn info(&self, s: &Session) -> CommandInfo { let e = (self.enabled)(s); + let schema = self.schema.map(vectorcraft_apischema::ParamSchema::to_json); CommandInfo { id: self.id, label: self.label, @@ -162,7 +177,8 @@ impl CommandSpec { params: self.params, enabled: e.is_ok(), disabled_reason: e.err(), - schema: self.schema.map(vectorcraft_apischema::ParamSchema::to_json), + input_schema: schema.as_ref().map(input_schema), + schema, } } } diff --git a/crates/engine/src/schemas.rs b/crates/engine/src/schemas.rs index c70c4bb820..a24613a075 100644 --- a/crates/engine/src/schemas.rs +++ b/crates/engine/src/schemas.rs @@ -323,6 +323,66 @@ mod tests { assert!(without.get("schema").is_none()); } + /// The listing gives each command with a schema an `inputSchema`, the form an MCP tool's input + /// schema takes: the same schema with the root `type` `"object"`, and every other keyword kept, + /// nested parameters, required names, defaults and VectorCraft's own fields included. A command + /// with no schema lists neither. + #[test] + fn engine_commands_list_an_input_schema_with_an_object_root() { + let s = Session::new(); + let listed: Vec<Value> = s.commands().iter().map(|c| serde_json::to_value(c).unwrap()).collect(); + let mut with_schema = 0; + for c in &listed { + let id = c["id"].as_str().unwrap(); + let Some(schema) = c.get("schema") else { + assert!(c.get("inputSchema").is_none(), "{id}"); + continue; + }; + with_schema += 1; + let input = c.get("inputSchema").unwrap_or_else(|| panic!("{id} has no inputSchema")); + assert_eq!(input["type"], "object", "{id}"); + assert_eq!(input["$schema"], vectorcraft_apischema::DIALECT, "{id}"); + check_schema(input, id).unwrap_or_else(|e| panic!("{e}\n{input:#}")); + assert!(!instance_valid(input, &Value::Null), "{id}: the arguments are an object"); + let without_type = |v: &Value| { + let mut v = v.clone(); + v.as_object_mut().map(|o| o.remove("type")); + v + }; + assert_eq!(without_type(schema), without_type(input), "{id}"); + } + assert_eq!(with_schema, schemas().len()); + let get = |id: &str| listed.iter().find(|c| c["id"] == id).unwrap(); + // No required parameter: the schema also takes null, the input schema does not. + let list = get("view.saved.list"); + assert_eq!(list["schema"]["type"], json!(["object", "null"])); + assert_eq!(list["inputSchema"]["type"], "object"); + // Required parameters and their constraints are kept. + let new_view = get("view.saved.new"); + assert_eq!(new_view["inputSchema"]["required"], json!(["center", "zoom"])); + assert_eq!(new_view["inputSchema"]["properties"], new_view["schema"]["properties"]); + // Nested objects in an array keep their keys. + let edit = &get("view.saved.edit")["inputSchema"]["properties"]["edits"]; + assert!(edit.to_string().contains("newName"), "{edit}"); + assert!(get("file.new").get("inputSchema").is_none()); + } + + /// A deprecated command and a deprecated parameter keep their deprecation in the input schema. + #[test] + fn an_input_schema_keeps_deprecation() { + static PARAMS: [Param; 2] = [Param::new("old", Type::Boolean).optional().deprecated(1), Param::new("on", Type::Boolean).optional()]; + let schema = ParamSchema::new(&PARAMS, "Old command.").deprecated(1).to_json(); + let input = crate::cmd::input_schema(&schema); + check_schema(&input, "synthetic").unwrap_or_else(|e| panic!("{e}\n{input:#}")); + assert_eq!(schema["type"], json!(["object", "null"])); + assert_eq!(input["type"], "object"); + assert_eq!(input["deprecated"], true); + assert_eq!(input["x-deprecatedSince"], 1); + assert_eq!(input["properties"]["old"], schema["properties"]["old"]); + assert_eq!(input["properties"]["old"]["deprecated"], true); + assert_eq!(input["description"], "Old command."); + } + /// The listing says what each View command changes when it runs. Only the commands that make an /// undo step list `document`. #[test] diff --git a/crates/mcp/src/tests/conventions.rs b/crates/mcp/src/tests/conventions.rs index 929851917a..0b7352bf4a 100644 --- a/crates/mcp/src/tests/conventions.rs +++ b/crates/mcp/src/tests/conventions.rs @@ -57,18 +57,63 @@ fn command_listings_take_query_menu_and_brief() { let menu: Vec<&str> = c["menu"].as_array().unwrap().iter().map(|m| m.as_str().unwrap()).collect(); assert_eq!(menu.get(..2), Some(&["Object", "Path"][..]), "{c}"); } - // `brief` drops the params text and nothing else. + // `brief` drops the params text and the schemas, and nothing else. let full = payload(&call(&mut s, "command_list", json!({}))); let brief = payload(&call(&mut s, "command_list", json!({"brief":true}))); assert_eq!(ids(&full), ids(&brief)); assert!(full.as_array().unwrap().iter().any(|c| c.get("params").is_some())); - assert!(brief.as_array().unwrap().iter().all(|c| c.get("params").is_none() && c.get("label").is_some())); + assert!(full.as_array().unwrap().iter().any(|c| c.get("inputSchema").is_some())); + assert!( + brief + .as_array() + .unwrap() + .iter() + .all(|c| c.get("params").is_none() && c.get("schema").is_none() && c.get("inputSchema").is_none() && c.get("label").is_some()) + ); assert!(brief.to_string().len() * 2 < full.to_string().len(), "brief is much shorter"); // The other listing takes them too. let r = payload(&call(&mut s, "list_commands", json!({"query":"rectangle","brief":true,"menu":""}))); assert_eq!(ids(&r["commands"]), ids(&by_filter)); } +#[path = "../../../apischema/src/conformance.rs"] +mod conformance; + +/// Both listings give a command with a parameter schema an `inputSchema` with an object root, +/// which follows the 2020-12 rules, and the filters select the same commands as before. +#[test] +fn command_listings_carry_input_schemas() { + let mut s = Server::new(Box::new(Headless::with_document())); + let list = payload(&call(&mut s, "list_commands", json!({"filter":"view.saved"}))); + let bare = payload(&call(&mut s, "command_list", json!({"filter":"view.saved"}))); + assert_eq!(list["commands"], bare); + assert_eq!(list["count"], bare.as_array().unwrap().len()); + let commands = bare.as_array().unwrap(); + let with_input: Vec<&Value> = commands.iter().filter(|c| c.get("inputSchema").is_some()).collect(); + assert!(with_input.len() >= 3, "{bare}"); + for c in with_input { + let input = &c["inputSchema"]; + assert_eq!(input["type"], "object", "{c}"); + conformance::check_schema(input, c["id"].as_str().unwrap()).unwrap_or_else(|e| panic!("{e}\n{input:#}")); + assert!(!conformance::instance_valid(input, &Value::Null)); + assert_eq!(conformance::instance_valid(&c["schema"], &Value::Null), c["schema"]["type"] != "object", "{c}"); + } + let saved_list = commands.iter().find(|c| c["id"] == "view.saved.list").unwrap(); + assert_eq!(saved_list["schema"]["type"], json!(["object", "null"])); + assert_eq!(saved_list["inputSchema"]["type"], "object"); + // A command without a schema lists neither. + let new = payload(&call(&mut s, "command_list", json!({"filter":"file.new","enabled_only":true}))); + let new = new.as_array().unwrap().iter().find(|c| c["id"] == "file.new").unwrap().clone(); + assert!(new.get("schema").is_none() && new.get("inputSchema").is_none(), "{new}"); + // `brief`, `menu` and the enabled filter select the same ids with and without the schemas. + let ids = |v: &Value| v.as_array().unwrap().iter().map(|c| c["id"].clone()).collect::<Vec<_>>(); + let view = payload(&call(&mut s, "list_commands", json!({"menu":"view","enabledOnly":true}))); + let view_brief = payload(&call(&mut s, "list_commands", json!({"menu":"view","enabledOnly":true,"brief":true}))); + assert_eq!(ids(&view["commands"]), ids(&view_brief["commands"])); + assert_eq!(view["count"], view_brief["count"]); + assert!(view_brief["commands"].as_array().unwrap().iter().all(|c| c.get("inputSchema").is_none() && c.get("schema").is_none())); +} + #[test] fn conventions_commands_batch_and_bounded_preview() { let mut s = Server::new(Box::new(Headless::with_document())); diff --git a/crates/mcp/src/tools/definitions.rs b/crates/mcp/src/tools/definitions.rs index 616e32aaef..8fe45b83eb 100644 --- a/crates/mcp/src/tools/definitions.rs +++ b/crates/mcp/src/tools/definitions.rs @@ -98,7 +98,7 @@ pub fn tool_definitions() -> Vec<Value> { tool( "command_list", "List commands", - "Discover command ids, labels, parameter descriptions and enabled state. Returns an array. The whole catalogue is large: narrow it with filter (or query), menu, and brief. list_commands lists the same commands; it takes enabledOnly instead of enabled_only and returns {count, commands}.", + "Discover command ids, labels, parameter descriptions and enabled state. A command with a parameter schema also lists it as schema (JSON Schema 2020-12) and as inputSchema (the same schema with an object root, the form of a tool's inputSchema). Returns an array. The whole catalogue is large: narrow it with filter (or query), menu, and brief. list_commands lists the same commands; it takes enabledOnly instead of enabled_only and returns {count, commands}.", obj( json!({"filter": string("Case-insensitive id, label or menu substring"), "query": string("Same as filter"), "menu": string(MENU_FILTER), "enabled_only": {"type":"boolean"}, "brief": {"type":"boolean", "description": BRIEF}}), &[], @@ -156,7 +156,7 @@ pub fn tool_definitions() -> Vec<Value> { tool( "list_commands", "List commands", - "List VectorCraft commands (id, label, menu path, shortcut, parameter description, enabled state). Every editing action is a command; run any of them with run_command. Coordinates are points in document space, y down, origin at the first artboard's top-left. command_list lists the same commands as a bare array and takes enabled_only.", + "List VectorCraft commands (id, label, menu path, shortcut, parameter description, enabled state, and schema and inputSchema for a command with a parameter schema). Every editing action is a command; run any of them with run_command. Coordinates are points in document space, y down, origin at the first artboard's top-left. command_list lists the same commands as a bare array and takes enabled_only.", obj( json!({"filter": string("Case-insensitive substring matched against id, label and menu path (e.g. \"align\", \"Object\")"), "query": string("Same as filter"), "menu": string(MENU_FILTER), "enabledOnly": {"type": "boolean", "description": "Only commands that can run right now"}, "brief": {"type": "boolean", "description": BRIEF}}), &[], diff --git a/crates/mcp/src/tools/dispatch.rs b/crates/mcp/src/tools/dispatch.rs index eabd73e3ec..b1c5af70bb 100644 --- a/crates/mcp/src/tools/dispatch.rs +++ b/crates/mcp/src/tools/dispatch.rs @@ -10,8 +10,7 @@ pub(super) const MENU_FILTER: &str = "Case-insensitive substring of the menu path, its levels joined with \" > \" (e.g. \"Object\", \"Object > Path\")"; /// The `brief` argument of the command listings. -pub(super) const BRIEF: &str = - "Leave out each command's params description (the bulk of the catalogue); run command_list with the id as filter to read it"; +pub(super) const BRIEF: &str = "Leave out each command's params description and its schema and inputSchema (the bulk of the catalogue); run command_list with the id as filter to read them"; /// The `max_side` argument of the window captures. pub(super) const MAX_SIDE_WINDOW: &str = @@ -30,7 +29,7 @@ pub(super) fn trace_views(a: &Args) -> Result<bool, String> { /// The command catalogue narrowed by the listing tools' arguments: `filter` (or `query`) on the /// id, label and menu path, `menu` on the menu path, `enabledOnly`, and `brief` (no `params` -/// text: the full catalogue is too large for many clients, #2173). +/// text, `schema` or `inputSchema`: the full catalogue is too large for many clients, #2173). fn filter_commands(all: Value, a: &Args) -> Value { let text = |k: &str| a.get(k).and_then(Value::as_str).map(str::to_lowercase).filter(|s| !s.is_empty()); let needle = text("filter").or_else(|| text("query")); @@ -63,7 +62,9 @@ fn filter_commands(all: Value, a: &Args) -> Value { .filter(hit) .map(|mut c| { if brief && let Some(o) = c.as_object_mut() { - o.remove("params"); + for key in ["params", "schema", "inputSchema"] { + o.remove(key); + } } c }) diff --git a/docs/mcp.md b/docs/mcp.md index 2618ebfa4c..e818579c83 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -369,10 +369,10 @@ The older names below remain listed because existing workflows use them. | Tool | Arguments and result | |---|---| -| `command_list` | Optional `filter` (or `query`), `menu` (a menu path substring, levels joined with ` > `), `enabled_only`, `brief` (no `params` text); returns the command array. `list_commands` lists the same commands; it takes `enabledOnly` and returns `{count, commands}` | +| `command_list` | Optional `filter` (or `query`), `menu` (a menu path substring, levels joined with ` > `), `enabled_only`, `brief` (no `params` text, `schema` or `inputSchema`); returns the command array. A command with a parameter schema lists `schema` and `inputSchema` (see [Parameter schemas](#parameter-schemas)). `list_commands` lists the same commands; it takes `enabledOnly` and returns `{count, commands}` | | `command_run` | `id`, optional `params`; runs the command through the existing backend. An unknown key in `params` is an error that names it and the nearest key the command takes (`invalid parameters for `file.new`: unknown parameter `widht`; did you mean `width`?`), so a misspelt key no longer runs the command with its default (see [Parameter keys](#parameter-keys)). `run_command` runs a command the same way and takes its id as `command` | | `command_batch` | `steps: [{id, params?}]` (at most 256), optional `stop_on_error` (default true), `atomic` and `label`; returns `completed`, `failed`, `results: [{ok, result\|error}]` and `created` (the ids the `draw_path` / `draw_shape` steps made, in order). A step's `id` is a command id, or `draw_path` / `draw_shape` with that tool's arguments as `params` (checked as `tools/call` checks them; the step's result is the tool's, so `"$N.id"` is the new object). Each edit has its own undo step, or with `atomic: true` the batch's edits are one undo step named `label` (else after its first edit; `edit.undoGroup.begin`/`end` underneath), and the edits made in a document that a step opens or creates (`document.open`, `file.new`) are one undo step there, named the same, also when no document was open before the batch. In a document that was open before the batch and that a step switches to (`document.activate`), each edit stays its own undo step; failures set `isError`. A param string `"$N.path"` or `"$last.path"` takes a value from an earlier step's result (see `run` below). Kept step results total at most 8 MiB; past that the batch stops with the reason. This is the MCP tool, not the engine command `command.batch` (that one takes `commands: [{command, params}]` and is what the Actions panel plays); use `command_run` with `command.batch` when replaying a recorded action | -| `command_list` | Optional `filter` (or `query`), `menu` (a menu path substring, levels joined with ` > `), `enabled_only`, `brief` (no `params` text); returns the command array | +| `command_list` | Optional `filter` (or `query`), `menu` (a menu path substring, levels joined with ` > `), `enabled_only`, `brief` (no `params` text, `schema` or `inputSchema`); returns the command array | | `command_run` | `id`, optional `params`; runs the command through the existing backend. An unknown key in `params` is an error that names it and the nearest key the command takes (`invalid parameters for `file.new`: unknown parameter `widht`; did you mean `width`?`), so a misspelt key no longer runs the command with its default (see [Parameter keys](#parameter-keys)) | | `doc_inspect` | Optional `depth`, `childLimit`, `ids`, `kind`, `limit`, `offset`; same summary as `inspect_document` | | `render_preview` | Optional `max_side` (1–4096, default 1024), `artboard` (0-based, default 0) and `traceViews`; inline PNG of that artboard without editing it, drawn as `export` draws a PNG with `background: "white"` at the scale that fits `max_side` (at most 1). With `traceViews: true`, Image Trace objects draw in the view set with `imageTrace.setView` (outlines, the source image…), as in the app's window; by default they draw their tracing result. Artboards too large to render within the allocation bound return a tool error | @@ -446,7 +446,7 @@ opens the picker through `run_command` with the remote backend. | Tool | Arguments | Notes | |---|---|---| -| `list_commands` | `{filter?, query?, menu?, enabledOnly?, brief?}` | The command catalogue: id, label, menu, shortcut, params doc (left out with `brief`), enablement. | +| `list_commands` | `{filter?, query?, menu?, enabledOnly?, brief?}` | The command catalogue: id, label, menu, shortcut, params doc, enablement, and `schema` and `inputSchema` for a command with a parameter schema (the params doc and both schemas are left out with `brief`). | | `run_command` | `{command, params?}` | Runs any command. Use it for everything without a dedicated tool. | | `inspect_document` | `{depth?, childLimit?, ids?, kind?, limit?, offset?}` | Artboards (with `color`, `#rrggbb`, for one with a background color), layer tree (ids, kinds, bounds, paint), selection, history, tool. A type node's `fill`, `stroke` and `strokeWidth` are the paint its characters show (its first run's); fills and strokes of the type object itself come as `objectFill`, `objectStroke` and `objectStrokeWidth`. Paint comes as a label (`"#ff0000"`, `"None"`, `"G1 40% (#ff9999)"` for a swatch at a tint); `paint.proxies` gives it as an object. | | `inspect_ui` | `{}` | UI state. Remote mode only. | @@ -1403,6 +1403,15 @@ JSON Schema defines one, and the command receives `2`. An `x-lenient` parameter' and the engine also passes a value of another type to the command. The command's `params` text equals the text the schema writes, and a test checks that they are the same. +The `engine.commands` list and the `command_list` and `list_commands` tools also give such a command an `inputSchema` +field: the same schema in the form of an MCP tool's `inputSchema` +(https://modelcontextprotocol.io/specification/2025-06-18/schema), whose root `type` is always `"object"` +(https://modelcontextprotocol.io/specification/2026-07-28/schema). It differs from `schema` only in the root `type`, +which is `"object"` also when no parameter is required; every other keyword, `$schema` and the `x-` fields included, +is the same. A command without a schema lists neither field. The commands remain commands run with `command_run` +or `run_command`: `tools/list` does not list them as tools. With `brief`, the listings leave out `schema` and +`inputSchema` as well as the `params` text; a listing with the command's id as `filter` gives them. + The engine checks the params of such a command against its schema before the command runs, for every new call: from the desktop app, the control channel, MCP or the CLI, and when one command runs another. The steps of `command.batch` and `action.batch` are checked before the first step runs. A value of the wrong type, out of range or not in its list, diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 17cc2849b9..31b606249c 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -669,6 +669,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Architecture:** 19+ crates with enforced layering (`cargo xtask layers`). Every action is a command (~400 engine + ~50 UI). Undo is unlimited via structural sharing. `command.batch` runs several commands as one transaction. - **Parameter schemas:** a command can carry a machine-readable parameter schema (the new `vectorcraft-apischema` crate): each parameter's type, range, unit, whether it is required, its default, whether null counts as left out, and the API levels it was added and deprecated at. `engine.commands` lists the schema in the shape of a JSON Schema 2020-12 object schema, a test keeps the command's `params` text equal to the text the schema writes, and a new call has the params checked against it before the command runs (batch steps before the first step), with an error naming the parameter and no change to the document; saved steps keep a key the schema does not name, and their values are checked. The View commands `view.capsLock`, `view.saved.new`, `view.saved.edit`, `view.saved.list`, `view.transparencyGrid` and `view.snapToGlyph.options` have schemas. The schemas belong to API level 1 (`vectorcraft_engine::schemas::API_VERSION`), and a test fails when a schema changes without a new level. Tests: `crates/apischema/src/tests.rs` (with `conformance.rs`, a separate check of the JSON form against the JSON Schema 2020-12 rules), `schemas::tests` and `cmd::views::tests::each_view_command_refuses_a_bad_value_naming_the_parameter` in `vectorcraft-engine`. +- **Input schemas in the command listings:** `engine.commands` and the MCP tools `command_list` and `list_commands` give each command with a parameter schema an `inputSchema` next to `schema`: the same schema with the root `type` `"object"`, the form an MCP tool's input schema takes ([MCP schema reference](https://modelcontextprotocol.io/specification/2026-07-28/schema)). A command without a schema lists neither, and `brief` leaves out both schemas with the `params` text. Tests: `schemas::tests::engine_commands_list_an_input_schema_with_an_object_root` and `schemas::tests::an_input_schema_keeps_deprecation` in `vectorcraft-engine`, and `tests::conventions::command_listings_carry_input_schemas` in `vectorcraft-mcp`, which check every listed input schema against the JSON Schema 2020-12 rules. - **Automation:** - Variables: Update Data Set writes what the art holds into the active data set for the variables that data set holds, and keeps the data set's value for a text, image or graph variable whose objects are locked or inside a locked layer or group; `dataset.update {all: true}` also adds the other variables that the art reports a value for. - Variables: `dataset.select`, `dataset.next` and `dataset.prev` rewrite type and apply the other values on hidden objects and in hidden layers and groups, which stay hidden unless a visibility value shows them, so a layer shown later holds the selected data set's text; objects that are locked or in a locked layer or group are skipped, except by a visibility value. From 1bf6d2e7930f6345a243ffd8cdc8006edf9c6aa1 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Mon, 9 Nov 2026 19:47:04 -0800 Subject: [PATCH 32/36] Transform: a butt-capped curve with a handle on its end point stops at that end With Use Preview Bounds on, a curve piece that bends more tightly than half the stroke weight after the last split had its box grown by half the weight on every side. A handle on an end point makes the curve's first or last control difference zero, so such a curve always reached that fallback and measured past its butt end: a vertical 2 pt curve from (50,10) to (50,60) with its first handle on (50,10) measured Rect(49, 9, 51, 60) instead of Rect(49, 10, 51, 60). The fallback now measures the piece from the directions its tangent can take, the nonzero control differences, when they all lie within a quarter turn of their sum. Pieces whose tangent turns further are still grown on every side. --- crates/doc/src/reach.rs | 67 +++++++++++++++++++++++++++++++++++++-- docs/target-app-parity.md | 1 + 2 files changed, 66 insertions(+), 2 deletions(-) diff --git a/crates/doc/src/reach.rs b/crates/doc/src/reach.rs index 4b5f1d435a..99fb6b03d9 100644 --- a/crates/doc/src/reach.rs +++ b/crates/doc/src/reach.rs @@ -115,14 +115,38 @@ fn gentle(seg: &PathSeg, side: f64) -> bool { slowest > 0.0 && side * bend < slowest * slowest } +/// The box a stroke reaching `side` from `seg` paints across it, from the directions its tangent +/// can take: the tangent is a mix of the nonzero control differences, so when their directions all +/// lie within a quarter turn of the directions' sum, every point across the stroke is in `seg`'s +/// box moved `side` along a normal of one of those directions. `None` when they do not. A handle on +/// its end point adds no direction, so a straight curve with one measures as a line. +fn turning_box(seg: &PathSeg, side: f64) -> Option<Rect> { + let c = seg.to_cubic(); + let d = [c.p1 - c.p0, c.p2 - c.p1, c.p3 - c.p2]; + let moving = || d.iter().map(|v| (*v, v.hypot())).filter(|(_, h)| *h > 0.0).map(|(v, h)| v / h); + let along = unit(moving().fold(Vec2::ZERO, |s, v| s + v)); + if moving().any(|v| v.dot(along) <= 0.0) { + return None; + } + let angle = |v: &Vec2| along.cross(*v).atan2(along.dot(*v)); + let lo = moving().min_by(|a, b| angle(a).total_cmp(&angle(b)))?; + let hi = moving().max_by(|a, b| angle(a).total_cmp(&angle(b)))?; + let n = |t: Vec2| (Vec2::new(t.y, -t.x) * side).to_point(); + let one_side = round_join_box(Point::ZERO, lo, hi, side).unwrap_or_else(|| Rect::from_points(n(lo), n(hi))); + let reach = one_side.union(Rect::new(-one_side.x1, -one_side.y1, -one_side.x0, -one_side.y0)); + let b = ParamCurveExtrema::bounding_box(seg); + Some(Rect::new(b.x0 + reach.x0, b.y0 + reach.y0, b.x1 + reach.x1, b.y1 + reach.y1)) +} + /// The box a stroke reaching `side` from `seg` paints when it stops square at both ends of `seg`: /// the corners at the ends and the points across the stroke where the tangent is square to an axis. /// A curve that bends tighter than `side` is halved up to `depth` times, and a piece still bending -/// that much is grown by `side` on every side. +/// that much is measured by [`turning_box`], or grown by `side` on every side when its tangent +/// turns too far. fn swept_box(seg: &PathSeg, side: f64, depth: u32) -> Rect { if !matches!(seg, PathSeg::Line(_)) && !gentle(seg, side) { if depth == 0 { - return ParamCurveExtrema::bounding_box(seg).inflate(side, side); + return turning_box(seg, side).unwrap_or_else(|| ParamCurveExtrema::bounding_box(seg).inflate(side, side)); } return swept_box(&seg.subsegment(0.0..0.5), side, depth - 1).union(swept_box(&seg.subsegment(0.5..1.0), side, depth - 1)); } @@ -614,6 +638,45 @@ mod tests { assert!((b.y1 - 50.0).abs() < 1e-9, "{b:?}"); } + #[test] + fn a_butt_curve_with_a_handle_on_its_end_point_stops_near_that_end() { + let butt = stroke(2.0, |s| s.cap = LineCap::Butt); + // A straight curve whose first or last handle sits on its end point measures like a line. + let mut first = BezPath::new(); + first.move_to((50.0, 10.0)); + first.curve_to((50.0, 10.0), (50.0, 40.0), (50.0, 60.0)); + let mut last = BezPath::new(); + last.move_to((50.0, 10.0)); + last.curve_to((50.0, 20.0), (50.0, 60.0), (50.0, 60.0)); + for bp in [first, last] { + assert_eq!(butt.bounds_on(&bp, bp.bounding_box()), Rect::new(49.0, 10.0, 51.0, 60.0)); + } + // A bend that leaves its start straight up from a handle on the start point: the stroke + // reaches only a little below the start, not half the weight. + let mut bend = BezPath::new(); + bend.move_to((0.0, 50.0)); + bend.curve_to((0.0, 50.0), (0.0, 0.0), (50.0, 0.0)); + let b = stroke(4.0, |s| s.cap = LineCap::Butt).bounds_on(&bend, bend.bounding_box()); + assert!(b.y1 >= 50.0 && b.y1 < 50.2, "{b:?}"); + assert!((b.x0 + 2.0).abs() < 1e-9 && (b.y0 + 2.0).abs() < 1e-9 && (b.x1 - 50.0).abs() < 1e-9, "{b:?}"); + // Nearly a cusp: the curve turns back at its lowest point, where its tangent is level and + // the stroke reaches the full half weight below it. + let (a0, a1, a2) = (1e5 * 81.0 / 256.0, -1e5 * 9.0 / 8.0, 1e5); + let x = |i: f64| 2e-9 * i / 3.0; + let mut cusp = BezPath::new(); + cusp.move_to((x(0.0), a0)); + cusp.curve_to((x(1.0), a0 + a1 / 3.0), (x(2.0), a0 + 2.0 * a1 / 3.0 + a2 / 3.0), (x(3.0), a0 + a1 + a2)); + let b = butt.bounds_on(&cusp, cusp.bounding_box()); + assert!(b.y0 <= -1.0 + 1e-6, "{b:?}"); + // A curve far shorter than the weight still reaches half the weight above where its + // tangent is level. + let mut tiny = BezPath::new(); + tiny.move_to((0.0, 0.9e-12)); + tiny.curve_to((0.3e-12, -0.5e-12), (1.7e-12, -0.2e-12), (2e-12, 1e-12)); + let b = butt.bounds_on(&tiny, tiny.bounding_box()); + assert!(b.y0 <= -1.0 + 1e-9, "{b:?}"); + } + #[test] fn a_butt_stroke_is_slanted_where_a_segment_starts_or_ends_at_a_slant() { let mut ap = Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 2.0); diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index c3edab442e..577986c92b 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1052,6 +1052,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Compound shapes ([Create compound shapes with Pathfinder](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/create-compound-shapes-with-pathfinder.html), [Pathfinder panel overview](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/pathfinder-panel-overview.html)): the Pathfinder panel menu's Make Compound Shape (every member in Add) and Alt-clicking a Shape Mode (every member in that mode) make the two or more selected paths, compound paths, groups, compound shapes or type a live compound shape (`object.compoundShape.make`): a group whose members each keep a shape mode and stay editable, drawn, printed and exported (SVG, PDF and the rest through the baked document) as the area the modes make from the backmost member up, open paths closed, painted with its own appearance, taken from the topmost member not subtracting. With members selected (Direct Selection, Layers panel, isolation mode) a Shape Mode button sets theirs (`object.compoundShape.setMode`) and is lit for the mode they share; paint given to a member goes to the compound shape. Release Compound Shape gives the members back as they were (`object.compoundShape.release`); Expand Compound Shape and the panel's Expand button turn it into the path or compound path of its area (`object.compoundShape.expand`). Shape Modes and Pathfinders take a compound shape as its area. Saved files keep it (`compound_shape`, `shape_mode`; an unknown mode reads as Add). Blends, envelopes and objects with a warp or another geometry effect are members as they draw (a blend's steps, an envelope's distorted art, the warped outline), and the area follows an edit of the blend, envelope or warp; Release gives them back as they were. Tests: `crates/engine/src/tests/compound_shape.rs`, `group::tests::compound_shapes_draw_the_area_their_shape_modes_make`, `group::tests::nested_compound_shapes_take_part_as_their_area`, `panels::pathfinder::tests::compound_shape_items_follow_the_selection`, the `CompoundShape` op of the property-test strategies. Not yet: the question marks on mixed member modes, hit-testing by the area (a click hits the members). - Effect › Path › Outline Object has no options. Type that carries it is drawn, exported (PDF and SVG as paths, with no font) and expanded as its glyph outlines while it stays editable, and the effects after it reshape those outlines. Paths and images draw as before. With Use Preview Bounds on, type is measured by its glyph outlines. Public reference: [Summary of effects](https://web.archive.org/web/20210901011839/https://helpx.adobe.com/illustrator/using/summary-effects.html) (archived copy). - A butt cap ends a stroke square at the end of an open path, and a projecting cap extends half the weight past it. VectorCraft's visual box of a butt-capped stroke on an open path does not grow past the path's ends: a straight segment's box comes from the corners of its stroked rectangle, and a curve's box comes from the stroke at its ends and where its tangent is square to an axis. Round and projecting caps and arrowheads still reach past an end. A curve that bends more tightly than half the weight can measure a little larger than it paints, and a butt-dashed path whose first or last dash is a gap is measured to the path's ends. Public reference: [Apply stroke on an object](https://helpx.adobe.com/illustrator/using/stroke-object.html). + - A butt-capped curve whose first or last handle sits on its end point also stops at that end: a straight 50 pt curve with a 2 pt stroke and its first handle on its start point measures 50 pt tall, not 51 pt (doc `reach::tests::a_butt_curve_with_a_handle_on_its_end_point_stops_near_that_end`). - With Use Preview Bounds on, the bounding box, the Transform panel, Align, Distribute, Distribute Spacing and `object.setBounds` measure each object after its geometry effects. A path is measured by its effected shape, and a fill or stroke with geometry effects of its own is measured by the shape it paints. Type, images, live objects and symbol instances are measured by their reshaped art, and a symbol instance is also measured by its reshaped art when its symbol's art has geometry effects. A group or layer with geometry or Pathfinder effects, or with a fill or stroke of its own that has geometry effects or paints members with geometry effects, is measured by its evaluated art. A blend, envelope or repeat whose objects have geometry effects is measured by its evaluated art, with those objects measured after their effects. A 100 pt square without a stroke, with Offset Path 10, measures 120 pt, and type with Outline Object aligns and sizes by its glyphs. Raster effects are not measured. Public reference: [Create text](https://web.archive.org/web/20220127090642/https://helpx.adobe.com/illustrator/using/creating-text.html) (archived copy). - Initial live Revolve (#605, #846): a portable geometry and shading crate (`three-d`), editable path and compound profiles, compact options with artboard rotation and light gizmos, SVG/PDF vector output and Expand Appearance that can trim covered faces. Cap is on by default with continuous curved vector rims; shading uses vector gradients automatically, with sharp profile corners, uniform caps and round seam joins. One light and approximate visibility remain ([revolve.md](revolve.md)). - Effect › Path › Outline Object has no options. Type that carries it is drawn, exported (PDF and SVG as paths, with no font) and expanded as its glyph outlines while it stays editable, and the effects after it reshape those outlines. Paths and images draw as before. With Use Preview Bounds on, type is still measured by its layout box. Public reference: [Summary of effects](https://web.archive.org/web/20210901011839/https://helpx.adobe.com/illustrator/using/summary-effects.html) (archived copy). From 7a5925359c96afdc3eb856a343c4f96e9765dce3 Mon Sep 17 00:00:00 2001 From: makesomethingshit <junsoo1172@gmail.com> Date: Tue, 10 Nov 2026 12:47:27 +0900 Subject: [PATCH 33/36] #6307: Keep spinner evidence beside numeric field history --- docs/roadmap.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/roadmap.md b/docs/roadmap.md index 7c68b48178..0082fcf331 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -192,12 +192,12 @@ separate. Scores and hours unchanged. [Behavior and command details](development | Date | Change | Summary | |---|---|---| -| 2026-11-10 | minor | Held numeric spinner arrows keep stepping past the click timeout and stop on release (#6307). Scores and estimates unchanged. | | 2026-11-09 | minor | Interim macOS canvas cursor delivery (#2292): existing artwork in aligned 1×/2× AppKit representations, visible system fallback and host-owned restoration. Headless routing, bitmap and ownership coverage; native timing, menus, focus/handback, mixed displays, Spotlight and full-screen Spaces remain unverified. Scores and estimates unchanged. | | 2026-11-09 | minor | M7: the custom dictionary commands and Ignore All (#4936); the dialogs and Auto Spell Check remain; scores and estimates unchanged. | | 2026-11-09 | minor | M4/G9: EPS import: the fonts an EPS carries draw their glyphs instead of being set as type in an installed font: composite (Type 0) fonts with the 8/8 mapping, plain Type 42 fonts (characters from their glyph names or character map, widths from the font), CID-keyed fonts over CMaps (Type 0 and Type 2 CIDFonts, CIDFonts in a FontSet's Compact Font Format data, base-font CMaps, named CIDFonts over predefined Unicode CMaps set as type) and embedded Type 1 fonts; the show operators' spacing along the baseline is kept as tracking, `kshow` included. Each step is charged to the interpreter's budgets. Hand-written test files only (no corpus file uses these fonts); scores and estimates unchanged. | | 2026-11-08 | minor | M7: spell check engine, `text.spelling.check` and `text.spelling.suggest` on craft-proof (#4936); the dialog, Auto Spell Check and the custom dictionary remain; scores and estimates unchanged. | | 2026-11-08 | minor | Floating tab groups collapse into the side icon rail and opened rail panels fold back into it (#5925, in part); tab groups remain intact. Scores and estimates unchanged. | +| 2026-11-10 | minor | Held numeric spinner arrows keep stepping past the click timeout and stop on release (#6307). Scores and estimates unchanged. | | 2026-11-09 | minor | Numeric fields step on wheel hover without taking focus; keyboard focus and existing rounding rules remain (#5949). Scores and estimates unchanged. | | 2026-11-07 | minor | M2: with the Pen or Curvature tool, Edit › Copy and Cut take the anchors and segments a Cmd/Ctrl-lent Direct Selection picked, not the whole path (#6123); estimates unchanged | | 2026-11-07 | minor | Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged. | From 55e6c0a5b1530d8e39d8c7d04b9364a646c81728 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Mon, 9 Nov 2026 19:47:40 -0800 Subject: [PATCH 34/36] Revolve: a hidden Revolve's profile is measured before the effects after it effect.setParams checks a Revolve before the edit, so turning a hidden Revolve back on measured its profile after every geometry effect on the object, those after the Revolve included, and could refuse a profile that draws. The check now measures the profile after only the geometry effects before the Revolve, hidden or shown, as drawing does. An object holds at most one Revolve, hidden ones included, so the check finds the same one that draws. --- crates/effects/src/revolve.rs | 20 +++++++++++++++++++- crates/engine/src/cmd/effectcmd/tests.rs | 16 ++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/crates/effects/src/revolve.rs b/crates/effects/src/revolve.rs index 99a6e053e8..a081070a6e 100644 --- a/crates/effects/src/revolve.rs +++ b/crates/effects/src/revolve.rs @@ -70,7 +70,8 @@ pub fn revolve_options(p: &Value) -> Revolve { pub fn validate_revolve(n: &Node, p: &Value) -> Result<(), String> { validate_revolve_params(p)?; let effects = &n.appearance.effects; - let before = effects.iter().position(|e| e.visible && e.id == REVOLVE).and_then(|i| effects.get(..i)).unwrap_or(effects); + // The profile is measured before the object's one Revolve, hidden or shown. + let before = effects.iter().position(|e| e.id == REVOLVE).and_then(|i| effects.get(..i)).unwrap_or(effects); if is_container(n) { const EMPTY: &str = "Revolve requires a group holding a shape with at least two anchors"; let mut shapes = members(n, "Revolve")?; @@ -812,6 +813,23 @@ mod tests { assert!(validate_revolve(&object, &json!({})).is_err(), "a reshaped object not yet revolved"); } + #[test] + fn validation_of_a_hidden_revolve_leaves_out_the_geometry_effects_after_it() { + let roughen = crate::new_effect("distort.roughen", &json!({"size": 1, "detail": 100})).unwrap(); + let mut hidden = crate::new_effect(REVOLVE, &json!({})).unwrap(); + hidden.visible = false; + let mut object = (*star(1)).clone(); + object.appearance.effects = vec![hidden.clone(), roughen.clone()]; + assert_eq!(validate_revolve(&object, &json!({})), Ok(()), "an object whose Revolve is shown again"); + let mut group = group_with(json!({}), vec![star(1)]); + group.appearance.effects = vec![hidden, roughen]; + assert_eq!(validate_revolve(&group, &json!({})), Ok(()), "a group whose Revolve is shown again"); + for n in [&mut object, &mut group] { + n.appearance.effects[0].visible = true; + assert!(revolve_art(n).is_some(), "shown, the Revolve draws"); + } + } + #[test] fn paint_copies_past_the_budget_are_refused_for_the_whole_group() { use vectorcraft_doc::color::{Gradient, GradientKind, GradientPaint, GradientStop}; diff --git a/crates/engine/src/cmd/effectcmd/tests.rs b/crates/engine/src/cmd/effectcmd/tests.rs index add1f3b79d..6604c99a2e 100644 --- a/crates/engine/src/cmd/effectcmd/tests.rs +++ b/crates/engine/src/cmd/effectcmd/tests.rs @@ -285,6 +285,22 @@ fn revolve_on_a_fill_is_checked_in_that_fills_paint() { assert!(err.contains("simplify the gradient"), "{err}"); } +/// A hidden Revolve still counts as the object's one Revolve: a second one is refused, and the +/// hidden one can be shown again over the geometry effects after it. +#[test] +fn a_second_revolve_is_refused_over_a_hidden_one() { + let (mut s, id) = session_with_rect(); + s.execute("effect.apply", &json!({"effect": "threeD.revolve", "ids": [id.0], "item": null})).unwrap(); + s.execute("effect.setParams", &json!({"index": 0, "ids": [id.0], "item": null, "visible": false})).unwrap(); + s.execute("effect.apply", &json!({"effect": "distort.roughen", "ids": [id.0], "item": null})).unwrap(); + let err = s.execute("effect.apply", &json!({"effect": "threeD.revolve", "ids": [id.0], "item": null})).unwrap_err().to_string(); + assert!(err.contains("one Revolve effect"), "{err}"); + let ids: Vec<_> = node(&s, id).appearance.effects.iter().map(|e| e.id.clone()).collect(); + assert_eq!(ids, ["threeD.revolve", "distort.roughen"]); + s.execute("effect.setParams", &json!({"index": 0, "ids": [id.0], "item": null, "visible": true})).unwrap(); + assert!(node(&s, id).appearance.effects[0].visible); +} + /// Revolve on a group sweeps every member about one axis on the left edge of their bounds, keeps /// each member's paint, follows an edited member, expands and undoes as one step. #[test] From fd0be1aca044d5e1fccec374f3fe6bd71c3343a8 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Mon, 9 Nov 2026 19:57:27 -0800 Subject: [PATCH 35/36] 3D: a profile's empty contours count toward its point limit revolve_curve_samples counted a profile's contours but never compared the count with the limit, so a profile made only of empty or one-anchor subpaths passed, although each contour takes one profile point. HoleSearch::new then built one entry for each subpath without a limit, and mesh export skips revolve_curve_samples when curve_samples is given. Both now refuse a profile with more than 16,384 contours as too complex. --- crates/three-d/src/lib.rs | 8 ++++++-- crates/three-d/src/tests/profile_limits.rs | 11 +++++++++++ 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/crates/three-d/src/lib.rs b/crates/three-d/src/lib.rs index 479481c2a6..fa22c10fb3 100644 --- a/crates/three-d/src/lib.rs +++ b/crates/three-d/src/lib.rs @@ -480,10 +480,10 @@ pub fn revolve_curve_samples<'a>(paths: impl IntoIterator<Item = &'a PathData>, let (mut anchors, mut contours, mut lines, mut curves, mut caps) = (0usize, 0usize, 0usize, 0usize, 0usize); for sp in paths.into_iter().flat_map(|p| &p.subpaths) { anchors = anchors.saturating_add(sp.anchors.len()); - if anchors > MAX_PROFILE_POINTS { + contours += 1; + if anchors > MAX_PROFILE_POINTS || contours > MAX_PROFILE_POINTS { return Err(TOO_COMPLEX_PROFILE.into()); } - contours += 1; let segments = if sp.closed { sp.anchors.len() } else { sp.anchors.len().saturating_sub(1) }; for i in 0..segments { if sp.segment_is_line(i) { @@ -551,6 +551,10 @@ struct HoleSearch { impl HoleSearch { fn new(path: &PathData, rule: FillRule) -> Result<Self, String> { + // Each contour takes a profile point, as in `revolve_curve_samples`. + if path.subpaths.len() > MAX_PROFILE_POINTS { + return Err(TOO_COMPLEX_PROFILE.into()); + } let contours: Vec<(BezPath, Rect, Option<i32>)> = path .subpaths .iter() diff --git a/crates/three-d/src/tests/profile_limits.rs b/crates/three-d/src/tests/profile_limits.rs index 5eeb10f2a1..148cf4fccc 100644 --- a/crates/three-d/src/tests/profile_limits.rs +++ b/crates/three-d/src/tests/profile_limits.rs @@ -68,6 +68,17 @@ fn too_many_anchors_are_refused_as_a_profile() { assert_eq!(refused, TOO_COMPLEX_PROFILE); } +#[test] +fn every_contour_takes_a_point_even_an_empty_one() { + // Contours of one anchor and of none, with no segment between them. + let lone = |i: usize| SubPath::new(if i.is_multiple_of(2) { vec![] } else { vec![Anchor::corner(Point::new(100.0, i as f64))] }, false); + let path = PathData::new((0..=MAX_PROFILE_POINTS).map(lone).collect()); + assert_eq!(revolve_curve_samples([&path], Revolve::default()), Err(TOO_COMPLEX_PROFILE.to_string())); + assert_eq!(hole_segments(&path, FillRule::NonZero), Err(TOO_COMPLEX_PROFILE.to_string()), "before any contour is built"); + let given = Revolve { curve_samples: Some(CURVE_SAMPLES), ..Revolve::default() }; + assert_eq!(revolve_mesh(&path, FillRule::NonZero, path.bounds().unwrap(), given).map(|_| ()), Err(TOO_COMPLEX_PROFILE.to_string())); +} + #[test] fn a_profile_at_the_point_limit_is_taken_and_one_past_it_is_refused() { let straight = |anchors: usize| { From 9d01c0e7b5bfeed18e2fbdd37f7d89dc4cf92aff Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Mon, 9 Nov 2026 19:59:29 -0800 Subject: [PATCH 36/36] MCP: test that list_commands gives input schemas over stdio --- crates/mcp/src/tests/protocol.rs | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/crates/mcp/src/tests/protocol.rs b/crates/mcp/src/tests/protocol.rs index 1fe41c3e51..a3c79aae74 100644 --- a/crates/mcp/src/tests/protocol.rs +++ b/crates/mcp/src/tests/protocol.rs @@ -261,3 +261,20 @@ fn log_records_go_out_as_notifications_once_asked_for() { assert!(lines[last_reply + 1..].iter().all(|l| l.get("id").is_none()), "{lines:?}"); result(&mut s, 4, "logging/setLevel", json!({"level": null})); } + +/// `list_commands` gives each command's input schema over stdin and stdout, as a client reads it. +#[test] +fn list_commands_over_stdio_carries_input_schemas() { + let mut s = server(); + let call = json!({"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {"name": "list_commands", "arguments": {"filter": "view.saved"}}}); + let mut out = Vec::new(); + s.serve((call.to_string() + "\n").as_bytes(), &mut out).unwrap(); + let lines: Vec<Value> = String::from_utf8(out).unwrap().lines().map(|l| serde_json::from_str(l).unwrap()).collect(); + let reply = lines.iter().find(|l| l["id"] == 1).cloned().unwrap_or(Value::Null); + assert_eq!(reply["result"]["isError"], false, "{reply}"); + let listed: Value = serde_json::from_str(reply["result"]["content"][0]["text"].as_str().unwrap_or("null")).unwrap_or(Value::Null); + let commands = listed["commands"].as_array().cloned().unwrap_or_default(); + assert!(commands.iter().filter(|c| c.get("inputSchema").is_some()).count() >= 3, "{listed}"); + let saved_list = commands.iter().find(|c| c["id"] == "view.saved.list").cloned().unwrap_or(Value::Null); + assert_eq!(saved_list["inputSchema"]["type"], "object", "{listed}"); +}