Compare commits

..
Author SHA1 Message Date
bflatasticandClaude Opus 5.5 c3719b2e79 #1163: MCP render_preview takes an artboard index
render_preview only rendered the first artboard, so an agent couldn't
look at pages 2 and on of a multi-artboard document without switching
to screenshot. It now takes an optional 0-based `artboard` (as
screenshot does) and refuses an index the document doesn't have.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 18:13:26 +07:00
BFlat 9aed6191d4 Merge pull request #1176 from storytold/fix/1163-release-subpaths
#1163: Release splits a path of several subpaths into one path each
2026-10-11 17:58:25 +07:00
bflatasticandClaude Opus 5.5 9b12fff215 #1163: Release splits a path of several subpaths into one path each
A path of several subpaths (an SVG `d` with several `M`s, PDF paths, a
path drawn in one go over MCP) is a compound path in Illustrator, and
Object › Compound Path › Release splits it into separate paths. Here
Release only acted on compound path objects, so such a path couldn't be
broken apart. Release now gives each subpath a path of its own, with the
path's paint, in stacking order; the first keeps the path's id. One undo
step, as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 17:58:13 +07:00
BFlat e428f38767 Merge pull request #1175 from storytold/fix/1158-alt-click-shows-clicked-layer
#1158: Alt-click on a layer's eye or lock isolates that layer
2026-10-11 17:50:46 +07:00
bflatasticandClaude Opus 5.5 da735914b6 #1158: Alt-click on a layer's eye or lock isolates that layer
Hide Others, Lock Others and Outline Others left the layer they keep as
it was, so Alt-clicking the eye of a hidden layer hid everything and
Alt-clicking a locked layer's lock left it locked. As Illustrator's
Layers panel does, the layer clicked now shows (unlocks, previews) while
the others hide (lock, outline), in the same undo step.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 17:50:35 +07:00
BFlat 2adf4465d6 Merge pull request #1174 from storytold/fix/1111-bend-blend-spine
#1111: a blend's spine bends into a curve
2026-10-11 17:49:59 +07:00
bflatasticandClaude Opus 5.5 d3c997329d #1111: a blend's spine bends into a curve
The Anchor Point tool drags any segment of a selected blend's spine into
a curve, straight ones too, and Direct Selection reshapes a curved spine
segment as it does a path's (a straight one it leaves, as on paths). Both
run the new `object.blend.spine.reshapeSegment`, which bends the segment
with the same math as `path.reshapeSegment`, now shared as
`SubPath::bend_segment`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 17:49:41 +07:00
BFlat 1637165600 Merge pull request #1171 from storytold/fix/1111-mesh-selection-grid
#1111: a selected gradient mesh shows its mesh lines and points
2026-10-11 17:36:34 +07:00
bflatasticandClaude Opus 5.5 1f5073e4d1 #1111: a selected gradient mesh shows its mesh lines and points
Only the Mesh tool drew a gradient mesh's grid, so with the Selection
or Direct Selection tool a selected mesh showed no outline and no
points, and its points couldn't be seen to be clicked. The selection
highlight now draws a gradient mesh's lines and points, as it already
did for mesh envelopes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 17:36:23 +07:00
BFlat 288e93c11f Merge pull request #1170 from storytold/fix/1151-xwayland-drops
#1151: Preferences › Performance › Linux Display Server opens the window through XWayland, where file drops work
2026-10-11 17:29:43 +07:00
bflatasticandClaude Opus 5.5 a809f270eb #1151: Preferences › Performance › Linux display server opens the window through Xwayland, where file drops work
winit 0.30 delivers no file drops on native Wayland. The new Linux Display
Server preference (Automatic / X11 (XWayland), shown on Linux only, read at
startup) has winit open the window on X11 when the session is Wayland,
DISPLAY is set and the X11 libraries winit loads are installed (ldconfig
check ported from PhotoCraft); otherwise the window opens on Wayland and
the log says why. README, development notes, parity docs, ROADMAP and the
complete catalogs updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 17:29:23 +07:00
BFlat 22ff29167e Merge pull request #1167 from storytold/fix/1103-marquee-inside-shape
#1103: a marquee selects only the objects whose paths it touches
2026-10-11 17:16:44 +07:00
bflatasticandClaude Opus 5.5 171c382618 #1103: a marquee selects only the objects whose paths it touches
A marquee drawn wholly inside a filled shape counted as touching it, so
a marquee over a background (the usual way to pick small objects off it
in Outline view) selected the background too. As in Illustrator, the
marquee now needs the path itself: an anchor inside it or a segment
crossing its edges.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 17:16:33 +07:00
BFlat e3e6869043 Merge pull request #1166 from storytold/fix/1152-offset-path-memory
#1152: Offset Path opens on the values it was last applied with
2026-10-11 17:16:28 +07:00
bflatasticandClaude Opus 5.5 c7522b7f0c #1152: Offset Path opens on the values it was last applied with
Object › Path › Offset Path (and `ui.menuDialog` for it) opens on the
offset, joins and miter limit of the last time OK was pressed, as in
Illustrator, instead of 10 pt and Miter every time. The values are kept
with the rest of the remembered dialog options.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 17:16:16 +07:00
BFlat 4eaa5f0b56 Merge pull request #1160 from storytold/fix/1133-zigzag-dialog
#1133: Zig Zag's dialog has sliders and radio buttons, as in Illustrator
2026-10-11 16:46:26 +07:00
bflatasticandClaude Opus 5.5 f68f66a579 #1133: Zig Zag's dialog has sliders and radio buttons, as in Illustrator
Effect › Distort & Transform › Zig Zag… lays out its options the way
Illustrator's dialog does: Size and Ridges per segment each have a
slider beside their field, Relative / Absolute are radio buttons under
Size, and Points offers Smooth and Corner as radio buttons instead of a
dropdown. The label reads "Ridges per segment".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 16:46:14 +07:00
BFlat 0c6814e760 Merge pull request #1159 from storytold/fix/1144-curved-live-corners
#1144: Live Corners round corners between curved segments too
2026-10-11 16:46:00 +07:00
bflatasticandClaude Opus 5.5 fbc1c494b2 #1144: Live Corners round corners between curved segments too
A corner is now any anchor that isn't smooth where two sides meet at an
angle, straight or curved, as in Illustrator. The cut meets each side at
its setback measured along the curve, leaves both sides along their
tangents there, and each curved side is trimmed to what the cuts at its
ends leave of it. The largest radius is still the one taking the cut
halfway along the shorter side, measured along the curve. Corners
between straight sides draw exactly as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 16:14:08 +07:00
BFlat b08482d640 Merge pull request #1155 from storytold/fix/vello-flatten-guard
Render: a shape far beyond any page is left undrawn, never crashing the app
2026-10-11 16:13:40 +07:00
bflatasticandClaude Opus 5.5 255fefe71f Render: a shape far beyond any page is left undrawn, never crashing the app
A property test of the importers (import_fuzz) crashed CI with
STATUS_STACK_BUFFER_OVERRUN: a shape with coordinates in the 1e20s from a
mutated file made vello_cpu either panic in flatten_simd on a rayon
worker (rayon then aborts the process, so the engine's panic guard never
sees it) or ask for a 132 GB allocation, which aborts too. A malformed
file could end the app and lose the user's work.

draw_shape now leaves a shape undrawn when its control points, through
the view, aren't finite or reach past 1e10 pixels, or a visible stroke
is that wide (MAX_DEVICE). A document's art stays within 4 million
points, 2.6e9 pixels at the closest zoom, and vello was checked safe
with 90,000 random shapes within 1e9 pixels and 30,000 within 1e12.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 16:13:30 +07:00
BFlat f5fab833d0 Merge pull request #1154 from storytold/fix/1139-multi-artboard-select
#1139: Shift-click selects several artboards, which move and delete together
2026-10-11 16:04:49 +07:00
52 changed files with 946 additions and 146 deletions

No files matched your search

