Merge main into feat/jxl-import

This commit is contained in:
dotCooCoo
2026-11-08 18:04:51 -08:00
7 changed files with 220 additions and 11 deletions
+27
View File
@@ -241,6 +241,33 @@ fn options_are_saved_with_the_preferences() {
assert_eq!(t.prefs.tool_settings["polygon"]["sides"], json!(9));
}
/// Escape on a polygon or rounded rectangle drag puts back the side count or corner radius its
/// arrows changed, in the saved options and the Corner Radius preference too.
#[test]
fn escape_on_a_shape_drag_saves_the_option_it_put_back() {
use vectorcraft_tools::{Mods, PointerEvent, PointerKind, ToolKey};
let mut s = session();
let v = ViewInfo { smart_guides: false, ..Default::default() };
let drag = |s: &mut Session| {
s.pointer(&PointerEvent::new(PointerKind::Down, 100.0, 100.0), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Drag, 200.0, 180.0), v).unwrap();
s.tool_key(ToolKey::Up, Mods::default(), v).unwrap();
// Saves every option, the one the arrow changed included.
s.set_tool_option("length", &json!(50));
};
select(&mut s, "polygon");
drag(&mut s);
assert_eq!(s.prefs.tool_settings["polygon"]["sides"], json!(7));
s.tool_key(ToolKey::Escape, Mods::default(), v).unwrap();
assert_eq!(s.prefs.tool_settings["polygon"]["sides"], json!(6));
select(&mut s, "roundedRectangle");
let radius = s.prefs.corner_radius;
drag(&mut s);
assert_eq!(s.prefs.corner_radius, radius + 1.0);
s.tool_key(ToolKey::Escape, Mods::default(), v).unwrap();
assert_eq!(s.prefs.corner_radius, radius);
}
/// The Fidelity an Alt smoothing drag uses on a selected path.
fn alt_smooth_fidelity(s: &mut Session) -> Option<f64> {
use vectorcraft_tools::{Action, Mods, PointerEvent, PointerKind};
+18 -5
View File
@@ -28,7 +28,11 @@
//! [`vectorcraft_brush::ArtBrush`]'s `overlap: false`).
//! - A Scatter, Art or Pattern brush's parameters are fields split by `/`: a number, the art's
//! section names (one for Scatter, five for Art and Pattern, empty where there is none, each
//! after a space for Art and Pattern), more fields, and last 15 numbers.
//! after a space for Art and Pattern), more fields, and last 15 numbers. Older files write
//! brushes with first number 1, one field fewer before the numbers and 8 numbers; an Art brush
//! with 8 numbers, whatever its first number, is read from its art's name alone (its numbers
//! aren't known, so it keeps the defaults and its paths their drawn look). A Pattern brush needs
//! only its first 3 numbers (its scale is the third).
//! - Scatter: the size (2, 3), spacing (5, 6) and scatter (8, 9) as (low, high) factors of the
//! art, the rotation in degrees (11, 12), how each varies (4, 7, 10, 13) and whether the
//! rotation follows the path (14), and 1 Tints (1).
@@ -312,11 +316,10 @@ pub(super) fn parse_tool_object(kind: Tool, name: &str, params: &str) -> Result<
})
.collect();
let n = fields.last().and_then(|f| numbers(f)).ok_or(BrushError::Values)?;
let n: &[f64; 15] = n.as_slice().try_into().map_err(|_| BrushError::Values)?;
let [_, p2, p3, p4, p5, p6, p7, p8, p9, p10, p11, p12, p13, p14, _] = n;
let art = || tiles.first().cloned().flatten().ok_or(BrushError::NoArt);
match kind {
Tool::Scatter => {
let [_, p2, p3, p4, p5, p6, p7, p8, p9, p10, p11, p12, p13, p14, _] = n.as_slice() else { return Err(BrushError::Values) };
let pct = |a: f64, b: f64| (a * 100.0, b * 100.0);
let values = [pct(*p2, *p3), pct(*p5, *p6), pct(*p8, *p9), (*p11, *p12)];
let codes = [*p4, *p7, *p10, *p13];
@@ -336,10 +339,20 @@ pub(super) fn parse_tool_object(kind: Tool, name: &str, params: &str) -> Result<
DraftKind::Scatter { brush, art: art()? }
}
Tool::Art => {
let colorization = if *p2 == 1.0 { Colorization::Tints } else { Colorization::None };
DraftKind::Art { brush: ArtBrush { colorization, ..ArtBrush::default() }, art: art()?, plain: art_is_plain(n) }
// The older form's 8 numbers aren't known: its brush keeps the defaults and its
// paths their drawn look. The count tells the form, not the first number.
// Any other count isn't a form this reads.
let (colorization, plain) = if let Ok(n15) = <&[f64; 15]>::try_from(n.as_slice()) {
(if n15[1] == 1.0 { Colorization::Tints } else { Colorization::None }, art_is_plain(n15))
} else if n.len() == 8 {
(ArtBrush::default().colorization, false)
} else {
return Err(BrushError::Values);
};
DraftKind::Art { brush: ArtBrush { colorization, ..ArtBrush::default() }, art: art()?, plain }
}
_ => {
let [_, _, p3, ..] = n.as_slice() else { return Err(BrushError::Values) };
let tiles: [Option<String>; 5] = tiles.try_into().map_err(|_| BrushError::Values)?;
DraftKind::Pattern { tiles, scale: p3 * 100.0 }
}
+48
View File
@@ -8881,6 +8881,54 @@ fn a_pattern_brushs_five_slots_are_kept() {
assert!(matches!(d.kind, brush::DraftKind::Art { ref art, .. } if art == "Ribbon art"), "{d:?}");
}
/// An Art brush in the older form (first number 1: one field fewer before its 8 numbers) is read
/// with the art of its section, but not as plain (its numbers aren't known); a Scatter brush in
/// that form isn't read (its values aren't all there).
#[test]
fn an_older_art_brush_comes_in_with_its_art() {
const OLD_RIBBON: &str = "1 / Ribbon art/ / / / / 0 1 / 1 0 1 0 1 0 0 0";
let d = brush::parse_tool_object(brush::Tool::Art, "Ribbon", OLD_RIBBON).unwrap();
assert!(matches!(d.kind, brush::DraftKind::Art { ref art, plain: false, .. } if art == "Ribbon art"), "{d:?}");
let brush::DraftKind::Art { ref brush, .. } = d.kind else { panic!("art") };
assert_eq!(brush.colorization, vectorcraft_brush::ArtBrush::default().colorization, "the default colorization");
// A count of numbers neither form has isn't read.
for bad in ["1 / Ribbon art/ / / / / 0 1 / 1 0 1 0 1 0 0", "1 / Ribbon art/ / / / / 0 1 / 1 0 1 0 1 0 0 0 0 0 0 0 0 0 0 0"] {
assert_eq!(brush::parse_tool_object(brush::Tool::Art, "Ribbon", bad), Err(brush::BrushError::Values), "{bad}");
}
assert_eq!(brush::parse_tool_object(brush::Tool::Scatter, "Spray", "1 /Dot art/ 0 1 / 1 0 1 0 1 0 0 0"), Err(brush::BrushError::Values));
// 8 numbers are the older form whatever the first number says: read with the defaults.
let d = brush::parse_tool_object(brush::Tool::Art, "Ribbon", "0 / Ribbon art/ / / / / 0 1 / 1 0 1 0 1 0 0 0").unwrap();
let brush::DraftKind::Art { ref brush, ref art, plain } = d.kind else { panic!("art") };
assert_eq!((art.as_str(), plain, brush.colorization), ("Ribbon art", false, vectorcraft_brush::ArtBrush::default().colorization));
let objects = [plugin_object(ART_BRUSH, "Ribbon", OLD_RIBBON), plugin_object(ART_BRUSH, "Bare", "1 / / / / / / 0 1 / 1 0 1 0 1 0 0 0")].concat();
let r = read_ok(&format!("{}{objects}{}", brush_art("Ribbon art", 4.0), layer("")));
let lib = vectorcraft_brush::library(&r.doc);
let vectorcraft_brush::BrushKind::Art(a) = brush_named(&lib, "Ribbon") else { panic!("art") };
assert_eq!(a.art.geometric_bounds().map(|b| (b.width(), b.height())), Some((4.0, 4.0)), "the art of its section");
assert_eq!(lib.len(), 1, "a brush naming no art is left out");
assert!(r.warnings.iter().any(|w| w.ends_with("Brushes panel (1)")), "{:?}", r.warnings);
}
/// A Pattern brush needs only its first 3 numbers, its scale the third (as a percentage), so the
/// older form's 8 numbers read too; fewer than 3 aren't read.
#[test]
fn a_pattern_brush_reads_its_scale_from_three_or_more_numbers() {
const SLOTS: &str = "1 / Tile one/ Tile two/ / / Tile five/ 0 1 / ";
for numbers in ["1 0 0.5", "1 0 0.5 0 1 0 0 0"] {
let d = brush::parse_tool_object(brush::Tool::Pattern, "Fence", &format!("{SLOTS}{numbers}")).unwrap();
let brush::DraftKind::Pattern { tiles, scale } = d.kind else { panic!("{d:?}") };
assert_eq!(tiles[1].as_deref(), Some("Tile two"), "{numbers}");
assert!((scale - 50.0).abs() < 1e-9, "{numbers}: {scale}");
}
for numbers in ["", "1", "1 0"] {
assert_eq!(
brush::parse_tool_object(brush::Tool::Pattern, "Fence", &format!("{SLOTS}{numbers}")),
Err(brush::BrushError::Values),
"{numbers:?}"
);
}
}
/// A brush's art filled with a pattern the file defines before it is read, with that pattern.
#[test]
fn a_brushs_art_takes_the_patterns_defined_before_it() {
+42
View File
@@ -6,6 +6,7 @@
//! Ctrl/Cmd keeps the inner radius where it is while the outer one follows the pointer, and Alt
//! keeps the star's shoulders straight (#812). The start point and
//! the dragged corner snap to Smart Guides ([`DrawSnap`]), hovering before the press too.
//! Escape cancels the drag and puts back the options its keys and Ctrl/Cmd changed.
//!
//! The selection keeps its bounding box and its Live Shape widgets on show with the Rectangle,
//! Rounded Rectangle, Ellipse, Polygon and Star tools, so a shape just drawn can be adjusted at
@@ -71,6 +72,9 @@ pub struct ShapeTool {
pub line_angle: f64,
pub fill_line: bool,
snap: DrawSnap,
/// The options a drag's keys and Ctrl/Cmd change, as they were when the drag began: Escape
/// puts them back.
before_drag: Option<DragOptions>,
/// A widget of the selection is being dragged instead of a shape drawn.
widget: Option<WidgetDrag>,
/// The selection is being moved or turned instead of a shape drawn.
@@ -84,6 +88,15 @@ pub struct ShapeTool {
snapped: bool,
}
/// The options that keys and Ctrl/Cmd change while a shape is dragged out.
#[derive(Clone, Copy)]
struct DragOptions {
sides: u32,
points: u32,
corner_radius: f64,
star_ratio: f64,
}
/// The inner-to-outer radius ratio that keeps a star of `points` points' shoulders straight (each
/// point's sides in line with the next points' sides: a regular star polygon {n/2}); none for
/// fewer than five points, whose shoulders can't line up.
@@ -117,6 +130,7 @@ impl ShapeTool {
line_length: 100.0,
line_angle: 0.0,
fill_line: false,
before_drag: None,
widget: None,
selection: None,
guides: vec![],
@@ -125,6 +139,18 @@ impl ShapeTool {
}
}
fn drag_options(&self) -> DragOptions {
DragOptions { sides: self.sides, points: self.points, corner_radius: self.corner_radius, star_ratio: self.star_ratio }
}
/// Put back the options a canceled drag changed.
fn restore_drag_options(&mut self, o: DragOptions) {
self.sides = o.sides;
self.points = o.points;
self.corner_radius = o.corner_radius;
self.star_ratio = o.star_ratio;
}
/// A press on a corner or pie widget of the selection: the drag adjusts it.
fn press_widget(&mut self, cx: &ToolContext, ev: &PointerEvent) -> bool {
if !shows_shape_widgets(self.id) {
@@ -428,6 +454,7 @@ impl Tool for ShapeTool {
self.last = ev.pos;
self.began = false;
self.inner = None;
self.before_drag = None;
vec![]
}
PointerKind::Drag => {
@@ -451,6 +478,7 @@ impl Tool for ShapeTool {
return out;
}
self.began = true;
self.before_drag = Some(self.drag_options());
out.push(Action::Begin(self.label().into()));
}
let (c, v) = self.command_at(s, ev.pos, ev.mods, cx.constrain_angle);
@@ -461,6 +489,7 @@ impl Tool for ShapeTool {
let Some(s) = self.start.take() else { return vec![] };
self.snap.clear();
self.inner = None;
self.before_drag = None;
if self.began {
self.began = false;
vec![Action::Commit]
@@ -528,6 +557,9 @@ impl Tool for ShapeTool {
self.start = None;
self.began = false;
self.inner = None;
if let Some(o) = self.before_drag.take() {
self.restore_drag_options(o);
}
self.snap.clear();
vec![Action::Cancel]
} else {
@@ -659,6 +691,16 @@ impl Tool for ShapeTool {
"fillLine" => self.fill_line = v.as_bool().unwrap_or(self.fill_line),
_ => {}
}
// An option set while a shape is dragged out stays when Escape cancels the drag.
if let Some(b) = self.before_drag.as_mut() {
match key {
"sides" => b.sides = self.sides,
"points" => b.points = self.points,
"cornerRadius" => b.corner_radius = self.corner_radius,
"starRatio" => b.star_ratio = self.star_ratio,
_ => {}
}
}
}
}
+79 -3
View File
@@ -166,15 +166,16 @@ fn a_star_drag_holds_the_inner_radius_it_shows_when_ctrl_goes_down() {
close(radii(&t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 170.0, 50.0).with_mods(ctrl))), (120.0, 40.0 * straight));
// Escape with the key held, then a new press and the key before the first move: the next star
// holds what it shows, not the canceled radius.
// has the ratio from before the canceled drag and holds the inner radius it shows, not the
// canceled one.
let mut t = ShapeTool::new("star");
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 50.0, 50.0));
t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 90.0, 50.0));
t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 130.0, 50.0).with_mods(ctrl));
assert_eq!(t.key(&cx, ToolKey::Escape, ctrl), vec![Action::Cancel]);
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 50.0, 50.0));
close(radii(&t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 60.0, 50.0).with_mods(ctrl))), (10.0, 2.5));
close(radii(&t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 70.0, 50.0).with_mods(ctrl))), (20.0, 2.5));
close(radii(&t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 60.0, 50.0).with_mods(ctrl))), (10.0, 5.0));
close(radii(&t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 70.0, 50.0).with_mods(ctrl))), (20.0, 5.0));
// Ctrl/Cmd pressed inside the drag threshold, where no star is shown yet.
let mut t = ShapeTool::new("star");
@@ -301,6 +302,81 @@ fn rounded_rectangle_radius_is_clamped_to_its_range() {
assert_eq!(t.options()["cornerRadius"], json!(MAX_CORNER_RADIUS));
}
/// Escape during a polygon, star or rounded rectangle drag puts back the side count, point count
/// or corner radius its arrows changed, for the next drag and the tool's options; a drag that is
/// let go keeps them.
#[test]
fn escape_puts_back_the_options_a_drag_changed() {
let (d, _) = doc_with_rect();
let s = Selection::default();
let p = paint();
let mut cx = cx(&d, &s, &p);
cx.smart_guides = false;
for (id, field) in [("polygon", "sides"), ("star", "points"), ("roundedRectangle", "radius")] {
let mut t = ShapeTool::new(id);
let value = |a: Vec<Action>| match a.last() {
Some(Action::Preview(_, v)) => v[field].clone(),
_ => panic!("{id}: {a:?}"),
};
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 10.0, 10.0));
let before = value(t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 60.0, 40.0)));
let options = t.options();
t.key(&cx, ToolKey::Up, Mods::default());
let changed = value(t.key(&cx, ToolKey::Up, Mods::default()));
assert_ne!(changed, before, "{id}: the arrows change the option");
assert_eq!(t.key(&cx, ToolKey::Escape, Mods::default()), vec![Action::Cancel], "{id}");
assert_eq!(t.options(), options, "{id}: the tool's options");
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 10.0, 10.0));
assert_eq!(value(t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 60.0, 40.0))), before, "{id}: the next drag");
// A drag that is let go keeps what its arrows changed.
t.key(&cx, ToolKey::Up, Mods::default());
t.key(&cx, ToolKey::Up, Mods::default());
assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 60.0, 40.0)), vec![Action::Commit]);
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 10.0, 10.0));
assert_eq!(value(t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 60.0, 40.0))), changed, "{id}: a finished drag keeps it");
t.key(&cx, ToolKey::Down, Mods::default());
t.key(&cx, ToolKey::Escape, Mods::default());
let kept = t.options();
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 10.0, 10.0));
// Escape puts back only what this drag changed.
assert_eq!(value(t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 60.0, 40.0))), changed, "{id}: only this drag");
assert_ne!(kept, options, "{id}");
}
}
/// Ctrl/Cmd changes a star's inner-to-outer ratio as it is dragged out; Escape puts it back, and
/// an option set during the drag stays.
#[test]
fn escape_puts_back_the_star_ratio_and_keeps_an_option_set_during_the_drag() {
let (d, _) = doc_with_rect();
let s = Selection::default();
let p = paint();
let mut cx = cx(&d, &s, &p);
cx.smart_guides = false;
let ctrl = Mods { cmd: true, ..Default::default() };
let mut t = ShapeTool::new("star");
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 50.0, 50.0));
t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 90.0, 50.0));
t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 130.0, 50.0).with_mods(ctrl));
assert!((t.star_ratio - 0.25).abs() < 1e-9, "{}", t.star_ratio);
t.key(&cx, ToolKey::Escape, ctrl);
assert_eq!(t.star_ratio, 0.5);
// Options set with `tool.setOption` during the drag stay; the arrows' change goes back.
for (id, key, set) in [("polygon", "sides", json!(9)), ("star", "points", json!(8)), ("roundedRectangle", "cornerRadius", json!(30.0))] {
let mut t = ShapeTool::new(id);
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 10.0, 10.0));
t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 60.0, 40.0));
t.key(&cx, ToolKey::Up, Mods::default());
t.set_option(key, &set);
t.set_option("starRatio", &json!(0.3));
t.key(&cx, ToolKey::Up, Mods::default());
t.key(&cx, ToolKey::Escape, Mods::default());
let o = t.options();
assert_eq!((o[key].clone(), o["starRatio"].clone()), (set, json!(0.3)), "{id}");
}
}
/// The start and the dragged corner land on another object's anchors (#506), hovering shows
/// it before the press, and the measurement label gives the size drawn.
#[test]
+2 -1
View File
@@ -1,6 +1,6 @@
# File format parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-07 · **Change:** minor (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-07 · **Change:** minor (`.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
@@ -313,6 +313,7 @@ An object in a pattern's art or in a brush's art takes the file's art styles in
| Date | Change | Summary |
|---|---|---|
| 2026-11-06 | minor | `.ai` and EPS import: Art brushes whose objects are in the older form (first number 1, one field fewer and 8 numbers at the end) come into the Brushes panel with their art instead of being left out with a note. In a survey of 302 users' EPS files (used locally, not committed), 652 Art brushes in 188 of the 197 files with the note were left out this way; afterwards 55 files have the note, all for Pattern brushes. Test `an_older_art_brush_comes_in_with_its_art` (crate `vectorcraft-eps`); scores unchanged |
| 2026-11-06 | minor | EPS import: composite (Type 0) fonts with the 8/8 mapping (`FMapType` 2: PLRM 3rd ed., §5.10) draw a Type 42 descendant's glyphs from the TrueType outlines its `sfnts` carry (Adobe Technical Note #5012), spaced by their own widths or the show operator's numbers, and `charpath` and `stringwidth` read them; descendants found by name set their characters as type at the composite font's size instead of a thousand times it. EPS from `pdftops` of a PDF with embedded TrueType fonts showed nothing, or one black shape, where its type was. |
| 2026-11-07 | minor | EPS and `.ai` editing data: opacity mask art that sets no paint of its own paints with the paint before the mask's dictionary, not the black a dictionary's state starts with. A logo of white letters masked by a rectangle with no colour no longer opens hidden: a user's `.ai` (used locally, not committed) shows its six logos. Scores and estimates unchanged. |
| 2026-11-06 | minor | EPS and `.ai` editing data: an art style's stroke aligned inside or outside its path (Stroke panel › Align Stroke, written as its own `Adobe Stroke Offset` filter) comes in as a stroke with that alignment, so objects whose style has one keep the style live instead of coming in as their drawn look. Without its inside/outside switch the drawn look stays. |
+4 -2
View File
@@ -1,6 +1,6 @@
# Parity with Adobe Illustrator
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-09 · **Change:** minor (numeric fields step on wheel hover without taking keyboard focus, and a panel wheel turn cannot seed a canvas zoom glide, #5949) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-09 · **Change:** minor (`.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) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026)
## Feature submission evidence
@@ -874,6 +874,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
- **Shapes:** all shape tools (including Flare) and the line, arc, spiral and grid tools. Space held while dragging out a shape, line, arc, spiral or grid moves it at its current size. The Flare tool's center drag turns the rays by 0.1 radian at each drag update in which the pointer's document y grows, and back by as much at each one in which it falls, Shift held keeps their turn, and Space held during either of its drags moves the flare. While a grid is dragged out, Up/Down change its rows (concentric dividers) and Left/Right its columns (radial dividers). A click with a grid tool asks for the size, the divider counts and the grid options (#1377, in part): a skew for each set of dividers, Use Outside Rectangle As Frame or Create Compound Path From Ellipses, and Fill Grid; they are parameters of `shape.rectangularGrid` and `shape.polarGrid`, stay as the tool's options and apply to drags too. The curve a skew percentage follows and the direction of the radial skew are our own (the public documentation gives neither), and the dialogs have no reference point locator. An ellipse's Pie Start and End Angle and Invert Pie (Transform and Properties panels' Ellipse Properties, `object.setLiveShape {pieStart, pieEnd, invertPie}`) cut it as a pie (#812). With the bounding box showing, the Selection tool shows a selected ellipse's two pie widgets just outside it; dragging one sets its angle in one undo step and double-clicking one makes the ellipse whole again. A live polygon's Polygon Properties there: side count, angle, corner type and radius, radius, side length and Make Sides Equal once an uneven scale has made its sides differ (`object.setLiveShape {sides, polygonAngle, polygonRadius, sideLength, makeSidesEqual}`, part of #812); a star is a plain path, as in Illustrator. The Flare Tool Options last as the tool's options: the next flare dragged out uses them, a click's dialog shows them and double-clicking the tool opens them (part of #812).
- **Shapes:** all shape tools (including Flare) and the line, arc, spiral and grid tools. Space held while dragging out a shape, line, arc, spiral or grid moves it at its current size. The Flare tool's center drag turns the rays with the pointer, Shift keeps them in their original orientation (unturned, or turned by a nonzero General › Constrain Angle), and Space held during either of its drags moves the flare. While a grid is dragged out, Up/Down change its rows (concentric dividers) and Left/Right its columns (radial dividers). A click with a grid tool asks for the size, the divider counts and the grid options (#1377, in part): a skew for each set of dividers, Use Outside Rectangle As Frame or Create Compound Path From Ellipses, and Fill Grid; they are parameters of `shape.rectangularGrid` and `shape.polarGrid`, stay as the tool's options and apply to drags too. The curve a skew percentage follows and the direction of the radial skew are our own (the public documentation gives neither), and the dialogs have no reference point locator. An ellipse's Pie Start and End Angle and Invert Pie (Transform and Properties panels' Ellipse Properties, `object.setLiveShape {pieStart, pieEnd, invertPie}`) cut it as a pie (#812). With the bounding box showing, the Selection tool shows a selected ellipse's two pie widgets just outside it; dragging one sets its angle in one undo step and double-clicking one makes the ellipse whole again. A live polygon's Polygon Properties there: side count, angle, corner type and radius, radius, side length and Make Sides Equal once an uneven scale has made its sides differ (`object.setLiveShape {sides, polygonAngle, polygonRadius, sideLength, makeSidesEqual}`, part of #812); a star is a plain path, as in Illustrator. The Flare Tool Options last as the tool's options: the next flare dragged out uses them, a click's dialog shows them and double-clicking the tool opens them (part of #812).
- **Shapes:** all shape tools (including Flare) and the line, arc, spiral and grid tools. Space held while dragging out a shape, line, arc, spiral or grid moves it at its current size. The Flare tool's center drag turns the rays with the pointer, Shift keeps them in their original orientation, and Space held during either of its drags moves the flare. While a grid is dragged out, Up/Down change its rows (concentric dividers) and Left/Right its columns (radial dividers). A click with a grid tool asks for the size, the divider counts and the grid options (#1377, in part): a skew for each set of dividers, Use Outside Rectangle As Frame or Create Compound Path From Ellipses, and Fill Grid; they are parameters of `shape.rectangularGrid` and `shape.polarGrid`, stay as the tool's options and apply to drags too. The curve a skew percentage follows and the direction of the radial skew are our own (the public documentation gives neither), and the dialogs have no reference point locator. An ellipse's Pie Start and End Angle and Invert Pie (Transform and Properties panels' Ellipse Properties, `object.setLiveShape {pieStart, pieEnd, invertPie}`) cut it as a pie (#812). With the bounding box showing, the Selection tool shows a selected ellipse's two pie widgets just outside it; dragging one sets its angle in one undo step and double-clicking one makes the ellipse whole again. A live polygon's Polygon Properties there: side count, angle, corner type and radius, radius, side length and Make Sides Equal once an uneven scale has made its sides differ (`object.setLiveShape {sides, polygonAngle, polygonRadius, sideLength, makeSidesEqual}`, part of #812); a star is a plain path, as in Illustrator. The Flare Tool Options last as the tool's options: the next flare dragged out uses them, a click's dialog shows them and double-clicking the tool opens them (part of #812).
Escape while a polygon, star or rounded rectangle is dragged out cancels it and puts back the side count, point count, corner radius or star ratio its Up/Down and Ctrl/Cmd changed, so the next drag, the tool's options and General › Corner Radius start from the values before the canceled drag; a drag that is let go keeps them, and an option set with `tool.setOption` during the drag stays. This is VectorCraft's own rule.
While a grid is dragged out, F lowers `rowSkew` (rectangular) or `radialSkew` (polar) by 10 percentage points and V raises it by 10; X lowers `columnSkew` (rectangular) or `concentricSkew` (polar) by 10 and C raises it by 10. Plain and Shift presses work; with Alt, Ctrl or Cmd held they do not, as for the other letters a tool takes while dragging. Each press changes the skew once and updates the preview; values stay within ±500% and remain in the tool options after release. The [default keyboard shortcuts](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html) (archived copy) list the four drag keys and a 10% change. The signs (positive values follow VectorCraft's existing skew convention, so F and X lower the stored value) and one change per press with key repeat ignored are our own rules.
While an arc is dragged out, C switches it between open and closed, F switches its base between the X and Y axes in the same box, and Up/Down raise and lower its slope by 10 within −100…100; each press switches once, and the values stay as the Arc tool's options. The [default keyboard shortcuts](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html) (archived copy) list C, F and Up/Down for an arc being dragged out. F as the base switch, the arrows changing the slope, the step of 10 and one switch per press are our own rules.
While a spiral is dragged out, Alt adds or removes whole winds of four segments and Ctrl/Cmd changes the decay. The spiral's inner end keeps the distance from the center it had on the first modified drag event, while the outer end follows the pointer. The [default keyboard shortcuts](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html) (archived copy) list Alt-drag to add or subtract winds and Ctrl-drag to change the decay rate; [Draw spirals](https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/draw-shapes/draw-spirals.html) defines four segments per full wind and Decay as the amount each wind decreases. An 80% spiral has 80% of its outer radius after four segments; newly drawn spirals with the same settings wind more broadly than in earlier VectorCraft builds. Rounding the fitted count to whole winds from the count on the first Alt-only drag event, the held inner radius, the ranges (2…1000 segments, 5…99.99 % decay), Ctrl/Cmd taking precedence when both are held, and the new values staying as Spiral tool options are our own rules. Switching back to Alt uses the current count as its new base while keeping the held inner end.
@@ -1343,7 +1344,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
`.ai` symbol definitions and placed instances open as live symbols, in the Symbols panel's order; an instance of a symbol that isn't read, or whose appearance differs from its symbol's, comes in as the page draws it (`crates/eps/src/import/ai/tests.rs`).
In a `.ai` opened from its editing data, an object in a pattern's art or a brush's art with an art style VectorCraft reads opens as itself with its effects live, as an object on a layer does, and an object in a pattern's art keeps the id of a graphic style in the file's list (`crates/eps/src/import/ai/tests.rs`).
Blends and envelopes in the definitions of a `.ai`'s symbols, patterns and brushes' art count against the same per-file limits on blend layout and envelope checks as the layers' art, so a file with many definitions does not get those limits again for each one (`crates/eps/src/import/ai/read/directives.rs`).
A `.ai` or EPS whose editing data has brushes opens with the file's Calligraphic, Scatter, Art and Bristle brushes in the Brushes panel in place of the default ones, in the file's order: the Calligraphic size, roundness and angle with their variation, the Scatter size, spacing, scatter and rotation, the Bristle settings, and the Scatter and Art brushes' art. A brush library `.ai` (brushes and no layers) opens from its PDF part with its brushes, so Other Library lists them. An Art brush in Tints keeps that colorization. Pattern brushes and brushes that can't be read are left out with a note. Paths with a Calligraphic brush, and paths with an Art brush at 100% in Tints that stretches art of solid-filled paths unflipped, open as the path with the brush on its stroke, and other brushed paths still come in as their drawn look (part of #372 and #637; `crates/eps/src/import/ai/tests.rs`, `crates/eps/src/import/tests_native.rs`).
A `.ai` or EPS whose editing data has brushes opens with the file's Calligraphic, Scatter, Art and Bristle brushes in the Brushes panel in place of the default ones, in the file's order: the Calligraphic size, roundness and angle with their variation, the Scatter size, spacing, scatter and rotation, the Bristle settings, and the Scatter and Art brushes' art (Art brushes in the older and newer forms of their objects). A brush library `.ai` (brushes and no layers) opens from its PDF part with its brushes, so Other Library lists them. An Art brush in Tints keeps that colorization. Pattern brushes and brushes that can't be read are left out with a note. Paths with a Calligraphic brush, and paths with an Art brush in the newer form at 100% in Tints that stretches art of solid-filled paths unflipped, open as the path with the brush on its stroke. Paths with an Art brush in the older form, and other brushed paths, still come in as their drawn look (part of #372 and #637; `crates/eps/src/import/ai/tests.rs`, `crates/eps/src/import/tests_native.rs`).
In a `.ai` opened from its editing data, a path or type object with Offset Path, Outline Object or Outline Stroke on the whole object opens as itself with those effects live, in their order and shown or hidden; on a fill or stroke of type they stay on that fill or stroke. Offset Path keeps its Offset, Joins and Miter Limit, and Outline Stroke takes its width from the stroke. An Offset Path with a setting missing, out of range or not read keeps the drawn art, as does a path with a Path effect on one fill or stroke (`crates/eps/src/import/ai/style/tests.rs`, `crates/eps/src/import/ai/tests.rs`, `crates/eps/src/import/tests_native.rs`).
In a `.ai` or EPS opened from its editing data, a Scatter brush whose last 15 numbers start with 1 comes into the Brushes panel in Tints, and each brush art section is read without its frame, an unnamed, unpainted rectangle with no settings of its own at the art's bounds (within 1/1000 pt). A wider frame stays in the art, and an art group with a state or look of its own (hidden, locked, isolated, knockout, an opacity, a blend mode, a name, a mask, a note or an appearance) stays a group (`crates/eps/src/import/ai/tests.rs`).
In a `.ai` opened from its editing data, type with Warp on the object or on one of its fills or strokes opens as type with the effect live when the live warp draws as the art saved with it. When it draws differently, or when the file's limit of checks has no check left for it, it opens as that art, and one note says how many did. Type stays live without a comparison when the saved art paints nothing or draws type of its own, in frames of threaded type, when a text variable binds it, and when its text object doesn't come in as one text object (`crates/eps/src/import/ai/envelope.rs`, `crates/eps/src/import/tests_native.rs`).
@@ -1872,6 +1873,7 @@ Edit › Copy and Cut put the selected ruler guides on the clipboard, with the s
| Date | Change | Summary |
|---|---|---|
| 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-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-06 | minor | Direct Selection: with Snap to Grid, a dragged straight segment lands its anchors on the grid (#3835); evidence added; scores unchanged. |