Compare commits

...
Author SHA1 Message Date
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 <[email protected]>
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 <[email protected]>
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 <[email protected]>
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 <[email protected]>
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
bflatasticandClaude Opus 5.5 423cb18f40 #1139: Shift-click selects several artboards, which move and delete together
The Artboard tool and the Artboards panel only ever had one artboard
selected, so Shift-clicking another didn't add it. As in Illustrator:

- Artboard tool: Shift-click adds an artboard to the selection (it
  becomes active) or takes one out; a plain click on a selected one keeps
  the selection, so a drag moves them all (Alt copies them all, the
  copies becoming the selection), and Delete removes them all, one
  artboard always staying. The other selected artboards show dashed
  bounds; `selected` is a tool option.
- Artboards panel: with the Artboard tool, Shift- or Ctrl/Cmd-click on a
  row adds or removes it, every selected row is highlighted, and Delete
  Artboard removes them all.
- `artboard.move` and `artboard.delete` take `indices` besides `index`:
  several artboards in one undo step, each with its art (an object moved
  once where artboards overlap).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 16:04:30 +07:00
BFlat 65a4847eed Merge pull request #1143 from Biromedic/fix/hidden-mask-art
Fix #1141: SVG import keeps a mask whose art is all hidden, so its object stays hidden
2026-10-11 16:02:41 +07:00
BFlat 6daba37d3b Merge pull request #1142 from Biromedic/fix/gradient-alpha-space
Fix #1140: gradient stops blend colour and opacity apart on the canvas, as the exports do
2026-10-11 16:02:26 +07:00
BFlat 6fc725d2fa Merge pull request #1132 from dotCooCoo/feat/web-clippy-dead-code
Lint: clippy -D warnings passes for the wasm32 web build
2026-10-11 16:02:09 +07:00
BFlat 34df89e8e0 Merge pull request #1130 from mateususa/fix/819-trace-noise
#819: more noise removal never gives more paths
2026-10-11 16:01:40 +07:00
BFlat b192e2cf30 Merge pull request #1128 from mateususa/fix/662-desktop-shortcut
Fix #662: the Windows installer offers a desktop shortcut
2026-10-11 16:01:17 +07:00
BFlat 93277ef87a Merge pull request #1116 from Ni-zav/fix/font-menu-highlight-rebuild-s20261011T0359Z-c8a1
fix(fonts): preserve highlighted font across menu list refreshes
2026-10-11 16:01:03 +07:00
bflatasticandClaude Opus 5.5 58c874b128 Review: progress log for SVG masks with hidden art (#1143)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 16:00:39 +07:00
Biromedic f4035c98d1 Fix #1141: SVG import keeps a mask whose art is all hidden, so its object stays hidden
Mask art that is all visibility:hidden imports as nothing, and opacity_mask
then returned no mask: the object was drawn whole. An empty mask is what the
file means (black all over); the renderer and the PDF and SVG exports already
draw empty mask art by its clip and invert options.
2026-10-11 16:00:38 +07:00
bflatasticandClaude Opus 5.5 369948f816 Review: progress log for gradient stop opacity on the canvas (#1142)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 16:00:37 +07:00
Biromedic 1726c600d2 Fix #1140: gradient stops blend colour and opacity apart on the canvas, as the exports do
The canvas built its peniko gradients premultiplied (peniko's default), while
Gradient::sample, the PDF export (shading plus SMask) and SVG viewers blend
colour and opacity separately: a transparent white stop into black drew grey
127 halfway instead of 191. Opaque gradients are unchanged.
2026-10-11 16:00:36 +07:00
bflatasticandClaude Opus 5.5 5aa1605c17 Review: progress log for the wasm32 clippy fix (#1132)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 16:00:35 +07:00
dotCooCoo d79aa607cc Lint: clippy -D warnings passes for the wasm32 web build
On main, cargo xtask wasm printed two dead-code warnings in
vectorcraft-ui-egui, and this clippy run reported the same two as
errors:

    cargo clippy --target wasm32-unknown-unknown -p vectorcraft-web -- -D warnings

ask_update_changed (dialogs/missing_links.rs) is called only from
tick_link_updates, and is_c_locale (i18n/mod.rs) is called only from
detect_system_lang. Both callers are compiled only for native targets.

Each function now has the same #[cfg(not(target_arch = "wasm32"))]
attribute as its caller. Native builds compile both functions as
before.
2026-10-11 16:00:33 +07:00
bflatasticandClaude Opus 5.5 3bd04a082e Review: progress log for Image Trace's Noise (#1130)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 16:00:32 +07:00
mateususaandClaude Opus 5.5 ae9cd2a03c #819: more noise removal never gives more paths
Remove Noise merged each small component into its most common neighbour
in one pass. When a speck was merged into a small region that was itself
merged away later, the region's flood fill skipped the speck's pixels
(already seen), so they stayed behind as a new island of the region's old
label. Higher Noise values merge more regions, so they left more such
islands: on noisy images the path count rose with Noise (70 -> 126 -> 206
paths from Noise 50 to 200 on one test image). denoise now repeats its pass
until one changes nothing; each merge joins two components, so it ends
(with a cap of 64 passes as a backstop). Labels shorter than w x h are left
alone instead of indexed past their end.

Tests: the minimal one-row case, a property test on random blocky noise
(no mergeable region under the minimum is left, and a larger minimum never
leaves more regions), and two noisy images whose path count must not rise
with Noise (both fail without the fix).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 16:00:31 +07:00
bflatasticandClaude Opus 5.5 3ce08507c1 Review: say why ICE validation flags the desktop shortcut (#1128)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 16:00:30 +07:00
mateususaandClaude Opus 5.5 1f3d469c33 Fix #662: the Windows installer offers a desktop shortcut
The MSI's wizard now has a Shortcuts page between the install folder and
Ready, with a ticked "Create a desktop shortcut" box (the Start Menu
shortcut is always installed, as the page says). The box sets the secure
INSTALLDESKTOPSHORTCUT property; the shortcut's component is installed
only with it, on the common desktop of this per-machine install, and is
removed with the app. msiexec /qn installs it too unless
INSTALLDESKTOPSHORTCUT="" is passed.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 16:00:29 +07:00
bflatasticandClaude Opus 5.5 eeb78520e1 Review: a headless test of the font menu's highlight across a list rebuild (#1116)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 16:00:28 +07:00
Nigel Ihza Firdausy eb189a36c3 fix(fonts): end live preview when filters remove its highlighted row 2026-10-11 16:00:27 +07:00
Nigel Ihza Firdausy 22690c3565 fix(fonts): retain the same highlighted font after list rebuilds 2026-10-11 16:00:27 +07:00
BFlat fad7248e7d Merge pull request #1150 from storytold/fix/selection-arrow-colours
Icons: the Selection arrow is black and Direct Selection's white in every theme
2026-10-11 15:30:34 +07:00
45 changed files with 1060 additions and 128 deletions

No files matched your search

+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 |
+15 -8
View File
@@ -129,7 +129,15 @@ pub fn specs() -> Vec<CommandSpec> {
has_doc,
artboard_new
),
cmd!("artboard.delete", "Delete Artboard", ["Window", "Artboards"], None, "{index}", has_doc, artboard_delete),
cmd!(
"artboard.delete",
"Delete Artboard",
["Window", "Artboards"],
None,
"{index | indices: [index…]} delete artboards (with their guides) in one step; one is always left",
has_doc,
artboard_delete
),
cmd!(
"artboard.setProps",
"Artboard Options…",
@@ -749,17 +757,16 @@ fn artboard_new(s: &mut Session, p: &Value) -> Result<Value> {
}
fn artboard_delete(s: &mut Session, p: &Value) -> Result<Value> {
let i = p.get("index").and_then(Value::as_u64).ok_or_else(|| bad("artboard.delete", "missing index"))? as usize;
let indices = super::panelcmds::artboard_indices(&s.doc()?.doc, p, "artboard.delete")?;
s.edit("Delete Artboard", |d, sel| {
if d.artboards.len() <= 1 {
if indices.len() >= d.artboards.len() {
return Err(EngineError::Other("a document needs at least one artboard".into()));
}
if i >= d.artboards.len() {
return Err(EngineError::Other("no such artboard".into()));
// The last first, so the others keep their places; their guides go with them.
for &i in indices.iter().rev() {
let id = d.artboards.remove(i).id;
d.retain_guides(sel, |_, g| g.artboard != Some(id));
}
// Its guides go with it.
let id = d.artboards.remove(i).id;
d.retain_guides(sel, |_, g| g.artboard != Some(id));
Ok(())
})?;
ok()
+1 -1
View File
@@ -196,7 +196,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
),
+15
View File
@@ -78,6 +78,21 @@ fn board_and_art(s: &Session, p: &Value) -> (usize, bool) {
/// The art that goes with artboard `i` when `art`: the objects fully inside it (locked and hidden
/// ones only with prefs moveLockedWithArtboard), in paint order.
/// The artboards `cmd` acts on: `indices` (several), else `index` (one), each an artboard of `d`,
/// in order and once each.
pub(crate) fn artboard_indices(d: &vectorcraft_doc::Document, p: &Value, cmd: &str) -> Result<Vec<usize>> {
let listed = match (p.get("indices"), p.get("index")) {
(Some(v), _) => point_indices(v),
(None, Some(i)) => point_indices(&json!([i])),
(None, None) => return Err(bad(cmd, "missing index")),
};
let mut indices =
listed.filter(|v| !v.is_empty() && v.iter().all(|i| *i < d.artboards.len())).ok_or_else(|| EngineError::Other("no such artboard".into()))?;
indices.sort_unstable();
indices.dedup();
Ok(indices)
}
pub(crate) fn artboard_art(s: &Session, i: usize, art: bool) -> Result<Vec<NodeId>> {
let d = &s.doc()?.doc;
let rect = d.artboards.get(i).map(|a| a.rect).ok_or_else(|| EngineError::Other("no such artboard".into()))?;
+31 -16
View File
@@ -45,7 +45,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Move Artboard",
[],
None,
"{index, dx, dy, moveArt?: bool, copy?: bool} move an artboard (and the art fully inside it; locked and hidden art only with prefs moveLockedWithArtboard); `copy` (Alt-drag) leaves them and moves copies → {index, moved: the art moved or the copies}",
"{index | indices: [index…], dx, dy, moveArt?: bool, copy?: bool} move artboards (and the art fully inside them; locked and hidden art only with prefs moveLockedWithArtboard), several in one step; `copy` (Alt-drag) leaves them and moves copies → {index (the first), indices: the artboards moved or the copies, moved: the art moved or the copies}",
has_doc,
artboard_move
),
@@ -366,26 +366,41 @@ fn sample_color(s: &mut Session, p: &Value) -> Result<Value> {
// ---------- artboards ----------
fn artboard_move(s: &mut Session, p: &Value) -> Result<Value> {
let i = p.get("index").and_then(Value::as_u64).ok_or_else(|| bad("artboard.move", "missing index"))? as usize;
let indices = super::panelcmds::artboard_indices(&s.doc()?.doc, p, "artboard.move")?;
let dv = Vec2::new(f64_or(p, "dx", 0.0), f64_or(p, "dy", 0.0));
let art = super::panelcmds::artboard_art(s, i, bool_or(p, "moveArt", false))?;
let move_art = bool_or(p, "moveArt", false);
// Each artboard's art, an object counted once (artboards can overlap).
let mut arts: Vec<(usize, Vec<NodeId>)> = vec![];
for &i in &indices {
let art = super::panelcmds::artboard_art(s, i, move_art)?;
let art = art.into_iter().filter(|id| !arts.iter().any(|(_, a)| a.contains(id))).collect();
arts.push((i, art));
}
let scale_strokes = s.prefs.scale_strokes;
let copy = bool_or(p, "copy", false);
let (index, moved) = s.edit(if copy { "Duplicate Artboard" } else { "Move Artboard" }, |d, _| {
// A copy keeps the artboard (and its art) where they are and moves duplicates instead.
if copy {
return super::panelcmds::copy_artboard(d, i, dv, &art, scale_strokes);
}
let a = d.artboards.get_mut(i).ok_or_else(|| EngineError::Other("no such artboard".into()))?;
a.rect = a.rect + dv;
let id = a.id;
d.move_artboard_guides(id, dv);
for id in &art {
if let Some(n) = d.node_mut(*id) {
n.transform(Affine::translate(dv), scale_strokes);
let (mut index, mut moved) = (vec![], vec![]);
for (i, art) in &arts {
// A copy keeps the artboard (and its art) where they are and moves duplicates instead.
if copy {
let (at, copies) = super::panelcmds::copy_artboard(d, *i, dv, art, scale_strokes)?;
index.push(at);
moved.extend(copies);
continue;
}
let a = d.artboards.get_mut(*i).ok_or_else(|| EngineError::Other("no such artboard".into()))?;
a.rect = a.rect + dv;
let id = a.id;
d.move_artboard_guides(id, dv);
for id in art {
if let Some(n) = d.node_mut(*id) {
n.transform(Affine::translate(dv), scale_strokes);
}
}
index.push(*i);
moved.extend(art.iter().copied());
}
Ok((i, art.clone()))
Ok((index, moved))
})?;
Ok(json!({ "index": index, "moved": moved.iter().map(|i| i.0).collect::<Vec<_>>() }))
Ok(json!({ "index": index.first(), "indices": index, "moved": moved.iter().map(|i| i.0).collect::<Vec<_>>() }))
}
+29
View File
@@ -627,3 +627,32 @@ fn distort_rejects_malformed_coordinates_and_ids_without_editing_art() {
s.execute("object.distort", &json!({"corners": corners, "from": [0, 0, 100, 100], "ids": [a.0]})).unwrap();
assert_eq!(undo_len(&s), history + 1);
}
/// `artboard.move` and `artboard.delete` take several artboards at once (#1139): one undo step
/// each, the art on each moving with it, and one artboard always left.
#[test]
fn several_artboards_move_and_delete_together() {
let mut s = session();
s.execute("artboard.new", &json!({"x": 1000, "y": 0, "width": 200, "height": 200})).unwrap();
s.execute("artboard.new", &json!({"x": 1400, "y": 0, "width": 200, "height": 200})).unwrap();
let r = rect(&mut s, 1050.0, 50.0, 50.0, 50.0);
let before: Vec<Rect> = s.doc().unwrap().doc.artboards.iter().map(|a| a.rect).collect();
let out = s.execute("artboard.move", &json!({"indices": [2, 1], "dx": 10, "dy": 20, "moveArt": true})).unwrap();
assert_eq!(out["indices"], json!([1, 2]));
let d = &s.doc().unwrap().doc;
assert_eq!(d.artboards[0].rect, before[0]);
assert_eq!(
(d.artboards[1].rect, d.artboards[2].rect),
(before[1] + vectorcraft_geom::Vec2::new(10.0, 20.0), before[2] + vectorcraft_geom::Vec2::new(10.0, 20.0))
);
assert_eq!(d.node(r).unwrap().geometric_bounds().unwrap().x0, 1060.0, "the art on artboard 2 moved with it");
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(s.doc().unwrap().doc.artboards[2].rect, before[2], "one undo step");
for bad in [json!({"indices": [1, 9]}), json!({"indices": []}), json!({"indices": [-1]}), json!({"indices": "1"})] {
assert!(s.execute("artboard.move", &json!({"indices": bad["indices"], "dx": 1})).is_err(), "{bad}");
}
assert!(s.execute("artboard.delete", &json!({"indices": [0, 1, 2]})).is_err(), "one is always left");
s.execute("artboard.delete", &json!({"indices": [2, 0]})).unwrap();
let d = &s.doc().unwrap().doc;
assert_eq!(d.artboards.iter().map(|a| a.rect).collect::<Vec<_>>(), [before[1]]);
}
+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;
+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 {
+3 -1
View File
@@ -43,7 +43,9 @@ pub(crate) fn set_paint(ctx: &mut RenderContext, p: &Paint, bounds: Rect, f: &Fr
peniko::Gradient::new_linear(s, e).with_stops(stops.as_slice())
}
};
ctx.set_paint(grad);
// Colour and opacity blend each on its own, as the model samples them and as the PDF
// and SVG exports draw them (peniko's default premultiplies).
ctx.set_paint(grad.with_interpolation_alpha_space(peniko::InterpolationAlphaSpace::Unpremultiplied));
true
}
Paint::Pattern { pattern, xf } => crate::pattern::set_pattern_paint(ctx, pattern, *xf, f),
+58
View File
@@ -0,0 +1,58 @@
//! Gradient stops with opacity: colour and opacity blend each on its own (not premultiplied), as
//! the model samples them ([`Gradient::sample`]) and as the SVG and PDF exports draw them. The colour
//! of a transparent stop counts: red fading into transparent black darkens as it fades.
use vectorcraft_color::{Color, Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop, Paint};
use vectorcraft_doc::{Appearance, Document};
use vectorcraft_geom::{Point, Rect, shapes};
use vectorcraft_testkit::fixtures::DocBuilder;
use vectorcraft_testkit::raster::{Image, render_view};
/// A 100 pt square filled with `gradient` on `geom`.
fn filled(gradient: &Gradient, geom: GradientGeom) -> Document {
let mut gp = GradientPaint::new(gradient.clone());
gp.geom = Some(geom);
let mut b = DocBuilder::new(100.0, 100.0);
b.path(shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::basic(Paint::Gradient(Box::new(gp)), Paint::None, 0.0), |_| {});
b.build()
}
fn stop(offset: f32, color: Color, opacity: f32, midpoint: f32) -> GradientStop {
GradientStop { opacity, midpoint, ..GradientStop::new(offset, color) }
}
/// Every 7th pixel is the model's colour at its opacity over white (pixel centres).
fn assert_follows_model(img: &Image, gradient: &Gradient, geom: &GradientGeom) {
for y in (3..100).step_by(7) {
for x in (3..100).step_by(7) {
let t = geom.param_at(gradient.kind, Point::new(x as f64 + 0.5, y as f64 + 0.5)).clamp(0.0, 1.0);
let (c, o) = gradient.sample(t as f32);
let want = c.to_rgb().map(|v| ((v * o + 1.0 - o) * 255.0).round() as u8);
let got = img.over_white(x, y);
let off = (0..3).map(|k| got[k].abs_diff(want[k])).max().unwrap_or(0);
assert!(off <= 4, "{:?} ({x},{y}) t={t:.3}: got {got:?}, want {want:?}", gradient.kind);
}
}
}
#[test]
fn transparent_stops_blend_colour_and_opacity_apart() {
let red = Color::rgb(1.0, 0.0, 0.0);
let fades = [
// Transparent white into black: mid-grey at half opacity halfway, light over white.
vec![stop(0.0, Color::WHITE, 0.0, 0.5), stop(1.0, Color::BLACK, 1.0, 0.5)],
// Red into transparent black: darker red as it fades.
vec![stop(0.0, red, 1.0, 0.5), stop(1.0, Color::BLACK, 0.0, 0.5)],
// A midpoint off centre (drawn as an extra stop) follows the same rule.
vec![stop(0.0, red, 1.0, 0.3), stop(1.0, Color::BLACK, 0.0, 0.5)],
];
for stops in fades {
for (kind, geom) in [
(GradientKind::Linear, GradientGeom { start: Point::new(0.0, 50.0), end: Point::new(100.0, 50.0), aspect: 1.0, focal: None }),
(GradientKind::Radial, GradientGeom { start: Point::new(50.0, 50.0), end: Point::new(100.0, 50.0), aspect: 1.0, focal: None }),
] {
let gradient = Gradient { kind, stops: stops.clone() };
assert_follows_model(&render_view(&filled(&gradient, geom), 100, 100), &gradient, &geom);
}
}
}
+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");
}
+2 -1
View File
@@ -965,8 +965,9 @@ impl Importer {
nodes = rest;
}
let mut children: Vec<Arc<Node>> = nodes.iter().filter_map(|c| self.node(c, ts)).map(Arc::new).collect();
// Mask art that is all hidden draws nothing: that is an empty mask, not no mask (black all
// over when clipped, white when not, inverted when the mask is).
let art = match children.len() {
0 => return None,
1 => Arc::unwrap_or_clone(children.pop()?),
_ => self.named("", NodeKind::Group { children, clip: false }),
};
+35
View File
@@ -8,6 +8,7 @@ use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::{Appearance, Document, Node, OpacityMask};
use vectorcraft_geom::{Rect, shapes};
use vectorcraft_svg::{ExportOptions, export, import_with_report};
use vectorcraft_testkit::raster::{Image, render_artboard};
/// A red square masked by white art over its left half.
fn masked(clip: bool, invert: bool) -> Document {
@@ -67,3 +68,37 @@ fn foreign_masks_keep_their_art() {
assert!(m.clip && !m.invert);
assert_eq!(m.art.children().map(|c| c.len()), Some(2));
}
/// `svg` drawn by resvg, `w`×`h` on white.
fn resvg_render(svg: &str, w: u32, h: u32) -> Image {
let tree = resvg::usvg::Tree::from_str(svg, &resvg::usvg::Options::default()).expect("parse");
let mut pm = resvg::tiny_skia::Pixmap::new(w, h).unwrap();
pm.fill(resvg::tiny_skia::Color::WHITE);
resvg::render(&tree, resvg::tiny_skia::Transform::identity(), &mut pm.as_mut());
Image { width: w, height: h, rgba: pm.data().to_vec() }
}
#[test]
fn a_mask_whose_art_is_all_hidden_hides_its_object() {
// Hidden mask art draws nothing: the mask is black all over and the red square doesn't show,
// as resvg draws the file. The square keeps its (empty) mask, so it can still be edited.
for art in [
r#"<rect width="100" height="100" fill="white" visibility="hidden"/>"#,
r#"<g visibility="hidden"><rect width="100" height="100" fill="white"/></g>"#,
] {
let svg = format!(
r#"<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100">
<mask id="m" maskUnits="userSpaceOnUse" x="0" y="0" width="100" height="100">{art}</mask>
<g mask="url(#m)"><rect width="100" height="100" fill="red"/></g></svg>"#
);
let (d, _) = import_with_report(&svg).unwrap();
let [m] = &masks(&d)[..] else { panic!("the square keeps its mask: {svg}") };
assert!(m.clip && !m.invert);
assert!(m.art.geometric_bounds().is_none(), "no visible art: {:?}", m.art.kind);
let (canvas, reference) = (render_artboard(&d), resvg_render(&svg, 100, 100));
for (x, y) in [(10, 10), (50, 50), (90, 90)] {
assert_eq!(reference.over_white(x, y), [255, 255, 255], "resvg ({x},{y}): {svg}");
assert_eq!(canvas.over_white(x, y), [255, 255, 255], "canvas ({x},{y}): {svg}");
}
}
}
+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));
}
}
+122 -18
View File
@@ -1,6 +1,8 @@
//! Artboard tool (Shift+O).
//!
//! Click an artboard to make it active (dashed bounds, 8 handles and its name). Drag inside moves
//! Click an artboard to make it active (dashed bounds, 8 handles and its name); Shift-click adds
//! artboards to the selection or takes them out (dashed bounds), and a drag or Delete then acts on
//! all of them (#1139). Drag inside moves
//! it (with its artwork when the `moveArt` option is on, Shift constrains; Alt moves a copy and
//! leaves the artboard where it was), drag a handle resizes it
//! (Shift proportional, Alt from centre; with the `scaleArt` option on, Scale Artwork with Artboard,
@@ -26,6 +28,8 @@ enum Drag {
/// last drag event (the step under way is a duplicate).
Move {
index: usize,
/// Several artboards move together (the selected ones, `index` among them).
many: bool,
start: Point,
rect: Rect,
began: bool,
@@ -48,6 +52,8 @@ enum Drag {
pub struct ArtboardTool {
/// Index of the active artboard.
pub active: usize,
/// The other selected artboards (Shift-click), in order.
pub others: Vec<usize>,
/// Move artwork with the artboard.
pub move_art: bool,
/// Scale artwork with the artboard: a resize takes the art fully inside it along, and keeps the
@@ -69,6 +75,7 @@ impl Default for ArtboardTool {
fn default() -> Self {
Self {
active: 0,
others: vec![],
move_art: true,
scale_art: false,
drag: None,
@@ -90,6 +97,31 @@ impl ArtboardTool {
cx.doc.artboards.get(self.active).map(|a| a.rect)
}
/// The selected artboards that are still in the document: the active one and the others,
/// in order.
fn selected(&self, cx: &ToolContext) -> Vec<usize> {
let mut all: Vec<usize> = std::iter::once(self.active).chain(self.others.iter().copied()).filter(|i| *i < cx.doc.artboards.len()).collect();
all.sort_unstable();
all.dedup();
all
}
/// Shift-click on artboard `i`: into the selection, active, or out of it (another becomes
/// active).
fn toggle(&mut self, cx: &ToolContext, i: usize) {
let mut sel = self.selected(cx);
if let Some(at) = sel.iter().position(|j| *j == i) {
if sel.len() > 1 {
sel.remove(at);
self.active = if i == self.active { sel.first().copied().unwrap_or(0) } else { self.active };
}
} else {
sel.push(i);
self.active = i;
}
self.others = sel.into_iter().filter(|j| *j != self.active).collect();
}
/// Gather the Smart Guide targets for dragging artboard `index` (call before the first preview,
/// while the document still shows where everything started).
fn begin_snapping(&mut self, cx: &ToolContext, index: usize, art_moves: bool) {
@@ -199,15 +231,24 @@ impl Tool for ArtboardTool {
if let Some(i) = cx.doc.artboard_at(p)
&& let Some(a) = cx.doc.artboards.get(i)
{
self.active = i;
self.drag = Some(Drag::Move { index: i, start: p, rect: a.rect, began: false, copy: false });
if m.shift {
self.toggle(cx, i);
} else if !self.selected(cx).contains(&i) {
// A plain click on an artboard that isn't selected selects just it; on a
// selected one, the selection stays, so the drag moves them all.
self.others.clear();
self.active = i;
}
let many = self.selected(cx).len() > 1 && self.selected(cx).contains(&i);
self.drag = Some(Drag::Move { index: i, many, start: p, rect: a.rect, began: false, copy: false });
} else {
self.others.clear();
let p = self.draw.press(cx, p, &[], None);
self.drag = Some(Drag::Create { start: p, cur: p });
}
vec![]
}
(PointerKind::Drag, Some(Drag::Move { index, start, rect, began, copy })) => {
(PointerKind::Drag, Some(Drag::Move { index, many, start, rect, began, copy })) => {
let mut out = vec![];
let label = |copy: bool| if copy { "Duplicate Artboard" } else { "Move Artboard" };
if !began {
@@ -221,13 +262,14 @@ impl Tool for ArtboardTool {
out.push(Action::Cancel);
out.push(Action::Begin(label(m.alt).into()));
}
self.drag = Some(Drag::Move { index, start, rect, began: true, copy: m.alt });
self.drag = Some(Drag::Move { index, many, start, rect, began: true, copy: m.alt });
let d = self.snap_move(cx, rect, move_delta(start, p, m.shift), m.alt);
self.preview = Some(rect + d);
out.push(Action::Preview(
"artboard.move".into(),
json!({ "index": index, "dx": d.x, "dy": d.y, "moveArt": self.move_art, "copy": m.alt }),
));
let mut v = json!({ "index": index, "dx": d.x, "dy": d.y, "moveArt": self.move_art, "copy": m.alt });
if many {
v["indices"] = json!(self.selected(cx));
}
out.push(Action::Preview("artboard.move".into(), v));
out
}
(PointerKind::Drag, Some(Drag::Resize { index, handle, rect, grab, began })) => {
@@ -259,10 +301,14 @@ impl Tool for ArtboardTool {
(PointerKind::Up, Some(d)) => {
self.end_drag();
match d {
Drag::Move { began: true, copy, .. } => {
// The copy, last in the previewed document, becomes the active artboard.
Drag::Move { began: true, copy, many, .. } => {
// The copies, last in the previewed document, become the selection (the
// last one active).
if copy {
self.active = cx.doc.artboards.len().saturating_sub(1);
let n = cx.doc.artboards.len();
let k = if many { self.selected(cx).len().min(n) } else { 1 };
self.active = n.saturating_sub(1);
self.others = (n.saturating_sub(k)..n.saturating_sub(1)).collect();
}
vec![Action::Commit]
}
@@ -300,7 +346,7 @@ impl Tool for ArtboardTool {
fn claims_key(&self, cx: &ToolContext, key: ToolKey) -> bool {
matches!(key, ToolKey::Delete | ToolKey::Backspace)
&& self.drag.is_none()
&& cx.doc.artboards.len() > 1
&& cx.doc.artboards.len() > self.selected(cx).len()
&& self.active < cx.doc.artboards.len()
}
@@ -310,9 +356,14 @@ impl Tool for ArtboardTool {
if !self.claims_key(cx, key) {
return vec![];
}
let i = self.active;
self.active = i.saturating_sub(1);
vec![Action::Exec("artboard.delete".into(), json!({ "index": i }))]
let sel = self.selected(cx);
self.active = sel.first().copied().unwrap_or(0).saturating_sub(1);
self.others.clear();
let params = match sel.as_slice() {
[i] => json!({ "index": i }),
_ => json!({ "indices": sel }),
};
vec![Action::Exec("artboard.delete".into(), params)]
}
ToolKey::Escape => {
let busy = matches!(self.drag, Some(Drag::Move { began: true, .. } | Drag::Resize { began: true, .. }));
@@ -326,6 +377,7 @@ impl Tool for ArtboardTool {
fn notify(&mut self, cx: &ToolContext, what: &str) {
if what == "created" {
self.active = cx.doc.artboards.len().saturating_sub(1);
self.others.clear();
}
}
@@ -340,6 +392,10 @@ impl Tool for ArtboardTool {
}
return o;
}
// The other selected artboards: dashed bounds (in a drag the document shows them moved).
for r in self.others.iter().filter_map(|i| cx.doc.artboards.get(*i)).map(|a| a.rect) {
o.push(Overlay::Path { path: polygon(&rect_corners(r), true), color: BLUE, width: 1.0, dashed: true });
}
let Some(ab) = cx.doc.artboards.get(self.active) else { return o };
let r = self.preview.unwrap_or(ab.rect);
o.push(Overlay::Path { path: polygon(&rect_corners(r), true), color: BLUE, width: 1.0, dashed: true });
@@ -377,12 +433,24 @@ impl Tool for ArtboardTool {
}
fn options(&self) -> Value {
json!({ "active": self.active, "moveArt": self.move_art, "scaleArt": self.scale_art })
json!({ "active": self.active, "selected": std::iter::once(self.active).chain(self.others.iter().copied()).collect::<Vec<_>>(), "moveArt": self.move_art, "scaleArt": self.scale_art })
}
fn set_option(&mut self, key: &str, value: &Value) {
match key {
"active" => self.active = value.as_u64().unwrap_or(0) as usize,
"active" => {
self.active = value.as_u64().unwrap_or(0) as usize;
self.others.clear();
}
// The selected artboards, the first active.
"selected" => {
let all: Vec<usize> =
value.as_array().into_iter().flatten().filter_map(Value::as_u64).filter_map(|i| usize::try_from(i).ok()).collect();
if let Some((first, rest)) = all.split_first() {
self.active = *first;
self.others = rest.iter().copied().filter(|i| i != first).collect();
}
}
"moveArt" => self.move_art = value.as_bool().unwrap_or(true),
"scaleArt" => self.scale_art = value.as_bool().unwrap_or(false),
_ => {}
@@ -523,6 +591,42 @@ mod tests {
assert_eq!(v["width"].as_f64(), Some(210.0));
}
/// Shift-click selects several artboards (#1139): a drag moves them all, Shift-click takes one
/// out again, a plain click on an unselected one selects just it, and Delete removes all the
/// selected ones (one artboard is always left).
#[test]
fn shift_click_selects_several_artboards_that_move_and_delete_together() {
let mut d = two_boards();
let mut third = d.artboards[0].clone();
third.rect = Rect::new(0.0, 600.0, 200.0, 800.0);
d.artboards.push(third);
let (s, p) = (Selection::default(), paint());
let c = cx(&d, &s, &p);
let shift = Mods { shift: true, ..Default::default() };
let click = |t: &mut ArtboardTool, x: f64, y: f64, m: Mods| {
t.pointer(&c, &ev(PointerKind::Down, x, y).with_mods(m));
t.pointer(&c, &ev(PointerKind::Up, x, y).with_mods(m));
};
let mut t = ArtboardTool::default();
click(&mut t, 450.0, 450.0, Mods::default());
click(&mut t, 700.0, 100.0, shift);
assert_eq!(t.options()["selected"], json!([1, 0]), "the Shift-clicked one is active");
// A drag from either moves both.
t.pointer(&c, &ev(PointerKind::Down, 450.0, 450.0));
let v = preview_params(&t.pointer(&c, &ev(PointerKind::Drag, 470.0, 450.0)));
assert_eq!((v["indices"].clone(), v["dx"].as_f64()), (json!([0, 1]), Some(20.0)));
assert_eq!(t.pointer(&c, &ev(PointerKind::Up, 470.0, 450.0)), vec![Action::Commit]);
// Delete removes both; with all three selected it can't, so the key isn't taken.
assert_eq!(t.key(&c, ToolKey::Delete, Mods::default()), vec![Action::Exec("artboard.delete".into(), json!({"indices": [0, 1]}))]);
t.set_option("selected", &json!([0, 1, 2]));
assert!(!t.claims_key(&c, ToolKey::Delete));
// Shift-click takes one out; a plain click on an unselected one selects just it.
click(&mut t, 100.0, 700.0, shift);
assert_eq!(t.options()["selected"], json!([0, 1]));
click(&mut t, 100.0, 700.0, Mods::default());
assert_eq!(t.options()["selected"], json!([2]));
}
/// Scale Artwork with Artboard (#602): the resize asks for the art to scale, keeps the artboard's
/// proportions, and never snaps to the art scaling with it.
#[test]
+19 -1
View File
@@ -376,10 +376,26 @@ fn refine(used: &[Used], mut centers: Vec<[f64; 3]>) -> Vec<[f64; 3]> {
/// Merge 4-connected same-label components smaller than `min_area` pixels into their most common
/// neighbouring label. Transparent pixels are never relabelled.
///
/// A pass can merge a speck into a small region that it merges away later, leaving the speck behind
/// as a new island of that region's old label (#819: more noise removal gave more paths). Passes
/// repeat until one changes nothing: each merge joins two components, so they always end.
pub fn denoise(labels: &mut [u16], w: usize, h: usize, min_area: usize) {
if min_area <= 1 || w == 0 || h == 0 {
if min_area <= 1 || w == 0 || h == 0 || labels.len() < w.saturating_mul(h) {
return;
}
// A backstop only: real images settle in a few passes.
const MAX_PASSES: usize = 64;
for _ in 0..MAX_PASSES {
if !denoise_pass(labels, w, h, min_area) {
break;
}
}
}
/// One [`denoise`] pass over every component; true when it relabelled any.
fn denoise_pass(labels: &mut [u16], w: usize, h: usize, min_area: usize) -> bool {
let mut changed = false;
let mut seen = vec![false; w * h];
let mut stack: Vec<usize> = Vec::new();
let mut comp: Vec<usize> = Vec::new();
@@ -433,6 +449,8 @@ pub fn denoise(labels: &mut [u16], w: usize, h: usize, min_area: usize) {
for &i in &comp {
labels[i] = to;
}
changed = true;
}
}
changed
}
+118
View File
@@ -557,3 +557,121 @@ fn create_strokes_survives_noise_and_tiny_images() {
}
}
}
/// The 4-connected same-label components of `labels` (transparent pixels left out): each one's size
/// and whether it touches a pixel of another opaque label.
fn components(labels: &[u16], w: usize, h: usize) -> Vec<(usize, bool)> {
let mut seen = vec![false; w * h];
let mut out = vec![];
for start in 0..w * h {
if seen[start] || labels[start] == TRANSPARENT {
continue;
}
let l = labels[start];
let (mut size, mut touches, mut stack) = (0, false, vec![start]);
seen[start] = true;
while let Some(i) = stack.pop() {
size += 1;
let (x, y) = (i % w, i / w);
let near = [(x > 0).then(|| i - 1), (x + 1 < w).then(|| i + 1), (y > 0).then(|| i - w), (y + 1 < h).then(|| i + w)];
for j in near.into_iter().flatten() {
if labels[j] == l {
if !seen[j] {
seen[j] = true;
stack.push(j);
}
} else if labels[j] != TRANSPARENT {
touches = true;
}
}
}
out.push((size, touches));
}
out
}
/// #819: a speck merged into a small region that is itself merged later was left behind as a new
/// island of the small region's old label.
#[test]
fn denoise_leaves_no_island_behind_a_merged_region() {
// One row: a 1-pixel speck, a 2-pixel region, then a large one; nothing under 3 pixels may stay.
let mut labels = vec![0, 1, 1, 2, 2, 2, 2, 2];
denoise(&mut labels, 8, 1, 3);
assert_eq!(labels, vec![2; 8]);
}
/// On noise, Remove Noise leaves no region under the minimum that could be merged, and a larger
/// minimum never leaves more regions (#819: more noise removal gave more paths).
#[test]
fn denoise_merges_every_small_region_and_more_noise_never_means_more_regions() {
let (w, h) = (48, 40);
let mut seed = 0x2545_f491_u32;
let mut next = || {
seed ^= seed << 13;
seed ^= seed >> 17;
seed ^= seed << 5;
seed
};
for colors in [2u16, 3, 5, 8] {
// Blocky noise: random 1-4 pixel runs, so regions of every size touch each other.
let mut base = vec![0u16; w * h];
let mut i = 0;
while i < w * h {
let (l, run) = ((next() % u32::from(colors)) as u16, 1 + next() as usize % 4);
for p in base.iter_mut().skip(i).take(run) {
*p = l;
}
i += run;
}
let mut last = usize::MAX;
for min_area in [1usize, 2, 4, 8, 16, 32, 64, 128] {
let mut labels = base.clone();
denoise(&mut labels, w, h, min_area);
let comps = components(&labels, w, h);
if min_area > 1 {
assert!(
comps.iter().all(|&(size, touches)| size >= min_area || !touches),
"{colors} colours, min {min_area}: a region under the minimum is left: {comps:?}"
);
}
assert!(comps.len() <= last, "{colors} colours: min {min_area} leaves {} regions, more than the {last} a smaller one left", comps.len());
last = comps.len();
}
}
}
/// #819: on noisy scan-like images, raising Noise never gives more paths. Before the fix, these
/// two went from 70 paths to 126 to 206 as Noise rose from 50 to 200 (and 22 to 25).
#[test]
fn more_noise_removal_never_gives_more_paths() {
for (seed0, colors) in [(15u32, 2usize), (9, 6)] {
let mut seed = seed0.wrapping_mul(0x9e37_79b9) | 1;
let mut next = move || {
seed ^= seed << 13;
seed ^= seed >> 17;
seed ^= seed << 5;
seed
};
let ink: Vec<[u8; 4]> = (0..colors)
.map(|_| {
let v = next();
[(v & 255) as u8, (v >> 8 & 255) as u8, (v >> 16 & 255) as u8, 255]
})
.collect();
// Diagonal bands of ink with a quarter of the pixels speckled at random.
let (w, h) = (121, 101);
let px: Vec<[u8; 4]> = (0..w * h)
.map(|i| {
let band = ((i % w) / 7 + (i / w) / 5) % colors;
if next() % 4 == 0 { ink[next() as usize % colors] } else { ink[band] }
})
.collect();
let img = Raster::from_fn(120, 100, |x, y| px[y as usize * w + x as usize]);
let mut last = (0, usize::MAX);
for noise in [1, 4, 8, 12, 25, 50, 100, 200] {
let n = trace(&img, &TraceParams { noise, ..TraceParams::default() }).paths.len();
assert!(n <= last.1, "image {seed0}: Noise {noise} gives {n} paths, more than the {} of Noise {}", last.1, last.0);
last = (noise, n);
}
}
}
+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).
@@ -66,6 +66,7 @@ fn ask_update(app: &mut VectorcraftApp, ids: Vec<Value>) {
/// The same offer for linked files another app changed while the document is open
/// ([`vectorcraft_engine::link_watch`]).
#[cfg(not(target_arch = "wasm32"))]
pub(crate) fn ask_update_changed(app: &mut VectorcraftApp, ids: Vec<Value>) {
ask(app, ids, tl!("{count} linked image(s) changed on disk. Show the new versions?"));
}
+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));
}
+47 -1
View File
@@ -131,6 +131,13 @@ impl Row {
}
}
/// Keep keyboard/pointer focus on the same font after filtering, loading new fonts or
/// expanding a family's styles. A row index on its own can now name a different font.
fn remap_highlight(before: &[Row], selected: Option<usize>, after: &[Row]) -> Option<usize> {
let row = before.get(selected?)?;
after.iter().position(|candidate| candidate == row)
}
/// What the rows were listed for: the query, the script, kind and favourites filters, Show Font
/// Names in English, the expanded families, the favourites and the font list's generation.
type RowsKey = (String, usize, Option<FontClass>, bool, bool, Vec<String>, Vec<String>, u64);
@@ -285,6 +292,10 @@ fn list(ui: &mut Ui, state_id: egui::Id, st: &mut MenuState, current: &str, samp
rows.extend(db.styles(f).into_iter().map(|s| Row::Style(f.clone(), s)));
}
}
// Filter changes (including un-starring a favourite) may remove rows; expanding
// another family or a background font scan can insert rows before the highlight.
// Never preview an unrelated family because the old numeric index now points to it.
st.highlight = remap_highlight(&st.rows, st.highlight, &rows);
st.rows = rows.into();
st.rows_key = Some(key);
}
@@ -320,7 +331,9 @@ fn list(ui: &mut Ui, state_id: egui::Id, st: &mut MenuState, current: &str, samp
}
if rows.is_empty() {
widgets::dim_label(ui, tl!("No matching fonts"));
return None;
// A filter or a font rescan can remove the row whose font was previewed.
// Do not leave that font applied while the list has no candidate to highlight.
return st.previewed.take().map(|_| FontPick::EndPreview);
}
// The rows: only those in view are laid out (there are thousands of families). The list is
// the menu's one scrolling part, under the search field and filters (#555): it fills the
@@ -438,6 +451,7 @@ fn list(ui: &mut Ui, state_id: egui::Id, st: &mut MenuState, current: &str, samp
st.previewed = Some(pick.clone());
Some(FontPick::Preview(pick.0, pick.1))
}
None => st.previewed.take().map(|_| FontPick::EndPreview),
_ => None,
}
}
@@ -731,3 +745,35 @@ pub(crate) fn sample_text(app: &VectorcraftApp) -> Option<String> {
let line = text.lines().map(str::trim).find(|l| !l.is_empty())?;
Some(line.chars().take(12).collect())
}
#[cfg(test)]
mod tests {
use super::{Row, remap_highlight};
fn family(name: &str) -> Row {
Row::Family(name.to_string())
}
#[test]
fn keeps_the_same_family_when_other_rows_are_inserted_or_removed() {
let before = [family("Alpha"), family("Bravo"), family("Charlie")];
assert_eq!(remap_highlight(&before, Some(2), &[family("Bravo"), family("Charlie")]), Some(1));
assert_eq!(remap_highlight(&before, Some(2), &[family("Alpha"), family("New"), family("Bravo"), family("Charlie")]), Some(3));
}
#[test]
fn clears_the_highlight_when_its_family_is_filtered_out() {
let before = [family("Alpha"), family("Bravo"), family("Charlie")];
assert_eq!(remap_highlight(&before, Some(1), &[family("Alpha"), family("Charlie")]), None);
assert_eq!(remap_highlight(&before, Some(10), &before), None);
assert_eq!(remap_highlight(&before, None, &before), None);
}
#[test]
fn expanded_style_rows_do_not_retarget_the_highlight() {
let before = [family("Alpha"), family("Bravo")];
let after = [family("Alpha"), Row::Style("Alpha".into(), "Bold".into()), family("Bravo")];
assert_eq!(remap_highlight(&before, Some(1), &after), Some(2));
assert_eq!(remap_highlight(&after, Some(1), &before), None);
}
}
+3 -1
View File
@@ -2583,6 +2583,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 +3431,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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2 -1
View File
@@ -2634,6 +2634,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 +3531,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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2 -1
View File
@@ -2631,6 +2631,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 +3528,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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2 -1
View File
@@ -2634,6 +2634,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 +3531,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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2 -1
View File
@@ -2456,6 +2456,7 @@
Size Tile to Art タイルをアートワークのサイズに合わせる
Size/Leading サイズ/行送り
Size: サイズ:
Ridges per segment: セグメントごとの山数:
Size: {size} Tracking: {tracking} サイズ:{size} トラッキング:{tracking}
Skip Blank Artboards 空白のアートボードをスキップ
Slice Options スライスオプション
@@ -3745,7 +3746,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…) 保存した選択範囲はありません (選択 → 選択範囲を保存…)
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -266,6 +266,7 @@ pub fn lang_from_tag(tag: &str) -> Option<Lang> {
/// True when `tag` names the generic `C` / `POSIX` locale (or a variant like `C.UTF-8`), which on
/// macOS means "no particular language" rather than English: the system language list decides.
#[cfg(not(target_arch = "wasm32"))]
fn is_c_locale(tag: &str) -> bool {
let base = tag.split(['.', '@']).next().unwrap_or("").to_ascii_lowercase();
matches!(base.as_str(), "c" | "posix")
+2 -1
View File
@@ -2630,6 +2630,7 @@
Size Tile to Art Подогнать плитку по рисунку
Size/Leading Размер/интерлиньяж
Size: Размер:
Ridges per segment: Гребней на сегмент:
Size: {size} Tracking: {tracking} Размер: {size} Трекинг: {tracking}
Skew Скос
Skin Tone Ramps Шкалы тонов кожи
@@ -3526,7 +3527,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 направляющие заблокированы
Can't render this file because it contains an unexpected character in line 2 and column 73.
+3 -1
View File
@@ -2626,6 +2626,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 +3515,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 напрямні заблоковані
Can't render this file because it contains an unexpected character in line 659 and column 40.
+1
View File
@@ -2522,6 +2522,7 @@
Size Tile to Art 使拼贴适合图稿
Size/Leading 字号/行距
Size: 尺寸:
Ridges per segment: 每段起伏数:
Size: {size} Tracking: {tracking} 大小:{size} 字距:{tracking}
Skew 倾斜
Skin Tone Ramps 肤色渐变
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -2612,6 +2612,7 @@
Size Tile to Art 拼貼符合圖稿大小
Size/Leading 大小/行距
Size: 大小:
Ridges per segment: 每段起伏數:
Size: {size} Tracking: {tracking} 大小:{size} 字距:{tracking}
Skew 傾斜
Skin Tone Ramps 膚色漸階
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));
+94 -5
View File
@@ -5,7 +5,7 @@
//! fits it in the window.
use egui::{Sense, Ui, pos2, vec2};
use serde_json::json;
use serde_json::{Value, json};
use super::{pstate, set_pstate};
use crate::theme::Tokens;
@@ -40,6 +40,46 @@ pub(crate) fn selected(app: &VectorcraftApp, n: usize) -> usize {
app.view().map_or(0, |v| v.artboard).min(n.saturating_sub(1))
}
/// The selected artboards (of `n`): while the Artboard tool is in use, those it has selected
/// (Shift-click, #1139), else the active one.
pub(crate) fn picked(app: &VectorcraftApp, n: usize) -> Vec<usize> {
let tool = (app.session.tool_id() == "artboard").then(|| app.session.tool_options()["selected"].clone());
let mut all: Vec<usize> = tool
.iter()
.filter_map(Value::as_array)
.flatten()
.filter_map(Value::as_u64)
.filter_map(|i| usize::try_from(i).ok())
.filter(|i| *i < n)
.collect();
if all.is_empty() {
all.push(selected(app, n));
}
all.sort_unstable();
all.dedup();
all
}
/// Shift- or Ctrl/Cmd-click on artboard `i`'s row while the Artboard tool is in use: into its
/// selection or out of it (the active one stays the navigator's).
fn toggle(app: &mut VectorcraftApp, i: usize, n: usize) {
let mut all = picked(app, n);
match all.iter().position(|j| *j == i) {
Some(at) if all.len() > 1 => {
all.remove(at);
}
Some(_) => return,
None => all.insert(0, i),
}
let active = if all.contains(&i) { i } else { all.first().copied().unwrap_or(0) };
all.retain(|j| *j != active);
all.insert(0, active);
if let Some(v) = app.view_mut() {
v.artboard = active;
}
app.session.set_tool_option("selected", &json!(all));
}
/// Make artboard `i` the active one, leaving the view where it is: the navigator's and, while it
/// is the tool, the Artboard tool's.
pub(crate) fn select(app: &mut VectorcraftApp, i: usize) {
@@ -72,6 +112,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
return;
}
let sel = selected(app, abs.len());
let all = picked(app, abs.len());
let editing: Option<usize> = pstate(ui.ctx(), "ab-edit");
widgets::list_box(ui, |ui| {
ui.set_min_height(110.0);
@@ -82,7 +123,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
if resp.drag_started() {
egui::DragAndDrop::set_payload(ui.ctx(), RowDrag(i));
}
if i == sel {
if all.contains(&i) {
ui.painter().rect_filled(r, 0.0, t.row_selected);
} else if resp.hovered() {
ui.painter().rect_filled(r, 0.0, t.hover);
@@ -120,7 +161,10 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
app.select_tool("artboard");
select(app, i);
}
if resp.clicked() {
let several = ui.input(|inp| inp.modifiers.shift || inp.modifiers.command);
if resp.clicked() && several && app.session.tool_id() == "artboard" {
toggle(app, i, abs.len());
} else if resp.clicked() {
select(app, i);
}
// Double-clicking the number goes to the artboard (fits it in the window); the name
@@ -159,8 +203,10 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
} else if new.clicked() && app.run("artboard.new", json!({})).is_ok() {
select(app, n);
}
if widgets::icon_button_enabled(ui, "trash-2", tl!("Delete Artboard"), false, n > 1, 24.0).clicked() {
app.run("artboard.delete", json!({"index": sel})).ok();
if widgets::icon_button_enabled(ui, "trash-2", tl!("Delete Artboard"), false, n > all.len(), 24.0).clicked()
&& app.run("artboard.delete", json!({ "indices": all })).is_ok()
{
select(app, all.first().copied().unwrap_or(0).saturating_sub(1));
}
});
}
@@ -255,6 +301,49 @@ mod tests {
assert_eq!(picked(&mut app, json!({})), json!([a]));
assert_eq!(picked(&mut app, json!({"artboard": 1})), json!([b]));
}
/// With the Artboard tool, Shift-click on a row adds its artboard to the tool's selection or
/// takes it out (#1139), and every selected row is highlighted.
#[test]
fn shift_clicking_rows_selects_several_artboards() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.session.execute("file.new", &json!({"width": 200, "height": 150})).unwrap();
for x in [400, 800] {
app.session.execute("artboard.new", &json!({"x": x, "y": 0, "width": 200, "height": 150})).unwrap();
}
app.select_tool("artboard");
select(&mut app, 0);
let ctx = egui::Context::default();
let screen = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, vec2(240.0, 400.0)));
let frame = |app: &mut VectorcraftApp, time: f64, events: Vec<egui::Event>| {
let raw = egui::RawInput { events, time: Some(time), screen_rect: screen, ..Default::default() };
let mut out = ctx.run_ui(raw, |ui| show(app, ui));
out.textures_delta.clear();
out.shapes
};
let shapes = frame(&mut app, 0.0, vec![]);
let third = shapes
.iter()
.find_map(|c| match &c.shape {
egui::Shape::Text(t) if t.galley.text() == "Artboard 3" => Some(t.pos + vec2(4.0, 4.0)),
_ => None,
})
.unwrap();
let click = |at: egui::Pos2| {
let b =
|pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::SHIFT };
vec![egui::Event::ModifiersChanged(egui::Modifiers::SHIFT), egui::Event::PointerMoved(at), b(true), b(false)]
};
frame(&mut app, 1.0, click(third));
assert_eq!(picked(&app, 3), [0, 2]);
let rows = frame(&mut app, 2.0, vec![egui::Event::ModifiersChanged(egui::Modifiers::NONE)])
.iter()
.filter(|c| matches!(&c.shape, egui::Shape::Rect(r) if r.fill == Tokens::get(&ctx).row_selected))
.count();
assert_eq!(rows, 2, "both rows highlighted");
frame(&mut app, 3.0, click(third));
assert_eq!(picked(&app, 3), [0]);
}
/// Double-clicking a row's number goes to its artboard and its name renames it; a row's
/// Options button edits that artboard with the Artboard tool, and the tool's artboard is the
/// active one.
+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(),
+51
View File
@@ -224,6 +224,57 @@ fn right_arrow_lists_a_familys_styles_and_they_preview() {
assert_eq!(picked, Some(FontPick::Chosen("Source Sans 3".into(), Some(styles[1].clone()))));
}
/// #1116: styles listed above the highlight (a click on another family's triangle) leave it on its
/// family; it doesn't land on one of those styles and preview it.
#[test]
fn styles_listed_above_the_highlight_leave_it_on_its_family() {
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
let frame = |events: Vec<egui::Event>| {
let input =
egui::RawInput { events, screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(600.0, 800.0))), ..Default::default() };
let (mut at, mut picked) = (egui::Pos2::ZERO, None);
let mut out = ctx.run_ui(input, |ui| {
at = ui.next_widget_position();
picked = font_menu(ui, "test-rebuild", "Source Sans 3", 260.0, None, MenuLook::default());
});
out.textures_delta.clear();
// The family's row: the lowest text naming it (the menu's button above names it too).
let row = out
.shapes
.iter()
.filter_map(|s| match &s.shape {
egui::Shape::Text(t) if t.galley.text() == "Source Sans 3" => Some(t.pos + egui::vec2(0.0, t.galley.size().y * 0.5)),
_ => None,
})
.max_by(|a, b| a.y.total_cmp(&b.y));
(at, picked, row)
};
let (at, ..) = frame(vec![]);
let click = at + egui::vec2(40.0, 10.0);
let button = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
frame(vec![egui::Event::PointerMoved(click), button(click, true)]);
frame(vec![button(click, false)]);
let mut row = None;
for _ in 0..5 {
row = frame(vec![]).2;
}
// The family's disclosure triangle, left of its name: hovered, it highlights the family.
let triangle = row.expect("the family is listed") - egui::vec2(7.0, 0.0);
frame(vec![egui::Event::PointerMoved(triangle)]);
let (_, picked, _) = frame(key(egui::Key::ArrowDown));
let Some(FontPick::Preview(next, None)) = picked else { panic!("↓ previews the next family: {picked:?}") };
// A click on the triangle, the pointer still: the styles are listed between the two families.
frame(vec![button(triangle, true)]);
frame(vec![button(triangle, false)]);
for _ in 0..3 {
let (_, picked, _) = frame(vec![]);
assert_eq!(picked, None, "{next} stays highlighted and previewed");
}
let (_, picked, _) = frame(key(egui::Key::Enter));
assert_eq!(picked, Some(FontPick::Chosen(next, None)));
}
#[test]
fn stars_mark_favourites_and_the_preferences_size_the_rows() {
let mut app = VectorcraftApp::new(Session::new(), crate::Services::default());
+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
@@ -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
+6 -5
View File
@@ -116,11 +116,12 @@ to the target triple, so host build scripts aren't affected.
candidates replace each other.
- Double-clicking the MSI opens a setup wizard (WixUI_InstallDir without the licence page):
Welcome, install folder (remembered for upgrades in `HKLM\Software\VectorCraft\InstallDir`),
Ready, a progress page, and a Finish page that says where to find the app, with a ticked "Launch
VectorCraft" box. The pages name the full version (`vectorcraft.en-us.wxl`). `package.ps1` draws
the wizard's banner and side bitmaps from the app icon, so no WiX stock art ships. `msiexec /qn`
still installs silently. Building needs the WiX UI and Util extensions:
`wix extension add -g WixToolset.UI.wixext/5.0.2 WixToolset.Util.wixext/5.0.2`.
Shortcuts (a ticked "Create a desktop shortcut" box), Ready, a progress page, and a Finish page
that says where to find the app, with a ticked "Launch VectorCraft" box. The pages name the full
version (`vectorcraft.en-us.wxl`). `package.ps1` draws the wizard's banner and side bitmaps from
the app icon, so no WiX stock art ships. `msiexec /qn` still installs silently, with the desktop
shortcut unless `INSTALLDESKTOPSHORTCUT=""` is passed. Building needs the WiX UI and Util
extensions: `wix extension add -g WixToolset.UI.wixext/5.0.2 WixToolset.Util.wixext/5.0.2`.
- The ARM64 build is cross-compiled on the x64 runner. `.github/workflows/windows-arm64.yml`
installs that MSI on a Windows 11 ARM64 runner, checks that both installed programs are ARM64,
runs `vectorcraft-cli --version` natively and uninstalls.
+5 -4
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 |
@@ -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,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
| Date | Change | Summary |
|---|---|---|
| 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 (Edit Toolbar: the All Tools drawer beside the toolbar, tools dragged in and out, #1018; earlier: Direct Selection whole-path drags and Alt-drag copies, #1104) · **Target:** Adobe Illustrator 2026 (30.x)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (Live Corners round corners between curved sides, #1144; earlier: several artboards selected at once, #1139) · **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,
@@ -70,7 +70,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 |
@@ -126,6 +126,7 @@ tools, never checked side by side) isn't captured row by row.
| Space: Hand; Cmd+Space / Cmd+Alt+Space: zoom in/out; middle button pans | done | |
| Cmd/Ctrl: last-used selection tool; Cmd+Alt: Group Selection inside Direct Selection | partial | |
| Alt-drag duplicates (objects, artboards with their art) | done | |
| Shift-click selects several artboards (Artboard tool, Artboards panel); they move, Alt-copy and delete together | done | `artboard.move`/`artboard.delete` `indices` (#1139) |
| Shape tools: Shift constrains, Alt from centre, Space moves while dragging, arrows change sides/points/rows | done | |
| Tool double-click opens its options | done | |
| Per-platform mapping (Cmd ↔ Ctrl, Option ↔ Alt) | done | |
@@ -168,6 +169,8 @@ re-measure of the panels added since.
| Date | Change | Summary |
|---|---|---|
| 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) |
| 2026-10-10 | major | First checklist, built from the tool sources and tests against Illustrator 2026's public documentation |
+4
View File
@@ -7,5 +7,9 @@
<String Id="WelcomeDlgTitle" Value="{\WixUI_Font_Bigger}Welcome to the [ProductName] [VectorcraftVersion] Setup Wizard" />
<String Id="WelcomeDlgDescription" Value="The Setup Wizard will install [ProductName] version [VectorcraftVersion] on your computer. Click Next to continue or Cancel to exit the Setup Wizard." />
<String Id="VerifyReadyDlgInstallTitle" Value="{\WixUI_Font_Title}Ready to install [ProductName] [VectorcraftVersion]" />
<String Id="ShortcutsDlgTitle" Value="{\WixUI_Font_Title}Shortcuts" />
<String Id="ShortcutsDlgDescription" Value="Choose where you can start [ProductName] from." />
<String Id="ShortcutsDlgDesktop" Value="Create a &amp;desktop shortcut" />
<String Id="ShortcutsDlgStartMenu" Value="[ProductName] is always added to the Start menu." />
<String Id="ExitDialogTitle" Value="{\WixUI_Font_Bigger}Completed the [ProductName] [VectorcraftVersion] Setup Wizard" />
</WixLocalization>
+42 -1
View File
@@ -10,7 +10,9 @@
Per-machine install with a setup wizard that names the version: Welcome, install folder (Program Files\VectorCraft by
default, remembered for upgrades), Ready, a progress page, and a Finish page with a ticked
"Launch VectorCraft" box. Installs a Start Menu shortcut and an App Paths entry (so `vectorcraft`
starts from the Run dialog), plus vectorcraft-cli. `msiexec /qn` still installs silently.
starts from the Run dialog), plus vectorcraft-cli. A Shortcuts page after the install folder offers
a desktop shortcut (ticked). `msiexec /qn` still installs silently, with the desktop shortcut
unless INSTALLDESKTOPSHORTCUT="" is passed.
-->
<Wix xmlns="http://wixtoolset.org/schemas/v4/wxs"
xmlns:ui="http://wixtoolset.org/schemas/v4/wxs/ui">
@@ -39,6 +41,7 @@
<Directory Id="INSTALLFOLDER" Name="VectorCraft" />
</StandardDirectory>
<StandardDirectory Id="ProgramMenuFolder" />
<StandardDirectory Id="DesktopFolder" />
<!-- An upgrade installs where the user put the previous version (written by VectorcraftApp). -->
<Property Id="INSTALLFOLDER">
@@ -47,14 +50,43 @@
<Feature Id="Main" Title="VectorCraft" Level="1">
<ComponentGroupRef Id="VectorcraftFiles" />
<ComponentRef Id="DesktopShortcut" />
</Feature>
<!-- A desktop shortcut: the Shortcuts page's box (ticked), or INSTALLDESKTOPSHORTCUT="" on the
msiexec command line for none. Secure, so the wizard's choice reaches the install. -->
<Property Id="INSTALLDESKTOPSHORTCUT" Value="1" Secure="yes" />
<!-- The setup wizard: WixUI_InstallDir without its licence page (the code is MIT OR
Apache-2.0; there is nothing to accept). A later NewDialog on the same button wins. -->
<ui:WixUI Id="WixUI_InstallDir" InstallDirectory="INSTALLFOLDER" />
<UI>
<Publish Dialog="WelcomeDlg" Control="Next" Event="NewDialog" Value="InstallDirDlg" Order="2" Condition="NOT Installed" />
<Publish Dialog="InstallDirDlg" Control="Back" Event="NewDialog" Value="WelcomeDlg" Order="2" />
<!-- The Shortcuts page sits between the install folder and Ready (once the folder is valid). -->
<Publish Dialog="InstallDirDlg" Control="Next" Event="NewDialog" Value="ShortcutsDlg" Order="5"
Condition="WIXUI_DONTVALIDATEPATH OR WIXUI_INSTALLDIR_VALID=&quot;1&quot;" />
<Publish Dialog="VerifyReadyDlg" Control="Back" Event="NewDialog" Value="ShortcutsDlg" Order="5" Condition="NOT Installed" />
<Dialog Id="ShortcutsDlg" Width="370" Height="270" Title="!(loc.InstallDirDlg_Title)">
<Control Id="Next" Type="PushButton" X="236" Y="243" Width="56" Height="17" Default="yes" Text="!(loc.WixUINext)">
<Publish Event="NewDialog" Value="VerifyReadyDlg" />
</Control>
<Control Id="Back" Type="PushButton" X="180" Y="243" Width="56" Height="17" Text="!(loc.WixUIBack)">
<Publish Event="NewDialog" Value="InstallDirDlg" />
</Control>
<Control Id="Cancel" Type="PushButton" X="304" Y="243" Width="56" Height="17" Cancel="yes" Text="!(loc.WixUICancel)">
<Publish Event="SpawnDialog" Value="CancelDlg" />
</Control>
<Control Id="BannerBitmap" Type="Bitmap" X="0" Y="0" Width="370" Height="44" TabSkip="no" Text="!(loc.InstallDirDlgBannerBitmap)" />
<Control Id="BannerLine" Type="Line" X="0" Y="44" Width="370" Height="0" />
<Control Id="BottomLine" Type="Line" X="0" Y="234" Width="370" Height="0" />
<Control Id="Title" Type="Text" X="15" Y="6" Width="200" Height="15" Transparent="yes" NoPrefix="yes" Text="!(loc.ShortcutsDlgTitle)" />
<Control Id="Description" Type="Text" X="25" Y="23" Width="280" Height="15" Transparent="yes" NoPrefix="yes" Text="!(loc.ShortcutsDlgDescription)" />
<Control Id="DesktopShortcutCheckBox" Type="CheckBox" X="20" Y="60" Width="330" Height="18"
Property="INSTALLDESKTOPSHORTCUT" CheckBoxValue="1" Text="!(loc.ShortcutsDlgDesktop)" />
<Control Id="StartMenuNote" Type="Text" X="20" Y="84" Width="330" Height="30" NoPrefix="yes" Text="!(loc.ShortcutsDlgStartMenu)" />
</Dialog>
<Publish Dialog="ExitDialog" Control="Finish" Event="DoAction" Value="LaunchVectorcraft"
Condition="WIXUI_EXITDIALOGOPTIONALCHECKBOX = 1 AND NOT Installed" />
</UI>
@@ -92,5 +124,14 @@
<File Id="VectorcraftCliExe" Source="$(var.BinDir)\vectorcraft-cli.exe" KeyPath="yes" />
</Component>
</ComponentGroup>
<!-- Installed only with INSTALLDESKTOPSHORTCUT set (the Shortcuts page's box); removed with the
app. A per-machine install puts it on the common desktop. `wix msi validate` reports ICE38,
ICE43 and ICE57 for it: they take a desktop shortcut for per-user data, which it isn't here. -->
<Component Id="DesktopShortcut" Directory="DesktopFolder" Guid="498F2D4C-E655-47F6-B406-2D4C449B8D26" Condition="INSTALLDESKTOPSHORTCUT">
<Shortcut Id="DesktopShortcut" Name="VectorCraft" Description="VectorCraft vector illustration app"
Target="[#VectorcraftExe]" WorkingDirectory="INSTALLFOLDER" Icon="VectorcraftIcon.ico" IconIndex="0" />
<RegistryValue Root="HKLM" Key="Software\VectorCraft" Name="DesktopShortcut" Value="1" Type="integer" KeyPath="yes" />
</Component>
</Fragment>
</Wix>