+4 -3
View File
@@ -170,12 +170,13 @@ next one. To pick one when starting the app, set `WGPU_POWER_PREF=high` (or `low
its name, such as `WGPU_ADAPTER_NAME=nvidia` (see [`docs/development.md`](docs/development.md#desktop-graphics-processor)).
On native Wayland, winit 0.30 does not deliver dropped files. To open an SVG, use File › Open;
to place artwork in the current document, copy and paste the file in a file manager; or run
under XWayland (`WAYLAND_DISPLAY= vectorcraft`) for file drag-and-drop
to place artwork in the current document, copy and paste the file in a file manager. For file
drag-and-drop, set **Preferences › Performance › Linux Display Server** to **X11 (XWayland)** and restart, or start
the app with `WAYLAND_DISPLAY= vectorcraft`: the window then opens through XWayland, where drops work
(see [`docs/development.md`](docs/development.md#linux-wayland-and-x11)).
On Linux under KDE Plasma 6.3 or later with Wayland, a drawing tablet's pen moves the cursor but VectorCraft doesn't
respond to it yet (#491). Start the app under XWayland instead: `WAYLAND_DISPLAY= vectorcraft` (see
respond to it yet (#491). Open the window through XWayland instead, with the same preference or `WAYLAND_DISPLAY= vectorcraft` (see
[`docs/development.md`](docs/development.md#linux-wayland-and-x11)).
### Use it from Claude Code and other agents
+1 -1
View File
@@ -127,7 +127,7 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap
| Date | What landed |
|---|---|
| 2026-10-11 | Effect › Brush Strokes: Accented Edges, Angled Strokes, Crosshatch, Dark Strokes, Ink Outlines, Spatter, Sprayed Strokes, Sumi-e (G2). Command parameters from agents and files checked before anything changes (paint, place, cuts, dashes, crop, asset ids, anchors, paths, distort; #984–#999); command dialogs keep whole numbers whole, so Create Object Mosaic takes the tiles typed (#1000); stepper arrows take Shift (×10) and Ctrl/Cmd (÷10) like the arrow keys (#991). Exports and Rasterize supersample Art Optimized edges, so shapes meeting edge to edge leave no seam (#983). Character and paragraph styles take leading, kerning, mojikumi and the other attributes left out at their defaults (#1001). Drawing, typing and pasting with no layer shown and unlocked is refused instead of adding art to a hidden or locked layer (#1002). MCP `add_text` gives type on or in a path the colour asked for (#1003). MCP `screenshot` of a connected app renders the artboard asked for (#1004). `vectorcraft-cli run` ends quietly when its reader closes early (#1005). Select › All on Active Artboard selects on the artboard made active, not always the first (#1006). Reverse Gradient keeps off-centre midpoints, mirrored (#1007). A polar grid with no radial dividers draws none (#1008). Use Precise Cursors also covers the Pen's join pointer (#1009). SVGs with one absolute and one unitless root side import at their size, unstretched (#1010). DXF US survey inches, yards and miles open at their size and name (#1011). A file placed into a document with no other links follows its first change even while the app sits idle (#1031). PDF import keeps the style a font name says when the file doesn't embed the font (#1023). Shift selects ruler guides and art together, and Delete removes both (#1022). Open filled paths are hit by their fill on every side, the rotate cursor bows around the corner it is at, the flip icons match their flips, the Color Picker previews on the selection, and Properties › Transform reaches Scale Corners and Scale Strokes & Effects (#1036). Offset Path and the other booleans finish on outlines where the sweep used to loop until memory ran out (linesweeper 0.5, #1028). Floating panel groups stack into sets that move together, collapse to their tabs and dock as extra dock columns that scroll (#1020). Ruler guides live on layers: listed in the Layers panel, shown, hidden, locked, duplicated, merged and deleted with their layer, and moved between layers (#1021). A `.ai` whose editing data uses global process colours (`Xk`/`XK`) or pattern fills keeps its sublayers: the colours come in as global swatches at their tint, the patterns as pattern swatches (#1025). A `.ai` in PostScript form opens through its layers, read from the program itself, also when it names the prolog defining its operators without including it, as Rhino and other CAD apps write it (#1027). A fill or stroke chosen while the Type tool has characters selected colours just those characters (#1063). Scaled type shows its scaled font size, leading, baseline shift and horizontal scale in the Character panel, and a size typed there is the size you get (#1034). Character panel menu › Proportional Metrics sets Japanese and Chinese full-width glyphs on the font's proportional widths in horizontal type (#966). Align, Distribute, Move, Arrange, Lock, Hide, Ungroup and other Object commands that would change nothing no longer record an undo step or mark the document changed (#1060). Spot colours defined in Lab open with their Lab values from `.ai` and PDF files, instead of as wrong RGB colours or their CMYK equivalents (#1032). Build scripts leave a generated source they wouldn't change untouched, so the next build doesn't compile most of the workspace again where file times lose their sub-second part (#1051). A fill colour recolours the gradient mesh point last clicked with the Mesh tool or Direct Selection, and a Mesh tool click on a mesh's point selects that mesh (#1052). The All Tools drawer stays two tools wide instead of filling a wide window (#1054). Text Wrap's Make, Release and Options refuse a list of object ids with a malformed or unknown id instead of skipping it (#1055). A freeform gradient line with a malformed point index is refused instead of being shortened to the indices that are valid (#1056). The Corners dialog (`ui.corners`) refuses a malformed object id or a listed anchor that isn't a corner instead of editing the selection or the other corners (#1057). New View refuses a centre that isn't exactly [x, y] and a rotation that isn't a number, instead of trimming the one and saving the other as 0° (#1058). The command sweeps try junk scale, spacing and artboard values too, which they had skipped since before those bugs were fixed (#1059). EPS and PostScript `arc` and `arcn` with huge angles no longer hang the import, and an arc whose angles are whole turns apart is empty, as the PostScript reference defines it (#1062). Align and DisLine truncated
| 2026-10-11 | Effect › Brush Strokes: Accented Edges, Angled Strokes, Crosshatch, Dark Strokes, Ink Outlines, Spatter, Sprayed Strokes, Sumi-e (G2). Command parameters from agents and files checked before anything changes (paint, place, cuts, dashes, crop, asset ids, anchors, paths, distort; #984–#999); command dialogs keep whole numbers whole, so Create Object Mosaic takes the tiles typed (#1000); stepper arrows take Shift (×10) and Ctrl/Cmd (÷10) like the arrow keys (#991). Exports and Rasterize supersample Art Optimized edges, so shapes meeting edge to edge leave no seam (#983). Character and paragraph styles take leading, kerning, mojikumi and the other attributes left out at their defaults (#1001). Drawing, typing and pasting with no layer shown and unlocked is refused instead of adding art to a hidden or locked layer (#1002). MCP `add_text` gives type on or in a path the colour asked for (#1003). MCP `screenshot` of a connected app renders the artboard asked for (#1004). `vectorcraft-cli run` ends quietly when its reader closes early (#1005). Select › All on Active Artboard selects on the artboard made active, not always the first (#1006). Reverse Gradient keeps off-centre midpoints, mirrored (#1007). A polar grid with no radial dividers draws none (#1008). Use Precise Cursors also covers the Pen's join pointer (#1009). SVGs with one absolute and one unitless root side import at their size, unstretched (#1010). DXF US survey inches, yards and miles open at their size and name (#1011). A file placed into a document with no other links follows its first change even while the app sits idle (#1031). PDF import keeps the style a font name says when the file doesn't embed the font (#1023). Shift selects ruler guides and art together, and Delete removes both (#1022). Open filled paths are hit by their fill on every side, the rotate cursor bows around the corner it is at, the flip icons match their flips, the Color Picker previews on the selection, and Properties › Transform reaches Scale Corners and Scale Strokes & Effects (#1036). Offset Path and the other booleans finish on outlines where the sweep used to loop until memory ran out (linesweeper 0.5, #1028). Floating panel groups stack into sets that move together, collapse to their tabs and dock as extra dock columns that scroll (#1020). Ruler guides live on layers: listed in the Layers panel, shown, hidden, locked, duplicated, merged and deleted with their layer, and moved between layers (#1021). A `.ai` whose editing data uses global process colours (`Xk`/`XK`) or pattern fills keeps its sublayers: the colours come in as global swatches at their tint, the patterns as pattern swatches (#1025). A `.ai` in PostScript form opens through its layers, read from the program itself, also when it names the prolog defining its operators without including it, as Rhino and other CAD apps write it (#1027). A fill or stroke chosen while the Type tool has characters selected colours just those characters (#1063). Scaled type shows its scaled font size, leading, baseline shift and horizontal scale in the Character panel, and a size typed there is the size you get (#1034). Character panel menu › Proportional Metrics sets Japanese and Chinese full-width glyphs on the font's proportional widths in horizontal type (#966). Align, Distribute, Move, Arrange, Lock, Hide, Ungroup and other Object commands that would change nothing no longer record an undo step or mark the document changed (#1060). Spot colours defined in Lab open with their Lab values from `.ai` and PDF files, instead of as wrong RGB colours or their CMYK equivalents (#1032). Build scripts leave a generated source they wouldn't change untouched, so the next build doesn't compile most of the workspace again where file times lose their sub-second part (#1051). A fill colour recolours the gradient mesh point last clicked with the Mesh tool or Direct Selection, and a Mesh tool click on a mesh's point selects that mesh (#1052). The All Tools drawer stays two tools wide instead of filling a wide window (#1054). Text Wrap's Make, Release and Options refuse a list of object ids with a malformed or unknown id instead of skipping it (#1055). A freeform gradient line with a malformed point index is refused instead of being shortened to the indices that are valid (#1056). The Corners dialog (`ui.corners`) refuses a malformed object id or a listed anchor that isn't a corner instead of editing the selection or the other corners (#1057). New View refuses a centre that isn't exactly [x, y] and a rotation that isn't a number, instead of trimming the one and saving the other as 0° (#1058). The command sweeps try junk scale, spacing and artboard values too, which they had skipped since before those bugs were fixed (#1059). EPS and PostScript `arc` and `arcn` with huge angles no longer hang the import, and an arc whose angles are whole turns apart is empty, as the PostScript reference defines it (#1062). Align and DisLine truncated
| 2026-10-10 | Initial live Revolve (Effect › 3D and Materials, #846, #605), in its own `three-d` crate. Progress docs re-measured and restructured to the craftrules standard (this page, `docs/target-app-parity.md`, `gaps.md`, `ui-parity.md`, the format, hardware, localization, effects and type checklists). Same day: v0.8.0; Effect › Distort, Pixelate and Texture filters (13); German interface; Variables (data merge); Graph Type value axes and tick marks; restart when the first frame never reaches the screen (#964) |
| 2026-10-09 | Radial Blur, Smart Blur, Unsharp Mask (gap 2 begins); 340 commits, the busiest day |
| 2026-10-08 | v0.5.0–v0.7.0; community contributions: Pen and shape-tool modifiers, Layers, Artboards, Japanese composition, Hebrew/Arabic type, interface languages, saved selections |
+20
View File
@@ -39,6 +39,7 @@ mod printing;
#[cfg(all(windows, not(target_vendor = "win7")))]
mod system_fonts;
mod window;
mod xwayland;
use vectorcraft_engine::Session;
use vectorcraft_engine::cmd::fileio;
@@ -674,6 +675,25 @@ fn main() -> std::process::ExitCode {
let startup = std::sync::Arc::new(gpu::Startup::default());
let custom_titlebar = custom_titlebar(saved.as_ref());
let options = native_options(custom_titlebar);
// winit picks Wayland whenever `WAYLAND_DISPLAY` is set; Preferences › Performance › Linux
// Display Server › X11 opens the window through XWayland instead, where file drops work (#1151).
#[cfg(target_os = "linux")]
let options = {
let mut options = options;
let pref = saved.as_ref().and_then(|ui| ui.engine_prefs.get("linuxDisplayServer")).and_then(serde_json::Value::as_str);
if xwayland::wanted(pref, |name| std::env::var_os(name)) {
if xwayland::x11_libraries_found() {
log::info!("opening the window through XWayland (Preferences › Performance › Linux Display Server)");
use winit::platform::x11::EventLoopBuilderExtX11 as _;
options.event_loop_builder = Some(Box::new(|builder| {
builder.with_x11();
}));
} else {
log::warn!("Linux Display Server is X11, but the X11 libraries aren't all installed: opening the window on Wayland");
}
}
options
};
#[cfg(feature = "wgpu")]
let options = {
let mut options = options;
+127
View File
@@ -0,0 +1,127 @@
//! Linux: Preferences › Performance › Linux Display Server › X11 opens the window through XWayland
//! on a Wayland session, where dropped files arrive (winit 0.30 has no Wayland drag and drop,
//! #1151). Only when XWayland can run the window: an X display is set and the X11 libraries winit
//! loads at runtime are installed. Otherwise the window opens on Wayland, so a missing library
//! never keeps the app from starting.
//!
//! The library check comes from PhotoCraft's `linux_libs.rs`: no `dlopen` (that needs `unsafe`),
//! just the dynamic linker's cache (`ldconfig -p`) and the usual library directories.
#![cfg_attr(not(target_os = "linux"), allow(dead_code))]
use std::ffi::OsString;
use std::path::{Path, PathBuf};
/// What winit 0.30 `dlopen`s for an X11 window: x11-dl opens Xlib, Xlib-xcb, Xcursor and XInput2,
/// x11rb opens libxcb, and xkbcommon-dl opens xkbcommon and xkbcommon-x11.
const X11_LIBS: &[&str] =
&["libX11.so.6", "libX11-xcb.so.1", "libxcb.so.1", "libXcursor.so.1", "libXi.so.6", "libxkbcommon.so.0", "libxkbcommon-x11.so.0"];
/// Whether the saved `linuxDisplayServer` and the environment ask for XWayland: `x11` chosen, a
/// Wayland session (winit picks Wayland whenever `WAYLAND_DISPLAY` or `WAYLAND_SOCKET` is set)
/// and an X display to open.
pub fn wanted(pref: Option<&str>, var: impl Fn(&str) -> Option<OsString>) -> bool {
let set = |name: &str| var(name).is_some_and(|v| !v.is_empty());
pref == Some("x11") && (set("WAYLAND_DISPLAY") || set("WAYLAND_SOCKET")) && set("DISPLAY")
}
/// Are all the libraries an X11 window needs found on this system? Libraries the check can't find
/// count as missing.
pub fn x11_libraries_found() -> bool {
let cache = read_ldconfig();
let dirs = search_dirs(std::env::var("LD_LIBRARY_PATH").ok().as_deref());
X11_LIBS
.iter()
.all(|lib| cache.as_deref().is_some_and(|out| ldconfig_has(out, lib, ldconfig_arch_tag())) || dirs.iter().any(|d| d.join(lib).exists()))
}
/// The `ldconfig -p` architecture tag for this build, when it's one we can tell apart from a
/// 32-bit library of the same name (`libc6,x86-64` vs plain `libc6` for i386).
fn ldconfig_arch_tag() -> Option<&'static str> {
if cfg!(target_arch = "x86_64") {
Some("x86-64")
} else if cfg!(target_arch = "aarch64") {
Some("AArch64")
} else {
None
}
}
/// Does `ldconfig -p` output list `soname` for our architecture? Lines look like
/// `\tlibxkbcommon-x11.so.0 (libc6,x86-64) => /lib/x86_64-linux-gnu/libxkbcommon-x11.so.0`.
fn ldconfig_has(output: &str, soname: &str, arch_tag: Option<&str>) -> bool {
output.lines().any(|line| {
let Some((name, rest)) = line.trim_start().split_once(' ') else { return false };
if name != soname {
return false;
}
match arch_tag {
None => true,
Some(tag) => {
let flags = rest.split_once('(').and_then(|(_, r)| r.split_once(')')).map_or("", |(f, _)| f);
flags.split(',').any(|f| f.trim() == tag)
}
}
})
}
/// `ldconfig -p`, from the usual locations (`/sbin` isn't on a normal user's PATH on Debian). A
/// real listing starts with "N libs found in cache".
fn read_ldconfig() -> Option<String> {
["/sbin/ldconfig", "/usr/sbin/ldconfig", "ldconfig"].into_iter().find_map(|cmd| {
let out = std::process::Command::new(cmd).arg("-p").output().ok().filter(|out| out.status.success())?;
let text = String::from_utf8_lossy(&out.stdout).into_owned();
text.lines().next().is_some_and(|l| l.contains("libs found in cache")).then_some(text)
})
}
/// Library directories to search: `LD_LIBRARY_PATH`, then the usual system and Flatpak paths.
fn search_dirs(ld_library_path: Option<&str>) -> Vec<PathBuf> {
let mut dirs: Vec<PathBuf> = ld_library_path.unwrap_or("").split(':').filter(|d| !d.is_empty()).map(PathBuf::from).collect();
let triplet = if cfg!(target_arch = "aarch64") {
"aarch64-linux-gnu"
} else if cfg!(target_arch = "riscv64") {
"riscv64-linux-gnu"
} else {
"x86_64-linux-gnu"
};
dirs.extend(["/app/lib", "/usr/local/lib", "/usr/lib64", "/lib64", "/usr/lib", "/lib"].map(PathBuf::from));
dirs.push(Path::new("/usr/lib").join(triplet));
dirs.push(Path::new("/lib").join(triplet));
dirs
}
#[cfg(test)]
mod tests {
use super::*;
fn env(pairs: &'static [(&'static str, &'static str)]) -> impl Fn(&str) -> Option<OsString> {
move |k| pairs.iter().find(|(name, _)| *name == k).map(|(_, v)| OsString::from(v))
}
#[test]
fn xwayland_only_when_chosen_on_wayland_with_an_x_display() {
let wayland = env(&[("WAYLAND_DISPLAY", "wayland-0"), ("DISPLAY", ":0")]);
assert!(wanted(Some("x11"), &wayland));
assert!(wanted(Some("x11"), env(&[("WAYLAND_SOCKET", "3"), ("DISPLAY", ":0")])));
assert!(!wanted(Some("automatic"), &wayland));
assert!(!wanted(None, &wayland));
assert!(!wanted(Some("wayland"), &wayland));
// An X11 session already opens on X11; without an X display there is no XWayland to use.
assert!(!wanted(Some("x11"), env(&[("DISPLAY", ":0")])));
assert!(!wanted(Some("x11"), env(&[("WAYLAND_DISPLAY", "wayland-0"), ("DISPLAY", "")])));
assert!(!wanted(Some("x11"), env(&[("WAYLAND_DISPLAY", "wayland-0")])));
}
#[test]
fn ldconfig_parsing_matches_name_and_architecture() {
let cache = "12 libs found in cache `/etc/ld.so.cache'\n\
\tlibX11.so.6 (libc6,x86-64) => /lib/x86_64-linux-gnu/libX11.so.6\n\
\tlibxkbcommon-x11.so.0 (libc6) => /lib/i386-linux-gnu/libxkbcommon-x11.so.0\n";
assert!(ldconfig_has(cache, "libX11.so.6", Some("x86-64")));
assert!(!ldconfig_has(cache, "libX11.so.6", Some("AArch64")));
// Only the 32-bit build is installed: doesn't count for a 64-bit app.
assert!(!ldconfig_has(cache, "libxkbcommon-x11.so.0", Some("x86-64")));
assert!(ldconfig_has(cache, "libxkbcommon-x11.so.0", None));
assert!(!ldconfig_has(cache, "libX11.so", None));
}
}
+14
View File
@@ -375,6 +375,20 @@ mod tests {
assert!(hit_test(&d, Point::new(50.5, 25.0), HitOptions::default()).is_some());
}
/// #1103: a marquee drawn over a background picks what is on it, not the background, whose
/// outline it doesn't touch.
#[test]
fn a_marquee_inside_a_background_leaves_the_background() {
let mut d = Document::new(200.0, 200.0);
let l = d.layers[0].id;
let (back, small) = (d.alloc_id(), d.alloc_id());
d.insert(Some(l), 0, Node::path(back, shapes::rectangle(Rect::new(0.0, 0.0, 200.0, 200.0)), Appearance::default_art())).unwrap();
d.insert(Some(l), 1, Node::path(small, shapes::rectangle(Rect::new(50.0, 50.0, 60.0, 60.0)), Appearance::default_art())).unwrap();
assert_eq!(marquee(&d, Rect::new(40.0, 40.0, 70.0, 70.0), None, false), vec![small]);
// Reaching the background's edge takes it too.
assert_eq!(marquee(&d, Rect::new(-5.0, 40.0, 70.0, 70.0), None, false), vec![back, small]);
}
#[test]
fn group_top_object() {
let mut d = Document::new(200.0, 200.0);
+1 -12
View File
@@ -825,20 +825,9 @@ fn reshape_segment(s: &mut Session, p: &Value) -> Result<Value> {
s.edit("Reshape", |d, _| {
let path = path_mut(d, id)?;
let sp = path.subpaths.get_mut(si).ok_or_else(|| EngineError::Other("no such subpath".into()))?;
if seg >= sp.segment_count() {
if !sp.bend_segment(seg, t, dv) {
return Err(EngineError::Other("no such segment".into()));
}
let n = sp.anchors.len();
// Moving both inner control points by v moves B(t) by 3t(1-t)·v. A smooth anchor stays
// smooth: its other handle turns with the moved one.
let v = dv / (3.0 * t * (1.0 - t));
let (i0, i1) = (seg % n, (seg + 1) % n);
if let Some(a) = sp.anchors.get_mut(i0) {
a.set_handle(true, a.h_out + v, false);
}
if let Some(b) = sp.anchors.get_mut(i1) {
b.set_handle(false, b.h_in + v, false);
}
Ok(())
})?;
ok()
+16 -14
View File
@@ -82,7 +82,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Hide Others",
["Window", "Layers"],
None,
"{ids?} hide every top-level layer but those holding the highlighted rows (else the current layer). One undo step → {count}",
"{ids?} hide every top-level layer but those holding the highlighted rows (else the current layer), which show. One undo step → {count}",
has_doc,
|s, p| others(s, p, Flag::Visible, false)
),
@@ -96,7 +96,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Outline Others",
["Window", "Layers"],
None,
"{ids?} turn Preview off (outline view) for every top-level layer but those holding the highlighted rows (else the current layer) → {count}",
"{ids?} turn Preview off (outline view) for every top-level layer but those holding the highlighted rows (else the current layer), which preview → {count}",
has_doc,
|s, p| others(s, p, Flag::Preview, false)
),
@@ -114,7 +114,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Lock Others",
["Window", "Layers"],
None,
"{ids?} lock every top-level layer but those holding the highlighted rows (else the current layer) → {count}",
"{ids?} lock every top-level layer but those holding the highlighted rows (else the current layer), which unlock → {count}",
has_doc,
|s, p| others(s, p, Flag::Locked, true)
),
@@ -176,23 +176,23 @@ impl Flag {
}
}
/// Set `flag` to `v` on `ids` (layers that differ) in one undo step labelled `label` → how many
/// Set `flag` on each of `changes` (id, value; layers that differ) in one undo step labelled `label` → how many
/// changed.
fn set_flag(s: &mut Session, ids: Vec<NodeId>, flag: Flag, v: bool, label: &str) -> Result<Value> {
fn set_flag(s: &mut Session, changes: Vec<(NodeId, bool)>, flag: Flag, label: &str) -> Result<Value> {
let d = &s.doc()?.doc;
let ids: Vec<NodeId> = ids.into_iter().filter(|id| d.node(*id).is_some_and(|n| flag.get(n) != v)).collect();
if !ids.is_empty() {
let changes: Vec<(NodeId, bool)> = changes.into_iter().filter(|(id, v)| d.node(*id).is_some_and(|n| flag.get(n) != *v)).collect();
if !changes.is_empty() {
s.edit(label, |d, sel| {
for id in &ids {
for (id, v) in &changes {
if let Some(n) = d.node_mut(*id) {
flag.set(n, v);
flag.set(n, *v);
}
}
sel.deselect_uneditable(d);
Ok(())
})?;
}
Ok(json!({ "count": ids.len() }))
Ok(json!({ "count": changes.len() }))
}
/// The top-level layers holding the rows a command acts on.
@@ -202,20 +202,22 @@ fn kept_layers(s: &Session, p: &Value) -> Result<HashSet<NodeId>> {
Ok(ids.iter().filter_map(|id| d.layer_of(*id)).collect())
}
/// Every top-level layer but the kept ones set to `v`, and the kept ones to the opposite: Alt-click
/// on a hidden layer's eye shows just that layer (#1158).
fn others(s: &mut Session, p: &Value, flag: Flag, v: bool) -> Result<Value> {
let keep = kept_layers(s, p)?;
let ids: Vec<NodeId> = s.doc()?.doc.layers.iter().map(|l| l.id).filter(|l| !keep.contains(l)).collect();
set_flag(s, ids, flag, v, flag.label(v))
let changes = s.doc()?.doc.layers.iter().map(|l| (l.id, !keep.contains(&l.id) == v)).collect();
set_flag(s, changes, flag, flag.label(v))
}
fn all(s: &mut Session, flag: Flag, v: bool) -> Result<Value> {
let mut ids = vec![];
s.doc()?.doc.walk(|n| {
if n.is_layer() {
ids.push(n.id);
ids.push((n.id, v));
}
});
set_flag(s, ids, flag, v, flag.label(v))
set_flag(s, ids, flag, flag.label(v))
}
fn template(s: &mut Session, p: &Value) -> Result<Value> {
+22 -1
View File
@@ -14,7 +14,7 @@ use vectorcraft_doc::live::{
PreserveShape, Spine,
};
use vectorcraft_doc::{Appearance, Document, Node, NodeId, NodeKind, Selection};
use vectorcraft_geom::{Affine, PathData, Point};
use vectorcraft_geom::{Affine, PathData, Point, Vec2};
use super::edit::selected_roots;
use super::expand::gradient_sampler;
@@ -95,6 +95,15 @@ pub fn specs() -> Vec<CommandSpec> {
has_doc,
spine_move_anchor
),
cmd!(
"object.blend.spine.reshapeSegment",
"Reshape Spine Segment",
[],
None,
"{id? (default: the selected blend), segment: index, t: 0..1, dx, dy} bend a segment of the blend's spine so its point at t moves by (dx, dy), as path.reshapeSegment bends a path's: a straight spine turns into a curve (#1111)",
has_doc,
spine_reshape_segment
),
cmd!(
"object.blend.spine.addAnchor",
"Add Spine Point",
@@ -640,6 +649,18 @@ fn spine_move_anchor(s: &mut Session, p: &Value) -> Result<Value> {
ok()
}
fn spine_reshape_segment(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "object.blend.spine.reshapeSegment";
let seg = index_param(p, "segment", C)?;
let t = f64_or(p, "t", 0.5).clamp(0.05, 0.95);
let dv = Vec2::new(f64_or(p, "dx", 0.0), f64_or(p, "dy", 0.0));
if !(t.is_finite() && dv.x.is_finite() && dv.y.is_finite() && dv.hypot() < 1e9) {
return Err(bad(C, "t, dx and dy must be finite"));
}
edit_spine(s, p, C, "Reshape Spine", |sp, _, _| if sp.bend_segment(seg, t, dv) { Ok(()) } else { Err(bad(C, "no such spine segment")) })?;
ok()
}
fn spine_add_anchor(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "object.blend.spine.addAnchor";
let q = finite_point(p, C)?;
+45 -11
View File
@@ -9,7 +9,7 @@ use vectorcraft_color::{BlendMode, Paint};
use vectorcraft_doc::corners::set_corners;
use vectorcraft_doc::{Appearance, Document, Knockout, LiveCorners, LiveShape, Node, NodeId, NodeKind, OrientedBox, Scaling};
use vectorcraft_geom::shapes::CornerKind;
use vectorcraft_geom::{Affine, FillRule, Point, Rect, Vec2};
use vectorcraft_geom::{Affine, FillRule, PathData, Point, Rect, Vec2};
use super::edit::{duplicate_in, selected_roots};
use super::opacitymask::percent;
@@ -106,7 +106,15 @@ pub fn specs() -> Vec<CommandSpec> {
cmd!("object.hide", "Selection", ["Object", "Hide"], Some("Cmd+3"), "{}", has_selection, hide),
cmd!("object.showAll", "Show All", ["Object"], Some("Cmd+Alt+3"), "{}", has_doc, show_all),
cmd!("object.compoundPath.make", "Make", ["Object", "Compound Path"], Some("Cmd+8"), "{}", has_selection, compound_make),
cmd!("object.compoundPath.release", "Release", ["Object", "Compound Path"], Some("Cmd+Alt+Shift+8"), "{}", has_selection, compound_release),
cmd!(
"object.compoundPath.release",
"Release",
["Object", "Compound Path"],
Some("Cmd+Alt+Shift+8"),
"{} release the selected compound paths into their paths, each with the compound path's paint; a path of several subpaths (an SVG `d` with several `M`s) splits into one path per subpath",
has_selection,
compound_release
),
cmd!(
"object.clippingMask.make",
"Make",
@@ -196,7 +204,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Live Shape Properties",
[],
None,
"{id?, ids?, radius?: pt, kind?: \"round\"|\"invertedRound\"|\"chamfer\", corners?: [i…], items?: [{id, corners?: [i…]}] (several objects, each with its own corners, in place of ids and corners: the canvas's corner widgets across a selection), sides?: n, polygonAngle?: degrees (counterclockwise from its first vertex straight up), polygonRadius?: pt (centre to vertex), sideLength?: pt (sets the radius), makeSidesEqual?: true (a polygon scaled unevenly: drops the uneven scale and shear, keeping its centre, angle and mean radius), pieStart?, pieEnd?: degrees (an ellipse's pie, counterclockwise from 3 o'clock; 0 to 360 is the whole ellipse), invertPie?: true (swaps them: the other part of the ellipse)} (Live Corners on any path: radius and kind set its corners (anchors without handles between two straight sides): `corners` (anchor indices of the path with its corners uncut, counting every subpath's anchors in order: a rectangle's 0 top-left, 1 top-right, 2 bottom-right, 3 bottom-left; a polygon's from the first vertex clockwise), else the corners with a Direct-Selected anchor, else every corner; each radius is drawn no larger than half the corner's shorter side allows; a path that isn't a live shape keeps its uncut outline so its corners stay editable, and is plain again once none is cut; sides: a polygon's, which keep the radius they shared; the polygon params apply in that order: sides, makeSidesEqual, polygonAngle, then sideLength or else polygonRadius)",
"{id?, ids?, radius?: pt, kind?: \"round\"|\"invertedRound\"|\"chamfer\", corners?: [i…], items?: [{id, corners?: [i…]}] (several objects, each with its own corners, in place of ids and corners: the canvas's corner widgets across a selection), sides?: n, polygonAngle?: degrees (counterclockwise from its first vertex straight up), polygonRadius?: pt (centre to vertex), sideLength?: pt (sets the radius), makeSidesEqual?: true (a polygon scaled unevenly: drops the uneven scale and shear, keeping its centre, angle and mean radius), pieStart?, pieEnd?: degrees (an ellipse's pie, counterclockwise from 3 o'clock; 0 to 360 is the whole ellipse), invertPie?: true (swaps them: the other part of the ellipse)} (Live Corners on any path: radius and kind set its corners (anchors that aren't smooth, where two sides, straight or curved (#1144), meet at an angle): `corners` (anchor indices of the path with its corners uncut, counting every subpath's anchors in order: a rectangle's 0 top-left, 1 top-right, 2 bottom-right, 3 bottom-left; a polygon's from the first vertex clockwise), else the corners with a Direct-Selected anchor, else every corner; each radius is drawn no larger than half the corner's shorter side allows; a path that isn't a live shape keeps its uncut outline so its corners stay editable, and is plain again once none is cut; sides: a polygon's, which keep the radius they shared; the polygon params apply in that order: sides, makeSidesEqual, polygonAngle, then sideLength or else polygonRadius)",
has_selection,
set_live_shape
),
@@ -658,26 +666,52 @@ fn compound_make(s: &mut Session, _: &Value) -> Result<Value> {
Ok(json!({ "id": id.0 }))
}
/// Is `n` a compound path to release: a compound path, or a path of several subpaths (an SVG `d`
/// with several `M`s, a PDF path), which is one in Illustrator too (#1163)?
fn releases(n: &Node) -> bool {
match &n.kind {
NodeKind::Compound { .. } => true,
NodeKind::Path { path, .. } => path.subpaths.len() > 1,
_ => false,
}
}
fn compound_release(s: &mut Session, _: &Value) -> Result<Value> {
let ids = selected_roots(s)?;
// With no compound path selected, the document, the selection and the undo history stay as
// they are.
let d = &s.doc()?.doc;
if !ids.iter().any(|id| d.node(*id).is_some_and(|n| matches!(n.kind, NodeKind::Compound { .. }))) {
if !ids.iter().any(|id| d.node(*id).is_some_and(releases)) {
return ok();
}
s.edit("Release Compound Path", |d, sel| {
let mut out = vec![];
for id in &ids {
let Some(n) = d.node(*id).cloned() else { continue };
let NodeKind::Compound { children, .. } = &n.kind else { continue };
let Some(n) = d.node(*id).cloned().filter(releases) else { continue };
let (par, idx, _) = d.position(*id).ok_or(EngineError::NoNode(*id))?;
d.remove(*id)?;
for (k, c) in children.iter().enumerate() {
let mut c = (**c).clone();
c.appearance = n.appearance.clone();
out.push(c.id);
d.insert(par, idx + k, c)?;
match &n.kind {
NodeKind::Compound { children, .. } => {
for (k, c) in children.iter().enumerate() {
let mut c = (**c).clone();
c.appearance = n.appearance.clone();
out.push(c.id);
d.insert(par, idx + k, c)?;
}
}
// Each subpath becomes a path of its own with the path's paint, in the same
// stacking order; the first keeps the path's id.
NodeKind::Path { path, .. } => {
for (k, sp) in path.subpaths.iter().enumerate() {
let mut c = if k == 0 { n.clone() } else { d.reid(&n) };
if let NodeKind::Path { path, live, .. } = &mut c.kind {
(*path, *live) = (PathData::single(sp.clone()), None);
}
out.push(c.id);
d.insert(par, idx + k, c)?;
}
}
_ => {}
}
}
sel.set(out);
+5
View File
@@ -87,6 +87,10 @@ const LINE_STYLE: &[(&str, &str)] = &[("lines", "Lines"), ("dots", "Dots")];
/// `automatic` (#502).
pub const GPU_PREFERENCES: &[(&str, &str)] =
&[("automatic", "Automatic"), ("lowPower", "Power Saving (integrated)"), ("highPerformance", "High Performance (discrete)")];
/// Performance › Linux Display Server (`linuxDisplayServer`), read by the desktop app at startup:
/// `x11` opens the window through XWayland on a Wayland session, where dropped files arrive
/// (winit 0.30 has no Wayland drag and drop, #1151).
const LINUX_DISPLAY_SERVERS: &[(&str, &str)] = &[("automatic", "Automatic"), ("x11", "X11 (XWayland)")];
const BLACK: &[(&str, &str)] = &[("accurate", "Display All Blacks Accurately"), ("rich", "Display All Blacks as Rich Black")];
const BLACK_OUT: &[(&str, &str)] = &[("accurate", "Output All Blacks Accurately"), ("rich", "Output All Blacks as Rich Black")];
@@ -290,6 +294,7 @@ pub const PREF_SPECS: &[PrefSpec] = &[
p!("historyStates", "Performance", "", "History States", int(5, 1000)),
p!("realTimeDrawing", "Performance", "", "Real-time Drawing and Editing", bool),
p!("renderThreads", "Performance", "", "Render Threads (-1 = Automatic)", int(-1, 64)),
p!("linuxDisplayServer", "Performance", "", "Linux Display Server", choice(LINUX_DISPLAY_SERVERS)),
// File Handling
p!("backgroundSave", "File Handling", "", "Enable Background Save", bool),
p!("backgroundExport", "File Handling", "", "Enable Background Export", bool),
+4
View File
@@ -564,6 +564,9 @@ pub struct Prefs {
pub real_time_drawing: bool,
/// Rasterizer worker threads; -1 = automatic.
pub render_threads: i32,
/// Linux: `automatic` opens the window on the session's display server; `x11` opens it through
/// XWayland on a Wayland session, where dropped files arrive (#1151). Read when the app starts.
pub linux_display_server: String,
// File Handling
pub background_save: bool,
pub background_export: bool,
@@ -769,6 +772,7 @@ impl Default for Prefs {
history_states: 500,
real_time_drawing: true,
render_threads: -1,
linux_display_server: s("automatic"),
background_save: true,
background_export: true,
autosave_recovery: true,
+25
View File
@@ -381,6 +381,31 @@ fn clipping_and_compound() {
assert_eq!(s.doc().unwrap().selection.objects, vec![b]);
}
/// #1163: a path of several subpaths (an SVG `d` with several `M`s) is a compound path to Release:
/// one path per subpath, each with the path's paint, in its stacking order, in one undo step.
#[test]
fn release_splits_a_path_of_several_subpaths() {
let mut s = session();
let id = NodeId(
s.execute("path.create", &json!({"d": "M0 0 L10 0 L10 10 Z M20 0 L30 0 L30 10 Z M40 0 L50 0 L50 10 Z"})).unwrap()["id"].as_u64().unwrap(),
);
s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap();
let undo = s.doc().unwrap().history.undo.len();
s.execute("object.compoundPath.release", &json!({})).unwrap();
let st = s.doc().unwrap();
assert_eq!(st.history.undo.len(), undo + 1);
let sel = st.selection.objects.clone();
assert_eq!((sel.len(), sel[0]), (3, id), "the first keeps its id");
let layer = st.doc.layers[0].children().unwrap();
for (k, n) in sel.iter().map(|i| st.doc.node(*i).unwrap()).enumerate() {
let NodeKind::Path { path, .. } = &n.kind else { panic!("a path") };
assert_eq!(path.subpaths.len(), 1);
assert_eq!(path.bounds().unwrap().x0, 20.0 * k as f64, "in order");
assert_eq!(n.appearance.fill_paint().color().unwrap().to_hex(), "#ff0000", "the path's paint");
assert_eq!(layer[k].id, n.id, "stacked bottom to top");
}
}
/// `object.align` without `horizontal` or `vertical` (an unknown key such as `align` counts as
/// neither) returns an error: nothing moves, and the undo and redo stacks stay as they were.
#[test]
+17
View File
@@ -187,3 +187,20 @@ fn key_anchors_round_trip() {
let old: BlendSpec = serde_json::from_value(json!({"spine": {"subpaths": []}})).unwrap();
assert!(old.key_anchors.is_empty());
}
/// #1111: a straight spine's segment bends into a curve, its middle moving where it's dragged, and
/// the steps follow it; junk segments and deltas are refused.
#[test]
fn a_straight_spine_segment_bends_into_a_curve() {
let mut s = session();
let (_, _, g) = blend(&mut s);
s.execute("object.blend.spine.reshapeSegment", &json!({"segment": 0, "t": 0.5, "dx": 0, "dy": 40})).unwrap();
let spine = spec(&s, g).spine.unwrap();
let seg = spine.subpaths[0].segment(0);
use vectorcraft_geom::kurbo::ParamCurve;
assert!(seg.eval(0.5).distance(Point::new(55.0, 45.0)) < 1e-9, "{seg:?}");
assert!(center(&steps(&s, g)[1]).y > 20.0, "the step follows the curve");
for bad in [json!({"segment": 1, "dy": 10}), json!({"segment": 0, "dx": f64::MAX, "dy": f64::MAX}), json!({"segment": -1})] {
assert!(s.execute("object.blend.spine.reshapeSegment", &bad).is_err(), "{bad}");
}
}
+17
View File
@@ -543,6 +543,23 @@ fn hide_lock_and_outline_others_and_their_all_counterparts() {
assert_eq!(run(&mut s, "layer.previewAll", json!({}))["count"], 0, "nothing to do: no undo step");
}
/// #1158: Alt-click on a hidden (locked) layer's eye (lock) isolates it: it shows (unlocks) as the
/// others hide (lock), in one undo step.
#[test]
fn hide_and_lock_others_show_and_unlock_the_layer_kept() {
let mut s = session();
let l1 = s.doc().unwrap().doc.layers[0].id;
let l2 = id(&run(&mut s, "layer.new", json!({})));
let flags = |s: &Session, f: fn(&Node) -> bool| [l1, l2].map(|l| f(&node(s, l)));
run(&mut s, "layer.setProps", json!({"ids": [l2.0], "visible": false, "locked": true}));
assert_eq!(run(&mut s, "layer.hideOthers", json!({"ids": [l2.0]}))["count"], 2);
assert_eq!(flags(&s, |n| n.visible), [false, true]);
run(&mut s, "edit.undo", json!({}));
assert_eq!(flags(&s, |n| n.visible), [true, false], "one undo step");
run(&mut s, "layer.lockOthers", json!({"ids": [l2.0]}));
assert_eq!(flags(&s, |n| n.locked), [true, false]);
}
#[test]
fn locate_object_highlights_its_row() {
let mut s = session();
+17
View File
@@ -156,6 +156,23 @@ fn gpu_preference_defaults_to_automatic_and_validates() {
assert_eq!(s.prefs.gpu_preference, "automatic");
}
/// Performance › Linux Display Server (#1151): automatic by default, X11 by value or label, and
/// anything else is an error that changes nothing.
#[test]
fn linux_display_server_defaults_to_automatic_and_validates() {
let mut s = Session::new();
assert_eq!(s.prefs.linux_display_server, "automatic");
s.execute("prefs.set", &json!({"key": "linuxDisplayServer", "value": "X11 (XWayland)"})).unwrap();
assert_eq!(s.prefs.linux_display_server, "x11");
for bad in [json!("wayland"), json!(""), json!(11), json!(null)] {
assert!(s.execute("prefs.set", &json!({"key": "linuxDisplayServer", "value": bad})).is_err(), "{bad}");
assert_eq!(s.prefs.linux_display_server, "x11");
}
assert_eq!(s.prefs.to_json()["linuxDisplayServer"], "x11");
s.execute("prefs.reset", &json!({"category": "Performance"})).unwrap();
assert_eq!(s.prefs.linux_display_server, "automatic");
}
/// Selection & Anchor Display › Tolerance, Object Selection by Path Only and Command Click to
/// Select Objects Behind, and General › Double Click To Isolate, as the Selection tool sees them
/// (#394).
+1
View File
@@ -367,6 +367,7 @@ fn structured_junk() {
("object.blend.options", json!({"spacing": "distance", "value": -1e308, "orientation": 5})),
("object.blend.spine.moveAnchor", json!({"id": 2, "anchor": u64::MAX, "x": 1e308, "y": -1e308, "handle": "sideways"})),
("object.blend.spine.addAnchor", json!({"id": u64::MAX, "x": 0, "y": 0})),
("object.blend.spine.reshapeSegment", json!({"id": 2, "segment": u64::MAX, "t": f64::NAN, "dx": 1e308, "dy": -1e308})),
("object.blend.spine.removeAnchor", json!({"anchor": -1})),
("object.puppetWarp", json!({"rest": true, "pins": [], "moved": []})),
("object.puppetWarp", json!({"rest": true, "pins": [[1e308, 0]], "moved": [[0, 0]], "expand": -5})),
+183 -44
View File
@@ -1,7 +1,8 @@
//! Live Corners on any path: the anchors where two straight sides meet at an angle, and the path
//! with those corners cut round, inverted round or chamfered. Rectangles cut theirs the same way.
//! Live Corners on any path: the anchors where two sides, straight or curved, meet at an angle,
//! and the path with those corners cut round, inverted round or chamfered (a curved side trimmed
//! to what the cut leaves of it). Rectangles cut theirs the same way.
use kurbo::{Point, Vec2};
use kurbo::{CubicBez, ParamCurve, ParamCurveArclen, ParamCurveDeriv, Point, Vec2};
use crate::path::{Anchor, AnchorKind, PathData, SubPath};
use crate::shapes::{CornerKind, KAPPA};
@@ -11,39 +12,91 @@ const EPS: f64 = 1e-9;
/// Sides closer than this to straight on (or folded back), as the sine of their angle, make no corner.
const MIN_SINE: f64 = 1e-6;
/// A corner Live Corners can cut: an anchor without handles between two straight sides that
/// meet at an angle.
/// How closely arc lengths along curved sides are measured (points).
const ARCLEN_ACCURACY: f64 = 1e-6;
/// A side of a corner: the segment from the corner's anchor to its neighbour, as a cubic leaving
/// the corner (a straight side has its handles at its ends).
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Side {
pub curve: CubicBez,
}
impl Side {
/// Is the side straight, its handles on its ends?
fn straight(&self) -> bool {
self.curve.p1.distance(self.curve.p0) <= EPS && self.curve.p2.distance(self.curve.p3) <= EPS
}
/// The unit direction the side leaves the corner in: towards its first control point not on
/// the corner. `None` when the side has no length.
fn direction(&self) -> Option<Vec2> {
let c = self.curve;
let d = [c.p1, c.p2, c.p3].into_iter().map(|q| q - c.p0).find(|d| d.hypot() > EPS)?;
let d = d / d.hypot();
d.x.is_finite().then_some(d)
}
/// The side's length.
fn length(&self) -> f64 {
if self.straight() { self.curve.p0.distance(self.curve.p3) } else { self.curve.arclen(ARCLEN_ACCURACY) }
}
/// The parameter `s` along the side from the corner, the point there and the direction the
/// side runs on in (away from the corner).
fn at_length(&self, s: f64, u: Vec2) -> (f64, Point, Vec2) {
let c = self.curve;
if self.straight() {
let len = c.p0.distance(c.p3);
let t = if len > EPS { (s / len).clamp(0.0, 1.0) } else { 0.0 };
return (t, c.p0 + u * s, u);
}
let t = c.inv_arclen(s, ARCLEN_ACCURACY).clamp(0.0, 1.0);
let d = c.deriv().eval(t).to_vec2();
let dir = if d.hypot() > EPS { d / d.hypot() } else { u };
(t, c.eval(t), dir)
}
}
/// A corner Live Corners can cut: an anchor that isn't smooth, where two sides (straight or
/// curved) meet at an angle (#1144).
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Corner {
/// The anchor's index in its path, counting the anchors of every subpath in order.
pub index: usize,
/// Where the sides meet.
pub at: Point,
/// Unit directions along the sides: towards the previous anchor, then towards the next one.
/// Unit directions the sides leave the corner in: towards the previous anchor, then towards
/// the next one.
pub u: Vec2,
pub v: Vec2,
/// The sides' lengths, in the same order.
/// The sides' lengths (along curves), in the same order.
pub lu: f64,
pub lv: f64,
/// The sides themselves, leaving the corner, in the same order.
pub side_u: Side,
pub side_v: Side,
}
impl Corner {
/// The corner of anchor `i` of `sp`, whose index in the path is `index`; `None` when the
/// anchor is an end of an open subpath, has handles or is smooth, when a side curves or has
/// no length, or when the sides run straight on.
/// anchor is an end of an open subpath or is smooth, when a side has no length, or when the
/// sides leave it straight on.
fn of(sp: &SubPath, i: usize, index: usize) -> Option<Self> {
let n = sp.anchors.len();
let (prev, next) = if sp.closed { ((i + n).checked_sub(1)? % n, (i + 1) % n) } else { (i.checked_sub(1)?, i + 1) };
let (a, prev, next) = (sp.anchors.get(i)?, sp.anchors.get(prev)?, sp.anchors.get(next)?);
if a.kind == AnchorKind::Smooth || a.has_in() || a.has_out() || prev.has_out() || next.has_in() {
if a.kind == AnchorKind::Smooth {
return None;
}
let (du, dv) = (prev.p - a.p, next.p - a.p);
let (lu, lv) = (du.hypot(), dv.hypot());
let side_u = Side { curve: CubicBez::new(a.p, a.h_in, prev.h_out, prev.p) };
let side_v = Side { curve: CubicBez::new(a.p, a.h_out, next.h_in, next.p) };
let (u, v) = (side_u.direction()?, side_v.direction()?);
let (lu, lv) = (side_u.length(), side_v.length());
if !(lu > EPS && lv > EPS && lu.is_finite() && lv.is_finite()) {
return None;
}
let c = Self { index, at: a.p, u: du / lu, v: dv / lv, lu, lv };
let c = Self { index, at: a.p, u, v, lu, lv, side_u, side_v };
(c.sin() > MIN_SINE).then_some(c)
}
@@ -92,32 +145,47 @@ impl Corner {
d.dot(self.u + self.v) * self.sin() / (2.0 + 2.0 * self.cos())
}
/// The two anchors replacing the corner when cut with `radius` (> 0, fitted): where the cut
/// meets the arriving side, then the leaving one.
fn cut(&self, radius: f64, kind: CornerKind) -> [Anchor; 2] {
let t = self.setback(radius);
let (start, end) = (self.at + self.u * t, self.at + self.v * t);
/// The cut of radius `radius` (> 0, fitted): where it meets each side, `setback` along it,
/// and the cut's own curve between them, its handles leaving the sides smoothly.
fn cut(&self, radius: f64, kind: CornerKind) -> Cut {
let s = self.setback(radius);
let (tu, start, du) = self.side_u.at_length(s, self.u);
let (tv, end, dv) = self.side_v.at_length(s, self.v);
let cos = du.dot(dv);
let (h_out, h_in) = match kind {
// An arc of `radius` tangent to both sides, turning by 180° less the angle.
// An arc tangent to both sides, turning by 180° less the angle between them there.
CornerKind::Round => {
let h = radius * arc_handle(-self.cos());
(start - self.u * h, end - self.v * h)
let h = radius * arc_handle(-cos);
(start - du * h, end - dv * h)
}
// An arc centred on the corner through both ends, spanning the angle.
CornerKind::InvertedRound => {
let h = t * arc_handle(self.cos());
(start + square_to(self.u, self.v) * h, end + square_to(self.v, self.u) * h)
let h = s * arc_handle(cos);
(start + square_to(du, dv) * h, end + square_to(dv, du) * h)
}
CornerKind::Chamfer => (start, end),
};
[Anchor { p: start, h_in: start, h_out, kind: AnchorKind::Corner }, Anchor { p: end, h_in, h_out: end, kind: AnchorKind::Corner }]
Cut { tu, tv, start, h_out, h_in, end }
}
}
/// A corner cut: the parameters where it meets its sides (from the corner), its two new anchors
/// and the handles between them.
#[derive(Clone, Copy, Debug)]
struct Cut {
tu: f64,
tv: f64,
start: Point,
h_out: Point,
h_in: Point,
end: Point,
}
/// The unit direction square to unit `a`, on `b`'s side.
fn square_to(a: Vec2, b: Vec2) -> Vec2 {
let w = b - a * a.dot(b);
w / w.hypot()
let l = w.hypot();
if l > EPS { w / l } else { Vec2::ZERO }
}
/// The handle length, per unit of radius, of a cubic drawing a circular arc that spans an angle
@@ -126,6 +194,7 @@ fn arc_handle(cos: f64) -> f64 {
if cos.abs() < 1e-12 {
return KAPPA;
}
let cos = cos.clamp(-1.0, 1.0);
let (sin_half, cos_half) = (((1.0 - cos) / 2.0).max(0.0).sqrt(), ((1.0 + cos) / 2.0).max(0.0).sqrt());
4.0 / 3.0 * sin_half / (1.0 + cos_half)
}
@@ -143,24 +212,55 @@ pub fn path_corners(path: &PathData) -> Vec<Corner> {
/// `path` with each of its corners ([`path_corners`]) cut by the radius in `radii` and the kind in
/// `kinds` at its anchor index (missing: 0 and round), each radius no larger than fits
/// ([`Corner::fitted`]). A cut corner becomes two anchors; a closed subpath whose first corner
/// is cut starts where that cut ends, so the cut closes it. Also returns, per subpath, the
/// anchor index in `path` each anchor comes from.
/// ([`Corner::fitted`]). A cut corner becomes two anchors, and a curved side between corners is
/// trimmed to what is left of it; a closed subpath whose first corner is cut starts where that
/// cut ends, so the cut closes it. Also returns, per subpath, the anchor index in `path` each
/// anchor comes from.
pub fn cut_corners(path: &PathData, radii: &[f64], kinds: &[CornerKind]) -> (PathData, Vec<Vec<usize>>) {
let corners = path_corners(path);
let mut corners = corners.iter().peekable();
let (mut subpaths, mut sources) = (Vec::with_capacity(path.subpaths.len()), Vec::with_capacity(path.subpaths.len()));
let mut first = 0;
for sp in &path.subpaths {
let (mut anchors, mut from) = (Vec::with_capacity(sp.anchors.len()), Vec::with_capacity(sp.anchors.len()));
let n = sp.anchors.len();
// The cut of each anchor, if any.
let cuts: Vec<Option<Cut>> = (0..n)
.map(|i| {
let index = first + i;
let c = corners.next_if(|c| c.index == index)?;
let radius = c.fitted(radii.get(index).copied().unwrap_or(0.0));
(radius > EPS).then(|| c.cut(radius, kinds.get(index).copied().unwrap_or_default()))
})
.collect();
// Each segment from anchor j, trimmed to the part the cuts at its ends leave: its handles
// (out of its first anchor, into its last), `None` when it is untouched.
let trimmed: Vec<Option<(Point, Point)>> = (0..n)
.map(|j| {
let k = (j + 1) % n;
let (a, b) = (sp.anchors.get(j)?, sp.anchors.get(k)?);
let (from, to) = (cuts.get(j).copied().flatten(), cuts.get(k).copied().flatten());
if (from.is_none() && to.is_none()) || (k == 0 && !sp.closed) {
return None;
}
// A straight segment stays straight, its handles on its ends.
if !a.has_out() && !b.has_in() {
return Some((from.map_or(a.p, |c| c.end), to.map_or(b.p, |c| c.start)));
}
let (s0, s1) = (from.map_or(0.0, |c| c.tv), to.map_or(1.0, |c| 1.0 - c.tu));
let sub = CubicBez::new(a.p, a.h_out, b.h_in, b.p).subsegment(s0..s1.max(s0));
Some((sub.p1, sub.p2))
})
.collect();
let (mut anchors, mut from) = (Vec::with_capacity(n), Vec::with_capacity(n));
let mut closing = None;
for (i, a) in sp.anchors.iter().enumerate() {
let index = first + i;
let corner = corners.next_if(|c| c.index == index);
let radius = corner.map_or(0.0, |c| c.fitted(radii.get(index).copied().unwrap_or(0.0)));
match corner {
Some(c) if radius > EPS => {
let [start, end] = c.cut(radius, kinds.get(index).copied().unwrap_or_default());
let h_in = trimmed.get((i + n - 1) % n).copied().flatten().map_or(a.h_in, |(_, h)| h);
let h_out = trimmed.get(i).copied().flatten().map_or(a.h_out, |(h, _)| h);
match cuts.get(i).copied().flatten() {
Some(c) => {
let start = Anchor { p: c.start, h_in, h_out: c.h_out, kind: AnchorKind::Corner };
let end = Anchor { p: c.end, h_in: c.h_in, h_out, kind: AnchorKind::Corner };
if i == 0 && sp.closed {
closing = Some(start);
} else {
@@ -170,8 +270,8 @@ pub fn cut_corners(path: &PathData, radii: &[f64], kinds: &[CornerKind]) -> (Pat
anchors.push(end);
from.push(index);
}
_ => {
anchors.push(*a);
None => {
anchors.push(Anchor { h_in, h_out, ..*a });
from.push(index);
}
}
@@ -180,7 +280,7 @@ pub fn cut_corners(path: &PathData, radii: &[f64], kinds: &[CornerKind]) -> (Pat
anchors.push(a);
from.push(first);
}
first += sp.anchors.len();
first += n;
subpaths.push(SubPath::new(anchors, sp.closed));
sources.push(from);
}
@@ -246,11 +346,7 @@ mod tests {
false,
));
assert_eq!(path_corners(&open).iter().map(|c| c.index).collect::<Vec<_>>(), [2, 3]);
// A curve on either side, or handles on the anchor, leave it alone.
let mut sp = SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(100.0, 0.0), Point::new(100.0, 100.0), Point::new(0.0, 100.0)], true);
sp.anchors[1].h_out = Point::new(110.0, 10.0);
let p = PathData::single(sp);
assert_eq!(path_corners(&p).iter().map(|c| c.index).collect::<Vec<_>>(), [0, 3]);
// Smooth anchors aren't corners.
assert!(path_corners(&shapes::ellipse(Rect::new(0.0, 0.0, 10.0, 10.0))).is_empty());
// A second subpath counts on from the first one's anchors.
let two = PathData::new(vec![
@@ -262,6 +358,49 @@ mod tests {
assert_eq!(cut.anchor_count(), 7);
}
/// The square `0..100` with its right side bulging out as a curve (#1144).
fn bulging_square() -> PathData {
let mut sp = SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(100.0, 0.0), Point::new(100.0, 100.0), Point::new(0.0, 100.0)], true);
sp.anchors[1].h_out = Point::new(140.0, 30.0);
sp.anchors[2].h_in = Point::new(140.0, 70.0);
PathData::single(sp)
}
/// Corners where a curve meets a line are cut too: the cut ends on the path, leaves both
/// sides smoothly, and what is left of the curve still follows it (#1144).
#[test]
fn corners_next_to_curves_are_cut_along_them() {
let path = bulging_square();
let corners = path_corners(&path);
assert_eq!(corners.iter().map(|c| c.index).collect::<Vec<_>>(), [0, 1, 2, 3]);
let side = kurbo::CubicBez::new(Point::new(100.0, 0.0), Point::new(140.0, 30.0), Point::new(140.0, 70.0), Point::new(100.0, 100.0));
// The curved side is longer than its chord, and limits its corners by its length.
assert!(corners[1].lv > 100.0 + 1.0);
let (cut, from) = cut_corners(&path, &[10.0; 4], &[]);
assert_eq!(from, [vec![0, 1, 1, 2, 2, 3, 3, 0]]);
let sp = &cut.subpaths[0];
let on_side = |p: Point| (0..=1000).map(|i| side.eval(f64::from(i) / 1000.0).distance(p)).fold(f64::MAX, f64::min);
// Corner 1's cut ends on the curve and corner 2's starts on it, and the trimmed curve
// between them stays on the original one.
let (a, b) = (sp.anchors[2], sp.anchors[3]);
assert!(on_side(a.p) < 0.1 && on_side(b.p) < 0.1);
let trimmed = kurbo::CubicBez::new(a.p, a.h_out, b.h_in, b.p);
for t in [0.25, 0.5, 0.75] {
assert!(on_side(trimmed.eval(t)) < 0.1);
}
// Every cut leaves its sides smoothly: each anchor's handles are in line.
for (i, x) in sp.anchors.iter().enumerate() {
let (into, out) = if x.has_in() && x.has_out() {
(x.p - x.h_in, x.h_out - x.p)
} else if x.has_out() {
(x.p - sp.anchors[(i + sp.anchors.len() - 1) % sp.anchors.len()].p, x.h_out - x.p)
} else {
(x.h_in - x.p, x.p - sp.anchors[(i + 1) % sp.anchors.len()].p)
};
assert!(into.normalize().cross(out.normalize()).abs() < 1e-6 && into.dot(out) > 0.0, "anchor {i}");
}
}
/// Inverted round and chamfer cuts end where the round one does, at any angle.
#[test]
fn kinds_share_their_ends() {
@@ -272,8 +411,8 @@ mod tests {
assert_eq!(ends(CornerKind::Chamfer), round);
// The inverted arc stays on the circle about the corner.
let c = path_corners(&star)[0];
let [s, e] = c.cut(3.0, CornerKind::InvertedRound);
let bez = kurbo::CubicBez::new(s.p, s.h_out, e.h_in, e.p);
let cut = c.cut(3.0, CornerKind::InvertedRound);
let bez = kurbo::CubicBez::new(cut.start, cut.h_out, cut.h_in, cut.end);
let r = c.setback(3.0);
for t in [0.25, 0.5, 0.75] {
use kurbo::ParamCurve;
+9 -13
View File
@@ -66,7 +66,10 @@ pub fn stroke_contains(path: &BezPath, width: f64, tol: f64, p: Point) -> bool {
quick.contains(p) && distance_to_outline(path, p) <= width / 2.0 + tol
}
/// Does the path intersect (or lie inside) the rect? Used for marquee selection.
/// Does the path touch the rect: an anchor inside it, or a segment crossing its edges? Used for
/// marquee selection, which (as in Illustrator) needs the path itself: a rect wholly inside a
/// filled shape doesn't touch it, so a marquee drawn over a background picks only what is on it
/// (#1103).
pub fn intersects_rect(path: &PathData, r: Rect) -> bool {
let Some(b) = path.bounds() else { return false };
if b.intersect(r).area() <= 0.0 && !(b.width() == 0.0 || b.height() == 0.0) {
@@ -83,16 +86,7 @@ pub fn intersects_rect(path: &PathData, r: Rect) -> bool {
kurbo::Line::new((r.x1, r.y1), (r.x0, r.y1)),
kurbo::Line::new((r.x0, r.y1), (r.x0, r.y0)),
];
let bp = path.to_bezpath();
for seg in bp.segments() {
for e in &edges {
if !seg.intersect_line(*e).is_empty() {
return true;
}
}
}
// Rect entirely inside a closed shape counts as touching it.
path.is_closed() && fill_contains(&bp, FillRule::NonZero, r.center())
path.to_bezpath().segments().any(|seg| edges.iter().any(|e| !seg.intersect_line(*e).is_empty()))
}
#[cfg(test)]
@@ -152,8 +146,10 @@ mod tests {
assert!(!intersects_rect(&p, Rect::new(20.0, 20.0, 30.0, 30.0)));
// corner region of the bbox that misses the ellipse outline
assert!(!intersects_rect(&p, Rect::new(0.0, 0.0, 0.5, 0.5)));
// fully inside
assert!(intersects_rect(&p, Rect::new(4.0, 4.0, 6.0, 6.0)));
// Wholly inside the shape, touching neither its anchors nor its outline (#1103).
assert!(!intersects_rect(&p, Rect::new(4.0, 4.0, 6.0, 6.0)));
// Around the whole shape.
assert!(intersects_rect(&p, Rect::new(-1.0, -1.0, 11.0, 11.0)));
// crossing a line
let l = shapes::line(Point::new(0.0, 0.0), Point::new(10.0, 10.0));
assert!(intersects_rect(&l, Rect::new(4.0, 0.0, 6.0, 10.0)));
+18
View File
@@ -252,6 +252,24 @@ impl SubPath {
_ => a,
}
}
/// Bend segment `seg` so its point at `t` moves by `dv`: both inner control points move by
/// dv / 3t(1-t), since B(t) moves by 3t(1-t) times that. A smooth anchor stays smooth: its other
/// handle turns with the moved one. False when there is no such segment.
pub fn bend_segment(&mut self, seg: usize, t: f64, dv: kurbo::Vec2) -> bool {
let n = self.anchors.len();
if seg >= self.segment_count() || n == 0 {
return false;
}
let v = dv / (3.0 * t * (1.0 - t));
if let Some(a) = self.anchors.get_mut(seg % n) {
a.set_handle(true, a.h_out + v, false);
}
if let Some(b) = self.anchors.get_mut((seg + 1) % n) {
b.set_handle(false, b.h_in + v, false);
}
true
}
/// Split segment `seg` at parameter `t`, inserting a new anchor. Returns the new anchor index.
pub fn insert_anchor(&mut self, seg: usize, t: f64) -> usize {
let n = self.anchors.len();
+11
View File
@@ -65,6 +65,17 @@ fn conventions_commands_batch_and_bounded_preview() {
for max in [json!(0), json!(4097), json!("64")] {
assert_eq!(call(&mut s, "render_preview", json!({"max_side":max}))["isError"], true);
}
// Another artboard, three times wider than tall, previews by its index (#1163).
let r = call(&mut s, "run_command", json!({"command":"artboard.new","params":{"x":2000,"y":0,"width":300,"height":100}}));
assert_eq!(r["isError"], false, "{r}");
let r = call(&mut s, "render_preview", json!({"max_side":60,"artboard":1}));
assert_eq!(r["isError"], false, "{r}");
let png = vectorcraft_format::base64_decode(r["content"][0]["data"].as_str().unwrap()).unwrap();
let img = image::load_from_memory(&png).unwrap();
assert_eq!((img.width(), img.height()), (60, 20));
for board in [json!(2), json!(-1), json!("1")] {
assert_eq!(call(&mut s, "render_preview", json!({"artboard":board}))["isError"], true, "{board}");
}
for name in ["ui_inspect", "ui_screenshot"] {
assert_eq!(call(&mut s, name, json!({}))["isError"], true);
}
+12 -4
View File
@@ -113,8 +113,11 @@ pub fn tool_definitions() -> Vec<Value> {
tool(
"render_preview",
"Render a preview",
"Render the first artboard as inline PNG without changing the document. max_side defaults to 1024, limited to 1..4096.",
obj(json!({"max_side":{"type":"integer","minimum":1,"maximum":4096}}), &[]),
"Render an artboard (default the first) as inline PNG without changing the document. max_side defaults to 1024, limited to 1..4096.",
obj(
json!({"max_side":{"type":"integer","minimum":1,"maximum":4096},"artboard":{"type":"integer","minimum":0,"description":"0-based artboard (default 0)"}}),
&[],
),
true,
),
tool("ui_inspect", "Inspect the interface", "Inspect the connected desktop interface; returns a tool error in headless mode.", empty(), true),
@@ -946,8 +949,13 @@ fn render_preview(b: &mut dyn Backend, a: &Args) -> Result<ToolResult, String> {
None => 1024,
Some(v) => v.as_u64().filter(|v| (1..=4096).contains(v)).ok_or("`max_side` must be an integer in 1..4096")?,
};
let index = match a.get("artboard") {
None => 0,
Some(v) => v.as_u64().ok_or("`artboard` must be a 0-based artboard index")?,
};
let doc = b.call("document.inspect", json!({"depth":0}))?;
let board = doc.get("artboards").and_then(Value::as_array).and_then(|v| v.first()).ok_or("no such artboard")?;
let board =
doc.get("artboards").and_then(Value::as_array).and_then(|v| v.get(usize::try_from(index).ok()?)).ok_or(format!("no artboard {index}"))?;
let width = board.get("width").and_then(Value::as_f64).ok_or("artboard has no width")?;
let height = board.get("height").and_then(Value::as_f64).ok_or("artboard has no height")?;
if !width.is_finite() || !height.is_finite() || width <= 0.0 || height <= 0.0 {
@@ -959,7 +967,7 @@ fn render_preview(b: &mut dyn Backend, a: &Args) -> Result<ToolResult, String> {
if width.max(height) * scale > 4096.0 {
return Err("artboard is too large for a bounded preview".into());
}
let r = b.call("ui.render", json!({"scale":scale}))?;
let r = b.call("ui.render", json!({"scale":scale, "artboard": index}))?;
let encoded = r.get("pngBase64").and_then(Value::as_str).ok_or("renderer returned no image")?;
let png = vectorcraft_format::base64_decode(encoded).ok_or("renderer returned bad base64")?;
let img = image::load_from_memory_with_format(&png, image::ImageFormat::Png).map_err(|e| format!("read preview: {e}"))?;
+18
View File
@@ -1186,6 +1186,9 @@ impl Renderer {
}
fn draw_shape(&mut self, ctx: &mut RenderContext, f: &Frame, n: &Node, bp: &BezPath, rule: FillRule) {
if !drawable(bp, f.view, n) {
return;
}
if fx::has_fx(n) {
return self.draw_shape_fx(ctx, f, n, bp, rule);
}
@@ -1700,6 +1703,21 @@ fn mask_value(r: u8, g: u8, b: u8, a: u8, clip: bool, invert: bool) -> u8 {
(l.clamp(0.0, 1.0) * 255.0 + 0.5) as u8
}
/// Most pixels from the origin that a shape handed to the rasterizer may reach, or a stroke be
/// wide. A document's art stays within 4 million points, 2.6 billion pixels at the closest zoom.
/// Geometry far beyond that (from a malformed file) makes vello_cpu panic on a worker thread or ask
/// for more memory than there is, either of which ends the app: it is left undrawn.
const MAX_DEVICE: f64 = 1e10;
/// Can shape `bp` of `n` be drawn through `view`: its control points (finite) within
/// [`MAX_DEVICE`] pixels, and its visible strokes no wider?
fn drawable(bp: &BezPath, view: Affine, n: &Node) -> bool {
let b = view.transform_rect_bbox(bp.control_box());
let scale = view.determinant().abs().sqrt();
let widest = n.appearance.items.iter().filter_map(|i| if let AppearanceItem::Stroke(st) = i { Some(st.width) } else { None }).fold(0.0, f64::max);
[b.x0, b.y0, b.x1, b.y1].iter().all(|v| v.is_finite() && v.abs() <= MAX_DEVICE) && (widest * scale).is_finite() && widest * scale <= MAX_DEVICE
}
/// A render context on the calling thread. vello_cpu's `RenderContext::new` defaults to a
/// multithreaded dispatcher, which panics on filter effects (glows, shadows, blur).
pub(crate) fn single_threaded_context(w: u16, h: u16) -> RenderContext {
+27
View File
@@ -181,3 +181,30 @@ fn a_head_past_the_end_counts_in_the_visual_bounds_and_is_never_culled() {
let img = render_region(&d, Rect::new(83.0, 40.0, 100.0, 60.0), S);
assert!(img.ink() > 0, "the head is drawn");
}
/// A shape far beyond any document's reach (from a malformed file) is left undrawn instead of
/// making the rasterizer panic on a worker thread or ask for terabytes, which would end the app;
/// the art next to it still draws.
#[test]
fn shapes_far_beyond_the_page_are_left_undrawn_without_crashing() {
let mut d = Document::new(100.0, 100.0);
let layer = d.layers[0].id;
let mut far = vectorcraft_geom::BezPath::new();
far.move_to((13607718712.4, 13.2));
far.curve_to((-0.03, 720.96), (-3.08e17, -8.36e20), (-3.35e20, -1.0));
far.close_path();
let black =
|| Appearance { items: vec![AppearanceItem::Fill(vectorcraft_doc::FillLayer::new(Paint::solid(Color::BLACK)))], ..Default::default() };
for (path, app) in [
(vectorcraft_geom::PathData::from_bezpath(&far), black()),
(shapes::rectangle(Rect::new(10.0, 10.0, 30.0, 30.0)), black()),
(shapes::rectangle(Rect::new(50.0, 50.0, 60.0, 60.0)), stroked(1e30, |_| {})),
] {
let id = d.alloc_id();
d.insert(Some(layer), usize::MAX, vectorcraft_doc::Node::path(id, path, app)).unwrap();
}
let img = Renderer::new().render_region(&d, Rect::new(0.0, 0.0, 100.0, 100.0), 1.0, true);
let px = |x: usize, y: usize| img.pixels.get((y * img.width as usize + x) * 4).copied().unwrap();
assert_eq!(px(20, 20), 0, "the rectangle next to them draws");
assert_eq!(px(90, 5), 255, "nothing of the far shape or the huge stroke");
}
+4 -4
View File
@@ -1,6 +1,6 @@
//! Live Corners: the widgets inside the corners of the selected paths. The Selection tool shows
//! them on live rectangles and polygons, Direct Selection on any path (a star, a pen path) at each
//! corner anchor: one without handles between two straight sides. Dragging one rounds (or
//! corner anchor: one that isn't smooth, where two sides, straight or curved, meet at an angle. Dragging one rounds (or
//! sharpens) the corners whose widgets show together, on every selected path (#938): every corner
//! of a path selected whole, those with a selected anchor when Direct Selection picked some. Alt-clicking a widget cycles their corner
//! kind (round, inverted round, chamfer); double-clicking one opens the Corners dialog. The
@@ -701,7 +701,7 @@ mod tests {
assert!(red, "six arcs at the limit");
}
/// A pen path's middle corner shows a widget; its ends and curved corners don't.
/// A pen path's corners show widgets, curved ones too (#1144); its ends don't.
#[test]
fn a_pen_path_shows_widgets_on_its_corners_only() {
let mut sp = vectorcraft_geom::SubPath::polyline(
@@ -713,7 +713,7 @@ mod tests {
let s = selected(id);
let p = paint();
let w = CornerWidgets::for_tool(&cx(&d, &s, &p), true).unwrap();
assert_eq!(w.corners(), [1]);
assert_eq!(pts(&w), [Point::new(290.0, 110.0)]);
assert_eq!(w.corners(), [1, 2]);
assert_eq!(pts(&w)[0], Point::new(290.0, 110.0));
}
}
+79
View File
@@ -90,6 +90,15 @@ enum State {
start: Point,
began: bool,
},
/// Dragging a curved segment of a selected blend's spine: it follows the pointer
/// (`object.blend.spine.reshapeSegment`, #1111).
SpineSegment {
id: NodeId,
seg: usize,
t: f64,
start: Point,
began: bool,
},
/// Dragging a handle of the clicked spine point.
SpineHandle {
id: NodeId,
@@ -170,6 +179,15 @@ fn hit_spine_point(cx: &ToolContext, p: Point, tol: f64) -> Option<(NodeId, usiz
})
}
/// The segment of a selected blend's spine passing within `tol` of `p`: (blend, segment, t, whether
/// it is straight).
pub(crate) fn hit_spine_segment(cx: &ToolContext, p: Point, tol: f64) -> Option<(NodeId, usize, f64, bool)> {
spines(cx).into_iter().filter(|(id, _)| cx.selection.contains(*id)).find_map(|(id, path)| {
let (si, seg, t, _, dist) = path.nearest(p)?;
(dist <= tol).then(|| (id, seg, t, path.subpaths.get(si).is_some_and(|sp| sp.segment_is_line(seg))))
})
}
/// The spine point `sel` and whether `p` is on its outgoing handle's end (`Some(true)`) or its
/// incoming one's.
fn hit_spine_handle(cx: &ToolContext, sel: (NodeId, usize), p: Point, tol: f64) -> Option<bool> {
@@ -402,6 +420,11 @@ impl Tool for DirectSelectionTool {
if let Some((id, anchors)) = hit_frame_edge(cx, p, tol) {
return self.press_type_area(cx, id, anchors, p, ev.mods.shift);
}
// A curved segment of a selected blend's spine reshapes, as a path's does.
if let Some((id, seg, t, false)) = hit_spine_segment(cx, p, tol) {
self.state = State::SpineSegment { id, seg, t, start: p, began: false };
return vec![];
}
if let Some(out) = self.guide.press(cx, ev) {
self.state = State::Idle;
return out;
@@ -546,6 +569,22 @@ impl Tool for DirectSelectionTool {
out.push(Action::Preview("object.blend.spine.moveAnchor".into(), json!({"id": id.0, "anchor": anchor, "x": q.x, "y": q.y})));
out
}
(PointerKind::Drag, State::SpineSegment { id, seg, t, start, began }) => {
let mut out = vec![];
if !began {
if p.distance(start) < cx.tol(3.0) {
return out;
}
out.push(Action::Begin("Reshape Spine".into()));
self.state = State::SpineSegment { id, seg, t, start, began: true };
}
let d = p - start;
out.push(Action::Preview(
"object.blend.spine.reshapeSegment".into(),
json!({"id": id.0, "segment": seg, "t": t, "dx": d.x, "dy": d.y}),
));
out
}
(PointerKind::Drag, State::SpineHandle { id, anchor, out }) => vec![Action::Preview(
"object.blend.spine.moveAnchor".into(),
json!({"id": id.0, "anchor": anchor, "handle": if out {"out"} else {"in"}, "x": p.x, "y": p.y, "independent": ev.mods.alt}),
@@ -583,6 +622,7 @@ impl Tool for DirectSelectionTool {
| State::MoveObject { began, .. }
| State::Segment { began, .. }
| State::SpinePoint { began, .. }
| State::SpineSegment { began, .. }
| State::TypeArea { began, .. },
) => {
self.state = State::Idle;
@@ -814,6 +854,45 @@ mod tests {
assert!(t.overlays(&cx).iter().any(|o| matches!(o, Overlay::Anchor { filled: true, .. })), "the clicked point shows");
}
/// #1111: the Anchor Point tool bends a selected blend's straight spine; Direct Selection
/// reshapes a curved spine segment and leaves a straight one alone, as it does a path's.
#[test]
fn spine_segments_bend_with_the_anchor_point_tool_and_direct_selection() {
let (mut d, g, _) = blend_doc();
let mut s = Selection::default();
s.set([g]);
let p = paint();
let drag = |t: &mut dyn crate::Tool, cx: &ToolContext| {
t.pointer(cx, &PointerEvent::new(PointerKind::Down, 160.0, 311.0));
let a = t.pointer(cx, &PointerEvent::new(PointerKind::Drag, 160.0, 351.0));
t.pointer(cx, &PointerEvent::new(PointerKind::Up, 160.0, 351.0));
a
};
let straight = cx(&d, &s, &p);
let a = drag(crate::create("anchorPoint").as_mut(), &straight);
assert!(
matches!(a.last(), Some(Action::Preview(c, v)) if c == "object.blend.spine.reshapeSegment" && v["id"] == g.0 && v["segment"] == 0 && v["dy"] == 40.0),
"{a:?}"
);
let a = drag(&mut DirectSelectionTool::new(false), &straight);
assert!(!a.iter().any(|x| matches!(x, Action::Preview(c, _) if c == "object.blend.spine.reshapeSegment")), "straight: {a:?}");
// The spine bent upward: Direct Selection takes it from where it passes.
let mut sp = vectorcraft_geom::SubPath::polyline(&[Point::new(110.0, 310.0), Point::new(210.0, 310.0)], false);
sp.bend_segment(0, 0.5, vectorcraft_geom::Vec2::new(0.0, 20.0));
let Some(NodeKind::Blend { spec, .. }) = d.node_mut(g).map(|n| &mut n.kind) else { panic!("blend") };
(spec.spine, spec.key_anchors) = (Some(PathData::single(sp)), vec![0, 1]);
let mut t = DirectSelectionTool::new(false);
let curved = cx(&d, &s, &p);
t.pointer(&curved, &PointerEvent::new(PointerKind::Down, 160.0, 330.0));
let a = t.pointer(&curved, &PointerEvent::new(PointerKind::Drag, 160.0, 345.0));
assert_eq!(a[0], Action::Begin("Reshape Spine".into()));
assert!(
matches!(&a[1], Action::Preview(c, v) if c == "object.blend.spine.reshapeSegment" && v["segment"] == 0 && v["dx"] == 0.0 && v["dy"] == 15.0),
"{a:?}"
);
assert_eq!(t.pointer(&curved, &PointerEvent::new(PointerKind::Up, 160.0, 345.0)), vec![Action::Commit]);
}
#[test]
fn direct_and_group_selection_pick_blend_keys() {
let (d, g, k1) = blend_doc();
+32 -5
View File
@@ -4,7 +4,7 @@
//! Delete: click an anchor to remove it, re-fitting the neighbouring curve (Alt = add).
//! Anchor Point: click a smooth anchor → corner; drag an anchor → pull out smooth handles; drag a
//! handle → move it independently (Shift: at 45° steps round its anchor); drag a segment → reshape
//! the curve.
//! the curve, a selected blend's spine's too, straight or curved (#1111).
//! Scissors: click a path to split it there.
use serde_json::json;
@@ -19,9 +19,30 @@ use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, Tool
#[derive(Clone, Copy, Debug)]
enum State {
Idle,
Convert { id: NodeId, si: usize, ai: usize, start: Point, began: bool },
Handle { id: NodeId, si: usize, ai: usize, out: bool, began: bool },
Reshape { id: NodeId, si: usize, seg: usize, t: f64, start: Point, began: bool },
Convert {
id: NodeId,
si: usize,
ai: usize,
start: Point,
began: bool,
},
Handle {
id: NodeId,
si: usize,
ai: usize,
out: bool,
began: bool,
},
/// Bending a segment: a path's, or (`spine`) a selected blend's spine's.
Reshape {
id: NodeId,
si: usize,
seg: usize,
t: f64,
start: Point,
began: bool,
spine: bool,
},
}
pub struct AnchorTool {
@@ -94,7 +115,9 @@ impl Tool for AnchorTool {
} else if let Some((id, si, ai)) = hit_anchor(cx, p, point) {
self.state = State::Convert { id, si, ai, start: p, began: false };
} else if let Some((id, si, seg, t)) = hit_segment(cx, p, tol) {
self.state = State::Reshape { id, si, seg, t, start: p, began: false };
self.state = State::Reshape { id, si, seg, t, start: p, began: false, spine: false };
} else if let Some((id, seg, t, _)) = crate::direct::hit_spine_segment(cx, p, tol) {
self.state = State::Reshape { id, si: 0, seg, t, start: p, began: false, spine: true };
}
vec![]
}
@@ -128,6 +151,10 @@ impl Tool for AnchorTool {
json!({"id": id.0, "subpath": si, "anchor": ai, "which": if out { "out" } else { "in" }, "x": q.x, "y": q.y, "independent": true}),
)
}
State::Reshape { id, seg, t, start, spine: true, .. } => Action::Preview(
"object.blend.spine.reshapeSegment".into(),
json!({"id": id.0, "segment": seg, "t": t, "dx": p.x - start.x, "dy": p.y - start.y}),
),
State::Reshape { id, si, seg, t, start, .. } => Action::Preview(
"path.reshapeSegment".into(),
json!({"id": id.0, "subpath": si, "segment": seg, "t": t, "dx": p.x - start.x, "dy": p.y - start.y}),
+38 -2
View File
@@ -1238,6 +1238,8 @@ fn node_outline(n: &Node) -> BezPath {
[b, c, d].into_iter().for_each(|q| bp.line_to(q));
bp.close_path();
}
// A gradient mesh shows its mesh lines (#1111).
NodeKind::Mesh(m) => bp.extend(m.lines().to_bezpath()),
// An envelope shows its mesh (or top object), not its content.
NodeKind::Envelope { .. } => {
if let Some((lines, _)) = vectorcraft_doc::live::envelope_overlay(c) {
@@ -1632,9 +1634,15 @@ fn selection_overlay(app: &mut VectorcraftApp, p: &egui::Painter, xf: &Xf) {
let partial = st.selection.partial(*id);
// Path outlines.
stroke_path(p, &node_outline(n), xf, Stroke::new(1.0, color));
// Anchors (and handles for selected anchors in direct mode); a mesh envelope's points.
// Anchors (and handles for selected anchors in direct mode); a gradient mesh's or a mesh
// envelope's points.
walk_drawn(n, &mut |c| {
if let Some((_, Some(grid))) = vectorcraft_doc::live::envelope_overlay(c) {
let envelope = vectorcraft_doc::live::envelope_overlay(c).and_then(|(_, grid)| grid);
let grid = match &c.kind {
NodeKind::Mesh(m) => Some(m),
_ => envelope.as_ref(),
};
if let Some(grid) = grid {
for q in &grid.points {
anchor_square(p, xf.to_screen(q.p), color, false, anchor(direct));
}
@@ -2548,6 +2556,34 @@ mod tests {
assert!(points > 200 && squares > 200, "{points} outline points, {squares} squares");
}
/// #1111: a selected gradient mesh shows its mesh lines and points to the selection tools, not
/// only to the Mesh tool.
#[test]
fn a_selected_gradient_mesh_shows_its_lines_and_points() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
app.session.execute("shape.rectangle", &json!({"x": 20, "y": 20, "width": 200, "height": 100})).unwrap();
app.session.execute("object.mesh.create", &json!({"rows": 2, "cols": 2})).unwrap();
let ctx = egui::Context::default();
let drawn = |app: &mut VectorcraftApp| {
frame(app, &ctx, vec![]);
let shapes = shapes(app, &ctx);
let lines = shapes.iter().filter(|s| matches!(s, Shape::Path(ps) if ps.points.len() > 1)).count();
(lines, shapes.iter().filter(|s| matches!(s, Shape::Rect(_))).count())
};
let (none_lines, none_points) = {
app.session.execute("select.none", &json!({})).unwrap();
drawn(&mut app)
};
for tool in ["selection", "directSelection"] {
app.select_tool(tool);
app.session.execute("select.all", &json!({})).unwrap();
let (lines, points) = drawn(&mut app);
// The outline, two inner lines each way; the 3 × 3 points.
assert!(lines >= none_lines + 3 && points >= none_points + 9, "{tool}: {lines} lines, {points} points");
}
}
/// #541: the key object's outline is drawn thicker than the rest of the selection's.
#[test]
fn the_key_object_has_a_thicker_outline() {
+1 -1
View File
@@ -46,7 +46,7 @@ pub fn open(app: &mut VectorcraftApp, p: &Value) -> Result<Value, String> {
};
// Every corner given is one of the path's: none is left out of the edit.
if corners.is_empty() || !corners.iter().all(|k| live.corner(*k).is_some()) {
return Err("give corners of the path: anchors between two straight sides".into());
return Err("give corners of the path: anchors where its sides meet at an angle".into());
}
let (radius, kind) = live.style(&corners);
let mut fields = json!({"id": id.0, "corners": corners});
+50
View File
@@ -138,6 +138,7 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
Some(plugin) => form::schema_fields(ui, d, &plugin.manifest().params),
None if vectorcraft_effects::is_adjustment(&id) => adjust_fields(ui, d),
None if id == TRANSFORM => transform_fields(ui, d, app.session.general_unit()),
None if id == ZIG_ZAG => zig_zag_fields(ui, d, app.session.general_unit()),
None => {
let doc = vectorcraft_effects::effect_info(&id).map(|e| e.params).unwrap_or_default();
let unit = app.session.general_unit();
@@ -313,6 +314,55 @@ fn transform_fields(ui: &mut egui::Ui, d: &mut Dialog, unit: vectorcraft_doc::Un
values_changed(d)
}
/// The Zig Zag effect (Effect › Distort & Transform › Zig Zag…).
const ZIG_ZAG: &str = "distort.zigZag";
/// Zig Zag's dialog (#1133): Size and Ridges per segment as sliders with their fields, Relative or
/// Absolute and the Points as radio buttons. Returns whether the values differ from those last
/// previewed.
fn zig_zag_fields(ui: &mut egui::Ui, d: &mut Dialog, unit: vectorcraft_doc::Unit) -> bool {
const LABEL_W: f32 = 124.0;
const FIELD_W: f32 = 70.0;
let rail = Tokens::get(ui.ctx()).input_border;
let track = move |_| rail;
let relative = d.bool("relative");
widgets::subheader(ui, tl!("Options"));
// Size is a share of the object's size (Relative) or a distance (Absolute).
widgets::label_row(ui, tl!("Size:"), LABEL_W, |ui| {
form::slider_rail(ui, d, "size", 0.0..=100.0, &track);
if !relative {
form::length(ui, d, "size", unit, FIELD_W);
} else if let Some(n) = widgets::range_field(ui, ("zz", "size"), d.f64("size", 10.0), 0.0..=100.0, "%", 0, FIELD_W) {
d.fields.insert("size".into(), json!(n));
}
});
ui.horizontal(|ui| {
ui.add_space(LABEL_W + ui.spacing().item_spacing.x);
for (label, on) in [(tl!("Relative"), true), (tl!("Absolute"), false)] {
if widgets::radio(ui, label, relative == on, true) {
d.fields.insert("relative".into(), json!(on));
}
}
});
widgets::label_row(ui, tl!("Ridges per segment:"), LABEL_W, |ui| {
form::slider_rail(ui, d, "ridges", 0.0..=100.0, &track);
if let Some(n) = widgets::range_field(ui, ("zz", "ridges"), d.f64("ridges", 4.0), 0.0..=100.0, "", 0, FIELD_W) {
d.fields.insert("ridges".into(), json!(n));
}
});
ui.add_space(4.0);
widgets::subheader(ui, crate::i18n::tr_ctx(crate::i18n::current(), "effect", "Points"));
let points = d.str("points");
ui.horizontal(|ui| {
for (label, value) in choices(ZIG_ZAG, "points").unwrap_or_default() {
if widgets::radio(ui, crate::i18n::tr_ctx(crate::i18n::current(), "effect", label), points == *value, true) {
d.fields.insert("points".into(), json!(value));
}
}
});
values_changed(d)
}
/// Curves' graph of its `points` (input across, output up, 0..255): drag a point to move it
/// between its neighbours, press on the graph away from the points to add one there, drag one
/// off the graph to remove it (two always stay).
+8 -1
View File
@@ -40,5 +40,12 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
let (id, params) = command(d);
if d.kind == "offsetPath" { form::commit_preview(app, id, params) } else { run_and_close(app, id, params) }
if d.kind != "offsetPath" {
return run_and_close(app, id, params);
}
let r = form::commit_preview(app, id, params)?;
// The fields as the dialog held them, so they show as typed when it opens again.
let kept = ["offset", "joins", "miterLimit"].into_iter().filter_map(|k| Some((k.to_string(), d.fields.get(k)?.clone())));
app.ui.offset_path = Value::Object(kept.collect());
Ok(r)
}
+15
View File
@@ -540,3 +540,18 @@ fn artboard_options_position_moves_art_only_with_the_tool_option_on() {
assert_eq!(*app.session.doc().unwrap().doc, *before);
}
}
/// Zig Zag's dialog has Size and Ridges per segment sliders, and Relative / Absolute and Smooth /
/// Corner as radio buttons, not dropdowns (#1133).
#[test]
fn zig_zag_has_sliders_and_radio_buttons() {
let mut app = app();
app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 50})).unwrap();
app.run("effect.dialog", json!({"effect": "distort.zigZag"})).unwrap();
let text = crate::tests_labels::painted_text(&mut app, |app, ui| show(app, ui.ctx()));
let lines: Vec<&str> = text.lines().map(str::trim).collect();
for want in ["Size:", "Relative", "Absolute", "Ridges per segment:", "Points", "Smooth", "Corner"] {
assert!(lines.contains(&want), "no {want:?} in {lines:?}");
}
assert!(!lines.contains(&"Ridges:"), "{lines:?}");
}
+16
View File
@@ -201,3 +201,19 @@ fn offset_path_previews() {
cancel(&mut app);
assert_eq!(count(&app), 1, "rolled back");
}
/// Offset Path opens on the values it was last confirmed with (#1152).
#[test]
fn offset_path_remembers_its_last_values() {
let mut app = app_with_selection();
app.run("select.all", json!({})).unwrap();
crate::menus::invoke(&mut app, "object.path.offsetPath", json!({}));
let d = app.ui.dialog.as_mut().unwrap();
d.fields.insert("offset".into(), json!(-4.5));
d.fields.insert("joins".into(), json!("round"));
confirm(&mut app).unwrap();
assert!(app.ui.dialog.is_none());
crate::menus::invoke(&mut app, "object.path.offsetPath", json!({}));
let d = app.ui.dialog.as_ref().unwrap();
assert_eq!((d.f64("offset", 0.0), d.str("joins"), d.f64("miterLimit", 0.0)), (-4.5, "round".to_string(), 4.0));
}
+6 -1
View File
@@ -1545,6 +1545,7 @@
Move Rows Zeilen verschieben
Move Slices Slices verschieben
Move Spine Point Achsenpunkt verschieben
Reshape Spine Segment Achsensegment umformen
Move Swatch Farbfeld verschieben
Move Up Nach oben verschieben
Move… Verschieben…
@@ -2583,6 +2584,8 @@
Size Tile to Art Kachelgröße an Grafik anpassen
Size/Leading Größe/Zeilenabstand
Size: Größe:
Ridges per segment: Zacken pro Segment:
effect Points Punkte
Size: {size} Tracking: {tracking} Größe: {size} Laufweite: {tracking}
Skew Neigen
Skin Tone Ramps Hauttonskalen
@@ -3429,7 +3432,7 @@ theme Appearance Erscheinungsbild
@msg freeform point {i} needs `at` [x, y] and a valid `color` Freiformpunkt {i} braucht `at` [x, y] und eine gültige `color`
@msg freeform line {i} must be an array of point indices Freiformlinie {i} muss ein Array von Punktindizes sein
@msg give both `start` and `end` [x, y] (or neither) geben Sie sowohl `start` als auch `end` [x, y] an (oder keines von beiden)
@msg give corners of the path: anchors between two straight sides geben Sie Ecken des Pfads an: Ankerpunkte zwischen zwei geraden Seiten
@msg give corners of the path: anchors where its sides meet at an angle geben Sie Ecken des Pfads an: Ankerpunkte, an denen seine Seiten im Winkel aufeinandertreffen
@msg give horizontal or vertical geben Sie horizontal oder vertical an
@msg give the path `id` and the width point `index` geben Sie die `id` des Pfads und den `index` des Breitenpunkts an
@msg guides are locked die Hilfslinien sind gesperrt
@@ -4095,3 +4098,5 @@ theme Appearance Erscheinungsbild
@msg invalid native panel set Ungültiger nativer Bedienfeldsatz
@msg missing panel layout Bedienfeldanordnung fehlt
@msg unsupported native docking operation Nicht unterstützte native Docking-Operation
Linux Display Server Linux-Anzeigeserver
X11 (XWayland) X11 (XWayland)
Can't render this file because it contains an unexpected character in line 2 and column 73.
+5 -1
View File
@@ -1563,6 +1563,7 @@
Move Rows Mover filas
Move Slices Mover sectores
Move Spine Point Mover punto del eje
Reshape Spine Segment Remodelar segmento del eje
Move Swatch Mover muestra
Move Up Subir
Move… Mover…
@@ -2634,6 +2635,7 @@
Size Tile to Art Ajustar tamaño del mosaico a la ilustración
Size/Leading Tamaño/interlineado
Size: Tamaño:
Ridges per segment: Crestas por segmento:
Size: {size} Tracking: {tracking} Tamaño: {size} Espaciado: {tracking}
Skew Sesgar
Skin Tone Ramps Rampas de tonos de piel
@@ -3530,7 +3532,7 @@ axis Both Ambos
@msg freeform point {i} needs `at` [x, y] and a valid `color` el punto de forma libre {i} necesita `at` [x, y] y un `color` válido
@msg freeform line {i} must be an array of point indices la línea de forma libre {i} debe ser una lista de índices de puntos
@msg give both `start` and `end` [x, y] (or neither) indica `start` y `end` [x, y] (o ninguno de los dos)
@msg give corners of the path: anchors between two straight sides indica esquinas del trazado: puntos de ancla entre dos lados rectos
@msg give corners of the path: anchors where its sides meet at an angle indica esquinas del trazado: puntos de ancla donde sus lados se unen en ángulo
@msg give horizontal or vertical indica horizontal o vertical
@msg give the path `id` and the width point `index` indica el `id` del trazado y el `index` del punto de anchura
@msg guides are locked las guías están bloqueadas
@@ -4140,3 +4142,5 @@ lighting Light Luz
@msg invalid native panel set Conjunto nativo de paneles no válido
@msg missing panel layout Falta la distribución de paneles
@msg unsupported native docking operation Operación nativa de anclaje no admitida
Linux Display Server Servidor de pantalla de Linux
X11 (XWayland) X11 (XWayland)
Can't render this file because it contains an unexpected character in line 2 and column 73.
+5 -1
View File
@@ -1562,6 +1562,7 @@
Move Rows Déplacer les lignes
Move Slices Déplacer les tranches
Move Spine Point Déplacer le point de la dorsale
Reshape Spine Segment Modeler le segment de la dorsale
Move Swatch Déplacer la nuance
Move Up Monter
Move… Déplacer…
@@ -2631,6 +2632,7 @@
Size Tile to Art Ajuster le carreau à l’illustration
Size/Leading Taille/interlignage
Size: Taille :
Ridges per segment: Crêtes par segment :
Size: {size} Tracking: {tracking} Taille : {size} Approche : {tracking}
Skew Inclinaison
Skin Tone Ramps Gammes de tons chair
@@ -3527,7 +3529,7 @@ axis Both Les deux
@msg freeform point {i} needs `at` [x, y] and a valid `color` le point de forme libre {i} nécessite `at` [x, y] et une `color` valide
@msg freeform line {i} must be an array of point indices la ligne de forme libre {i} doit être un tableau d’indices de points
@msg give both `start` and `end` [x, y] (or neither) indiquez à la fois `start` et `end` [x, y] (ou aucun des deux)
@msg give corners of the path: anchors between two straight sides indiquez des angles du tracé : des points d’ancrage entre deux côtés droits
@msg give corners of the path: anchors where its sides meet at an angle indiquez des angles du tracé : des points d’ancrage où ses côtés se rejoignent en angle
@msg give horizontal or vertical indiquez horizontal ou vertical
@msg give the path `id` and the width point `index` indiquez l’`id` du tracé et l’`index` du point de largeur
@msg guides are locked les repères sont verrouillés
@@ -4150,3 +4152,5 @@ lighting Light Lumière
@msg invalid native panel set Ensemble natif de panneaux invalide
@msg missing panel layout Disposition des panneaux manquante
@msg unsupported native docking operation Opération native d’ancrage non prise en charge
Linux Display Server Serveur d’affichage Linux
X11 (XWayland) X11 (XWayland)
Can't render this file because it contains an unexpected character in line 2 and column 73.
+5 -1
View File
@@ -1563,6 +1563,7 @@
Move Rows Sposta righe
Move Slices Sposta sezioni
Move Spine Point Sposta punto dorsale
Reshape Spine Segment Rimodella segmento della dorsale
Move Swatch Sposta campione
Move Up Sposta su
Move… Sposta…
@@ -2634,6 +2635,7 @@
Size Tile to Art Adatta tassello alla grafica
Size/Leading Dimensione/interlinea
Size: Dimensione:
Ridges per segment: Creste per segmento:
Size: {size} Tracking: {tracking} Dimensione: {size} Spaziatura caratteri: {tracking}
Skew Inclinazione
Skin Tone Ramps Scale di tonalità della pelle
@@ -3530,7 +3532,7 @@ axis Both Entrambi
@msg freeform point {i} needs `at` [x, y] and a valid `color` il punto a forma libera {i} richiede `at` [x, y] e un `color` valido
@msg freeform line {i} must be an array of point indices la linea a forma libera {i} deve essere un array di indici di punti
@msg give both `start` and `end` [x, y] (or neither) specifica sia `start` sia `end` [x, y] (o nessuno dei due)
@msg give corners of the path: anchors between two straight sides specifica angoli del tracciato: punti di ancoraggio tra due lati diritti
@msg give corners of the path: anchors where its sides meet at an angle specifica angoli del tracciato: punti di ancoraggio in cui i suoi lati si incontrano ad angolo
@msg give horizontal or vertical specifica horizontal o vertical
@msg give the path `id` and the width point `index` specifica l'`id` del tracciato e l'`index` del punto di larghezza
@msg guides are locked le guide sono bloccate
@@ -4142,3 +4144,5 @@ lighting Light Luce
@msg invalid native panel set Gruppo nativo di pannelli non valido
@msg missing panel layout Disposizione dei pannelli mancante
@msg unsupported native docking operation Operazione nativa di ancoraggio non supportata
Linux Display Server Server di visualizzazione Linux
X11 (XWayland) X11 (XWayland)
Can't render this file because it contains an unexpected character in line 2 and column 73.
+5 -1
View File
@@ -1697,6 +1697,7 @@
Move Rows 行を移動
Move Slices スライスを移動
Move Spine Point スパインポイントを移動
Reshape Spine Segment スパインセグメントをシェイプ変形
Move Swatch スウォッチを移動
Move Up 上へ移動
Move in Perspective 遠近で移動
@@ -2456,6 +2457,7 @@
Size Tile to Art タイルをアートワークのサイズに合わせる
Size/Leading サイズ/行送り
Size: サイズ:
Ridges per segment: セグメントごとの山数:
Size: {size} Tracking: {tracking} サイズ:{size} トラッキング:{tracking}
Skip Blank Artboards 空白のアートボードをスキップ
Slice Options スライスオプション
@@ -3745,7 +3747,7 @@ theme Appearance 外観
@msg no blend options ブレンドオプションがありません
@msg bad color {v} カラー {v} が正しくありません
@msg Corners edits the corners of a path 「コーナー」で編集できるのはパスの角だけです
@msg give corners of the path: anchors between two straight sides パスの角 (2 本の直線の辺に挟まれたアンカーポイント) を指定してください
@msg give corners of the path: anchors where its sides meet at an angle パスの角 (辺が角度をなして交わるアンカーポイント) を指定してください
@msg select one path, or give its `id` パスを 1 つ選択するか、その `id` を指定してください
@msg the drawing is no longer loaded 図面はもう読み込まれていません
@msg No saved selections (Select → Save Selection…) 保存した選択範囲はありません (選択 → 選択範囲を保存…)
@@ -4136,3 +4138,5 @@ lighting Light ライト
@msg invalid native panel set ネイティブパネルセットが無効です
@msg missing panel layout パネル配置がありません
@msg unsupported native docking operation ネイティブドッキング操作は未対応です
Linux Display Server Linux ディスプレイサーバー
X11 (XWayland) X11 (XWayland)
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -1334,6 +1334,7 @@
Move Plane to Match Object Mover Plano para Corresponder ao Objeto
Move Slices Mover Fatias
Move Spine Point Mover Ponto de Coluna
Reshape Spine Segment Remodelar Segmento da Coluna
Move Swatch Mover Amostra
Move Up Mover para Cima
Move in Perspective Mover em Perspectiva
Can't render this file because it contains an unexpected character in line 6 and column 47.
+5 -1
View File
@@ -1561,6 +1561,7 @@
Move Rows Переместить строки
Move Slices Переместить фрагменты
Move Spine Point Переместить точку остова
Reshape Spine Segment Изменить форму сегмента остова
Move Swatch Переместить образец
Move Up Переместить вверх
Move… Переместить…
@@ -2630,6 +2631,7 @@
Size Tile to Art Подогнать плитку по рисунку
Size/Leading Размер/интерлиньяж
Size: Размер:
Ridges per segment: Гребней на сегмент:
Size: {size} Tracking: {tracking} Размер: {size} Трекинг: {tracking}
Skew Скос
Skin Tone Ramps Шкалы тонов кожи
@@ -3526,7 +3528,7 @@ axis Both Оба
@msg freeform point {i} needs `at` [x, y] and a valid `color` произвольной точке {i} нужны `at` [x, y] и допустимый `color`
@msg freeform line {i} must be an array of point indices произвольная линия {i} должна быть массивом индексов точек
@msg give both `start` and `end` [x, y] (or neither) укажите и `start`, и `end` [x, y] (или ни одного)
@msg give corners of the path: anchors between two straight sides укажите углы контура: опорные точки между двумя прямыми сторонами
@msg give corners of the path: anchors where its sides meet at an angle укажите углы контура: опорные точки, в которых его стороны сходятся под углом
@msg give horizontal or vertical укажите horizontal или vertical
@msg give the path `id` and the width point `index` укажите `id` контура и `index` точки ширины
@msg guides are locked направляющие заблокированы
@@ -4138,3 +4140,5 @@ lighting Light Свет
@msg invalid native panel set Недопустимый собственный набор панелей
@msg missing panel layout Отсутствует расположение панелей
@msg unsupported native docking operation Неподдерживаемая собственная операция прикрепления
Linux Display Server Дисплейный сервер Linux
X11 (XWayland) X11 (XWayland)
Can't render this file because it contains an unexpected character in line 2 and column 73.
+6 -1
View File
@@ -1557,6 +1557,7 @@
Move Rows Перемістити рядки
Move Slices Перемістити фрагменти
Move Spine Point Перемістити точку осі
Reshape Spine Segment Змінити форму сегмента осі
Move Swatch Перемістити зразок
Move Up Перемістити вгору
Move… Перемістити…
@@ -2626,6 +2627,8 @@
Size Tile to Art Підігнати плитку за малюнком
Size/Leading Розмір/інтерліньяж
Size: Розмір:
Ridges per segment: Гребенів на сегмент:
effect Points Точки
Size: {size} Tracking: {tracking} Розмір: {size} Трекінг: {tracking}
Skew Скіс
Skin Tone Ramps Шкали тонів шкіри
@@ -3513,7 +3516,7 @@ axis Both Обидві
@msg freeform point {i} needs `at` [x, y] and a valid `color` довільній точці {i} потрібні `at` [x, y] та допустимий `color`
@msg freeform line {i} must be an array of point indices довільна лінія {i} має бути масивом індексів точок
@msg give both `start` and `end` [x, y] (or neither) вкажіть `start`, `end` [x, y] (або жодного)
@msg give corners of the path: anchors between two straight sides вкажіть кути контуру: опорні точки між двома прямими сторонами
@msg give corners of the path: anchors where its sides meet at an angle вкажіть кути контуру: опорні точки, у яких його сторони сходяться під кутом
@msg give horizontal or vertical вкажіть horizontal або vertical
@msg give the path `id` and the width point `index` вкажіть `id` контуру та `index` точки ширини
@msg guides are locked напрямні заблоковані
@@ -4121,3 +4124,5 @@ lighting Light Світло
@msg invalid native panel set Неприпустимий власний набір панелей
@msg missing panel layout Відсутнє розташування панелей
@msg unsupported native docking operation Непідтримувана власна операція прикріплення
Linux Display Server Дисплейний сервер Linux
X11 (XWayland) X11 (XWayland)
Can't render this file because it contains an unexpected character in line 659 and column 40.
+4
View File
@@ -1494,6 +1494,7 @@
Move Plane to Match Object 移动平面以符合对象
Move Slices 移动切片
Move Spine Point 移动脊线点
Reshape Spine Segment 整形脊线线段
Move Swatch 移动色板
Move Up 上移
Move… 移动…
@@ -2522,6 +2523,7 @@
Size Tile to Art 使拼贴适合图稿
Size/Leading 字号/行距
Size: 尺寸:
Ridges per segment: 每段起伏数:
Size: {size} Tracking: {tracking} 大小:{size} 字距:{tracking}
Skew 倾斜
Skin Tone Ramps 肤色渐变
@@ -3741,3 +3743,5 @@ lighting Light 光源
Vertical Guide 垂直参考线
Horizontal Guide 水平参考线
Move Guide to Layer 将参考线移至图层
Linux Display Server Linux 显示服务器
X11 (XWayland) X11 (XWayland)
Can't render this file because it contains an unexpected character in line 2 and column 73.
+4
View File
@@ -1538,6 +1538,7 @@
Move Rows 移動列
Move Slices 移動切片
Move Spine Point 移動脊線點
Reshape Spine Segment 改變脊線線段外框
Move Swatch 移動色票
Move Up 上移
Move… 移動…
@@ -2612,6 +2613,7 @@
Size Tile to Art 拼貼符合圖稿大小
Size/Leading 大小/行距
Size: 大小:
Ridges per segment: 每段起伏數:
Size: {size} Tracking: {tracking} 大小:{size} 字距:{tracking}
Skew 傾斜
Skin Tone Ramps 膚色漸階
@@ -3522,3 +3524,5 @@ lighting Light 光源
Vertical Guide 垂直參考線
Horizontal Guide 水平參考線
Move Guide to Layer 將參考線移至圖層
Linux Display Server Linux 顯示伺服器
X11 (XWayland) X11 (XWayland)
Can't render this file because it contains an unexpected character in line 6 and column 47.
+5 -3
View File
@@ -1331,7 +1331,7 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<R
"ui.spotColors" => crate::dialogs::spot_colors::open(app),
"ui.menuDialog" => match s("command").as_deref() {
Some(c) if crate::dialogs::envelope::opens(c) => crate::dialogs::envelope::open(app, c),
c => match c.and_then(menu_dialog) {
c => match c.and_then(|c| menu_dialog(app, c)) {
Some((kind, fields)) => {
app.ui.dialog = Some(crate::state::Dialog::new(kind, fields));
Ok(Value::Null)
@@ -2976,7 +2976,7 @@ fn queried_dialog(id: &str) -> Option<(&'static str, &'static [&'static str])> {
}
/// The dialog (kind, fields) the menu item of command `id` opens, as the reference app's does.
fn menu_dialog(id: &str) -> Option<(&'static str, Value)> {
fn menu_dialog(app: &VectorcraftApp, id: &str) -> Option<(&'static str, Value)> {
Some(match id {
"object.move" => ("move", json!({"dx": "0 pt", "dy": "0 pt"})),
"object.rotate" => ("rotate", json!({"angle": 0})),
@@ -2985,6 +2985,8 @@ fn menu_dialog(id: &str) -> Option<(&'static str, Value)> {
"object.reflect" => ("reflect", json!({"axis": "vertical"})),
"object.shear" => ("shear", json!({"angle": 0, "axis": "horizontal"})),
"path.average" => ("average", json!({"axis": "both"})),
// Offset Path opens on the values it was last confirmed with.
"object.path.offsetPath" if app.ui.offset_path.is_object() => ("offsetPath", app.ui.offset_path.clone()),
"object.path.offsetPath" => ("offsetPath", json!({"offset": "10 pt", "joins": "miter", "miterLimit": 4})),
"object.path.simplify" => ("simplify", json!({"tolerance": "1 pt"})),
"object.path.splitIntoGrid" => ("splitIntoGrid", json!({"rows": 2, "columns": 2, "gutter": "12 pt"})),
@@ -3041,7 +3043,7 @@ pub fn invoke(app: &mut VectorcraftApp, id: &str, p: Value) {
if waits_for_ime(app, id) {
return;
}
if let Some((kind, fields)) = menu_dialog(id)
if let Some((kind, fields)) = menu_dialog(app, id)
&& p.as_object().is_none_or(|o| o.is_empty())
{
app.ui.dialog = Some(crate::state::Dialog::new(kind, fields));
+4 -2
View File
@@ -306,6 +306,8 @@ fn category_fields(ui: &mut egui::Ui, d: &mut Dialog, cat: &str) {
}
});
}
// Linux only: the other platforms have a single display server.
PrefKind::Choice(_) if sp.key == "linuxDisplayServer" && !cfg!(target_os = "linux") => {}
PrefKind::Choice(opts) => {
labeled(ui, sp.label, |ui| {
let cur = v.as_str().unwrap_or("");
@@ -315,8 +317,8 @@ fn category_fields(ui: &mut egui::Ui, d: &mut Dialog, cat: &str) {
d.fields.insert(sp.key.into(), json!(opts[i].0));
}
});
if sp.key == "gpuPreference" {
// The window's graphics device is chosen once, when the app starts.
if sp.key == "gpuPreference" || sp.key == "linuxDisplayServer" {
// The window's graphics device and display server are chosen once, when the app starts.
ui.label(egui::RichText::new(tl!("Applies the next time VectorCraft starts.")).color(t.text_dim).size(11.0));
}
}
+5
View File
@@ -388,6 +388,10 @@ pub struct UiState {
/// The EPS Options chosen last (`document.exportEps` options; null: never used).
#[serde(default)]
pub eps_options: Value,
/// The Offset Path fields confirmed last (offset, joins, miterLimit; null: never used): its
/// dialog opens on them (#1152).
#[serde(default)]
pub offset_path: Value,
/// The DXF Import Options chosen last (fit, scaleLineweights, center, mergeLayers; null:
/// never used).
#[serde(default)]
@@ -639,6 +643,7 @@ impl Default for UiState {
dialog_search: None,
dxf_options: Value::Null,
eps_options: Value::Null,
offset_path: Value::Null,
dxf_import: Value::Null,
home: None,
layers_panel: Default::default(),
+3 -1
View File
@@ -692,7 +692,9 @@ new blends start with. The Blend tool (`ui.tool.select {tool: "blend"}`): `ui.po
another, blends them; down on an anchor point blends from that point; each further object clicked joins the blend.
A selected blend shows its spine. With Direct Selection (`directSelection`) a `ui.pointer` drag from a spine point
moves it (`object.blend.spine.moveAnchor`, one undo step) and, once a point is clicked, from one of its handles
reshapes the curve; Direct and Group Selection pick a blend's key objects. With the Pen tool a click on a selected
reshapes the curve, and a drag on a curved spine segment bends it (`object.blend.spine.reshapeSegment`); the Anchor
Point tool (`anchorPoint`) bends any segment of a selected blend's spine, straight ones too. Direct and Group Selection
pick a blend's key objects. With the Pen tool a click on a selected
blend's spine adds a point (on a point no key sits on, deletes it).
Perspective grid view options: View → Perspective Grid lists Show/Hide Grid, Show/Hide Rulers, Snap to Grid
+2 -2
View File
@@ -102,8 +102,8 @@ The window runs natively on Wayland (eframe's `wayland` feature) and on X11. On
- **Clipboard:** under Wayland arboard talks to the compositor through the data-control protocol (wlroots compositors such as Sway and Hyprland, KDE Plasma), so bitmaps (`image/png`), SVG markup and text copied in any app paste (#398). Where the compositor lacks the protocol (GNOME's Mutter, [arboard#223](https://github.com/1Password/arboard/issues/223)), arboard falls back to the X11 clipboard through XWayland, which only holds what X11 apps copied. egui's own text paste into fields goes through smithay-clipboard and works either way.
- **Clipboard owners that never answer (#510):** reading the clipboard waits on the app that owns it: arboard gives up on an X11 owner after 4 s, and a Wayland read waits for the owner to send its data. So on Linux the check that enables the Paste menu items runs on a background thread with a second clipboard handle (`crates/ui-egui/src/clipboard_probe.rs`, installed as `Services::clipboard_probe`), only while nothing is copied in the app and a document is open, and the window keeps drawing. A paste itself still reads on the UI thread, so pasting from such an owner waits for it once. Windows and macOS keep the check on the UI thread: Windows only asks which formats the clipboard holds, macOS asks the pasteboard server.
- **Copied files:** files copied in a file manager (`text/uri-list`; `CF_HDROP` on Windows, file URLs on macOS) paste as the first one that is art (SVG, PDF, EMF/WMF or a bitmap), before the clipboard's other formats (which include the files' paths as text).
- **Dropping files on the window does not work under Wayland:** winit 0.30, which eframe 0.36 runs on, has no Wayland drag and drop ([winit#1881](https://github.com/rust-windowing/winit/issues/1881), added in winit 0.31; [egui#1563](https://github.com/emilk/egui/issues/1563)), so no `dropped_files` arrive. Until eframe moves to winit 0.31: copy the files in the file manager and paste them, use File › Place or Open, or run the app under XWayland, where drops work (`WAYLAND_DISPLAY= vectorcraft`).
- **Pens and pen displays under Wayland (#491):** winit 0.30 doesn't bind the Wayland tablet protocol (`zwp_tablet_v2`; tablet input arrives in winit 0.31, [winit#4318](https://github.com/rust-windowing/winit/pull/4318), [egui#7731](https://github.com/emilk/egui/pull/7731)). Since Plasma 6.3, KWin no longer turns tablet input into pointer input for such apps ([kwin!6336](https://invent.kde.org/plasma/kwin/-/merge_requests/6336)): the pen moves the compositor's cursor over the window, but the app gets no motion or presses and shows none of its own cursors. Until eframe moves to winit 0.31, either run the app under XWayland, which supports the tablet protocol and hands the pen to X11 apps as a pointer (`WAYLAND_DISPLAY= vectorcraft`, or `env -u WAYLAND_DISPLAY vectorcraft`; for a desktop launcher, put `env WAYLAND_DISPLAY=` before the command in its `Exec=` line), or turn KWin's deprecated emulation back on for the whole session by setting `KWIN_WAYLAND_EMULATE_TABLET=1` in KWin's environment (for example in a file under `~/.config/environment.d/` or in `/etc/environment`) and logging in again. The pen then works as a mouse, without pressure: winit 0.30 reads pen pressure on Windows only (Windows Ink, which reaches the Liquify tools' Use Pressure Pen). Other compositors that don't emulate a pointer for tablets behave the same way.
- **Dropping files on the window does not work under Wayland:** winit 0.30, which eframe 0.36 runs on, has no Wayland drag and drop ([winit#1881](https://github.com/rust-windowing/winit/issues/1881), added in winit 0.31; [egui#1563](https://github.com/emilk/egui/issues/1563)), so no `dropped_files` arrive. Until eframe moves to winit 0.31: copy the files in the file manager and paste them, use File › Place or Open, or open the window through XWayland, where drops work: set **Preferences › Performance › Linux Display Server** to **X11 (XWayland)** and restart (#1151), or start the app with `WAYLAND_DISPLAY= vectorcraft`. The preference (`linuxDisplayServer`, read at startup in `apps/vectorcraft/src/main.rs`, shown on Linux only) has winit open the window on X11 (`EventLoopBuilderExtX11::with_x11`) when the session is Wayland, `DISPLAY` is set and the X11 libraries winit loads are installed (`apps/vectorcraft/src/xwayland.rs` reads `ldconfig -p` and the usual library directories); otherwise the window opens on Wayland and the log says why. Unlike clearing `WAYLAND_DISPLAY`, it leaves the clipboard on the compositor's own.
- **Pens and pen displays under Wayland (#491):** winit 0.30 doesn't bind the Wayland tablet protocol (`zwp_tablet_v2`; tablet input arrives in winit 0.31, [winit#4318](https://github.com/rust-windowing/winit/pull/4318), [egui#7731](https://github.com/emilk/egui/pull/7731)). Since Plasma 6.3, KWin no longer turns tablet input into pointer input for such apps ([kwin!6336](https://invent.kde.org/plasma/kwin/-/merge_requests/6336)): the pen moves the compositor's cursor over the window, but the app gets no motion or presses and shows none of its own cursors. Until eframe moves to winit 0.31, either run the app under XWayland, which supports the tablet protocol and hands the pen to X11 apps as a pointer (Preferences › Performance › Linux Display Server › X11 (XWayland), `WAYLAND_DISPLAY= vectorcraft`, or `env -u WAYLAND_DISPLAY vectorcraft`; for a desktop launcher, put `env WAYLAND_DISPLAY=` before the command in its `Exec=` line), or turn KWin's deprecated emulation back on for the whole session by setting `KWIN_WAYLAND_EMULATE_TABLET=1` in KWin's environment (for example in a file under `~/.config/environment.d/` or in `/etc/environment`) and logging in again. The pen then works as a mouse, without pressure: winit 0.30 reads pen pressure on Windows only (Windows Ink, which reaches the Liquify tools' Use Pressure Pen). Other compositors that don't emulate a pointer for tablets behave the same way.
## Fonts: craft-fonts (optional build input)
+2 -1
View File
@@ -1,6 +1,6 @@
# Effects parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (first checklist, counted from the effects catalogue) · **Target:** Adobe Illustrator 2026 (30.x)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (Zig Zag's dialog with sliders and radio buttons, #1133; earlier: Brush Strokes landed) · **Target:** Adobe Illustrator 2026 (30.x)
Every entry of Illustrator's Effect menu, by its public feature name, against VectorCraft's effects catalogue
(`effect_catalog()` in `crates/effects/src/lib.rs`) and the Effect menu built from it
@@ -72,6 +72,7 @@ rendering, Turntable, 3D export) is 45–75 h, still the largest single gap.
| Date | Change | Summary |
|---|---|---|
| 2026-10-11 | minor | Zig Zag's dialog: Size and Ridges per segment sliders, Points as Smooth / Corner radio buttons (#1133) |
| 2026-10-11 | minor | Brush Strokes landed (8 filters): 28/57 Photoshop effects |
| 2026-10-10 | minor | Initial Revolve landed (#846) |
| 2026-10-10 | major | First checklist, counted from the effects catalogue (20/57 Photoshop effects, 44/50 Illustrator effects) |
+3 -2
View File
@@ -1,6 +1,6 @@
# Hardware parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (first checklist) · **Target:** Adobe Illustrator 2026 (30.x)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (Linux Display Server preference opens the window through XWayland, #1151) · **Target:** Adobe Illustrator 2026 (30.x)
What hardware Illustrator uses, per platform, against VectorCraft. Illustrator's side is from its public
documentation (GPU Performance, touch workspace, pen pressure pages); ours from the code
@@ -19,7 +19,7 @@ on huge documents. Pen input is pressure-only and Windows-first.
| Multithreaded rendering off the UI thread | partial | yes | yes | yes | single-threaded | beyond Illustrator |
| Hybrid-graphics GPU choice | automatic | power-saving GPU by default, Preferences › Graphics Processor | same; DX12 then OpenGL; no Vulkan unless asked (#545, #806) | the desktop's GPU | browser | done |
| Restart on another GPU when the first fails to present | — | yes (#502, #964) | yes | yes | restart on lost device (#369) | done |
| Pen pressure | yes (Wacom, Windows Ink, macOS tablet events) | **no** (winit reports none, #852) | yes (pointer force via `WM_POINTER`) | X11 only via XWayland; none on Wayland (#491, #764) | browser pointer events | partial: 4–8 h for macOS and Wayland (winit 0.31) |
| Pen pressure | yes (Wacom, Windows Ink, macOS tablet events) | **no** (winit reports none, #852) | yes (pointer force via `WM_POINTER`) | X11 only via XWayland (Preferences › Performance › Linux Display Server, #1151); none on Wayland (#491, #764) | browser pointer events | partial: 4–8 h for macOS and Wayland (winit 0.31) |
| Pen tilt, bearing, barrel rotation (6D Art Pen) | yes, drives brushes | no | no | no | no | missing: 4–8 h after the input layer (#372) |
| Pen eraser end | yes | no | no | no | no | missing |
| Touch gestures (pinch zoom, two-finger pan, rotate view, Touch workspace) | yes (Windows touch, Surface) | trackpad pinch and scroll | pinch, pan; two-finger tap undo, three-finger redo (#585) | same where winit reports touch | same | partial: touch workspace and rotate gesture missing, 3–5 h |
@@ -33,4 +33,5 @@ on huge documents. Pen input is pressure-only and Windows-first.
| Date | Change | Summary |
|---|---|---|
| 2026-10-11 | minor | Preferences › Performance › Linux Display Server › X11 (XWayland) opens the window through XWayland on a Wayland session, where pens and dropped files reach the app (#1151); score unchanged (estimated) |
| 2026-10-10 | major | First checklist from the canvas, touch and graphics code and the open hardware issues |
+7 -5
View File
@@ -237,7 +237,7 @@ The older names below remain listed because existing workflows use them.
| `command_run` | `id`, optional `params`; runs the command through the existing backend |
| `command_batch` | `steps: [{id, params?}]`, optional `stop_on_error` (default true); returns `completed`, `failed`, `results: [{ok, result\|error}]`. Each edit has its own undo step; failures set `isError`. A param string `"$N.path"` or `"$last.path"` takes a value from an earlier step's result (see `run` below) |
| `doc_inspect` | Optional `depth`, `childLimit`; same summary as `inspect_document` |
| `render_preview` | Optional `max_side` (1–4096, default 1024); inline PNG of the first artboard without editing it. Artboards too large to render within the allocation bound return a tool error |
| `render_preview` | Optional `max_side` (1–4096, default 1024) and `artboard` (0-based, default 0); inline PNG of that artboard without editing it. Artboards too large to render within the allocation bound return a tool error |
| `ui_inspect`, `ui_screenshot` | Connected desktop state/window capture; tool errors in headless mode |
Every tool declares read-only, destructive, idempotent and open-world hints. File-writing
@@ -1434,8 +1434,9 @@ size. The journal entry of a scaling command records the `strokes` and `corners`
## Live Corners
`object.setLiveShape {id?, ids?, radius?, kind?, corners?, items?}` sets the corners of any path (one undo step): a live
rectangle's or polygon's, a star's, a pen path's. A corner is an anchor without handles between two straight sides
(not an open path's ends, not a smooth anchor, not one the sides run straight on through). `radius` (pt) and `kind`
rectangle's or polygon's, a star's, a pen path's. A corner is an anchor where two sides, straight or curved, meet at an angle
(not an open path's ends, not a smooth anchor, not one the sides run straight on through); a curved side is trimmed to
what the cut leaves of it. `radius` (pt) and `kind`
(`round`, `invertedRound` or `chamfer`) go to the `corners` given, else to the corners holding a Direct-Selected
anchor (`select.anchors`), else to every corner. `corners` are anchor indices of the path with its corners uncut,
counting every subpath's anchors in order: a rectangle's 0 top-left, 1 top-right, 2 bottom-right, 3 bottom-left; a
@@ -3076,8 +3077,9 @@ among the objects takes the others in as more key objects, keeping its options,
A blend's spine is the straight lines between its key centres until it is edited. `object.blend.info` lists it
(`spine.anchors`, `keyAnchors`: the point each key sits on). `object.blend.spine.addAnchor {x, y}` adds a point where
the spine passes nearest, `object.blend.spine.moveAnchor {anchor, x, y}` moves a point (a key on it moves along; with
`handle: "in" | "out"` it places that handle) and `object.blend.spine.removeAnchor {anchor}` deletes a point no key
sits on; the first edit turns the spine into a path. Moving a key moves its end of the spine. Release
`handle: "in" | "out"` it places that handle), `object.blend.spine.reshapeSegment {segment, t, dx, dy}` bends a segment
so its point at `t` moves by (dx, dy), turning a straight spine into a curve, and `object.blend.spine.removeAnchor
{anchor}` deletes a point no key sits on; the first edit turns the spine into a path. Moving a key moves its end of the spine. Release
(`object.blend.release`) leaves the spine as a path that paints nothing (`spines` in the result):
```json
+7 -5
View File
@@ -1,6 +1,6 @@
# Parity with Adobe Illustrator
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (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-11 · **Change:** minor (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),
@@ -68,7 +68,7 @@ dominate; 3D, graphs and automation are specialist). They sum to 105. Feature de
| Area | Weight | Done | Main gaps | One agent (h) | Kind |
|---|---:|---:|---|---:|---|
| Selection, transform & align tools | 6 | 85% | Free Transform/Scale/Reflect along a rotated box, Start Global Edit, transform nuances | 4–6 | estimated |
| Drawing tools (Pen, Curvature, Pencil, shapes, Shaper) | 7 | 85% | Shaper construction widget, Pencil/Pen nuances, Blob Brush options, Touch Type, Dimension tool, Live Corners on curved corners; see [ui-parity.md](ui-parity.md) | 8–13 | estimated |
| Drawing tools (Pen, Curvature, Pencil, shapes, Shaper) | 7 | 85% | Shaper construction widget, Pencil/Pen nuances, Blob Brush options, Touch Type, Dimension tool, Live Corners' Relative rounding; see [ui-parity.md](ui-parity.md) | 8–13 | estimated |
| Path operations, Pathfinder, Shape Builder, Live Paint | 5 | 80% | Shape Builder misses planar regions in real files (#937), its gap detection and options; Live Paint Gap Options | 4–8 | estimated (lowered from 86%: open issues) |
| Colour, swatches, gradients, patterns, mesh, recolor | 7 | 92% | 30.0's gradient dither, perceptual blending and gradient presets; freeform and mixed spot/process gradients in SVG/PDF | 4–7 | estimated (lowered from 96%: target moved) |
| Strokes, brushes, width profiles | 5 | 88% | Art brush Overlap, Pattern brush auto corners, brush libraries, pen pressure on macOS, tilt/bearing/rotation | 8–15 | estimated |
@@ -297,7 +297,7 @@ about 1–1.5 agent-hours each, so the other rows (estimated on the older scale)
| Area | Weight | Done | Missing (main items) | One agent (h) |
|---|---:|---:|---|---:|
| Selection, transform & align tools | 6 | 85% | Free Transform/Scale/Reflect along a rotated box, Start Global Edit, transform nuances (the rotated persistent bounding box is done) | 4–6 |
| Drawing tools | 7 | 85% | rough-square Shaper recognition and orientation steps corrected; editable Shaper Groups landed; remaining: Shaper construction widget/gesture refinements, pencil and the remaining pen modifier nuances, the freehand Tool Options' Keep selected, Alt to the Smooth tool, Blob Brush Merge Only with Selection and brush angle/roundness, Touch Type, Live Corners on corners between curved sides (and Relative rounding) | 8–13 |
| Drawing tools | 7 | 85% | rough-square Shaper recognition and orientation steps corrected; editable Shaper Groups landed; remaining: Shaper construction widget/gesture refinements, pencil and the remaining pen modifier nuances, the freehand Tool Options' Keep selected, Alt to the Smooth tool, Blob Brush Merge Only with Selection and brush angle/roundness, Touch Type, Live Corners' Relative rounding | 8–13 |
| Path operations, Pathfinder, Shape Builder, Live Paint | 5 | 86% | Live Paint gap options, Shape Builder gap detection, options (Tool Options dialog, clicking a stroke in merge mode) and Shift-drag marquee | 3–6 |
| Colour, swatches, gradients, patterns, mesh, recolor | 7 | 96% | freeform and mixed spot/process gradients in SVG/PDF (exported as stops or process colours), pattern fills in Expand | 2–4 |
| Strokes, brushes, width profiles | 5 | 90% | the Art brush's Overlap, auto-generated Pattern brush corners and new tile art in Pattern Brush Options (Brush Options for every brush type done), brush libraries (generated in code), pen pressure on macOS (the windowing layer reports none there), brushes driven by pen tilt, bearing and barrel rotation (6D art pens) | 8–15 |
@@ -353,7 +353,7 @@ since 2026-10-01.
| **Bundled content:** brush, symbol, style and swatch libraries | ~30% | Ours are original and generated in code, and far fewer than Illustrator ships; brush and symbol libraries are still missing |
| **Performance** | unverified | Multithreaded, off-thread rendering and caches are in place. The last budget run (2026-10-01, loaded machine) measured 290 ms for a 50k-path fit against a 16 ms budget. Re-run `vectorcraft-cli perf` on an idle machine |
| **Robustness** | good, new | ~2,840 tests, property tests, no panics in shipped code (lints, the `guard` safety net, import fuzzing), Data Recovery. Missing: a corpus of real-world files, Windows/Linux/browser QA |
| **Platforms and 1.0 polish** | ~60% | The macOS app and the web build work, and Finder opens every readable format with the macOS app (#295, #354); on macOS the menus are in the system menu bar, laid out as a Mac app's, with the in-window menus as a fallback (`--in-window-menus`; verified on Windows and by cross-compilation, not yet by hand on a Mac); hybrid-graphics laptops render on the power-saving GPU by default on Windows and macOS (#306), and Windows starts on Direct3D 12, falling back to OpenGL, and loads no Vulkan driver unless `WGPU_BACKEND` asks for it (#545: Vulkan flickered black on an Intel GPU, DX12 and OpenGL didn't; #806: a faulty Vulkan driver crashed the app before its window appeared; verified by unit tests and the reporter's runs with `WGPU_BACKEND`), and Linux on the GPU the desktop runs on, since a Wayland compositor may refuse frames from the other one; a window that fails on its GPU while starting up starts again on the next one instead of crashing (#502; the dual-GPU Wayland case is verified by unit tests and cross-compilation, not on such hardware); web drops land in the document they were dropped on (#359); desktop drops carry no pointer position (winit 0.30), so a dropped document opens as a tab of its own and a picture or text is placed in the middle of the view, while the web places anything dropped on the canvas and opens what is dropped off it, as Illustrator does (#472); a web page that loses its WebGPU device or WebGL context restarts its graphics with the same documents (#369); on Linux/Wayland the clipboard reads the compositor's own (data-control) and copied image files paste, but files can't be dropped on the window under Wayland until eframe moves to winit 0.31 (#398; the Wayland side is verified by compilation only); on Linux the Paste menu checks the system clipboard on a background thread, so a clipboard owner that never answers no longer freezes the window, while Windows and macOS keep their quick in-line check (#510; the X11 hang verified by the contributor, here by headless tests); on KDE Plasma 6.3+ under Wayland, pens and pen displays reach the app only under XWayland (`WAYLAND_DISPLAY=`) or with KWin's `KWIN_WAYLAND_EMULATE_TABLET=1`, since winit 0.30 has no Wayland tablet input (#491, not verified on hardware); Windows/Linux packaging and accessibility are pending; an experimental, unsupported Windows 7 x64 OpenGL build exists (community-contributed, not verified by maintainers) |
| **Platforms and 1.0 polish** | ~60% | The macOS app and the web build work, and Finder opens every readable format with the macOS app (#295, #354); on macOS the menus are in the system menu bar, laid out as a Mac app's, with the in-window menus as a fallback (`--in-window-menus`; verified on Windows and by cross-compilation, not yet by hand on a Mac); hybrid-graphics laptops render on the power-saving GPU by default on Windows and macOS (#306), and Windows starts on Direct3D 12, falling back to OpenGL, and loads no Vulkan driver unless `WGPU_BACKEND` asks for it (#545: Vulkan flickered black on an Intel GPU, DX12 and OpenGL didn't; #806: a faulty Vulkan driver crashed the app before its window appeared; verified by unit tests and the reporter's runs with `WGPU_BACKEND`), and Linux on the GPU the desktop runs on, since a Wayland compositor may refuse frames from the other one; a window that fails on its GPU while starting up starts again on the next one instead of crashing (#502; the dual-GPU Wayland case is verified by unit tests and cross-compilation, not on such hardware); web drops land in the document they were dropped on (#359); desktop drops carry no pointer position (winit 0.30), so a dropped document opens as a tab of its own and a picture or text is placed in the middle of the view, while the web places anything dropped on the canvas and opens what is dropped off it, as Illustrator does (#472); a web page that loses its WebGPU device or WebGL context restarts its graphics with the same documents (#369); on Linux/Wayland the clipboard reads the compositor's own (data-control) and copied image files paste, but files can't be dropped on the window under native Wayland until eframe moves to winit 0.31 (#398; the Wayland side is verified by compilation only), so Preferences › Performance › Linux Display Server › X11 (XWayland) opens the window through XWayland, where drops work (#1151; verified by unit tests and cross-compilation, not on a Wayland desktop); on Linux the Paste menu checks the system clipboard on a background thread, so a clipboard owner that never answers no longer freezes the window, while Windows and macOS keep their quick in-line check (#510; the X11 hang verified by the contributor, here by headless tests); on KDE Plasma 6.3+ under Wayland, pens and pen displays reach the app only under XWayland (`WAYLAND_DISPLAY=`) or with KWin's `KWIN_WAYLAND_EMULATE_TABLET=1`, since winit 0.30 has no Wayland tablet input (#491, not verified on hardware); Windows/Linux packaging and accessibility are pending; an experimental, unsupported Windows 7 x64 OpenGL build exists (community-contributed, not verified by maintainers) |
| **Agent automation** | beyond Illustrator | Every command, gesture and dialog is drivable over MCP, the CLI and the control channel; what an agent reads and writes on disk can be confined to chosen folders (`--automation-read-root` / `--automation-write-root`, as in PhotoCraft, #832) |
| **Scrubby numeric fields** | beyond Illustrator | As in Photoshop, dragging a numeric field's label (`W:`, `Opacity:`, the Character panel's icons) left or right steps its value live; one undo step per drag, Escape cancels (#400) |
@@ -406,7 +406,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
- **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.
- **Live Corners:** drag a live rectangle's corner widgets (Selection or Direct Selection) to round all corners, with a radius readout; with Direct Selection, select corner anchors to show and round just those corners (#381). Each corner keeps its own radius and kind (Round, Inverted Round, Chamfer): Alt-click a widget to cycle the kind, double-click it for the Corners dialog, and the Properties and Transform radius fields set the selected corners (`object.setLiveShape {corners, radius, kind}`). Corners stay circular when the rectangle is scaled unevenly or along its own sides when rotated (the radius keeps its size, or scales by the mean scale with Scale Corners on); only a shear distorts them. On a rectangle that isn't square every corner is the same circle: a radius past half the shorter side stops there for all of them, the widgets sit on the corners as drawn and turn the edited corners red at that limit, CSS export writes the drawn radii, and rectangles saved stretched by older versions get circular corners when their corners are next set (#442). Live Corners work on any path's corners too, as on a rectangle's (#511): a star's, a live polygon's (which also shows them to the Selection tool, has a Corner Radius field in the Properties and Transform panels and keeps its radius as its sides change) and a pen path's, wherever two straight sides meet at an anchor. Direct Selection shows the widgets of every corner, or of the corners holding selected anchors; each corner is a circle tangent to both sides, drawn no larger than takes its cut halfway along its shorter side; a path keeps its uncut outline so its corners stay editable, and the same command, dialog, angle preference and View › Hide Corner Widget apply.
- **Live Corners:** drag a live rectangle's corner widgets (Selection or Direct Selection) to round all corners, with a radius readout; with Direct Selection, select corner anchors to show and round just those corners (#381). Each corner keeps its own radius and kind (Round, Inverted Round, Chamfer): Alt-click a widget to cycle the kind, double-click it for the Corners dialog, and the Properties and Transform radius fields set the selected corners (`object.setLiveShape {corners, radius, kind}`). Corners stay circular when the rectangle is scaled unevenly or along its own sides when rotated (the radius keeps its size, or scales by the mean scale with Scale Corners on); only a shear distorts them. On a rectangle that isn't square every corner is the same circle: a radius past half the shorter side stops there for all of them, the widgets sit on the corners as drawn and turn the edited corners red at that limit, CSS export writes the drawn radii, and rectangles saved stretched by older versions get circular corners when their corners are next set (#442). Live Corners work on any path's corners too, as on a rectangle's (#511): a star's, a live polygon's (which also shows them to the Selection tool, has a Corner Radius field in the Properties and Transform panels and keeps its radius as its sides change) and a pen path's, wherever two sides meet at an angle at an anchor that isn't smooth: curved sides too, trimmed to what the cut leaves of them (#1144). Direct Selection shows the widgets of every corner, or of the corners holding selected anchors; each corner is a circle tangent to both sides, drawn no larger than takes its cut halfway along its shorter side; a path keeps its uncut outline so its corners stay editable, and the same command, dialog, angle preference and View › Hide Corner Widget apply.
- **Pen:** Alt pressed in the middle of dragging out an anchor splits its handles and keeps the curve already shaped into it (only the outgoing handle follows the pointer); Space held while dragging out an anchor moves it, handles and all. A press on the first anchor of the path being drawn closes it whatever the drag then does: the drag shapes the closing curve (symmetric handles, Alt breaks them) and the release closes the path wherever it ends (#501). Clicking the last anchor of the path being drawn retracts its outgoing handle (convert cursor), and dragging from it pulls out a new one on its own. Auto Add/Delete: over a selected path, a click on a segment adds an anchor and a click on an anchor deletes it (add and delete cursors); Shift held, or Preferences › General › Disable Auto Add/Delete, starts a new path instead. Alt held over a handle end or an anchor of a selected path (the one being drawn too) works as the Anchor Point tool, with its convert cursor: dragging a handle moves it alone, clicking a smooth anchor makes it a corner, dragging an anchor pulls out new symmetric handles, dragging a segment reshapes it; drawing then goes on. Cmd (Ctrl) held drags with the selection tool chosen last, Direct Selection until one is (handles, anchors and segments of the path being drawn or another), and the Pen then goes on drawing its path while that stays selected; the pointer shows that tool's while Cmd is held. The same holds for agents (`pointer_gesture` with `mods.cmd`), as the borrowing now lives in the engine (#494).
- **Path editing (#494):** the handles shown are the handles that drag: Direct Selection drags those of a path selected as a whole (as the Pen leaves it), the Anchor Point tool and Pen+Alt no longer grab handles that Show handles when multiple anchors are selected hides, a handle shorter than the tolerance leaves its anchor to be picked, and the anchor tools pick within Selection & Anchor Display › Tolerance. A dragged segment keeps a smooth anchor at either end smooth. With Direct Selection, the Pen, the Curvature and the anchor tools, a path selected as a whole shows all its anchors selected, and the Control bar and the Properties panel offer Convert (corner, smooth) for them too; Remove and Cut wait for anchors direct-selected (#504). A handle drag gathers its snap targets once per drag (it took 21 ms per pointer move on a 20,000-path document, now 1 ms; `vectorcraft-cli perf` measures handle and segment drags on a 5,000-anchor path).
- **Graphs:** Column, Stacked Column, Bar, Stacked Bar, Line, Area, Scatter, Pie and Radar graph tools with Graph Data and Graph Type. A series starts in greyscale; Group Selection's fill and stroke are kept separately on the graph and reapplied when its data, type or size changes, and through save, open and undo. Graph Type's Override Calculated Values (both `axisMin` and `axisMax`) uses exactly that value-axis range, split into the tick count (5 when automatic). Line graphs take Edge-to-Edge Lines (off by default: the points sit at the centres of their categories; graphs saved before the option keep their edge-to-edge lines), and radar graphs follow Mark Data Points and Connect Data Points. Blank cells (an empty CSV cell or `null`) are values left out, not zeros: columns, bars and stacked segments skip them, lines and scatter lines break around them and the value axis ignores them, while area, pie and radar draw them as 0; a number in straight quotes is a label, and the data window writes it back quoted. Graph types combine: Graph Type on series picked with Group Selection (or `series` indexes) draws them as their own type, with column, stacked column, line and area mixing on the vertical value axis and bar and stacked bar on the horizontal one; stacked columns take one slot of the cluster, lines draw in front of columns and areas behind. The value axis goes on the left, the right or both sides (column, stacked column, line, area and scatter graphs; bar graphs keep theirs along the bottom), and Separate Scales gives the series assigned to the right axis a scale of their own with its own tick values. Each value axis takes tick marks of no, short or full (across the plot) length and a count per division, and a prefix and suffix for its numbers; the category axis takes tick marks too (none by default), at or between its labels. Graph Design, Column and Marker artwork remain open.
@@ -460,6 +460,8 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
| Date | Change | Summary |
|---|---|---|
| 2026-10-11 | minor | Platforms: Preferences › Performance › Linux Display Server opens the window through XWayland, where file drops work (#1151); score unchanged (estimated) |
| 2026-10-11 | minor | Drawing tools: Live Corners on corners between curved sides (#1144) leaves the missing list; score and hours unchanged at this rounding (estimated) |
| 2026-10-11 | minor | Type advanced: Proportional Metrics in vertical type (`vpal`, #966) leaves the missing list; score and hours unchanged at this rounding (estimated) |
| 2026-10-11 | minor | Shape Builder #893 fixed (planar-map faces share their corners exactly, so merges leave no seams); Path operations stays 80% and Shape Builder 70% while #937 is open (estimated) |
| 2026-10-10 | minor | Merged main: initial live Revolve landed (#846). 3D 0% → ~8% (50–85 h), menu stubs 28 → 27; depth, readiness and totals unchanged within rounding |
+5 -2
View File
@@ -1,6 +1,6 @@
# UI and interaction parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (several artboards selected at once, #1139; earlier: the Edit Toolbar drawer, #1018) · **Target:** Adobe Illustrator 2026 (30.x)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (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,
@@ -62,6 +62,7 @@ tools, never checked side by side) isn't captured row by row.
| Behaviour | Status | Notes |
|---|---|---|
| Click/Shift-click anchors; marquee toggles with Shift | done | #483 |
| A marquee selects the objects whose paths it touches (anchors or segments): drawn inside a filled shape, over a background, it leaves that shape (Preview and Outline) | done | #1103 |
| Drag a curved segment to bend it, a straight one to move its anchors | done | |
| Drag by an anchor of a path selected whole moves the whole path; Alt-drag by an anchor or a segment copies it, Alt toggles the copy during the drag, and the copy snaps by the anchor pressed, onto the original too | done | #1104 |
| Handles shown are the handles that drag; tolerance from Selection & Anchor Display | done | #494 |
@@ -70,7 +71,7 @@ tools, never checked side by side) isn't captured row by row.
| Convert selected anchors to corner/smooth from the Control bar and Properties | done | |
| Cut path at selected anchors, Remove Anchor Points, Delete removes segments | done | |
| Live Corners widgets (radius readout, Alt-click cycles kind, double-click the Corners dialog) | done | clicking a widget without pre-selecting the anchor (#908) missing |
| Corners between curved sides, Relative rounding | missing | |
| Corners between curved sides (#1144), Relative rounding | partial | Relative rounding missing |
| Isolation mode by double-click, breadcrumbs, Esc to exit | done | |
| "Magnetic" moves of selected anchors with a modifier (#955 request) | missing | not an Illustrator feature as documented; under discussion |
@@ -169,6 +170,8 @@ re-measure of the panels added since.
| Date | Change | Summary |
|---|---|---|
| 2026-10-11 | minor | A marquee inside a filled shape no longer selects it: only touching its path does (#1103); score unchanged (estimated) |
| 2026-10-11 | minor | Live Corners: corners between curved sides round too, the curve trimmed to what the cut leaves (#1144); score unchanged (estimated) |
| 2026-10-11 | minor | Artboards: Shift-click selects several, which move, copy and delete together (#1139); score unchanged (estimated) |
| 2026-10-11 | minor | Edit Toolbar opens the All Tools drawer beside the toolbar instead of a centred dialog, dims the tools the toolbar holds, and edits the toolbar by drag and drop while it is open (#1018); score unchanged (estimated) |
| 2026-10-11 | minor | Direct Selection: dragging a path selected whole by an anchor moves it whole; Alt-drag copies by an anchor too, toggles during the drag and snaps onto the original (#1104); score unchanged (estimated) |