mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-14 19:26:56 -04:00
Part of #1377: Rectangular Grid and Polar Grid tool options (skew, frame, compound path, fill)
This commit is contained in:
1 parent
dc640f3d61
commit
ddebe5e479
27 files changed
+700
-93
No files matched your search
+2
-1
@@ -6,7 +6,7 @@
|
||||
|
||||
**Stage: alpha** · next: beta, ~13 points (ready for real work ~62% → ~75%, and reliable `.ai` exchange) and ~170–270 h away
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (`.ai` files with placed (linked) pictures open from their editing data, #1264; community PRs: `.ai` and PDF bleed and editing-data import, gradient shapes imported as paths, ruler origin, Align To, Live Corners corner selection; headline numbers unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; community PRs: graph designs, Graph Data worksheet and Graph Type options; Align both centres; Pen endpoint fixes; earlier: Effect › Brush Strokes landed: Photoshop-style filters 28 of 57, raster effects 40% → 48%) · **Target:** Adobe Illustrator 2026 (30.x)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (Rectangular Grid and Polar Grid tool options, #1377 in part; `.ai` files with placed (linked) pictures open from their editing data, #1264; community PRs: `.ai` and PDF bleed and editing-data import, gradient shapes imported as paths, ruler origin, Align To, Live Corners corner selection; headline numbers unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; community PRs: graph designs, Graph Data worksheet and Graph Type options; Align both centres; Pen endpoint fixes; earlier: Effect › Brush Strokes landed: Photoshop-style filters 28 of 57, raster effects 40% → 48%) · **Target:** Adobe Illustrator 2026 (30.x)
|
||||
|
||||
VectorCraft is a clean-room, open-source, pure-Rust reimplementation of the Adobe Illustrator workflow. It runs
|
||||
on macOS, Windows, Linux, FreeBSD and the web (WASM), and agents can drive all of it over MCP, the CLI and a JSON
|
||||
@@ -127,6 +127,7 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap
|
||||
|
||||
| Date | What landed |
|
||||
|---|---|
|
||||
| 2026-10-12 | The Rectangular Grid and Polar Grid tools take their options: a click opens the size, the divider counts with a skew slider each, Use Outside Rectangle As Frame (rectangular) or Create Compound Path From Ellipses (polar) and Fill Grid; the choices stay with the tool, so dragging draws with them, and scripts and agents pass them to `shape.rectangularGrid` and `shape.polarGrid` (#1377, in part). |
|
||||
| 2026-10-12 | A `.ai` file with placed (linked) pictures opens from its editing data, with its guides, sublayers and hidden objects: each picture is a linked image in its place, showing the low-resolution preview the file keeps until its file is found or relinked (#1264). Scores unchanged. |
|
||||
| 2026-10-12 | Desktop printing adopts the shared `craft-print` spooler with exact physical sheet sizes. Windows 7 retains its WMI/viewer transport and excludes the shared backend; compatibility CI checks the dependency tree and PE imports. Scores unchanged. |
|
||||
| 2026-10-12 | Graphs: Object › Graph › Design… and Marker… (designs with a preview, New Design, Rename, Delete Design, Paste Design, Select Unused, and markers drawn with a design or None) (#1092); Graph Data opens as a worksheet with Import Data…, column width and decimals (#1138), and takes Transpose, Switch x/y, `|` line breaks in labels and the pie Legend, Position and Sort options (#1115); Graph Type adds Add Drop Shadow and Add Legend Across Top (#1119), widths past 100%, First Row and First Column in Front, and Draw Filled Lines (#1136); graphs keep their label formatting when drawn again (#1137). Align: a Horizontal & Vertical Align Center button (#1073); Align to Key Object makes the topmost selected object the key (#1221), and the palette's key entry reads Cancel Key Object (#1224). Pen: the nearest end wins over snapping, Alt on the last anchor edits it, and Enter, Escape or a tool switch finish a handle preview as one undo step (#821). Direct Selection: a click on an anchor of an already selected path selects that anchor alone (#1237). Make Compound Path keeps the backmost object's paint, and a selected member reads and changes the compound's fill rule (#1225). Shape Builder and Live Paint no longer turn a line's loose end past a crossing into a piece that runs out and back (#1230). Art moved or duplicated with an artboard follows Transform Pattern Tiles (#1219). A click with the Rectangular Grid tool opens its options dialog (#1169). Commands that change nothing (Lock, Hide, Transform Each, Rearrange Artboards and others) add no undo step (#1233). Saving with missing placed files keeps their relink data, and .ai export honours the chosen artboards (#718). SVG export keeps Unicode data-attribute names, and SVG Options refuses an invalid artboard range (#989). CMYK colours given in percent with a component under 1 keep their scale (#1249). Colour Image Trace keeps close dark tones apart (#1247). macOS: the Save sheet has a Format menu (#1235). The Hand and Zoom tools show drawn cursors on every platform (#1238). Czech: the remaining strings are translated, and Pathfinder's Add reads "Sjednotit" (#1226). MCP: a lost reply reports that the edit may have happened instead of replaying it (#1246); `select.key` checks its id and agents can read the key object (#1222). Tests: the command sweeps and import fuzz tests fail on a panic caught by the engine's guard (#1218), and the envelope export test ignores PDF dates and file IDs (#1220). Effect › Artistic › Film Grain (Grain, Highlight Area, Intensity; Photoshop-style filters 29/57) (#1282), and Craquelure, Mosaic Tiles and Stained Glass redraw 25–30% faster with identical pixels (#1265). Direct Selection: clicking a segment selects only that segment, and Delete removes just it (#1300); Shift-click deselects one anchor of a path selected whole (#1263). Pen + Alt converts anchors of paths inside a selected group or compound path (#1306). The Curvature tool shows circular point markers without direction handles (#1284). Snap to Point: pressing near an anchor drags by that anchor, with Smart Guides on or off (#1313). Transform Again repeats the options of the transform it repeats (#1303). Aligning anchors that are already aligned adds no undo step (#1276), and type commands that change nothing record none either (#1316). Simplify keeps the path selected with its new anchors, and its dialog previews and shows the anchor count (#1305). Adding a stroke colour to an unpainted zero-width stroke gives it a visible 1 pt width (#1256). The Eyedropper samples locked objects, template layers and art outside isolation mode (#1260), and shows a pipette cursor (#1288). Fonts named by their full name resolve to the right face (#1267), and Proportional Metrics brings the matching kerning (`apkn`, `vkrn`, `vapk`) (#1291). Artboards: Escape leaves Artboard editing (#1296), Document Setup's Edit Artboards switches to the Artboard tool (#1294), and the Artboard tool starts on the active artboard (#1309). New and imported documents use the Gridline every and Subdivisions preferences (#1314). Send to Current Layer refuses a hidden or locked current layer (#1301), and layer row commands check explicit ids (#1255). Links: Find in Folder… finds missing linked files in a folder and its subfolders and can copy them into the document's folder (#1287). Image Trace traces images over 64 megapixels scaled down instead of refusing them (#1315). DXF export marks closed curves as closed SPLINE entities (#1320). EPS import: closepath respects the path size limit (#1311). The interface is available in Hungarian (#1302), and more Czech labels are translated (#1259). Linux RPMs declare shared-library capabilities (#1290). CI pins every third-party action to a verified commit (#1321). Select menu commands skip hidden and locked art and stay inside the isolated group in isolation mode (#1325). Fill, stroke, gradient and Appearance panel commands that change nothing add no undLine truncated
|
||||
|
||||
@@ -33,8 +33,24 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
cmd!("shape.line", "Line Segment", [], None, "{x1, y1, x2, y2} → {id}", has_doc, line),
|
||||
cmd!("shape.spiral", "Spiral", [], None, "{cx, cy, radius, decay=80 (%), segments=10, clockwise?} → {id}", has_doc, spiral),
|
||||
cmd!("shape.arc", "Arc", [], None, "{x1, y1, x2, y2, closed?} → {id}", has_doc, arc),
|
||||
cmd!("shape.rectangularGrid", "Rectangular Grid", [], None, "{x, y, width, height, rows=5, columns=5} → {id}", has_doc, rect_grid),
|
||||
cmd!("shape.polarGrid", "Polar Grid", [], None, "{x, y, width, height, concentric=5, radial=5} → {id}", has_doc, polar_grid),
|
||||
cmd!(
|
||||
"shape.rectangularGrid",
|
||||
"Rectangular Grid",
|
||||
[],
|
||||
None,
|
||||
"{x, y, width, height, rows=5, columns=5, rowSkew=0, columnSkew=0 (−500…500 %: + crowds the horizontal dividers toward the top, the vertical ones toward the right), frame=true (the outside as one rectangle, else its four sides), fill=false (the current fill, else none)} → {id}",
|
||||
has_doc,
|
||||
rect_grid
|
||||
),
|
||||
cmd!(
|
||||
"shape.polarGrid",
|
||||
"Polar Grid",
|
||||
[],
|
||||
None,
|
||||
"{x, y, width, height, concentric=5, radial=5, concentricSkew=0, radialSkew=0 (−500…500 %: + crowds the ellipses toward the outside, the radial dividers clockwise of the top one), compound=false (the ellipses pair up into compound-path rings), fill=false (the current fill, else none)} → {id}",
|
||||
has_doc,
|
||||
polar_grid
|
||||
),
|
||||
cmd!(
|
||||
"path.create",
|
||||
"Create Path",
|
||||
@@ -351,14 +367,24 @@ fn arc(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
add_art(s, "Arc", path_kind(shapes::arc(a, b, 0.0, bool_or(p, "closed", false)), None), None)
|
||||
}
|
||||
|
||||
fn grid_group(s: &mut Session, label: &str, paths: Vec<PathData>) -> Result<Value> {
|
||||
/// The grid `paths` (bottom first) as one selected group in the new-art stroke, filled with the
|
||||
/// current fill when `fill`; a path of several subpaths becomes a compound path of them.
|
||||
fn grid_group(s: &mut Session, label: &str, paths: Vec<PathData>, fill: bool) -> Result<Value> {
|
||||
let parent = s.doc()?.target_parent()?;
|
||||
let look = s.new_art_look(vectorcraft_color::Paint::None, s.paint.stroke.clone(), s.paint.stroke_width);
|
||||
let fill = if fill { s.paint.fill.clone() } else { vectorcraft_color::Paint::None };
|
||||
let look = s.new_art_look(fill, s.paint.stroke.clone(), s.paint.stroke_width);
|
||||
let id = s.edit(label, |d, sel| {
|
||||
let children = paths
|
||||
.into_iter()
|
||||
.map(|pd| {
|
||||
let mut n = Node::path(d.alloc_id(), pd, Appearance::default());
|
||||
let mut n = if pd.subpaths.len() > 1 {
|
||||
let members =
|
||||
pd.subpaths.into_iter().map(|sp| std::sync::Arc::new(Node::path(d.alloc_id(), PathData::single(sp), Appearance::default())));
|
||||
let children = members.collect();
|
||||
Node::new(d.alloc_id(), NodeKind::Compound { children, rule: FillRule::NonZero })
|
||||
} else {
|
||||
Node::path(d.alloc_id(), pd, Appearance::default())
|
||||
};
|
||||
look.apply(d, &mut n);
|
||||
std::sync::Arc::new(n)
|
||||
})
|
||||
@@ -371,18 +397,43 @@ fn grid_group(s: &mut Session, label: &str, paths: Vec<PathData>) -> Result<Valu
|
||||
Ok(json!({ "id": id.0 }))
|
||||
}
|
||||
|
||||
/// A grid's divider count `count` with its skew `key` in percent (0 when absent, clamped to
|
||||
/// ±[`shapes::GRID_SKEW_MAX`]).
|
||||
fn dividers(p: &Value, count: &str, key: &str, cmd: &str) -> Result<(u32, f64)> {
|
||||
let n = p.get(count).and_then(Value::as_u64).unwrap_or(5).min(999) as u32;
|
||||
let skew = match p.get(key) {
|
||||
None | Some(Value::Null) => 0.0,
|
||||
Some(v) => v.as_f64().filter(|v| v.is_finite()).ok_or_else(|| bad(cmd, format!("`{key}` must be a number")))?,
|
||||
};
|
||||
Ok((n, skew.clamp(-shapes::GRID_SKEW_MAX, shapes::GRID_SKEW_MAX)))
|
||||
}
|
||||
|
||||
/// A grid's on/off option `key` (`default` when absent).
|
||||
fn switch(p: &Value, key: &str, default: bool, cmd: &str) -> Result<bool> {
|
||||
match p.get(key) {
|
||||
None | Some(Value::Null) => Ok(default),
|
||||
Some(v) => v.as_bool().ok_or_else(|| bad(cmd, format!("`{key}` must be true or false"))),
|
||||
}
|
||||
}
|
||||
|
||||
fn rect_grid(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let r = rect_of(p, "shape.rectangularGrid")?;
|
||||
let rows = p.get("rows").and_then(Value::as_u64).unwrap_or(5).min(999) as u32;
|
||||
let cols = p.get("columns").and_then(Value::as_u64).unwrap_or(5).min(999) as u32;
|
||||
grid_group(s, "Rectangular Grid", shapes::rectangular_grid(r, rows, cols, true))
|
||||
const CMD: &str = "shape.rectangularGrid";
|
||||
let r = rect_of(p, CMD)?;
|
||||
let paths =
|
||||
shapes::rectangular_grid(r, dividers(p, "rows", "rowSkew", CMD)?, dividers(p, "columns", "columnSkew", CMD)?, switch(p, "frame", true, CMD)?);
|
||||
grid_group(s, "Rectangular Grid", paths, switch(p, "fill", false, CMD)?)
|
||||
}
|
||||
|
||||
fn polar_grid(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let r = rect_of(p, "shape.polarGrid")?;
|
||||
let c = p.get("concentric").and_then(Value::as_u64).unwrap_or(5).min(999) as u32;
|
||||
let rad = p.get("radial").and_then(Value::as_u64).unwrap_or(5).min(999) as u32;
|
||||
grid_group(s, "Polar Grid", shapes::polar_grid(r, c, rad))
|
||||
const CMD: &str = "shape.polarGrid";
|
||||
let r = rect_of(p, CMD)?;
|
||||
let paths = shapes::polar_grid(
|
||||
r,
|
||||
dividers(p, "concentric", "concentricSkew", CMD)?,
|
||||
dividers(p, "radial", "radialSkew", CMD)?,
|
||||
switch(p, "compound", false, CMD)?,
|
||||
);
|
||||
grid_group(s, "Polar Grid", paths, switch(p, "fill", false, CMD)?)
|
||||
}
|
||||
|
||||
/// An anchor `{x, y, in?, out?, smooth?}` given to `cmd` (handles `[x, y]`, at the anchor when
|
||||
|
||||
@@ -442,6 +442,132 @@ fn line_family_tools_draw_and_click_asks_dialog() {
|
||||
assert_eq!(ui, vec![UiRequest::Dialog("polarGrid".into(), json!({"x": 10.0, "y": 10.0}))]);
|
||||
}
|
||||
|
||||
/// The children of the grid a `shape.rectangularGrid` / `shape.polarGrid` call made, bottom first.
|
||||
fn grid(s: &mut Session, cmd: &str, p: serde_json::Value) -> Vec<std::sync::Arc<vectorcraft_doc::Node>> {
|
||||
let id = NodeId(s.execute(cmd, &p).unwrap()["id"].as_u64().unwrap());
|
||||
s.doc().unwrap().doc.node(id).unwrap().children().unwrap().to_vec()
|
||||
}
|
||||
|
||||
/// `base` with the keys of `extra` added.
|
||||
fn with(base: &serde_json::Value, extra: serde_json::Value) -> serde_json::Value {
|
||||
let mut p = base.clone();
|
||||
p.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone());
|
||||
p
|
||||
}
|
||||
|
||||
fn is_filled(n: &vectorcraft_doc::Node) -> bool {
|
||||
n.appearance.fill().is_some_and(|f| f.paint != vectorcraft_color::Paint::None)
|
||||
}
|
||||
|
||||
/// #1377: the Rectangular Grid options. By default the frame is one rectangle under evenly spaced
|
||||
/// dividers and nothing is filled; `frame: false` draws its four sides instead, `fill` paints the
|
||||
/// current fill and the skews crowd the dividers to the top / right (positive) or bottom / left.
|
||||
#[test]
|
||||
fn rectangular_grid_options_frame_fill_and_skew() {
|
||||
const CMD: &str = "shape.rectangularGrid";
|
||||
let mut s = session();
|
||||
s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap();
|
||||
let base = json!({"x": 0, "y": 0, "width": 100, "height": 100, "rows": 3, "columns": 3});
|
||||
// The y of the three horizontal dividers and the x of the three vertical ones (the last six children).
|
||||
let dividers = |g: &[std::sync::Arc<vectorcraft_doc::Node>]| {
|
||||
let starts: Vec<Point> = g[g.len() - 6..].iter().map(|n| n.path_data().unwrap().subpaths[0].anchors[0].p).collect();
|
||||
starts[..3].iter().map(|p| p.y).chain(starts[3..].iter().map(|p| p.x)).collect::<Vec<_>>()
|
||||
};
|
||||
let plain = grid(&mut s, CMD, base.clone());
|
||||
assert_eq!(plain.len(), 7);
|
||||
assert!(plain[0].path_data().unwrap().is_closed(), "the frame lies under the dividers");
|
||||
assert!(plain.iter().all(|n| !is_filled(n) && n.appearance.stroke().is_some()));
|
||||
assert_eq!(dividers(&plain), [25.0, 50.0, 75.0, 25.0, 50.0, 75.0]);
|
||||
// No frame: its four sides are lines of their own.
|
||||
let sides = grid(&mut s, CMD, with(&base, json!({"frame": false})));
|
||||
assert_eq!(sides.len(), 10);
|
||||
assert!(sides.iter().all(|n| !n.path_data().unwrap().is_closed() && n.path_data().unwrap().anchor_count() == 2));
|
||||
assert_eq!(dividers(&sides), dividers(&plain));
|
||||
let filled = grid(&mut s, CMD, with(&base, json!({"fill": true})));
|
||||
assert!(filled.iter().all(|n| is_filled(n)), "Fill Grid paints the current fill");
|
||||
assert_eq!(filled[0].appearance.fill().unwrap().paint, s.paint.fill);
|
||||
// Skew: toward the top and the right when positive, the bottom and the left when negative;
|
||||
// past ±500 % is ±500 %.
|
||||
let top_right = dividers(&grid(&mut s, CMD, with(&base, json!({"rowSkew": 100, "columnSkew": 100}))));
|
||||
for (got, want) in top_right.iter().zip([6.25, 25.0, 56.25, 43.75, 75.0, 93.75]) {
|
||||
assert!((got - want).abs() < 1e-9, "{top_right:?}");
|
||||
}
|
||||
let bottom_left = dividers(&grid(&mut s, CMD, with(&base, json!({"rowSkew": -100, "columnSkew": -100}))));
|
||||
assert!(bottom_left[1] > 50.0 && bottom_left[4] < 50.0, "{bottom_left:?}");
|
||||
let most = dividers(&grid(&mut s, CMD, with(&base, json!({"rowSkew": 500, "columnSkew": -500}))));
|
||||
let beyond = dividers(&grid(&mut s, CMD, with(&base, json!({"rowSkew": 1e300, "columnSkew": -1e300}))));
|
||||
assert_eq!(most, beyond);
|
||||
assert!(most.iter().all(|v| *v > 0.0 && *v < 100.0), "{most:?}");
|
||||
}
|
||||
|
||||
/// #1377: the Polar Grid options. The ellipses lie under the radial dividers, largest first;
|
||||
/// `compound` pairs them into compound-path rings, `fill` paints the current fill and the skews
|
||||
/// crowd the ellipses outward and the radial dividers clockwise of the top one (positive).
|
||||
#[test]
|
||||
fn polar_grid_options_compound_fill_and_skew() {
|
||||
const CMD: &str = "shape.polarGrid";
|
||||
let mut s = session();
|
||||
s.execute("paint.setFill", &json!({"color": "#00ff00"})).unwrap();
|
||||
let base = json!({"x": 0, "y": 0, "width": 100, "height": 100, "concentric": 3, "radial": 4});
|
||||
let width = |n: &vectorcraft_doc::Node| n.geometric_bounds().unwrap().width();
|
||||
let tips =
|
||||
|g: &[std::sync::Arc<vectorcraft_doc::Node>]| g[4..].iter().map(|n| n.path_data().unwrap().subpaths[0].anchors[1].p).collect::<Vec<_>>();
|
||||
let plain = grid(&mut s, CMD, base.clone());
|
||||
assert_eq!(plain.len(), 8);
|
||||
assert_eq!(plain[..4].iter().map(|n| width(n).round()).collect::<Vec<_>>(), [100.0, 75.0, 50.0, 25.0], "largest at the bottom");
|
||||
assert!(plain[..4].iter().all(|n| n.path_data().unwrap().is_closed()) && plain[4..].iter().all(|n| !n.path_data().unwrap().is_closed()));
|
||||
assert!(plain.iter().all(|n| !is_filled(n)));
|
||||
assert!(grid(&mut s, CMD, with(&base, json!({"fill": true}))).iter().all(|n| is_filled(n)));
|
||||
// Compound: 4 ellipses make 2 rings, each a compound path of an outside and a hole running
|
||||
// against it that carries the grid's paint itself; with 5, the innermost ellipse stays a path.
|
||||
let rings = grid(&mut s, CMD, with(&base, json!({"compound": true, "fill": true})));
|
||||
assert_eq!(rings.len(), 6);
|
||||
for (ring, sizes) in rings[..2].iter().zip([[100.0, 75.0], [50.0, 25.0]]) {
|
||||
let NodeKind::Compound { children, rule } = &ring.kind else { panic!("{:?}", ring.kind) };
|
||||
assert_eq!(*rule, FillRule::NonZero);
|
||||
assert_eq!(children.iter().map(|n| width(n).round()).collect::<Vec<_>>(), sizes);
|
||||
let areas: Vec<f64> = children.iter().map(|n| n.path_data().unwrap().subpaths[0].signed_area()).collect();
|
||||
assert!(areas[0] * areas[1] < 0.0, "the hole runs against the outside: {areas:?}");
|
||||
assert!(is_filled(ring) && ring.appearance.stroke().is_some());
|
||||
}
|
||||
let five = grid(&mut s, CMD, with(&base, json!({"compound": true, "concentric": 4, "radial": 0})));
|
||||
assert_eq!(five.iter().map(|n| matches!(n.kind, NodeKind::Compound { .. })).collect::<Vec<_>>(), [true, true, false]);
|
||||
// Skew.
|
||||
let out = grid(&mut s, CMD, with(&base, json!({"concentricSkew": 100, "radialSkew": 500})));
|
||||
for (n, want) in out[..4].iter().zip([100.0, 93.75, 75.0, 43.75]) {
|
||||
assert!((width(n) - want).abs() < 1e-6, "{}", width(n));
|
||||
}
|
||||
assert!(tips(&out)[0].distance(Point::new(50.0, 0.0)) < 1e-9, "the top divider stays");
|
||||
assert!(tips(&out)[1..].iter().all(|t| t.x > 50.0), "clockwise of the top: {:?}", tips(&out));
|
||||
let inward = grid(&mut s, CMD, with(&base, json!({"concentricSkew": -100, "radialSkew": -500})));
|
||||
assert!((width(&inward[1]) - 56.25).abs() < 1e-6);
|
||||
assert!(tips(&inward)[1..].iter().all(|t| t.x < 50.0), "counterclockwise of the top: {:?}", tips(&inward));
|
||||
}
|
||||
|
||||
/// The grid options are checked: a skew is a number and a switch true or false, or the command
|
||||
/// fails without an undo step.
|
||||
#[test]
|
||||
fn grid_commands_reject_options_of_the_wrong_type() {
|
||||
let mut s = session();
|
||||
let size = json!({"x": 0, "y": 0, "width": 10, "height": 10});
|
||||
for (cmd, key, value) in [
|
||||
("shape.rectangularGrid", "rowSkew", json!("50")),
|
||||
("shape.rectangularGrid", "columnSkew", json!(true)),
|
||||
("shape.rectangularGrid", "frame", json!(1)),
|
||||
("shape.rectangularGrid", "fill", json!("yes")),
|
||||
("shape.polarGrid", "concentricSkew", json!([1])),
|
||||
("shape.polarGrid", "radialSkew", json!({})),
|
||||
("shape.polarGrid", "compound", json!(0)),
|
||||
("shape.polarGrid", "fill", json!("true")),
|
||||
] {
|
||||
let before = s.doc().unwrap().history.undo.len();
|
||||
let p = with(&size, json!({key: value}));
|
||||
assert!(s.execute(cmd, &p).is_err(), "{cmd} {p}");
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), before, "{cmd} left an undo step");
|
||||
assert!(s.execute(cmd, &with(&size, json!({key: null}))).is_ok(), "{cmd}: null is the default");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn draw2_commands_reject_bad_params() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -325,6 +325,19 @@ fn structured_junk() {
|
||||
("object.path.offsetPath", json!({"offset": 1e308})),
|
||||
("object.path.offsetPath", json!({"offset": -1e308})),
|
||||
("object.path.simplify", json!({"tolerance": -1, "cornerAngle": 1e308})),
|
||||
(
|
||||
"shape.rectangularGrid",
|
||||
json!({"x": 0, "y": 0, "width": 1e308, "height": -1e308, "rows": 999, "columns": u64::MAX, "rowSkew": 1e308, "columnSkew": -1e308, "frame": false, "fill": true}),
|
||||
),
|
||||
("shape.rectangularGrid", json!({"x": 0, "y": 0, "width": 0, "height": 0, "rowSkew": "x", "frame": 3})),
|
||||
(
|
||||
"shape.polarGrid",
|
||||
json!({"x": 0, "y": 0, "width": 1e308, "height": 1e-308, "concentric": 999, "radial": 999, "concentricSkew": -1e308, "radialSkew": 1e308, "compound": true, "fill": true}),
|
||||
),
|
||||
(
|
||||
"shape.polarGrid",
|
||||
json!({"x": 0, "y": 0, "width": 10, "height": 10, "concentric": -1, "radial": 0.5, "radialSkew": null, "compound": "yes"}),
|
||||
),
|
||||
("object.path.splitIntoGrid", json!({"rows": 500, "columns": 1, "gutter": 1e308})),
|
||||
("object.path.splitIntoGrid", json!({"rows": 0.4})),
|
||||
("object.setBounds", json!({"width": 0, "height": 0})),
|
||||
|
||||
+129
-18
@@ -208,36 +208,70 @@ pub fn spiral(center: Point, radius: f64, decay_percent: f64, segments: u32, clo
|
||||
PathData::single(sp)
|
||||
}
|
||||
|
||||
/// Rectangular grid as separate open lines (plus an optional frame rectangle).
|
||||
pub fn rectangular_grid(r: Rect, h_dividers: u32, v_dividers: u32, frame: bool) -> Vec<PathData> {
|
||||
/// The largest grid divider skew, in percent either way.
|
||||
pub const GRID_SKEW_MAX: f64 = 500.0;
|
||||
|
||||
/// Where a divider at fraction `t` (0..1) of a grid's extent lands under `skew` percent: evenly
|
||||
/// spaced at 0, crowded toward 1 by a positive skew and toward 0 by a negative one (`t` raised to
|
||||
/// 1 + |skew| / 100 from the far end).
|
||||
fn skewed(t: f64, skew: f64) -> f64 {
|
||||
// NaN reads as no skew.
|
||||
let k = 1.0 + skew.abs().min(GRID_SKEW_MAX) / 100.0;
|
||||
if skew > 0.0 {
|
||||
1.0 - (1.0 - t).powf(k)
|
||||
} else if skew < 0.0 {
|
||||
t.powf(k)
|
||||
} else {
|
||||
t
|
||||
}
|
||||
}
|
||||
|
||||
/// Rectangular grid, bottom object first: the outside as one rectangle (`frame`) or as its four
|
||||
/// sides (top, bottom, left, right), then the horizontal and the vertical dividers as open lines.
|
||||
/// A positive `h_skew` crowds the horizontal dividers toward the top and a positive `v_skew` the
|
||||
/// vertical ones toward the right (percent, see [`GRID_SKEW_MAX`]).
|
||||
pub fn rectangular_grid(r: Rect, (h_dividers, h_skew): (u32, f64), (v_dividers, v_skew): (u32, f64), frame: bool) -> Vec<PathData> {
|
||||
let r = r.abs();
|
||||
let mut out = Vec::new();
|
||||
let (tl, tr, br, bl) = (Point::new(r.x0, r.y0), Point::new(r.x1, r.y0), Point::new(r.x1, r.y1), Point::new(r.x0, r.y1));
|
||||
let mut out = if frame { vec![rectangle(r)] } else { vec![line(tl, tr), line(bl, br), line(tl, bl), line(tr, br)] };
|
||||
for i in 1..=h_dividers {
|
||||
let y = r.y0 + r.height() * i as f64 / (h_dividers + 1) as f64;
|
||||
// From the top down: toward the top is toward 0.
|
||||
let y = r.y0 + r.height() * skewed(i as f64 / (h_dividers + 1) as f64, -h_skew);
|
||||
out.push(line(Point::new(r.x0, y), Point::new(r.x1, y)));
|
||||
}
|
||||
for i in 1..=v_dividers {
|
||||
let x = r.x0 + r.width() * i as f64 / (v_dividers + 1) as f64;
|
||||
let x = r.x0 + r.width() * skewed(i as f64 / (v_dividers + 1) as f64, v_skew);
|
||||
out.push(line(Point::new(x, r.y0), Point::new(x, r.y1)));
|
||||
}
|
||||
if frame {
|
||||
out.push(rectangle(r));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Polar grid: concentric ellipses plus radial dividers.
|
||||
pub fn polar_grid(r: Rect, concentric: u32, radial: u32) -> Vec<PathData> {
|
||||
/// Polar grid, bottom object first: the concentric ellipses from the outside in, then the radial
|
||||
/// dividers from the top one clockwise. A positive `c_skew` crowds the ellipses toward the outside
|
||||
/// and a positive `r_skew` the radial dividers clockwise of the top one (percent, see
|
||||
/// [`GRID_SKEW_MAX`]). With `compound`, the ellipses pair up from the outside in into rings (two
|
||||
/// subpaths, the inner one reversed so that it is a hole under either fill rule); an odd one out
|
||||
/// is the innermost ellipse alone.
|
||||
pub fn polar_grid(r: Rect, (concentric, c_skew): (u32, f64), (radial, r_skew): (u32, f64), compound: bool) -> Vec<PathData> {
|
||||
let r = r.abs();
|
||||
let c = r.center();
|
||||
let mut out = Vec::new();
|
||||
for i in 1..=concentric + 1 {
|
||||
let f = i as f64 / (concentric + 1) as f64;
|
||||
out.push(ellipse(Rect::from_center_size(c, (r.width() * f, r.height() * f))));
|
||||
let mut out: Vec<PathData> = Vec::new();
|
||||
for i in (1..=concentric + 1).rev() {
|
||||
let f = skewed(i as f64 / (concentric + 1) as f64, c_skew);
|
||||
let mut e = ellipse(Rect::from_center_size(c, (r.width() * f, r.height() * f)));
|
||||
// Every second ellipse from the outside is the hole of the one before it.
|
||||
match out.last_mut().filter(|_| compound && (concentric + 1 - i) % 2 == 1) {
|
||||
Some(ring) => {
|
||||
e.reverse();
|
||||
ring.subpaths.append(&mut e.subpaths);
|
||||
}
|
||||
None => out.push(e),
|
||||
}
|
||||
}
|
||||
// No dividers at 0 (the loop never divides by it).
|
||||
for i in 0..radial {
|
||||
let a = -PI / 2.0 + TAU * i as f64 / radial as f64;
|
||||
// The turn runs clockwise on screen: clockwise of the top is toward its start.
|
||||
let a = -PI / 2.0 + TAU * skewed(i as f64 / radial as f64, -r_skew);
|
||||
out.push(line(c, Point::new(c.x + r.width() / 2.0 * a.cos(), c.y + r.height() / 2.0 * a.sin())));
|
||||
}
|
||||
out
|
||||
@@ -401,11 +435,88 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn grids() {
|
||||
assert_eq!(rectangular_grid(Rect::new(0.0, 0.0, 10.0, 10.0), 5, 5, true).len(), 11);
|
||||
assert_eq!(polar_grid(Rect::new(0.0, 0.0, 10.0, 10.0), 5, 5).len(), 11);
|
||||
let r = Rect::new(0.0, 0.0, 10.0, 10.0);
|
||||
assert_eq!(rectangular_grid(r, (5, 0.0), (5, 0.0), true).len(), 11);
|
||||
assert_eq!(polar_grid(r, (5, 0.0), (5, 0.0), false).len(), 11);
|
||||
// #1008: no radial dividers is none, only the outer ellipse without concentric ones.
|
||||
for (radial, len) in [(0, 1), (1, 2), (2, 3)] {
|
||||
assert_eq!(polar_grid(Rect::new(0.0, 0.0, 10.0, 10.0), 0, radial).len(), len, "{radial}");
|
||||
assert_eq!(polar_grid(r, (0, 0.0), (radial, 0.0), false).len(), len, "{radial}");
|
||||
}
|
||||
}
|
||||
|
||||
fn close(got: &[f64], want: &[f64]) -> bool {
|
||||
got.len() == want.len() && got.iter().zip(want).all(|(a, b)| (a - b).abs() < 1e-9)
|
||||
}
|
||||
|
||||
/// The first anchor of each of `paths`.
|
||||
fn starts(paths: &[PathData]) -> Vec<Point> {
|
||||
paths.iter().map(|p| p.subpaths[0].anchors[0].p).collect()
|
||||
}
|
||||
|
||||
/// #1377: the frame is one rectangle or its four sides, at the bottom; a skew moves the
|
||||
/// dividers toward the top / right (positive) or the bottom / left, in order and inside the
|
||||
/// grid, and none leaves them evenly spaced.
|
||||
#[test]
|
||||
fn a_rectangular_grid_frames_and_skews_its_dividers() {
|
||||
let r = Rect::new(0.0, 0.0, 100.0, 100.0);
|
||||
let framed = rectangular_grid(r, (3, 0.0), (1, 0.0), true);
|
||||
assert!(framed[0].is_closed() && framed[1..].iter().all(|p| !p.is_closed()));
|
||||
assert_eq!(starts(&framed[1..]), [Point::new(0.0, 25.0), Point::new(0.0, 50.0), Point::new(0.0, 75.0), Point::new(50.0, 0.0)]);
|
||||
let sides = rectangular_grid(r, (3, 0.0), (1, 0.0), false);
|
||||
assert_eq!(sides.len(), 8);
|
||||
assert!(sides.iter().all(|p| !p.is_closed() && p.anchor_count() == 2));
|
||||
assert_eq!(sides[4..], framed[1..]);
|
||||
for (skew, toward_start) in [(100.0, true), (-100.0, false), (GRID_SKEW_MAX, true), (-1e9, false), (f64::INFINITY, true)] {
|
||||
let g = rectangular_grid(r, (3, skew), (3, -skew), true);
|
||||
let ys: Vec<f64> = starts(&g[1..4]).iter().map(|p| p.y).collect();
|
||||
let xs: Vec<f64> = starts(&g[4..]).iter().map(|p| p.x).collect();
|
||||
for v in [&ys, &xs] {
|
||||
assert!(v.windows(2).all(|w| w[0] < w[1]) && v[0] > 0.0 && v[2] < 100.0, "{skew}: {v:?}");
|
||||
// Toward the top (y) and the left (x) is toward 0: the middle divider passes the middle.
|
||||
assert_eq!(v[1] < 50.0, toward_start, "{skew}: {v:?}");
|
||||
}
|
||||
assert_eq!(ys, xs, "one curve for both axes");
|
||||
}
|
||||
// 100 % squares the fraction from the side the dividers crowd to: 1/4, 1/2 and 3/4 from the top.
|
||||
let g = rectangular_grid(r, (3, 100.0), (0, 0.0), true);
|
||||
assert!(close(&starts(&g[1..]).iter().map(|p| p.y).collect::<Vec<_>>(), &[6.25, 25.0, 56.25]), "{g:?}");
|
||||
assert_eq!(rectangular_grid(r, (3, f64::NAN), (1, f64::NAN), true), framed);
|
||||
}
|
||||
|
||||
/// #1377: the ellipses come from the outside in and skew toward the outside (positive) or the
|
||||
/// centre; the radial dividers keep the top one and skew clockwise (positive) of it or
|
||||
/// counterclockwise; as compound paths the ellipses pair up into rings.
|
||||
#[test]
|
||||
fn a_polar_grid_skews_its_dividers_and_pairs_ellipses_into_rings() {
|
||||
let r = Rect::new(0.0, 0.0, 100.0, 100.0);
|
||||
let widths = |g: &[PathData], n: usize| g[..n].iter().map(|p| p.bounds().unwrap().width()).collect::<Vec<_>>();
|
||||
for (skew, want) in [(0.0, [100.0, 75.0, 50.0, 25.0]), (100.0, [100.0, 93.75, 75.0, 43.75]), (-100.0, [100.0, 56.25, 25.0, 6.25])] {
|
||||
let got = widths(&polar_grid(r, (3, skew), (0, 0.0), false), 4);
|
||||
assert!(close(&got, &want), "{skew}: {got:?}");
|
||||
}
|
||||
// Radial: four dividers point up, right, down and left; skewed, the first stays up and the
|
||||
// other three all point to the right (clockwise of it) or to the left.
|
||||
let ends = |skew: f64| polar_grid(r, (0, 0.0), (4, skew), false)[1..].iter().map(|p| p.subpaths[0].anchors[1].p).collect::<Vec<_>>();
|
||||
let even = ends(0.0);
|
||||
for (p, want) in even.iter().zip([(50.0, 0.0), (100.0, 50.0), (50.0, 100.0), (0.0, 50.0)]) {
|
||||
assert!(p.distance(Point::new(want.0, want.1)) < 1e-9, "{even:?}");
|
||||
}
|
||||
for (skew, right) in [(GRID_SKEW_MAX, true), (-GRID_SKEW_MAX, false)] {
|
||||
let e = ends(skew);
|
||||
assert!(e[0].distance(even[0]) < 1e-9);
|
||||
assert!(e[1..].iter().all(|p| (p.x > 50.0) == right && (p.x - 50.0).abs() > 1e-6), "{skew}: {e:?}");
|
||||
}
|
||||
// Compound: 4 ellipses are 2 rings, 5 are 2 rings and the innermost ellipse; each ring's
|
||||
// hole runs against its outside.
|
||||
for (concentric, shapes) in [(3, vec![2, 2]), (4, vec![2, 2, 1]), (0, vec![1])] {
|
||||
let g = polar_grid(r, (concentric, 0.0), (2, 0.0), true);
|
||||
assert_eq!(g.len(), shapes.len() + 2);
|
||||
assert_eq!(g[..shapes.len()].iter().map(|p| p.subpaths.len()).collect::<Vec<_>>(), shapes);
|
||||
for ring in g.iter().filter(|p| p.subpaths.len() == 2) {
|
||||
assert!(ring.is_closed());
|
||||
assert!(ring.subpaths[0].signed_area() * ring.subpaths[1].signed_area() < 0.0);
|
||||
assert!(ring.subpaths[0].signed_area().abs() > ring.subpaths[1].signed_area().abs());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -27,6 +27,14 @@ pub struct FamilyTool {
|
||||
pub rows: u32,
|
||||
/// Columns / radial dividers.
|
||||
pub columns: u32,
|
||||
/// The grids' divider skews in percent: rows / concentric, then columns / radial.
|
||||
pub skew: [f64; 2],
|
||||
/// Rectangular Grid: the outside is one rectangle (else its four sides).
|
||||
pub frame: bool,
|
||||
/// Polar Grid: the ellipses pair up into compound paths.
|
||||
pub compound: bool,
|
||||
/// The grids take the current fill.
|
||||
pub fill: bool,
|
||||
snap: DrawSnap,
|
||||
}
|
||||
|
||||
@@ -50,6 +58,10 @@ impl FamilyTool {
|
||||
clockwise: true,
|
||||
rows: 5,
|
||||
columns: 5,
|
||||
skew: [0.0; 2],
|
||||
frame: true,
|
||||
compound: false,
|
||||
fill: false,
|
||||
snap: DrawSnap::default(),
|
||||
}
|
||||
}
|
||||
@@ -79,19 +91,14 @@ impl FamilyTool {
|
||||
json!({"cx": start.x, "cy": start.y, "radius": r, "decay": self.decay, "segments": self.segments, "clockwise": self.clockwise}),
|
||||
)
|
||||
}
|
||||
"rectangularGrid" => {
|
||||
// The grids: the dragged box with the tool's options.
|
||||
"rectangularGrid" | "polarGrid" => {
|
||||
let r = drag_rect(start, p, m);
|
||||
(
|
||||
"shape.rectangularGrid".into(),
|
||||
json!({"x": r.x0, "y": r.y0, "width": r.width(), "height": r.height(), "rows": self.rows, "columns": self.columns}),
|
||||
)
|
||||
}
|
||||
"polarGrid" => {
|
||||
let r = drag_rect(start, p, m);
|
||||
(
|
||||
"shape.polarGrid".into(),
|
||||
json!({"x": r.x0, "y": r.y0, "width": r.width(), "height": r.height(), "concentric": self.rows, "radial": self.columns}),
|
||||
)
|
||||
let mut params = self.options();
|
||||
if let Some(o) = params.as_object_mut() {
|
||||
o.extend([("x", r.x0), ("y", r.y0), ("width", r.width()), ("height", r.height())].map(|(k, v)| (k.to_string(), json!(v))));
|
||||
}
|
||||
(format!("shape.{}", self.id), params)
|
||||
}
|
||||
_ => {
|
||||
let d = if m.shift { square(p - start) } else { p - start };
|
||||
@@ -206,8 +213,12 @@ impl Tool for FamilyTool {
|
||||
fn options(&self) -> Value {
|
||||
match self.id {
|
||||
"spiral" => json!({"decay": self.decay, "segments": self.segments, "clockwise": self.clockwise}),
|
||||
"rectangularGrid" => json!({"rows": self.rows, "columns": self.columns}),
|
||||
"polarGrid" => json!({"concentric": self.rows, "radial": self.columns}),
|
||||
"rectangularGrid" => json!({
|
||||
"rows": self.rows, "columns": self.columns, "rowSkew": self.skew[0], "columnSkew": self.skew[1], "frame": self.frame, "fill": self.fill,
|
||||
}),
|
||||
"polarGrid" => json!({
|
||||
"concentric": self.rows, "radial": self.columns, "concentricSkew": self.skew[0], "radialSkew": self.skew[1], "compound": self.compound, "fill": self.fill,
|
||||
}),
|
||||
_ => json!({"closed": self.closed}),
|
||||
}
|
||||
}
|
||||
@@ -219,11 +230,22 @@ impl Tool for FamilyTool {
|
||||
"clockwise" => self.clockwise = v.as_bool().unwrap_or(self.clockwise),
|
||||
"rows" | "concentric" => self.rows = v.as_u64().unwrap_or(5).min(999) as u32,
|
||||
"columns" | "radial" => self.columns = v.as_u64().unwrap_or(5).min(999) as u32,
|
||||
"rowSkew" | "concentricSkew" => self.skew[0] = grid_skew(v, self.skew[0]),
|
||||
"columnSkew" | "radialSkew" => self.skew[1] = grid_skew(v, self.skew[1]),
|
||||
"frame" => self.frame = v.as_bool().unwrap_or(self.frame),
|
||||
"compound" => self.compound = v.as_bool().unwrap_or(self.compound),
|
||||
"fill" => self.fill = v.as_bool().unwrap_or(self.fill),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A grid divider skew from option value `v`: a finite number within the range, else `current`.
|
||||
fn grid_skew(v: &Value, current: f64) -> f64 {
|
||||
let max = vectorcraft_geom::shapes::GRID_SKEW_MAX;
|
||||
v.as_f64().filter(|s| s.is_finite()).map_or(current, |s| s.clamp(-max, max))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -243,7 +265,10 @@ mod tests {
|
||||
assert_eq!(a[0], Action::Begin("Rectangular Grid".into()));
|
||||
assert_eq!(
|
||||
a[1],
|
||||
Action::Preview("shape.rectangularGrid".into(), json!({"x": 10.0, "y": 10.0, "width": 50.0, "height": 30.0, "rows": 5, "columns": 5}))
|
||||
Action::Preview(
|
||||
"shape.rectangularGrid".into(),
|
||||
json!({"x": 10.0, "y": 10.0, "width": 50.0, "height": 30.0, "rows": 5, "columns": 5, "rowSkew": 0.0, "columnSkew": 0.0, "frame": true, "fill": false})
|
||||
)
|
||||
);
|
||||
let a = t.key(&cx, ToolKey::Right, Mods::default());
|
||||
assert!(matches!(&a[0], Action::Preview(_, v) if v["columns"] == 6));
|
||||
@@ -256,6 +281,36 @@ mod tests {
|
||||
assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 90.0, 60.0)), vec![Action::Commit]);
|
||||
}
|
||||
|
||||
/// #1377: a drag draws with the tool's stored options, which keep to their types and range.
|
||||
#[test]
|
||||
fn a_grid_drag_uses_the_stored_options() {
|
||||
let mut t = FamilyTool::new("polarGrid");
|
||||
for (key, value) in [
|
||||
("concentric", json!(2)),
|
||||
("concentricSkew", json!(-40)),
|
||||
("radialSkew", json!(9000)),
|
||||
("compound", json!(true)),
|
||||
("fill", json!(true)),
|
||||
// Not this tool's, and not values: ignored.
|
||||
("frame", json!("no")),
|
||||
("concentricSkew", json!("far")),
|
||||
("fill", json!(null)),
|
||||
] {
|
||||
t.set_option(key, &value);
|
||||
}
|
||||
let options = json!({"concentric": 2, "radial": 5, "concentricSkew": -40.0, "radialSkew": 500.0, "compound": true, "fill": true});
|
||||
assert_eq!(t.options(), options);
|
||||
let (command, params) = t.command(Point::new(10.0, 20.0), Point::new(40.0, 80.0), Mods::default());
|
||||
assert_eq!(command, "shape.polarGrid");
|
||||
let mut want = options;
|
||||
want.as_object_mut().unwrap().extend(json!({"x": 10.0, "y": 20.0, "width": 30.0, "height": 60.0}).as_object().unwrap().clone());
|
||||
assert_eq!(params, want);
|
||||
let mut t = FamilyTool::new("rectangularGrid");
|
||||
t.set_option("frame", &json!(false));
|
||||
t.set_option("rowSkew", &json!(12.5));
|
||||
assert_eq!(t.options(), json!({"rows": 5, "columns": 5, "rowSkew": 12.5, "columnSkew": 0.0, "frame": false, "fill": false}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn click_opens_dialog_and_arc_shift() {
|
||||
let (d, _) = doc_with_rect();
|
||||
|
||||
@@ -27,8 +27,8 @@ const ROWS: &[Row] = &[
|
||||
(&["puppetWarp"], None, &["showMesh", "expand"]),
|
||||
(&["arc"], None, &["closed"]),
|
||||
(&["spiral"], None, &["decay", "segments", "clockwise"]),
|
||||
(&["rectangularGrid"], None, &["rows", "columns"]),
|
||||
(&["polarGrid"], None, &["concentric", "radial"]),
|
||||
(&["rectangularGrid"], None, &["rows", "columns", "rowSkew", "columnSkew", "frame", "fill"]),
|
||||
(&["polarGrid"], None, &["concentric", "radial", "concentricSkew", "radialSkew", "compound", "fill"]),
|
||||
(&["pencil", "paintbrush"], None, &["fidelity", "fill", "editWithin", "closeWithin"]),
|
||||
(&["smooth"], None, &["fidelity"]),
|
||||
(&["blobBrush", "eraser"], None, &["size"]),
|
||||
|
||||
@@ -67,13 +67,25 @@ fn order(kind: &str) -> &'static [&'static str] {
|
||||
match kind {
|
||||
"offsetPath" => &["offset", "joins", "miterLimit"],
|
||||
"splitIntoGrid" => &["rows", "columns", "gutter"],
|
||||
"rectangularGrid" => &["width", "height", "rows", "columns"],
|
||||
"polarGrid" => &["width", "height", "concentric", "radial"],
|
||||
"rectangularGrid" => &["width", "height", "rows", "rowSkew", "columns", "columnSkew"],
|
||||
"polarGrid" => &["width", "height", "concentric", "concentricSkew", "radial", "radialSkew"],
|
||||
"artboardOptions" => &["name", "width", "height"],
|
||||
_ => &[],
|
||||
}
|
||||
}
|
||||
|
||||
/// The form dialog ([`grid`]) fields that are a grid tool's divider skew, a slider in percent: the
|
||||
/// words at the two ends of its rail (where the dividers crowd to), in the UI language.
|
||||
fn skew_ends(kind: &str, key: &str) -> Option<(&'static str, &'static str)> {
|
||||
match (kind, key) {
|
||||
("rectangularGrid", "rowSkew") => Some((tl!("Bottom"), tl!("Top"))),
|
||||
// The radial dividers gather on that side of the top one.
|
||||
("rectangularGrid", "columnSkew") | ("polarGrid", "radialSkew") => Some((tl!("Left"), tl!("Right"))),
|
||||
("polarGrid", "concentricSkew") => Some((tl!("Inside"), tl!("Outside"))),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The choices of a form dialog's ([`grid`]) dropdown field, as (value, label in the UI
|
||||
/// language); empty for a field typed as text.
|
||||
fn choices(kind: &str, key: &str) -> Vec<(&'static str, &'static str)> {
|
||||
@@ -163,8 +175,10 @@ fn dropdown(ui: &mut egui::Ui, d: &mut Dialog, key: &str, width: f32, options: &
|
||||
}
|
||||
|
||||
/// The generic dialog body: a field per non-boolean value (positions, indices and UI-only keys
|
||||
/// hidden) in the kind's [`order`], the distances in `unit`, the [`choices`] as dropdowns.
|
||||
/// hidden) in the kind's [`order`], the distances in `unit`, the [`choices`] as dropdowns and the
|
||||
/// [`skew_ends`] fields as sliders.
|
||||
pub(super) fn grid(ui: &mut egui::Ui, d: &mut Dialog, unit: Unit) {
|
||||
let rail = Tokens::get(ui.ctx()).input_border;
|
||||
let (lengths, first) = (lengths(&d.kind), order(&d.kind));
|
||||
let mut keys: Vec<String> = d
|
||||
.fields
|
||||
@@ -179,6 +193,14 @@ pub(super) fn grid(ui: &mut egui::Ui, d: &mut Dialog, unit: Unit) {
|
||||
let options = choices(&d.kind, &k);
|
||||
if lengths.contains(&k.as_str()) {
|
||||
length_field(ui, d, &k, &humanized(&k), unit);
|
||||
} else if let Some(ends) = skew_ends(&d.kind, &k) {
|
||||
let max = vectorcraft_geom::shapes::GRID_SKEW_MAX;
|
||||
row_label(ui, tl!("Skew:"));
|
||||
ui.vertical(|ui| {
|
||||
ui.horizontal(|ui| slider_field(ui, d, &k, -max..=max, "%", &|_| rail));
|
||||
rail_ends(ui, ends);
|
||||
});
|
||||
ui.end_row();
|
||||
} else if options.is_empty() {
|
||||
let label = match (d.kind.as_str(), k.as_str()) {
|
||||
("rectangularGrid", "rows") => tl!("Horizontal Dividers:").into(),
|
||||
@@ -465,17 +487,22 @@ pub(super) fn slider_rail(ui: &mut egui::Ui, d: &mut Dialog, key: &str, range: s
|
||||
|
||||
/// The words at the two ends of a slider's rail (Less … More), under a slider whose label column
|
||||
/// is `label_w` wide.
|
||||
pub(super) fn slider_ends(ui: &mut egui::Ui, label_w: f32, (left, right): (&str, &str)) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
pub(super) fn slider_ends(ui: &mut egui::Ui, label_w: f32, ends: (&str, &str)) {
|
||||
ui.horizontal(|ui| {
|
||||
ui.add_space(label_w + ui.spacing().item_spacing.x);
|
||||
let (r, _) = ui.allocate_exact_size(egui::vec2(SLIDER_WIDTH, 14.0), egui::Sense::hover());
|
||||
let font = egui::FontId::proportional(11.0);
|
||||
ui.painter().text(r.left_center(), egui::Align2::LEFT_CENTER, left, font.clone(), t.text_dim);
|
||||
ui.painter().text(r.right_center(), egui::Align2::RIGHT_CENTER, right, font, t.text_dim);
|
||||
rail_ends(ui, ends);
|
||||
});
|
||||
}
|
||||
|
||||
/// The words of [`slider_ends`] under a rail that starts where the next widget does.
|
||||
fn rail_ends(ui: &mut egui::Ui, (left, right): (&str, &str)) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let (r, _) = ui.allocate_exact_size(egui::vec2(SLIDER_WIDTH, 14.0), egui::Sense::hover());
|
||||
let font = egui::FontId::proportional(11.0);
|
||||
ui.painter().text(r.left_center(), egui::Align2::LEFT_CENTER, left, font.clone(), t.text_dim);
|
||||
ui.painter().text(r.right_center(), egui::Align2::RIGHT_CENTER, right, font, t.text_dim);
|
||||
}
|
||||
|
||||
/// The field where [`preview`] keeps the parameters it last previewed.
|
||||
pub(super) const PREVIEWED: &str = "__previewed";
|
||||
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
//! Shape size dialogs (a click with a shape tool): Rectangle, Rounded Rectangle, Ellipse, Polygon,
|
||||
//! Star and Line Segment Tool Options.
|
||||
//! Star, Line Segment Tool Options and the two grids (size, divider counts and skews, switches).
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{DialogSpec, run_and_close};
|
||||
use super::{DialogSpec, form, run_and_close};
|
||||
use crate::VectorcraftApp;
|
||||
use crate::state::Dialog;
|
||||
|
||||
pub(super) const SPEC: DialogSpec = DialogSpec { heading: |d| title(&d.kind).into(), confirm, ..DialogSpec::FORM };
|
||||
pub(super) const SPEC: DialogSpec = DialogSpec { heading: |d| title(&d.kind).into(), body, confirm, ..DialogSpec::FORM };
|
||||
|
||||
fn title(kind: &str) -> &'static str {
|
||||
match kind {
|
||||
@@ -22,6 +22,26 @@ fn title(kind: &str) -> &'static str {
|
||||
}
|
||||
}
|
||||
|
||||
/// The grids' divider counts and skews: (rows / concentric, columns / radial).
|
||||
fn grid_keys(kind: &str) -> ([&'static str; 2], [&'static str; 2]) {
|
||||
if kind == "polarGrid" { (["concentric", "radial"], ["concentricSkew", "radialSkew"]) } else { (["rows", "columns"], ["rowSkew", "columnSkew"]) }
|
||||
}
|
||||
|
||||
/// The form's fields, then a grid's switches.
|
||||
fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
|
||||
form::grid(ui, d, app.session.general_unit());
|
||||
let own = match d.kind.as_str() {
|
||||
"rectangularGrid" => ("frame", tl!("Use Outside Rectangle As Frame")),
|
||||
"polarGrid" => ("compound", tl!("Create Compound Path From Ellipses")),
|
||||
_ => return false,
|
||||
};
|
||||
ui.add_space(8.0);
|
||||
for (key, label) in [own, ("fill", tl!("Fill Grid"))] {
|
||||
form::check(ui, d, key, label);
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
|
||||
let (x, y) = (d.f64("x", 0.0), d.f64("y", 0.0));
|
||||
let (id, params) = match d.kind.as_str() {
|
||||
@@ -31,16 +51,18 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
|
||||
"shape.star",
|
||||
json!({"cx": x, "cy": y, "radius1": d.f64("radius1", 50.0), "radius2": d.f64("radius2", 25.0), "points": d.f64("points", 5.0) as u64}),
|
||||
),
|
||||
"rectangularGrid" => (
|
||||
"shape.rectangularGrid",
|
||||
json!({"x": x, "y": y, "width": d.f64("width", 100.0), "height": d.f64("height", 100.0),
|
||||
"rows": (d.f64("rows", 5.0) as u64).min(999), "columns": (d.f64("columns", 5.0) as u64).min(999)}),
|
||||
),
|
||||
"polarGrid" => (
|
||||
"shape.polarGrid",
|
||||
json!({"x": x, "y": y, "width": d.f64("width", 100.0), "height": d.f64("height", 100.0),
|
||||
"concentric": (d.f64("concentric", 5.0) as u64).min(999), "radial": (d.f64("radial", 5.0) as u64).min(999)}),
|
||||
),
|
||||
kind @ ("rectangularGrid" | "polarGrid") => {
|
||||
// The switches as they are; the typed fields as numbers.
|
||||
let (mut p, (counts, skews)) = (form::params(d), grid_keys(kind));
|
||||
for (k, default) in [("width", 100.0), ("height", 100.0)] {
|
||||
p[k] = json!(d.f64(k, default));
|
||||
}
|
||||
for (count, skew) in counts.into_iter().zip(skews) {
|
||||
p[count] = json!((d.f64(count, 5.0) as u64).min(999));
|
||||
p[skew] = json!(d.f64(skew, 0.0));
|
||||
}
|
||||
(if kind == "polarGrid" { "shape.polarGrid" } else { "shape.rectangularGrid" }, p)
|
||||
}
|
||||
"lineSegment" => {
|
||||
let (l, a) = (d.f64("length", 100.0), d.f64("angle", 0.0).to_radians());
|
||||
("shape.line", json!({"x1": x, "y1": y, "x2": x + l * a.cos(), "y2": y - l * a.sin()}))
|
||||
|
||||
@@ -729,6 +729,54 @@ fn polar_grid_shared_canvas_click_defaults_units_counts_and_history() {
|
||||
assert_eq!(app.session.doc().unwrap().doc, made);
|
||||
}
|
||||
|
||||
/// #1377: Polar Grid's dialog has a skew slider under each divider count, Create Compound Path
|
||||
/// From Ellipses and Fill Grid, opened with the tool's stored values; OK draws with them and
|
||||
/// stores them.
|
||||
#[test]
|
||||
fn polar_grid_dialog_edits_skews_compound_and_fill() {
|
||||
use vectorcraft_doc::{NodeId, NodeKind};
|
||||
let mut app = app();
|
||||
app.run("paint.setFill", json!({"color": "#ff0000"})).unwrap();
|
||||
app.run("tool.setOption", json!({"tool": "polarGrid", "values": {"concentric": 3, "radial": 4, "radialSkew": 500}})).unwrap();
|
||||
open_tool_dialog(&mut app, "polarGrid", json!({"x": 10, "y": 20}));
|
||||
let text = crate::tests_labels::painted_text(&mut app, |app, ui| show(app, ui.ctx()));
|
||||
let labels = [
|
||||
"Concentric Dividers:",
|
||||
"Skew:",
|
||||
"Inside",
|
||||
"Outside",
|
||||
"Radial Dividers:",
|
||||
"Skew:",
|
||||
"Left",
|
||||
"Right",
|
||||
"Create Compound Path From Ellipses",
|
||||
"Fill Grid",
|
||||
];
|
||||
let mut from = 0;
|
||||
for label in labels {
|
||||
from += text[from..].find(&format!("{label}\n")).unwrap_or_else(|| panic!("no {label:?} in order in {text}")) + label.len();
|
||||
}
|
||||
assert!(!text.contains("Use Outside Rectangle As Frame"), "{text}");
|
||||
let d = app.ui.dialog.as_mut().unwrap();
|
||||
assert_eq!((d.fields["concentricSkew"].clone(), d.fields["radialSkew"].clone()), (json!(0.0), json!(500.0)));
|
||||
assert_eq!((d.fields["compound"].clone(), d.fields["fill"].clone()), (json!(false), json!(false)));
|
||||
for (key, value) in [("concentricSkew", json!(100)), ("compound", json!(true)), ("fill", json!(true))] {
|
||||
d.fields.insert(key.into(), value);
|
||||
}
|
||||
let result = confirm(&mut app).unwrap();
|
||||
let s = app.session.doc().unwrap();
|
||||
let children = s.doc.node(NodeId(result["id"].as_u64().unwrap())).unwrap().children().unwrap();
|
||||
// Two rings under four radial dividers, all in the current fill.
|
||||
assert_eq!(children.iter().map(|n| matches!(n.kind, NodeKind::Compound { .. })).collect::<Vec<_>>(), [true, true, false, false, false, false]);
|
||||
assert!(children.iter().all(|n| n.appearance.fill().is_some_and(|f| f.paint != vectorcraft_color::Paint::None)));
|
||||
let widths: Vec<f64> = children[0].children().unwrap().iter().map(|n| n.geometric_bounds().unwrap().width()).collect();
|
||||
assert!((widths[0] - 100.0).abs() < 1e-6 && (widths[1] - 93.75).abs() < 1e-6, "outward: {widths:?}");
|
||||
let tips: Vec<f64> = children[3..].iter().map(|n| n.path_data().unwrap().subpaths[0].anchors[1].p.x).collect();
|
||||
assert!(tips.iter().all(|x| *x > 60.0), "right of the top divider: {tips:?}");
|
||||
let saved = json!({"concentric": 3, "radial": 4, "concentricSkew": 100.0, "radialSkew": 500.0, "compound": true, "fill": true});
|
||||
assert_eq!(app.session.tool_options_of("polarGrid"), saved);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn polar_grid_shared_cancel_reopen_zero_counts_and_failed_creation() {
|
||||
let mut app = app();
|
||||
|
||||
@@ -85,11 +85,17 @@ fn assert_path(path: &PathData, expected: &[Point], closed: bool) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Independent coordinate oracle: counts mean interior dividers, plus one closed outer frame.
|
||||
/// Independent coordinate oracle: one closed outer frame at the bottom, then the interior
|
||||
/// dividers the counts mean.
|
||||
fn assert_grid(app: &VectorcraftApp, id: NodeId, bounds: Rect, rows: u32, columns: u32) {
|
||||
let paths = paths(app, id);
|
||||
assert_eq!(paths.len(), (rows + columns + 1) as usize);
|
||||
let mut remaining = paths.iter();
|
||||
assert_path(
|
||||
remaining.next().unwrap(),
|
||||
&[Point::new(bounds.x0, bounds.y0), Point::new(bounds.x1, bounds.y0), Point::new(bounds.x1, bounds.y1), Point::new(bounds.x0, bounds.y1)],
|
||||
true,
|
||||
);
|
||||
for i in 1..=rows {
|
||||
let y = bounds.y0 + bounds.height() * f64::from(i) / f64::from(rows + 1);
|
||||
assert_path(remaining.next().unwrap(), &[Point::new(bounds.x0, y), Point::new(bounds.x1, y)], false);
|
||||
@@ -98,11 +104,6 @@ fn assert_grid(app: &VectorcraftApp, id: NodeId, bounds: Rect, rows: u32, column
|
||||
let x = bounds.x0 + bounds.width() * f64::from(i) / f64::from(columns + 1);
|
||||
assert_path(remaining.next().unwrap(), &[Point::new(x, bounds.y0), Point::new(x, bounds.y1)], false);
|
||||
}
|
||||
assert_path(
|
||||
remaining.next().unwrap(),
|
||||
&[Point::new(bounds.x0, bounds.y0), Point::new(bounds.x1, bounds.y0), Point::new(bounds.x1, bounds.y1), Point::new(bounds.x0, bounds.y1)],
|
||||
true,
|
||||
);
|
||||
assert!(remaining.next().is_none());
|
||||
}
|
||||
|
||||
@@ -196,7 +197,8 @@ fn rectangular_grid_creates_exact_dividers_as_one_undo_redo_step() {
|
||||
assert_eq!(app.session.doc().unwrap().doc, before.doc);
|
||||
assert_eq!(app.session.doc().unwrap().selection.objects, before.selected);
|
||||
assert_eq!(app.session.doc().unwrap().history.undo.len(), before.undo);
|
||||
assert_eq!(app.session.tool_options_of(KIND), json!({"rows": 2, "columns": 3}), "undo does not undo the saved tool settings");
|
||||
let saved = json!({"rows": 2, "columns": 3, "rowSkew": 0.0, "columnSkew": 0.0, "frame": true, "fill": false});
|
||||
assert_eq!(app.session.tool_options_of(KIND), saved, "undo does not undo the saved tool settings");
|
||||
app.run("edit.redo", json!({})).unwrap();
|
||||
assert_eq!(app.session.doc().unwrap().doc, created);
|
||||
assert_grid(&app, id, Rect::new(125.0, 75.0, 245.0, 135.0), 2, 3);
|
||||
@@ -273,16 +275,16 @@ fn rectangular_grid_perspective_click_projects_each_anchor_and_is_one_undo_step(
|
||||
values(&mut app, json!(96), json!(54), json!(1), json!(2));
|
||||
let id = confirm(&mut app);
|
||||
let expected = [
|
||||
vec![at, Point::new(at.x + 96.0, at.y), Point::new(at.x + 96.0, at.y + 54.0), Point::new(at.x, at.y + 54.0)],
|
||||
vec![Point::new(at.x, at.y + 27.0), Point::new(at.x + 96.0, at.y + 27.0)],
|
||||
vec![Point::new(at.x + 32.0, at.y), Point::new(at.x + 32.0, at.y + 54.0)],
|
||||
vec![Point::new(at.x + 64.0, at.y), Point::new(at.x + 64.0, at.y + 54.0)],
|
||||
vec![at, Point::new(at.x + 96.0, at.y), Point::new(at.x + 96.0, at.y + 54.0), Point::new(at.x, at.y + 54.0)],
|
||||
];
|
||||
let actual = paths(&app, id);
|
||||
assert_eq!(actual.len(), expected.len());
|
||||
for (index, (path, points)) in actual.iter().zip(expected).enumerate() {
|
||||
let projected: Vec<Point> = points.into_iter().map(|p| map.apply(p).unwrap()).collect();
|
||||
assert_path(path, &projected, index == 3);
|
||||
assert_path(path, &projected, index == 0);
|
||||
}
|
||||
let state = app.session.doc().unwrap();
|
||||
assert_eq!(PerspectiveGrid::effective(&state.doc).attachment_of(&state.doc, id), Some((Plane::Right, 0.0)));
|
||||
@@ -311,3 +313,63 @@ fn rectangular_grid_failed_creation_does_not_save_counts() {
|
||||
assert_eq!(app.session.tool_options_of(KIND), before.options);
|
||||
assert_eq!(app.session.doc().unwrap().history.undo.len(), before.undo);
|
||||
}
|
||||
|
||||
/// #1377: the dialog shows the tool's stored skews (sliders under their divider counts) and
|
||||
/// switches; OK draws with them and stores them, and the next drag uses them too.
|
||||
#[test]
|
||||
fn rectangular_grid_dialog_edits_skews_frame_and_fill() {
|
||||
let mut app = app();
|
||||
app.run("paint.setFill", json!({"color": "#ff0000"})).unwrap();
|
||||
app.run("tool.setOption", json!({"tool": KIND, "values": {"rowSkew": 100, "frame": false}})).unwrap();
|
||||
click(&mut app, Point::new(10.0, 20.0));
|
||||
assert_eq!((field(&app, "rowSkew"), field(&app, "columnSkew")), (json!(100.0), json!(0.0)));
|
||||
assert_eq!((field(&app, "frame"), field(&app, "fill")), (json!(false), json!(false)));
|
||||
let text = crate::tests_labels::painted_text(&mut app, |app, ui| super::show(app, ui.ctx()));
|
||||
let labels = [
|
||||
"Horizontal Dividers:",
|
||||
"Skew:",
|
||||
"Bottom",
|
||||
"Top",
|
||||
"Vertical Dividers:",
|
||||
"Skew:",
|
||||
"Left",
|
||||
"Right",
|
||||
"Use Outside Rectangle As Frame",
|
||||
"Fill Grid",
|
||||
];
|
||||
let mut from = 0;
|
||||
for label in labels {
|
||||
from += text[from..].find(&format!("{label}\n")).unwrap_or_else(|| panic!("no {label:?} in order in {text}")) + label.len();
|
||||
}
|
||||
assert!(!text.contains("Row Skew") && !text.contains("false"), "the skews and switches aren't plain fields: {text}");
|
||||
values(&mut app, json!(100), json!(100), json!(3), json!(1));
|
||||
set(&mut app, "columnSkew", json!("-250"));
|
||||
set(&mut app, "fill", json!(true));
|
||||
let id = confirm(&mut app);
|
||||
let paths = paths(&app, id);
|
||||
// No frame: four sides, then the dividers, crowded toward the top (100 %: 1/16, 1/4 and 9/16 down).
|
||||
assert_eq!(paths.len(), 8);
|
||||
assert!(paths.iter().all(|p| !p.is_closed()));
|
||||
for (path, y) in paths[4..7].iter().zip([6.25, 25.0, 56.25]) {
|
||||
assert_path(path, &[Point::new(10.0, 20.0 + y), Point::new(110.0, 20.0 + y)], false);
|
||||
}
|
||||
let x = paths[7].subpaths[0].anchors[0].p.x;
|
||||
assert!(x > 10.0 && x < 30.0, "toward the left: {x}");
|
||||
let saved = json!({"rows": 3, "columns": 1, "rowSkew": 100.0, "columnSkew": -250.0, "frame": false, "fill": true});
|
||||
assert_eq!(app.session.tool_options_of(KIND), saved);
|
||||
// A drag draws with the same options.
|
||||
pointer(&mut app, PointerKind::Down, Point::new(300.0, 300.0));
|
||||
pointer(&mut app, PointerKind::Drag, Point::new(400.0, 400.0));
|
||||
pointer(&mut app, PointerKind::Up, Point::new(400.0, 400.0));
|
||||
let dragged = app.session.doc().unwrap().selection.objects[0];
|
||||
assert_ne!(dragged, id);
|
||||
let doc = &app.session.doc().unwrap().doc;
|
||||
let offset =
|
||||
|id: NodeId| doc.node(id).unwrap().children().unwrap().iter().map(|n| n.path_data().unwrap().subpaths[0].anchors[0].p).collect::<Vec<_>>();
|
||||
for (a, b) in offset(id).iter().zip(offset(dragged)) {
|
||||
assert!((b.x - a.x - 290.0).abs() < 1e-9 && (b.y - a.y - 280.0).abs() < 1e-9, "{a:?} {b:?}");
|
||||
}
|
||||
assert!(
|
||||
doc.node(dragged).unwrap().children().unwrap().iter().all(|n| n.appearance.fill().is_some_and(|f| f.paint != vectorcraft_color::Paint::None))
|
||||
);
|
||||
}
|
||||
@@ -16,16 +16,13 @@ pub fn open_tool_dialog(app: &mut VectorcraftApp, kind: &str, p: Value) {
|
||||
"polygon" => Dialog::new(kind, json!({"x": x, "y": y, "radius": "50 pt", "sides": 6})),
|
||||
"star" => Dialog::new(kind, json!({"x": x, "y": y, "radius1": "50 pt", "radius2": "25 pt", "points": 5})),
|
||||
"lineSegment" => Dialog::new(kind, json!({"x": x, "y": y, "length": "100 pt", "angle": 0})),
|
||||
"rectangularGrid" => {
|
||||
let options = app.session.tool_options_of(kind);
|
||||
Dialog::new(kind, json!({"x": x, "y": y, "width": "100 pt", "height": "100 pt", "rows": options["rows"], "columns": options["columns"]}))
|
||||
}
|
||||
"polarGrid" => {
|
||||
let options = app.session.tool_options_of(kind);
|
||||
Dialog::new(
|
||||
kind,
|
||||
json!({"x": x, "y": y, "width": "100 pt", "height": "100 pt", "concentric": options["concentric"], "radial": options["radial"]}),
|
||||
)
|
||||
// The grids: the tool's stored options (divider counts and skews, switches) at the default size.
|
||||
"rectangularGrid" | "polarGrid" => {
|
||||
let mut fields = app.session.tool_options_of(kind);
|
||||
for (key, value) in [("x", json!(x)), ("y", json!(y)), ("width", json!("100 pt")), ("height", json!("100 pt"))] {
|
||||
fields[key] = value;
|
||||
}
|
||||
Dialog::new(kind, fields)
|
||||
}
|
||||
// Graph tool click: the graph's size.
|
||||
"graph" => Dialog::new(
|
||||
|
||||
@@ -4502,3 +4502,10 @@ mark-style Japanese Japonské
|
||||
@msg the file stopped being read (please report this bug) čtení souboru se přerušilo (nahlaste prosím tuto chybu)
|
||||
Concentric Dividers: Soustředné dělicí čáry:
|
||||
Radial Dividers: Radiální dělicí čáry:
|
||||
# Grid tool options (the Rectangular Grid and Polar Grid click dialogs).
|
||||
Skew: Zkosení:
|
||||
Inside Uvnitř
|
||||
Outside Vně
|
||||
Use Outside Rectangle As Frame Použít vnější obdélník jako rámeček
|
||||
Create Compound Path From Ellipses Vytvořit složenou cestu z elips
|
||||
Fill Grid Vyplnit mřížku
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -4153,3 +4153,10 @@ theme Appearance Erscheinungsbild
|
||||
@msg the file stopped being read (please report this bug) das Lesen der Datei wurde abgebrochen (bitte melden Sie diesen Fehler)
|
||||
Concentric Dividers: Konzentrische Trennlinien:
|
||||
Radial Dividers: Radiale Trennlinien:
|
||||
# Grid tool options (the Rectangular Grid and Polar Grid click dialogs).
|
||||
Skew: Neigung:
|
||||
Inside Innen
|
||||
Outside Außen
|
||||
Use Outside Rectangle As Frame Äußeres Rechteck als Rahmen verwenden
|
||||
Create Compound Path From Ellipses Zusammengesetzten Pfad aus Ellipsen erstellen
|
||||
Fill Grid Raster füllen
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -4196,6 +4196,13 @@ lighting Light Luz
|
||||
@msg the file stopped being read (please report this bug) se dejó de leer el archivo (informe de este error)
|
||||
Concentric Dividers: Divisiones concéntricas:
|
||||
Radial Dividers: Divisiones radiales:
|
||||
# Grid tool options (the Rectangular Grid and Polar Grid click dialogs).
|
||||
Skew: Sesgo:
|
||||
Inside Dentro
|
||||
Outside Fuera
|
||||
Use Outside Rectangle As Frame Usar rectángulo exterior como marco
|
||||
Create Compound Path From Ellipses Crear trazado compuesto a partir de elipses
|
||||
Fill Grid Rellenar cuadrícula
|
||||
|
||||
# Rows other catalogs already had: document and print presets, keyboard-shortcut editor, print and
|
||||
# PDF options, arrowheads, harmony rules, Save for Web, graphic styles and file filters.
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -4206,3 +4206,10 @@ lighting Light Lumière
|
||||
@msg the file stopped being read (please report this bug) la lecture du fichier s’est interrompue (veuillez signaler ce bogue)
|
||||
Concentric Dividers: Divisions concentriques :
|
||||
Radial Dividers: Divisions radiales :
|
||||
# Grid tool options (the Rectangular Grid and Polar Grid click dialogs).
|
||||
Skew: Inclinaison :
|
||||
Inside Intérieur
|
||||
Outside Extérieur
|
||||
Use Outside Rectangle As Frame Utiliser le rectangle extérieur comme cadre
|
||||
Create Compound Path From Ellipses Créer un tracé transparent à partir des ellipses
|
||||
Fill Grid Remplir la grille
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -4596,3 +4596,10 @@ theme Appearance Kinézet
|
||||
@msg the file stopped being read (please report this bug) a fájl beolvasása megszakadt (kérjük, jelentsd ezt a hibát)
|
||||
Concentric Dividers: Koncentrikus osztók:
|
||||
Radial Dividers: Sugárirányú osztók:
|
||||
# Grid tool options (the Rectangular Grid and Polar Grid click dialogs).
|
||||
Skew: Ferdítés:
|
||||
Inside Belül
|
||||
Outside Kívül
|
||||
Use Outside Rectangle As Frame Külső téglalap használata keretként
|
||||
Create Compound Path From Ellipses Összetett útvonal létrehozása az ellipszisekből
|
||||
Fill Grid Rács kitöltése
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -4198,3 +4198,10 @@ lighting Light Luce
|
||||
@msg the file stopped being read (please report this bug) la lettura del file si è interrotta (segnala questo errore)
|
||||
Concentric Dividers: Divisori concentrici:
|
||||
Radial Dividers: Divisori radiali:
|
||||
# Grid tool options (the Rectangular Grid and Polar Grid click dialogs).
|
||||
Skew: Inclinazione:
|
||||
Inside Interno
|
||||
Outside Esterno
|
||||
Use Outside Rectangle As Frame Usa rettangolo esterno come cornice
|
||||
Create Compound Path From Ellipses Crea tracciato composto dalle ellissi
|
||||
Fill Grid Riempi griglia
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -4192,3 +4192,10 @@ lighting Light ライト
|
||||
@msg the file stopped being read (please report this bug) ファイルの読み込みが中断されました (このバグを報告してください)
|
||||
Concentric Dividers: 同心円の分割線:
|
||||
Radial Dividers: 放射状の分割線:
|
||||
# Grid tool options (the Rectangular Grid and Polar Grid click dialogs).
|
||||
Skew: 歪み:
|
||||
Inside 内側
|
||||
Outside 外側
|
||||
Use Outside Rectangle As Frame 外側の長方形をフレームとして使用
|
||||
Create Compound Path From Ellipses 楕円形から複合パスを作成
|
||||
Fill Grid グリッドを塗りつぶす
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -4162,3 +4162,10 @@ lighting Light 광원
|
||||
@msg the file stopped being read (please report this bug) 파일 읽기가 중단되었습니다(이 버그를 신고해 주세요)
|
||||
Concentric Dividers: 동심원 구분선:
|
||||
Radial Dividers: 방사형 구분선:
|
||||
# Grid tool options (the Rectangular Grid and Polar Grid click dialogs).
|
||||
Skew: 기울임:
|
||||
Inside 안쪽
|
||||
Outside 바깥쪽
|
||||
Use Outside Rectangle As Frame 바깥쪽 사각형을 프레임으로 사용
|
||||
Create Compound Path From Ellipses 원에서 컴파운드 패스 만들기
|
||||
Fill Grid 격자 칠하기
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -3679,3 +3679,10 @@ axis Both Ambos
|
||||
@msg the file stopped being read (please report this bug) a leitura do arquivo foi interrompida (informe este erro)
|
||||
Concentric Dividers: Divisórias concêntricas:
|
||||
Radial Dividers: Divisórias radiais:
|
||||
# Grid tool options (the Rectangular Grid and Polar Grid click dialogs).
|
||||
Skew: Inclinação:
|
||||
Inside Dentro
|
||||
Outside Fora
|
||||
Use Outside Rectangle As Frame Usar Retângulo Externo como Moldura
|
||||
Create Compound Path From Ellipses Criar Caminho Composto a partir das Elipses
|
||||
Fill Grid Preencher Grade
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -4194,3 +4194,10 @@ lighting Light Свет
|
||||
@msg the file stopped being read (please report this bug) чтение файла прервалось (пожалуйста, сообщите об этой ошибке)
|
||||
Concentric Dividers: Концентрические разделители:
|
||||
Radial Dividers: Радиальные разделители:
|
||||
# Grid tool options (the Rectangular Grid and Polar Grid click dialogs).
|
||||
Skew: Скос:
|
||||
Inside Внутри
|
||||
Outside Снаружи
|
||||
Use Outside Rectangle As Frame Использовать внешний прямоугольник как рамку
|
||||
Create Compound Path From Ellipses Создать составной контур из эллипсов
|
||||
Fill Grid Залить сетку
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -4178,3 +4178,10 @@ lighting Light Світло
|
||||
@msg the file stopped being read (please report this bug) читання файлу перервалося (будь ласка, повідомте про цю помилку)
|
||||
Concentric Dividers: Концентричні розділювачі:
|
||||
Radial Dividers: Радіальні розділювачі:
|
||||
# Grid tool options (the Rectangular Grid and Polar Grid click dialogs).
|
||||
Skew: Скіс:
|
||||
Inside Усередині
|
||||
Outside Зовні
|
||||
Use Outside Rectangle As Frame Використовувати зовнішній прямокутник як рамку
|
||||
Create Compound Path From Ellipses Створити складений контур з еліпсів
|
||||
Fill Grid Залити сітку
|
||||
|
Can't render this file because it contains an unexpected character in line 659 and column 40.
|
@@ -3790,3 +3790,10 @@ lighting Light 光源
|
||||
Highlight Area 高光区域
|
||||
Concentric Dividers: 同心分隔线:
|
||||
Radial Dividers: 径向分隔线:
|
||||
# Grid tool options (the Rectangular Grid and Polar Grid click dialogs).
|
||||
Skew: 倾斜:
|
||||
Inside 内侧
|
||||
Outside 外侧
|
||||
Use Outside Rectangle As Frame 使用外部矩形作为框架
|
||||
Create Compound Path From Ellipses 从椭圆形创建复合路径
|
||||
Fill Grid 网格填色
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -3571,3 +3571,10 @@ lighting Light 光源
|
||||
Highlight Area 亮部範圍
|
||||
Concentric Dividers: 同心分隔線:
|
||||
Radial Dividers: 徑向分隔線:
|
||||
# Grid tool options (the Rectangular Grid and Polar Grid click dialogs).
|
||||
Skew: 傾斜:
|
||||
Inside 內側
|
||||
Outside 外側
|
||||
Use Outside Rectangle As Frame 使用外部矩形作為框架
|
||||
Create Compound Path From Ellipses 從橢圓形建立複合路徑
|
||||
Fill Grid 格線填色
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -1,6 +1,6 @@
|
||||
# Parity with Adobe Illustrator
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (`.ai` placed (linked) files read from the editing data, #1264, scores unchanged; gradient shapes import as paths, #1304; white constant alpha masks, #1286 in part; Live Corners widget click selects its corner, #1239; scores and hours unchanged; custom ruler origin, #1212; Artboard click-to-fit verified for objects, groups, visible bounds, undo and cancellation (#1227); scores and hours unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; Localization: Korean catalog added, 54% → 63%, full readiness unchanged at ~62%; desktop print transport adopts craft-print, Windows 7 fallback retained, scores unchanged; Image Trace: an image over 64 megapixels traces scaled down instead of being refused, scores unchanged; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; Path operations: Simplify preview/counts and component selection (#1272); scores unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; Platforms: RPM runtime capabilities and glibc floor (#1275); scores unchanged; Raster effects: squared-distance texture redraws (#1257); pixels unchanged; scores and hours unchanged; Drawing: Curvature point display improved (#1236, in part); scores unchanged; Type core: full-name font matching (#1258), scores and hours unchanged; Selection: no-op anchor Align preserves history and saved state (#1180 follow-up); scores unchanged; UI chrome: Eyedropper cursor and precise-setting regressions (#1269); scores unchanged; Colour: Eyedropper reads visible locked and outside-isolation sources (#1251); scores and hours unchanged; Selection: segment click/Delete preserves neighboring edges (#1278); scores unchanged; Colour: unpainted zero-width strokes regain 1 pt when painted (#1253); scores and hours unchanged; graphs: designs and markers, data, Graph Type and label options; UI chrome: native macOS save Format menu, #866, scores unchanged; compound paths inherit the backmost appearance and member fill-rule edits reach their owner, #1197; scores unchanged; Type core: Middle Eastern digit types done, Character panel menu › Digits; PDF export warns when the pixel cap lowers raster-effect resolution; Drawing tools: Live Corners round corners between curved sides, #1144; Type advanced: proportional vertical metrics (`vpal`) done, #966; Shape Builder: #893 fixed, merged regions of crossing lines no longer keep seams; scores unchanged while #937 is open; #1034: document-point character controls after object/group scaling; full readiness recomputed as the standard's additive weighted sum, ~62%; per-audience hours; essentials-user score ~75% added; ready-for-real-work re-examined: 50% → 55%, mainstream 65% added; earlier the same day: full re-measure against Illustrator 2026 30.x; merged the ROADMAP's parity estimate, honest assessment and "Shipped so far" into this file) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (grid tool options, #1377 in part, scores unchanged; `.ai` placed (linked) files read from the editing data, #1264, scores unchanged; gradient shapes import as paths, #1304; white constant alpha masks, #1286 in part; Live Corners widget click selects its corner, #1239; scores and hours unchanged; custom ruler origin, #1212; Artboard click-to-fit verified for objects, groups, visible bounds, undo and cancellation (#1227); scores and hours unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; Localization: Korean catalog added, 54% → 63%, full readiness unchanged at ~62%; desktop print transport adopts craft-print, Windows 7 fallback retained, scores unchanged; Image Trace: an image over 64 megapixels traces scaled down instead of being refused, scores unchanged; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; Path operations: Simplify preview/counts and component selection (#1272); scores unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; Platforms: RPM runtime capabilities and glibc floor (#1275); scores unchanged; Raster effects: squared-distance texture redraws (#1257); pixels unchanged; scores and hours unchanged; Drawing: Curvature point display improved (#1236, in part); scores unchanged; Type core: full-name font matching (#1258), scores and hours unchanged; Selection: no-op anchor Align preserves history and saved state (#1180 follow-up); scores unchanged; UI chrome: Eyedropper cursor and precise-setting regressions (#1269); scores unchanged; Colour: Eyedropper reads visible locked and outside-isolation sources (#1251); scores and hours unchanged; Selection: segment click/Delete preserves neighboring edges (#1278); scores unchanged; Colour: unpainted zero-width strokes regain 1 pt when painted (#1253); scores and hours unchanged; graphs: designs and markers, data, Graph Type and label options; UI chrome: native macOS save Format menu, #866, scores unchanged; compound paths inherit the backmost appearance and member fill-rule edits reach their owner, #1197; scores unchanged; Type core: Middle Eastern digit types done, Character panel menu › Digits; PDF export warns when the pixel cap lowers raster-effect resolution; Drawing tools: Live Corners round corners between curved sides, #1144; Type advanced: proportional vertical metrics (`vpal`) done, #966; Shape Builder: #893 fixed, merged regions of crossing lines no longer keep seams; scores unchanged while #937 is open; #1034: document-point character controls after object/group scaling; full readiness recomputed as the standard's additive weighted sum, ~62%; per-audience hours; essentials-user score ~75% added; ready-for-real-work re-examined: 50% → 55%, mainstream 65% added; earlier the same day: full re-measure against Illustrator 2026 30.x; merged the ROADMAP's parity estimate, honest assessment and "Shipped so far" into this file) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026)
|
||||
|
||||
The authoritative parity assessment. [`ROADMAP.md`](../ROADMAP.md) summarizes it; [`gaps.md`](gaps.md) is
|
||||
the work list made from it. Deep areas have their own checklists: [UI and interaction](ui-parity.md),
|
||||
@@ -429,7 +429,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
|
||||
- **Saved selections:** Select ▸ Save Selection… asks for a name and keeps the selection in the document (saved with the file, undoable, up to 25); each one is listed at the bottom of the Select menu and a click selects its objects again; Edit Selection… lists them to rename or delete, applied together on OK. A file's saved selections are capped and checked against its objects when it opens. Not yet: Update Selection and the Layers panel's saved-selection menu.
|
||||
- **Drawing:** Pen, Curvature, anchor tools, Pencil, Paintbrush, Blob Brush, Smooth, Path Eraser, Join. Direction handles dragged with Direct Selection or the Anchor Point tool snap to smart guides, and Shift keeps them at 45° steps from their anchor. The Pen continues any open path from the end clicked, and while drawing, a click on an end of another open path joins the two into one (#776). The Curvature tool edits any selected path, whatever drew it, keeping its shape except where edited (#798): a click on a point makes it current and a drag moves it, re-curving only its two segments; Alt-click or double-click toggles it smooth or corner and Delete removes it; a click on a segment adds a point without changing the shape; with an end of an open path current, clicks go on from it and its other end closes it (`path.curvatureEdit`). Every point a drawing tool places snaps to Smart Guides through one snapper (#506): the Pen's and Curvature tool's anchors, the Pencil's ends, the shape tools' start and dragged corner or radius (Rectangle, Rounded Rectangle, Ellipse, Polygon, Star, Line Segment, Arc, Spiral, the grids), the Type tool's click and frame, a new artboard's corners.
|
||||
Curvature point markers are circular, with no direction handles. While drawing, its own point feedback replaces generic selection markers; existing paths use the selection overlay, including partial selections and Hide Edges. Anchor Size applies, and Direct Selection and Pen retain their handles. The real egui-frame regressions in `crates/ui-egui/src/canvas.rs` verify marker positions, counts and unchanged documents (#1236, in part); the existing-path interpolation request remains open.
|
||||
- **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. While a grid is dragged out, Up/Down change its rows (concentric dividers) and Left/Right its columns (radial dividers). 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; the on-canvas pie widget is still to come). 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. 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; the on-canvas pie widget is still to come). 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).
|
||||
- **Cutting:** Eraser, Scissors, Knife, Mirror & Cut, Line Cut and Rectangle Cut (real geometry, compound paths keep their holes).
|
||||
- **Transform:** Rotate, Reflect, Scale, Shear (click or Alt-click snaps the reference point to anchors and centres; Alt-click opens the tool's dialog, and a drag with Alt down at the release, from the press or pressed mid-drag, transforms a copy), Reshape, Free Transform (free and perspective distort and shear, switched by the keys held during a handle drag).
|
||||
Align, Distribute, Distribute Spacing, Reflect and Shear return an error for any `horizontal`, `vertical`, `to`, `axis`, `bounds` or `spacing` value they don't take, listing the choices, and Align returns one when a call gives neither `horizontal` nor `vertical`; nothing moves and no undo step is recorded.
|
||||
@@ -519,6 +519,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-12 | minor | Rectangular Grid and Polar Grid tool options (skew, frame, compound path, Fill Grid; #1377, in part); skew curve our own, no reference point locator; scores and hours unchanged (estimated) |
|
||||
| 2026-10-12 | minor | File formats: placed (linked) files in a `.ai`'s editing data come in as linked images showing their saved preview, so such a file opens through its layers with its guides (#1264); own-fixture reader, lexer and link tests; score and hours unchanged (estimated) |
|
||||
| 2026-10-12 | minor | File formats: gradient-filled shapes import as paths (#1304); constant alpha soft masks export white for luminosity readers (#1286, in part); scores and hours unchanged (estimated) |
|
||||
| 2026-10-12 | minor | Polar Grid click dialog (#1192); `.ai` images on hidden lines read (#1264, in part); Affinity rectangles keep per-corner radii and cut corners (#666, in part); Tools panel buttons centred (#1363); Properties panel width with Image Trace (#1358, in part); scores and hours unchanged (estimated) |
|
||||
|
||||
+3
-1
@@ -1,6 +1,6 @@
|
||||
# UI and interaction parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (Live Corners: a Direct Selection click on a widget selects its corner, #1239; custom ruler origin, #1212; Artboard tool clicks fit the topmost editable object in one undo step (#1227); scores unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; Simplify preview/counts and valid rewritten-path selection (#1272); parity remains partial; Texture-effect drag redraws optimized (#1257); idle Windows interaction still unverified; scores unchanged; Curvature point display improved (#1236, in part); scores unchanged; Direct Selection: no-op anchor Align preserves Undo, Redo and saved state (#1180 follow-up); Eyedropper cursor distinguished from precise crosshair (#1269); scores unchanged; Eyedropper can read visible locked and outside-isolation sources (#1251); scores unchanged; Direct Selection: independent segment picks and Delete (#1278); scores unchanged; Adding paint to an unpainted zero-width stroke restores the 1 pt default (#1253); scores unchanged; macOS save sheets offer a Format menu, #866; Attributes fill-rule controls edit the compound when a member is selected, #1197; a marquee needs to touch a path, not just lie inside its fill, #1103; earlier: Live Corners round corners between curved sides, #1144) · **Target:** Adobe Illustrator 2026 (30.x)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (Rectangular Grid and Polar Grid click dialogs take the skew, frame, compound path and Fill Grid options, #1377 in part, score unchanged; Live Corners: a Direct Selection click on a widget selects its corner, #1239; custom ruler origin, #1212; Artboard tool clicks fit the topmost editable object in one undo step (#1227); scores unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; Simplify preview/counts and valid rewritten-path selection (#1272); parity remains partial; Texture-effect drag redraws optimized (#1257); idle Windows interaction still unverified; scores unchanged; Curvature point display improved (#1236, in part); scores unchanged; Direct Selection: no-op anchor Align preserves Undo, Redo and saved state (#1180 follow-up); Eyedropper cursor distinguished from precise crosshair (#1269); scores unchanged; Eyedropper can read visible locked and outside-isolation sources (#1251); scores unchanged; Direct Selection: independent segment picks and Delete (#1278); scores unchanged; Adding paint to an unpainted zero-width stroke restores the 1 pt default (#1253); scores unchanged; macOS save sheets offer a Format menu, #866; Attributes fill-rule controls edit the compound when a member is selected, #1197; a marquee needs to touch a path, not just lie inside its fill, #1103; earlier: Live Corners round corners between curved sides, #1144) · **Target:** Adobe Illustrator 2026 (30.x)
|
||||
|
||||
How VectorCraft's tools, handles, snapping and panels behave next to Illustrator's. A vector illustrator's
|
||||
trust is in the details: where a handle lands, what Shift and Alt do mid-drag, what a smart guide snaps to,
|
||||
@@ -124,6 +124,7 @@ tools, never checked side by side) isn't captured row by row.
|
||||
| Field precision: 3 decimals for steps; values kept as f64 | done | |
|
||||
| Scrubby labels (beyond Illustrator) | done | #400 |
|
||||
| Dialogs open with the first field focused and selected; Enter confirms | done | |
|
||||
| Rectangular Grid and Polar Grid click dialogs: size, divider counts with a skew slider each, Use Outside Rectangle As Frame / Create Compound Path From Ellipses, Fill Grid; kept as the tool's options, used by drags too | partial | #1377. The options are the documented ones; how far a skew percentage moves the dividers and which way the radial skew turns are our own choice (the public documentation gives neither), and the reference point locator and a double-click on the tool are missing |
|
||||
|
||||
## Modifier keys and temporary tools
|
||||
|
||||
@@ -186,6 +187,7 @@ re-measure of the panels added since.
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-12 | minor | Rectangular Grid and Polar Grid tool options (skew, frame, compound path, Fill Grid; #1377, in part): a skew slider under each divider count, kept as tool options and used by drags; the skew curve is our own; score unchanged (estimated) |
|
||||
| 2026-10-12 | minor | Live Corners: a Direct Selection click on a widget selects its corner, Shift-click toggles it (#1239); #908 and #1232 stay open; score unchanged (estimated) |
|
||||
| 2026-10-12 | minor | Custom ruler origin (#1212): corner drag previews and sets it; double-click resets to the active artboard; X/Y fields use it and native files keep it; scores unchanged (estimated) |
|
||||
| 2026-10-12 | minor | Artboard tool clicks fit the topmost editable object in one undo step (#1227); scores unchanged |
|
||||
|
||||
Reference in new issue
Block a user