mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-16 12:16:54 -04:00
Compare commits
157
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c7522b7f0c | ||
|
|
4eaa5f0b56 | ||
|
|
f68f66a579 | ||
|
|
0c6814e760 | ||
|
|
fbc1c494b2 | ||
|
|
b08482d640 | ||
|
|
255fefe71f | ||
|
|
f5fab833d0 | ||
|
|
423cb18f40 | ||
|
|
65a4847eed | ||
|
|
6daba37d3b | ||
|
|
6fc725d2fa | ||
|
|
34df89e8e0 | ||
|
|
b192e2cf30 | ||
|
|
93277ef87a | ||
|
|
58c874b128 | ||
|
|
f4035c98d1 | ||
|
|
369948f816 | ||
|
|
1726c600d2 | ||
|
|
5aa1605c17 | ||
|
|
d79aa607cc | ||
|
|
3bd04a082e | ||
|
|
ae9cd2a03c | ||
|
|
3ce08507c1 | ||
|
|
1f3d469c33 | ||
|
|
eeb78520e1 | ||
|
|
eb189a36c3 | ||
|
|
22690c3565 | ||
|
|
fad7248e7d | ||
|
|
2afd0b659c | ||
|
|
0089e7bb8b | ||
|
|
539759429c | ||
|
|
08ab1676b0 | ||
|
|
eb510f4194 | ||
|
|
cb9951918a | ||
|
|
82b22af43c | ||
|
|
9bcac8f1e6 | ||
|
|
8c69173bb7 | ||
|
|
c7d29e5b92 | ||
|
|
b9aca50c3c | ||
|
|
a2314cf803 | ||
|
|
6c04fb56c8 | ||
|
|
0f162176be | ||
|
|
91eeb5610c | ||
|
|
632cff0068 | ||
|
|
1992239325 | ||
|
|
ded39b51cc | ||
|
|
5e9b7d09a2 | ||
|
|
a75b465419 | ||
|
|
aa9f0153a4 | ||
|
|
af98309c01 | ||
|
|
5dcc64e757 | ||
|
|
abfcd0800e | ||
|
|
fe5955e521 | ||
|
|
fe9838f296 | ||
|
|
9896eda208 | ||
|
|
aecf88c1c1 | ||
|
|
2c8b8b0cc1 | ||
|
|
f79858c12c | ||
|
|
8957545c33 | ||
|
|
bc762eee99 | ||
|
|
cfa36fc55f | ||
|
|
a3b6c3d969 | ||
|
|
7ff0269a1d | ||
|
|
cd1fac5393 | ||
|
|
525b79a51a | ||
|
|
6ca54579cd | ||
|
|
558b8b621c | ||
|
|
012fe675a2 | ||
|
|
12ad39e7cc | ||
|
|
f657110510 | ||
|
|
b10d6c63ee | ||
|
|
3eeb22179d | ||
|
|
b0ba64a940 | ||
|
|
050e355e6c | ||
|
|
b1e4c4e522 | ||
|
|
c7b46dbb3e | ||
|
|
4d52b043cf | ||
|
|
2b93e8a930 | ||
|
|
cc6fd8a5dc | ||
|
|
cd34e4097a | ||
|
|
d10962ac45 | ||
|
|
a31cdab606 | ||
|
|
975dea056c | ||
|
|
0a8d334510 | ||
|
|
a2abe63e8d | ||
|
|
0ff9e35374 | ||
|
|
e02c2dc899 | ||
|
|
8e4158b364 | ||
|
|
008644ea2c | ||
|
|
a8eb9024da | ||
|
|
05bc89827b | ||
|
|
e3978d158a | ||
|
|
83ad194819 | ||
|
|
61c333b8ed | ||
|
|
0cfdefa853 | ||
|
|
6c3f9f60aa | ||
|
|
6fd342563d | ||
|
|
8a31fda88f | ||
|
|
c4e139af19 | ||
|
|
389f75fa7b | ||
|
|
34eb19a5ab | ||
|
|
0c57b1d5d2 | ||
|
|
5d847ffc88 | ||
|
|
2a52b2901f | ||
|
|
7f4f7bd8d2 | ||
|
|
2729483851 | ||
|
|
5226d5a728 | ||
|
|
dec085e1e5 | ||
|
|
eaf96c60ae | ||
|
|
85bbc35176 | ||
|
|
3ce51a3487 | ||
|
|
a031ff6c7b | ||
|
|
0d748500d6 | ||
|
|
d47857a20f | ||
|
|
c9236ed3f7 | ||
|
|
2480a505cb | ||
|
|
231a97dd51 | ||
|
|
1819921e03 | ||
|
|
06226bcb73 | ||
|
|
20eaecd624 | ||
|
|
653945c8f8 | ||
|
|
bfde4dd918 | ||
|
|
a71348821a | ||
|
|
4f60296e77 | ||
|
|
98ccc7980b | ||
|
|
1639666a34 | ||
|
|
47af13c3d4 | ||
|
|
1da3c0c052 | ||
|
|
b19731b307 | ||
|
|
2d95ac1694 | ||
|
|
68241453dd | ||
|
|
39099dcc57 | ||
|
|
9ea392e6ee | ||
|
|
fffec4a714 | ||
|
|
adfe6e3820 | ||
|
|
935a588aa2 | ||
|
|
212fb9994a | ||
|
|
f5111045a9 | ||
|
|
c3e0ca3dfa | ||
|
|
50dbe4c10e | ||
|
|
ec41d72e0d | ||
|
|
63026402a1 | ||
|
|
a785b53e7b | ||
|
|
1217d58ca1 | ||
|
|
c912ef59d1 | ||
|
|
af505b197a | ||
|
|
f043a1a392 | ||
|
|
d01d7b2ef0 | ||
|
|
dc6e8a4253 | ||
|
|
327737b21a | ||
|
|
a7a4d952ea | ||
|
|
621e1a6640 | ||
|
|
c5274beb23 | ||
|
|
611f6855ef | ||
|
|
75c587c8a0 | ||
|
|
ed14640756 |
No files matched your search
@@ -98,6 +98,7 @@ Generated-in-code art is original and has no file to list. This covers the defau
|
||||
| `assets/icons/dc-arc.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-arrow-down.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-arrow-extend.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-arrow-fill.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | The Selection and Direct Selection arrows' fill, painted black or white under their outline |
|
||||
| `assets/icons/dc-arrow-tip.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-arrow-up.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-artboard-options.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
|
||||
Generated
+116
@@ -200,6 +200,12 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anyhow"
|
||||
version = "1.0.104"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
|
||||
|
||||
[[package]]
|
||||
name = "arboard"
|
||||
version = "3.6.1"
|
||||
@@ -713,6 +719,16 @@ version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b"
|
||||
|
||||
[[package]]
|
||||
name = "colored"
|
||||
version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "117725a109d387c937a1533ce01b450cbde6b88abceea8473c4d7a85853cda3c"
|
||||
dependencies = [
|
||||
"lazy_static",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "combine"
|
||||
version = "4.6.8"
|
||||
@@ -790,6 +806,15 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-ui"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=5c874d51eeb3de70e735c8ce87a59a7dedd47675#5c874d51eeb3de70e735c8ce87a59a7dedd47675"
|
||||
dependencies = [
|
||||
"egui",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crc32fast"
|
||||
version = "1.5.2"
|
||||
@@ -861,6 +886,19 @@ version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376"
|
||||
|
||||
[[package]]
|
||||
name = "dify"
|
||||
version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "90ce0fb972943b4e88cd03b8f92953df0c71bb05e0bde8e5b684895d808013cc"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"colored",
|
||||
"getopts",
|
||||
"image",
|
||||
"rayon",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "digest"
|
||||
version = "0.10.7"
|
||||
@@ -1066,6 +1104,27 @@ dependencies = [
|
||||
"winit",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "egui_kittest"
|
||||
version = "0.36.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5eb3f3effb2726fee23449293e67acbf6cc6ce35525b1722bd5743bb348114d5"
|
||||
dependencies = [
|
||||
"dify",
|
||||
"eframe",
|
||||
"egui",
|
||||
"egui-wgpu",
|
||||
"image",
|
||||
"kittest",
|
||||
"log",
|
||||
"open",
|
||||
"pollster 1.0.1",
|
||||
"serde",
|
||||
"tempfile",
|
||||
"toml",
|
||||
"wgpu",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "either"
|
||||
version = "1.18.0"
|
||||
@@ -1438,6 +1497,15 @@ dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getopts"
|
||||
version = "0.2.24"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df"
|
||||
dependencies = [
|
||||
"unicode-width",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.3.4"
|
||||
@@ -1940,6 +2008,25 @@ dependencies = [
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "is-docker"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3"
|
||||
dependencies = [
|
||||
"once_cell",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "is-wsl"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5"
|
||||
dependencies = [
|
||||
"is-docker",
|
||||
"once_cell",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itertools"
|
||||
version = "0.15.0"
|
||||
@@ -2067,6 +2154,16 @@ version = "3.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc"
|
||||
|
||||
[[package]]
|
||||
name = "kittest"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "90ceaa75eb0036a32b6b9833962eb18137449e9817e2e586006471925b727fd5"
|
||||
dependencies = [
|
||||
"accesskit",
|
||||
"accesskit_consumer 0.35.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "krilla"
|
||||
version = "0.8.2"
|
||||
@@ -2119,6 +2216,12 @@ dependencies = [
|
||||
"smallvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lazy_static"
|
||||
version = "1.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb"
|
||||
|
||||
[[package]]
|
||||
name = "leb128fmt"
|
||||
version = "0.1.0"
|
||||
@@ -2740,6 +2843,16 @@ version = "1.21.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
||||
|
||||
[[package]]
|
||||
name = "open"
|
||||
version = "5.4.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "aa576c76302b7b808eecc68061e67336c47833ef9d22caa74dda10fa9675eebc"
|
||||
dependencies = [
|
||||
"is-wsl",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "orbclient"
|
||||
version = "0.3.55"
|
||||
@@ -4335,6 +4448,7 @@ dependencies = [
|
||||
"objc2-core-foundation",
|
||||
"objc2-core-text",
|
||||
"objc2-foundation 0.3.2",
|
||||
"pollster 1.0.1",
|
||||
"raw-window-handle",
|
||||
"rfd",
|
||||
"serde_json",
|
||||
@@ -4744,8 +4858,10 @@ dependencies = [
|
||||
name = "vectorcraft-ui-egui"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"craft-ui",
|
||||
"egui",
|
||||
"egui_extras",
|
||||
"egui_kittest",
|
||||
"image",
|
||||
"log",
|
||||
"serde",
|
||||
|
||||
@@ -11,6 +11,7 @@ rust-version = "1.95"
|
||||
repository = "https://github.com/storytold/vectorcraft"
|
||||
|
||||
[workspace.dependencies]
|
||||
craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "5c874d51eeb3de70e735c8ce87a59a7dedd47675", features = ["serde"] }
|
||||
# internal
|
||||
vectorcraft-geom = { path = "crates/geom" }
|
||||
vectorcraft-color = { path = "crates/color" }
|
||||
|
||||
+5
-1
@@ -1,5 +1,9 @@
|
||||
# VectorCraft Roadmap
|
||||
|
||||
- UI.5: Shared docking adds tab reordering, docking into other groups, dock splits and resizable floating panel groups. Panel positions and return locations persist with workspaces; the native icon rail and Tools panel remain available.
|
||||
|
||||
- UI.4: Dock and floating-panel headers use shared accessible tabs with bounded overflow. Widget-state theme application uses `craft-ui`; all four palettes, font stacks and preferences remain app-owned.
|
||||
|
||||
**Stage: alpha** · next: beta, ~13 points (ready for real work ~62% → ~75%, and reliable `.ai` exchange) and ~170–270 h away
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (Effect › Brush Strokes landed: Photoshop-style filters 28 of 57, raster effects 40% → 48%) · **Target:** Adobe Illustrator 2026 (30.x)
|
||||
@@ -123,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). |
|
||||
| 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 |
|
||||
|
||||
@@ -308,7 +308,8 @@ fn notice(lines: &[&str]) {
|
||||
}
|
||||
|
||||
/// Wraps the app to read dropped files asynchronously (browsers can't read them synchronously)
|
||||
/// and feed them through the inboxes: placed where they were dropped on the canvas, else opened.
|
||||
/// and feed them through the inboxes: placed where they were dropped on the canvas, else opened
|
||||
/// (libraries, presets and plug-ins open wherever they are dropped).
|
||||
/// When the graphics are lost it hands the app to a new runner on a new canvas.
|
||||
struct WebShell {
|
||||
/// `None` once handed on.
|
||||
|
||||
@@ -63,6 +63,9 @@ raw-window-handle = "0.6"
|
||||
# objc2-foundation.
|
||||
objc2-core-foundation = { version = "0.3", default-features = false, features = ["std", "CFArray", "CFString", "CFURL"] }
|
||||
objc2-core-text = { version = "0.3", default-features = false, features = ["std", "CTFontManager"] }
|
||||
# Waits for the answer of a file dialog shown as a sheet (src/main.rs, #867). MIT OR Apache-2.0,
|
||||
# already in the build through eframe.
|
||||
pollster = "1"
|
||||
|
||||
[build-dependencies]
|
||||
# Icon + VERSIONINFO for vectorcraft.exe; build.rs only uses it when targeting Windows.
|
||||
|
||||
+236
-33
@@ -80,8 +80,9 @@ impl eframe::App for App {
|
||||
self.app.titlebar_inset = inset.unwrap_or(mac_window::DEFAULT_INSET);
|
||||
}
|
||||
file_access::unconfined(|| self.app.ui(ui));
|
||||
// Not while a file dialog's sheet has the keyboard: its name field's composition would go.
|
||||
#[cfg(target_os = "macos")]
|
||||
if self.app.take_ime_discard() {
|
||||
if !self.app.file_dialog_open() && self.app.take_ime_discard() {
|
||||
discard_marked_text();
|
||||
}
|
||||
}
|
||||
@@ -114,8 +115,16 @@ impl App {
|
||||
vectorcraft_ui_egui::background::wait_all(&mut self.app);
|
||||
return;
|
||||
}
|
||||
// Files from the Finder and the Dock wait while a file dialog is open: what asked for it runs
|
||||
// again on its document when it answers.
|
||||
#[cfg(target_os = "macos")]
|
||||
open_files(&mut self.app, open_documents::take());
|
||||
if !self.app.file_dialog_open() {
|
||||
// Files from the Finder and the Dock wait while a file dialog is open: what asked for it runs
|
||||
// again on its document when it answers.
|
||||
if !self.app.file_dialog_open() {
|
||||
open_files(&mut self.app, open_documents::take());
|
||||
}
|
||||
}
|
||||
self.app.logic(ctx);
|
||||
window::track(ctx, &mut self.app.ui.window);
|
||||
if self.app.ui.status == "quit" {
|
||||
@@ -189,7 +198,42 @@ fn read_prefs() -> Option<vectorcraft_ui_egui::UiState> {
|
||||
return None;
|
||||
}
|
||||
let bytes = prefs_path().and_then(|p| std::fs::read(p).ok()).or_else(|| legacy_prefs_path().and_then(|p| std::fs::read(p).ok()))?;
|
||||
serde_json::from_slice(&bytes).ok()
|
||||
decode_saved_prefs(&bytes)
|
||||
}
|
||||
|
||||
/// Recover only optional persisted docking metadata. Runtime UiState and command decoding
|
||||
/// remains strict, and an unreadable workspace layout cannot discard unrelated preferences.
|
||||
fn decode_saved_prefs(bytes: &[u8]) -> Option<vectorcraft_ui_egui::UiState> {
|
||||
use serde_json::{Map, Value, json};
|
||||
|
||||
fn recover(object: &mut Map<String, Value>, hidden_key: &str) {
|
||||
if let Some(layout) = object.get("docking")
|
||||
&& serde_json::from_value::<vectorcraft_ui_egui::UiState>(json!({"docking": layout})).is_err()
|
||||
{
|
||||
object.remove("docking");
|
||||
}
|
||||
if let Some(hidden) = object.get_mut(hidden_key) {
|
||||
if let Value::Object(entries) = hidden {
|
||||
entries.retain(|panel, location| {
|
||||
serde_json::from_value::<vectorcraft_ui_egui::UiState>(json!({"docking_hidden": {panel: location}})).is_ok()
|
||||
});
|
||||
} else {
|
||||
object.remove(hidden_key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut value: Value = serde_json::from_slice(bytes).ok()?;
|
||||
let object = value.as_object_mut()?;
|
||||
recover(object, "docking_hidden");
|
||||
if let Some(Value::Array(workspaces)) = object.get_mut("custom_workspaces") {
|
||||
for workspace in workspaces {
|
||||
if let Some(object) = workspace.as_object_mut() {
|
||||
recover(object, "dockingHidden");
|
||||
}
|
||||
}
|
||||
}
|
||||
serde_json::from_value(value).ok()
|
||||
}
|
||||
|
||||
fn load_prefs(app: &mut VectorcraftApp, saved: Option<vectorcraft_ui_egui::UiState>) {
|
||||
@@ -220,48 +264,99 @@ fn save_prefs(app: &VectorcraftApp) {
|
||||
/// The window file dialogs belong to.
|
||||
type Parent = Option<std::sync::Arc<winit::window::Window>>;
|
||||
|
||||
/// A native file dialog for `request` over `parent` (Windows and Linux, where the portal then
|
||||
/// shows it over the window and keeps it in front; macOS shows them as before).
|
||||
fn file_dialog(request: &PickRequest, parent: &Parent) -> rfd::FileDialog {
|
||||
let d = match parent {
|
||||
Some(w) if !cfg!(target_os = "macos") => rfd::FileDialog::new().set_parent(&**w),
|
||||
_ => rfd::FileDialog::new(),
|
||||
};
|
||||
/// What rfd's blocking and asynchronous file dialogs are both set up with.
|
||||
trait DialogSettings: Sized {
|
||||
fn parent(self, window: &winit::window::Window) -> Self;
|
||||
fn title(self, title: &str) -> Self;
|
||||
fn filter(self, name: &str, exts: &[&str]) -> Self;
|
||||
fn folder(self, folder: &str) -> Self;
|
||||
fn file_name(self, name: &str) -> Self;
|
||||
}
|
||||
|
||||
macro_rules! dialog_settings {
|
||||
($($dialog:ty),*) => {$(
|
||||
impl DialogSettings for $dialog {
|
||||
fn parent(self, window: &winit::window::Window) -> Self {
|
||||
self.set_parent(window)
|
||||
}
|
||||
fn title(self, title: &str) -> Self {
|
||||
self.set_title(title)
|
||||
}
|
||||
fn filter(self, name: &str, exts: &[&str]) -> Self {
|
||||
self.add_filter(name, exts)
|
||||
}
|
||||
fn folder(self, folder: &str) -> Self {
|
||||
self.set_directory(folder)
|
||||
}
|
||||
fn file_name(self, name: &str) -> Self {
|
||||
self.set_file_name(name)
|
||||
}
|
||||
}
|
||||
)*};
|
||||
}
|
||||
|
||||
dialog_settings!(rfd::FileDialog, rfd::AsyncFileDialog);
|
||||
|
||||
/// `d` set up for `request`: its title, file types, folder and suggested name.
|
||||
fn dialog_for<D: DialogSettings>(d: D, request: &PickRequest) -> D {
|
||||
let pick = match request {
|
||||
PickRequest::Open(pick) | PickRequest::Save(pick) => pick,
|
||||
PickRequest::OpenMany => return fileio::place_filters().fold(d.set_title("Place"), |d, (name, exts)| d.add_filter(name, exts)),
|
||||
PickRequest::OpenMany => return fileio::place_filters().fold(d.title("Place"), |d, (name, exts)| d.filter(name, exts)),
|
||||
PickRequest::Folder => return d,
|
||||
};
|
||||
let d = pick.filters.iter().fold(d, |d, (name, exts)| d.add_filter(*name, exts));
|
||||
let d = pick.filters.iter().fold(d, |d, (name, exts)| d.filter(name, exts));
|
||||
let d = match &pick.folder {
|
||||
Some(folder) => d.set_directory(folder),
|
||||
Some(folder) => d.folder(folder),
|
||||
None => d,
|
||||
};
|
||||
if pick.name.is_empty() { d } else { d.set_file_name(&pick.name) }
|
||||
if pick.name.is_empty() { d } else { d.file_name(&pick.name) }
|
||||
}
|
||||
|
||||
/// A native file dialog for `request` over `parent` (on Linux the portal then shows it over the
|
||||
/// window and keeps it in front).
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
fn file_dialog(request: &PickRequest, parent: &Parent) -> rfd::FileDialog {
|
||||
match parent {
|
||||
Some(w) => dialog_for(rfd::FileDialog::new().parent(w), request),
|
||||
None => dialog_for(rfd::FileDialog::new(), request),
|
||||
}
|
||||
}
|
||||
|
||||
/// Before `request`'s dialog shows: a save dialog's folder (the Templates folder) may not exist yet.
|
||||
fn make_folder(request: &PickRequest) {
|
||||
if let PickRequest::Save(FilePick { folder: Some(folder), .. }) = request {
|
||||
let _ = std::fs::create_dir_all(folder);
|
||||
}
|
||||
}
|
||||
|
||||
/// Show `dialog` for `request` (on the calling thread, until it closes) → the paths picked.
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
fn show_dialog(dialog: rfd::FileDialog, request: &PickRequest) -> Vec<String> {
|
||||
make_folder(request);
|
||||
let paths = match request {
|
||||
PickRequest::Open(_) => dialog.pick_file().into_iter().collect(),
|
||||
PickRequest::OpenMany => dialog.pick_files().unwrap_or_default(),
|
||||
PickRequest::Save(pick) => {
|
||||
// The Templates folder may not exist yet.
|
||||
if let Some(folder) = &pick.folder {
|
||||
let _ = std::fs::create_dir_all(folder);
|
||||
}
|
||||
dialog.save_file().into_iter().collect()
|
||||
}
|
||||
PickRequest::Save(_) => dialog.save_file().into_iter().collect(),
|
||||
PickRequest::Folder => dialog.pick_folder().into_iter().collect(),
|
||||
};
|
||||
paths.into_iter().map(|p| p.to_string_lossy().to_string()).collect()
|
||||
}
|
||||
|
||||
/// Show the dialog for `request` over `parent` now → the paths picked.
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
fn pick_now(request: PickRequest, parent: &Parent) -> Vec<String> {
|
||||
show_dialog(file_dialog(&request, parent), &request)
|
||||
}
|
||||
|
||||
/// macOS shows no dialog in line: its modal loop would run inside winit's event handler, and an
|
||||
/// event winit held back meanwhile aborts the app (#867). Dialogs are sheets there ([`start_pick`]);
|
||||
/// one asked for any other way (a bug: outside a command, a dialog's OK or a button) picks nothing.
|
||||
#[cfg(target_os = "macos")]
|
||||
fn pick_now(request: PickRequest, _parent: &Parent) -> Vec<String> {
|
||||
log::error!("no file dialog shown for {request:?}: macOS shows them as sheets, from commands, dialogs' OK and buttons only");
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
/// File → Show in Folder: select `path` in Finder / Explorer, or open its folder elsewhere.
|
||||
fn reveal(path: &str) -> Result<(), String> {
|
||||
reveal_command(path).spawn().map(|_| ()).map_err(|e| format!("can't show {path}: {e}"))
|
||||
@@ -297,34 +392,97 @@ fn write_file(path: &str, bytes: &[u8]) -> Result<(), String> {
|
||||
fileio::write_atomic(std::path::Path::new(path), bytes).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Linux and macOS: show the dialog for `request` over `parent` on a thread of its own, answering
|
||||
/// on the receiver. Shown on the UI thread, nothing would answer the compositor meanwhile on Linux,
|
||||
/// which then offers to kill the window as not responding (#592); on macOS the panel ran modally
|
||||
/// inside the window's event handler, and resizing it delivered window events to that handler
|
||||
/// again, which crashed the app (#867). From another thread, rfd runs the panel on the main thread
|
||||
/// from the run loop, outside the handler.
|
||||
fn start_pick(request: PickRequest, parent: &Parent) -> Option<std::sync::mpsc::Receiver<Vec<String>>> {
|
||||
/// Linux: show the dialog for `request` over `parent` on a thread of its own, answering on the
|
||||
/// receiver. Shown on the UI thread, nothing would answer the compositor meanwhile, which then
|
||||
/// offers to kill the window as not responding (#592).
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
fn start_pick(request: PickRequest, parent: &Parent, ui: &egui::Context) -> Option<std::sync::mpsc::Receiver<Vec<String>>> {
|
||||
let dialog = file_dialog(&request, parent);
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
let ui = ui.clone();
|
||||
std::thread::Builder::new()
|
||||
.name("file-dialog".into())
|
||||
.spawn(move || {
|
||||
// The app gone meanwhile has no use for the answer.
|
||||
let _ = tx.send(show_dialog(dialog, &request));
|
||||
ui.request_repaint();
|
||||
})
|
||||
.ok()?;
|
||||
Some(rx)
|
||||
}
|
||||
|
||||
fn services(parent: Parent) -> Services {
|
||||
/// The paths a dialog shown as a sheet picks, once it closes.
|
||||
#[cfg(target_os = "macos")]
|
||||
type SheetAnswer = std::pin::Pin<Box<dyn std::future::Future<Output = Vec<String>> + Send>>;
|
||||
|
||||
/// macOS: show the dialog for `request` as a sheet on `parent`'s window, answering on the receiver
|
||||
/// (#867). Shown in line, the panel ran AppKit's modal event loop inside winit's event handler: an
|
||||
/// event winit had held back meanwhile (one came as a resize of the panel ended) then re-entered
|
||||
/// the handler, and winit's panic there, inside an AppKit callback, aborted the app. The sheet
|
||||
/// returns at once and the window keeps running; a thread of its own waits for the answer.
|
||||
#[cfg(target_os = "macos")]
|
||||
fn start_pick(request: PickRequest, parent: &Parent, ui: &egui::Context) -> Option<std::sync::mpsc::Receiver<Vec<String>>> {
|
||||
use winit::raw_window_handle::HasWindowHandle as _;
|
||||
// rfd runs the panel modally after all unless it has the running app's window to hold the
|
||||
// sheet (and from another thread it can't reach the window): no dialog then (`pick_now`).
|
||||
let mtm = objc2::MainThreadMarker::new();
|
||||
let running = mtm.is_some_and(|mtm| objc2_app_kit::NSApplication::sharedApplication(mtm).isRunning());
|
||||
let Some(window) = parent.as_ref().filter(|w| running && w.window_handle().is_ok()) else {
|
||||
log::error!("no window to show the file dialog on (main thread: {}, running: {running})", mtm.is_some());
|
||||
return None;
|
||||
};
|
||||
// The thread first, so the answer of a sheet once shown always has somewhere to go.
|
||||
let (sheet_tx, sheet_rx) = std::sync::mpsc::channel::<SheetAnswer>();
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
let ui = ui.clone();
|
||||
std::thread::Builder::new()
|
||||
.name("file-dialog".into())
|
||||
.spawn(move || {
|
||||
if let Ok(answer) = sheet_rx.recv() {
|
||||
// The app gone meanwhile has no use for the answer.
|
||||
let _ = tx.send(pollster::block_on(answer));
|
||||
ui.request_repaint();
|
||||
}
|
||||
})
|
||||
.ok()?;
|
||||
make_folder(&request);
|
||||
// rfd builds the panel and begins the sheet here, on the main thread (as it must).
|
||||
let dialog = dialog_for(rfd::AsyncFileDialog::new().parent(window), &request);
|
||||
let path = |f: &rfd::FileHandle| f.path().to_string_lossy().to_string();
|
||||
let answer: SheetAnswer = match request {
|
||||
PickRequest::Open(_) => {
|
||||
let picked = dialog.pick_file();
|
||||
Box::pin(async move { picked.await.iter().map(path).collect() })
|
||||
}
|
||||
PickRequest::OpenMany => {
|
||||
let picked = dialog.pick_files();
|
||||
Box::pin(async move { picked.await.unwrap_or_default().iter().map(path).collect() })
|
||||
}
|
||||
PickRequest::Save(_) => {
|
||||
let picked = dialog.save_file();
|
||||
Box::pin(async move { picked.await.iter().map(path).collect() })
|
||||
}
|
||||
PickRequest::Folder => {
|
||||
let picked = dialog.pick_folder();
|
||||
Box::pin(async move { picked.await.iter().map(path).collect() })
|
||||
}
|
||||
};
|
||||
// The thread can't be gone: it waits for this. If it were, its receiver answers as cancelled.
|
||||
let _ = sheet_tx.send(answer);
|
||||
Some(rx)
|
||||
}
|
||||
|
||||
/// The host's services; `ui` is woken when a file dialog answers.
|
||||
fn services(parent: Parent, ui: egui::Context) -> Services {
|
||||
let (p1, p2, p3, p4, p5) = (parent.clone(), parent.clone(), parent.clone(), parent.clone(), parent);
|
||||
Services {
|
||||
pick_open: Some(Box::new(move |pick: &FilePick| pick_now(PickRequest::Open(pick.clone()), &p1).into_iter().next())),
|
||||
pick_open_multi: Some(Box::new(move || pick_now(PickRequest::OpenMany, &p2))),
|
||||
pick_save: Some(Box::new(move |pick: &FilePick| pick_now(PickRequest::Save(pick.clone()), &p3).into_iter().next())),
|
||||
// Windows dialogs run the window's events while they are open; Linux's don't, and macOS
|
||||
// ones re-enter the window's event handler (#867).
|
||||
start_pick: cfg!(unix).then(|| Box::new(move |request: PickRequest| start_pick(request, &p5)) as vectorcraft_ui_egui::picks::StartPick),
|
||||
// Linux shows dialogs on a thread of their own (#592), macOS as sheets (#867); Windows
|
||||
// dialogs run the window's events while they are open.
|
||||
start_pick: cfg!(unix).then(|| Box::new(move |request: PickRequest| start_pick(request, &p5, &ui)) as vectorcraft_ui_egui::picks::StartPick),
|
||||
dialogs_are_sheets: cfg!(target_os = "macos"),
|
||||
read: Some(Box::new(|p: &str| std::fs::read(p).map_err(|e| e.to_string()))),
|
||||
write: Some(Box::new(write_file)),
|
||||
// Background Save and Export write from a worker thread.
|
||||
@@ -549,7 +707,7 @@ fn main() -> std::process::ExitCode {
|
||||
"VectorCraft",
|
||||
options,
|
||||
Box::new(move |cc| {
|
||||
let mut app = VectorcraftApp::new(Session::new(), services(cc.winit_window().cloned()));
|
||||
let mut app = VectorcraftApp::new(Session::new(), services(cc.winit_window().cloned(), cc.egui_ctx.clone()));
|
||||
load_prefs(&mut app, saved);
|
||||
// Fit the window to its monitor, or put it back where it was (still hidden).
|
||||
if let Some(w) = cc.winit_window() {
|
||||
@@ -649,6 +807,51 @@ mod tests {
|
||||
vectorcraft_ui_egui::UiState { engine_prefs, ..Default::default() }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unreadable_saved_docking_keeps_preferences_and_every_workspace() {
|
||||
use serde_json::json;
|
||||
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
|
||||
app.run("window.panel.float", json!({"panel":"layers"})).unwrap();
|
||||
app.ui.toolbar = false;
|
||||
app.ui.engine_prefs = json!({"interfaceLanguage":"fr", "uiBrightness":"light"});
|
||||
app.ui.custom_workspaces =
|
||||
vec![vectorcraft_ui_egui::workspaces::capture(&app.ui, "Future"), vectorcraft_ui_egui::workspaces::capture(&app.ui, "Valid")];
|
||||
let original = serde_json::to_value(&app.ui).unwrap();
|
||||
let valid_location = original["docking_hidden"]["layers"].clone();
|
||||
assert!(valid_location.is_object());
|
||||
for invalid_layout in [json!({"root":{"FutureNode":{}},"floating":[]}), json!(false)] {
|
||||
for invalid_hidden in [json!({"layers":{"placement":{"FuturePlacement":{}}},"swatches":valid_location}), json!(["malformed"])] {
|
||||
let mut raw = original.clone();
|
||||
raw["docking"] = invalid_layout.clone();
|
||||
raw["docking_hidden"] = invalid_hidden.clone();
|
||||
raw["custom_workspaces"][0]["docking"] = invalid_layout.clone();
|
||||
raw["custom_workspaces"][0]["dockingHidden"] = invalid_hidden.clone();
|
||||
assert!(serde_json::from_value::<vectorcraft_ui_egui::UiState>(raw.clone()).is_err(), "runtime serde stays strict");
|
||||
let loaded = decode_saved_prefs(&serde_json::to_vec(&raw).unwrap()).unwrap();
|
||||
assert_eq!(loaded.engine_prefs, app.ui.engine_prefs);
|
||||
assert!(!loaded.toolbar);
|
||||
assert!(loaded.docking.is_none());
|
||||
assert!(!loaded.docking_hidden.contains_key("layers"));
|
||||
if invalid_hidden.is_object() {
|
||||
assert_eq!(serde_json::to_value(loaded.docking_hidden.get("swatches")).unwrap(), valid_location);
|
||||
} else {
|
||||
assert!(loaded.docking_hidden.is_empty());
|
||||
}
|
||||
assert_eq!(loaded.custom_workspaces.len(), 2);
|
||||
assert_eq!(loaded.custom_workspaces[0].name, "Future");
|
||||
assert!(loaded.custom_workspaces[0].docking.is_none());
|
||||
assert!(!loaded.custom_workspaces[0].docking_hidden.contains_key("layers"));
|
||||
assert_eq!(serde_json::to_value(&loaded.custom_workspaces[1]).unwrap(), original["custom_workspaces"][1]);
|
||||
}
|
||||
}
|
||||
let roundtrip = decode_saved_prefs(&serde_json::to_vec(&original).unwrap()).unwrap();
|
||||
assert_eq!(serde_json::to_value(roundtrip).unwrap(), original);
|
||||
assert!(
|
||||
decode_saved_prefs(&serde_json::to_vec(&json!({"toolbar":"invalid"})).unwrap()).is_none(),
|
||||
"unrelated fields retain normal strict parsing"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_system_title_bar_preference_keeps_the_window_decorations() {
|
||||
// Preferences › User Interface › System Title Bar gives the window back its system
|
||||
|
||||
@@ -4,21 +4,15 @@
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[path = "profile.rs"]
|
||||
mod profile;
|
||||
|
||||
/// The settings file `ui.json` of the app `name`: in a folder named `name` in ~/Library/Application
|
||||
/// Support (macOS) or %APPDATA% (Windows), or `lower` in $XDG_CONFIG_HOME or ~/.config (Linux and
|
||||
/// BSD).
|
||||
pub fn prefs_path_for(name: &str, lower: &str) -> Option<PathBuf> {
|
||||
let base = if cfg!(target_os = "macos") {
|
||||
std::env::var_os("HOME").map(|h| std::path::PathBuf::from(h).join("Library/Application Support").join(name))
|
||||
} else if cfg!(windows) {
|
||||
std::env::var_os("APPDATA").map(|a| std::path::PathBuf::from(a).join(name))
|
||||
} else {
|
||||
std::env::var_os("XDG_CONFIG_HOME")
|
||||
.map(std::path::PathBuf::from)
|
||||
.or_else(|| std::env::var_os("HOME").map(|h| std::path::PathBuf::from(h).join(".config")))
|
||||
.map(|c| c.join(lower))
|
||||
};
|
||||
base.map(|b| b.join("ui.json"))
|
||||
let legacy = name == "DrawCraft" || lower == "drawcraft";
|
||||
profile::prefs_path(legacy)
|
||||
}
|
||||
|
||||
/// Have the font scans read VectorCraft's own Fonts folder, next to the settings, which
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
//! Native profile paths. Isolated profiles never migrate preferences from a user's old profile.
|
||||
|
||||
use std::ffi::OsString;
|
||||
use std::path::PathBuf;
|
||||
|
||||
pub fn prefs_path(legacy: bool) -> Option<PathBuf> {
|
||||
prefs_path_with(std::env::consts::OS, legacy, |key| std::env::var_os(key))
|
||||
}
|
||||
|
||||
fn prefs_path_with(os: &str, legacy: bool, env: impl Fn(&str) -> Option<OsString>) -> Option<PathBuf> {
|
||||
if let Some(root) = env("VECTORCRAFT_CONFIG_DIR") {
|
||||
// Never fall back to DrawCraft's real profile when the isolated file does not exist.
|
||||
return (!legacy && !root.is_empty()).then(|| PathBuf::from(root).join("ui.json"));
|
||||
}
|
||||
let (name, lower) = if legacy { ("DrawCraft", "drawcraft") } else { ("VectorCraft", "vectorcraft") };
|
||||
let base = match os {
|
||||
"macos" => env("HOME").map(|p| PathBuf::from(p).join("Library/Application Support").join(name)),
|
||||
"windows" => env("APPDATA").map(|p| PathBuf::from(p).join(name)),
|
||||
_ => env("XDG_CONFIG_HOME").map(PathBuf::from).or_else(|| env("HOME").map(|p| PathBuf::from(p).join(".config"))).map(|p| p.join(lower)),
|
||||
};
|
||||
base.map(|p| p.join("ui.json"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn environment(key: &str) -> Option<OsString> {
|
||||
match key {
|
||||
"HOME" => Some("/user".into()),
|
||||
"APPDATA" => Some("/roaming".into()),
|
||||
"XDG_CONFIG_HOME" => Some("/xdg-config".into()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn isolated_profile_has_no_legacy_fallback_on_any_platform() {
|
||||
for os in ["macos", "windows", "linux"] {
|
||||
let env = |key: &str| {
|
||||
if key == "VECTORCRAFT_CONFIG_DIR" { Some("/isolated".into()) } else { environment(key) }
|
||||
};
|
||||
assert_eq!(prefs_path_with(os, false, env), Some("/isolated/ui.json".into()));
|
||||
assert_eq!(prefs_path_with(os, true, env), None);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_override_does_not_fall_back_to_user_files() {
|
||||
let env = |key: &str| if key == "VECTORCRAFT_CONFIG_DIR" { Some(OsString::new()) } else { environment(key) };
|
||||
assert_eq!(prefs_path_with("macos", false, env), None);
|
||||
assert_eq!(prefs_path_with("macos", true, env), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unset_override_keeps_platform_paths_and_legacy_migration() {
|
||||
assert_eq!(prefs_path_with("macos", false, environment), Some("/user/Library/Application Support/VectorCraft/ui.json".into()));
|
||||
assert_eq!(prefs_path_with("macos", true, environment), Some("/user/Library/Application Support/DrawCraft/ui.json".into()));
|
||||
assert_eq!(prefs_path_with("windows", false, environment), Some("/roaming/VectorCraft/ui.json".into()));
|
||||
assert_eq!(prefs_path_with("linux", false, environment), Some("/xdg-config/vectorcraft/ui.json".into()));
|
||||
assert_eq!(
|
||||
prefs_path_with("linux", true, |key| if key == "HOME" { environment(key) } else { None }),
|
||||
Some("/user/.config/drawcraft/ui.json".into())
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M6 3 L6 19 L10 15 L13 21 L15.5 20 L12.6 14 L18 14 Z" fill="currentColor"/></svg>
|
||||
|
After Width: | Height: | Size: 173 B |
@@ -1 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M6 3 L6 19 L10 15 L13 21 L15.5 20 L12.6 14 L18 14 Z" fill="currentColor" stroke-width="1"/></svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M6 3 L6 19 L10 15 L13 21 L15.5 20 L12.6 14 L18 14 Z" fill="none"/></svg>
|
||||
|
Before Width: | Height: | Size: 290 B After Width: | Height: | Size: 265 B |
@@ -17,6 +17,9 @@
|
||||
# To remove your names, delete your entry (or set fields empty) in a PR. The next
|
||||
# contributors.py run, and the next build, drop them.
|
||||
|
||||
[people.lollinoo]
|
||||
sync_github_name = true
|
||||
|
||||
[people.echelon]
|
||||
real_name = "Brandon Thomas"
|
||||
display_name = "Brandon Thomas"
|
||||
@@ -62,3 +65,11 @@ sync_github_name = false
|
||||
[people.KattKnight]
|
||||
display_name = "KattKnight (ayla.dsc)"
|
||||
sync_github_name = false
|
||||
|
||||
[people.NoJuanNobody]
|
||||
real_name = "Alejandro Londono"
|
||||
display_name = "alex"
|
||||
sync_github_name = false
|
||||
|
||||
[people.webdevtodayjason]
|
||||
display_name = "Jason Brashear"
|
||||
@@ -57,6 +57,19 @@ impl PaletteFormat {
|
||||
pub fn binary(self) -> bool {
|
||||
self == PaletteFormat::Ase
|
||||
}
|
||||
/// Does [`write`] keep a swatch with `paint` in a file of this format? None and patterns are
|
||||
/// never written; `.gpl` and `.ase` hold solid colors only, and CSS holds no freeform gradients.
|
||||
pub fn holds(self, paint: &Paint) -> bool {
|
||||
match paint {
|
||||
Paint::Solid { .. } => true,
|
||||
Paint::Gradient(g) => match self {
|
||||
PaletteFormat::Native => true,
|
||||
PaletteFormat::Css => g.gradient.kind != GradientKind::Freeform,
|
||||
PaletteFormat::Gpl | PaletteFormat::Ase => false,
|
||||
},
|
||||
Paint::None | Paint::Pattern { .. } => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The native file: a header around the library.
|
||||
@@ -88,7 +101,7 @@ const ACB_CUT_SHORT: &str = "the color book is cut short";
|
||||
/// colors in their own model. Writing `.ase` fails for a library with more colors and groups than
|
||||
/// [`read_bytes`] reads from one file.
|
||||
pub fn write(lib: &SwatchLibrary, format: PaletteFormat) -> Result<Vec<u8>, String> {
|
||||
let keep = |w: &&Swatch| matches!(w.paint, Paint::Solid { .. } | Paint::Gradient(_));
|
||||
let keep = |w: &&Swatch| format.holds(&w.paint);
|
||||
let text = match format {
|
||||
PaletteFormat::Native => {
|
||||
let strip = |list: &[Swatch]| list.iter().filter(keep).cloned().collect::<Vec<_>>();
|
||||
@@ -792,6 +805,23 @@ mod tests {
|
||||
assert!(read(&text, "x").is_err(), "CSS is written only");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn holds_counts_the_swatches_each_format_writes() {
|
||||
// `sample()` holds the spot color Ink, the linear gradient Fade, the pattern Tiles and two solid colors in a group.
|
||||
let mut lib = sample();
|
||||
let Paint::Gradient(mut mesh) = lib.swatches[1].paint.clone() else { panic!("Fade is a gradient") };
|
||||
mesh.gradient.kind = GradientKind::Freeform;
|
||||
lib.swatches.push(Swatch { name: "Mesh".into(), paint: Paint::Gradient(mesh), global: false, spot: false });
|
||||
lib.swatches.push(Swatch { name: "[None]".into(), paint: Paint::None, global: false, spot: false });
|
||||
let held = |f: PaletteFormat| lib.iter().filter(|w| f.holds(&w.paint)).count();
|
||||
assert_eq!(PaletteFormat::ALL.map(held), [5, 3, 3, 4], "vcswatches, gpl, ase, css");
|
||||
for f in [PaletteFormat::Native, PaletteFormat::Gpl, PaletteFormat::Ase] {
|
||||
assert_eq!(read_bytes(&write(&lib, f).unwrap(), "x").unwrap().len(), held(f), "{f:?}");
|
||||
}
|
||||
let css = written(&lib, PaletteFormat::Css);
|
||||
assert_eq!(css.lines().filter(|l| l.starts_with(" --")).count(), held(PaletteFormat::Css), "one property each: {css}");
|
||||
}
|
||||
|
||||
/// A swatch exchange file of `blocks` (type, body).
|
||||
fn ase(blocks: &[(u16, Vec<u8>)]) -> Vec<u8> {
|
||||
let mut out = b"ASEF\0\x01\0\0".to_vec();
|
||||
@@ -1088,5 +1118,15 @@ mod tests {
|
||||
prop_assert!(same_swatches(&g.swatches, &ase_expected(&want.swatches)), "{}: {:?}", g.name, g.swatches);
|
||||
}
|
||||
}
|
||||
|
||||
/// A written `.vcswatches`, `.gpl` or `.ase` file reads back with as many swatches as
|
||||
/// [`PaletteFormat::holds`] counts.
|
||||
#[test]
|
||||
fn written_files_read_back_with_the_swatches_holds_counts(lib in arb_library()) {
|
||||
for f in [PaletteFormat::Native, PaletteFormat::Gpl, PaletteFormat::Ase] {
|
||||
let back = read_bytes(&write(&lib, f).unwrap(), &lib.name).unwrap();
|
||||
prop_assert_eq!(back.len(), lib.iter().filter(|w| f.holds(&w.paint)).count(), "{:?}", f);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,9 @@ pub struct Selection {
|
||||
/// Direct-selected anchors per path. A path in `objects` with no entry here is fully selected.
|
||||
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||
pub anchors: BTreeMap<NodeId, BTreeSet<AnchorRef>>,
|
||||
/// Key object for Align.
|
||||
/// Key object for Align, one object of a selection of two or more. [`Selection::prune`] clears
|
||||
/// it when every selected object is the key or lies inside it, as when one object is left
|
||||
/// selected or when the key is a group selected with only its own members.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub key: Option<NodeId>,
|
||||
/// The object, group or layer targeted through its target circle in the Layers panel, which
|
||||
@@ -118,7 +120,15 @@ impl Selection {
|
||||
pub fn prune(&mut self, doc: &Document) {
|
||||
self.objects.retain(|id| doc.node(*id).is_some());
|
||||
self.anchors.retain(|id, _| doc.node(*id).is_some());
|
||||
if self.key.is_some_and(|k| doc.node(k).is_none()) {
|
||||
// The key object stays while another selected object lies outside it.
|
||||
let keep = self.key.and_then(|k| doc.node(k)).is_some_and(|n| {
|
||||
let mut inside = BTreeSet::new();
|
||||
n.walk(&mut |c| {
|
||||
inside.insert(c.id);
|
||||
});
|
||||
self.objects.iter().any(|o| !inside.contains(o))
|
||||
});
|
||||
if !keep {
|
||||
self.key = None;
|
||||
}
|
||||
if self.target.is_some_and(|t| doc.node(t).is_none()) {
|
||||
@@ -128,6 +138,15 @@ impl Selection {
|
||||
// Guides on a hidden or locked layer can't stay selected.
|
||||
self.guides.retain(|i| doc.guides.get(*i).is_some_and(|g| doc.guide_editable(g)));
|
||||
}
|
||||
/// Deselect the objects that are hidden or locked, themselves or through a group or layer
|
||||
/// around them (and so the key object when it is one): hidden or locked art can't stay
|
||||
/// selected.
|
||||
pub fn deselect_uneditable(&mut self, doc: &Document) {
|
||||
let out: Vec<NodeId> = self.objects.iter().copied().filter(|o| !doc.is_editable(*o)).collect();
|
||||
for o in out {
|
||||
self.remove(o);
|
||||
}
|
||||
}
|
||||
/// Target `id` (see [`Selection::target`]): a layer gets its visible, unlocked art selected
|
||||
/// (the art of its sublayers too), anything else is selected itself.
|
||||
pub fn set_target(&mut self, doc: &Document, id: NodeId) {
|
||||
@@ -253,4 +272,47 @@ mod tests {
|
||||
s.set([NodeId(3)]);
|
||||
assert_eq!(s.target, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_key_object_needs_two_or_more_selected_objects() {
|
||||
let mut s = Selection::default();
|
||||
s.set([NodeId(1), NodeId(2)]);
|
||||
s.key = Some(NodeId(2));
|
||||
s.remove(NodeId(1));
|
||||
assert_eq!(s.key, Some(NodeId(2)), "another object left: remove keeps the key");
|
||||
s.remove(NodeId(2));
|
||||
assert_eq!(s.key, None, "the key object itself left");
|
||||
// Pruning drops objects the document no longer has and clears the key once no other
|
||||
// selected object lies outside it.
|
||||
let mut d = Document::new(100.0, 100.0);
|
||||
let layer = d.layers[0].id;
|
||||
let mut ids = vec![];
|
||||
for _ in 0..4 {
|
||||
let id = d.alloc_id();
|
||||
ids.push(d.insert(Some(layer), usize::MAX, crate::Node::group(id, vec![])).unwrap());
|
||||
}
|
||||
s.set(ids.iter().copied());
|
||||
s.key = Some(ids[3]);
|
||||
d.remove(ids[0]).unwrap();
|
||||
s.prune(&d);
|
||||
assert_eq!(s.key, Some(ids[3]), "three objects left: the key stays");
|
||||
s.toggle(ids[1]);
|
||||
s.prune(&d);
|
||||
assert_eq!(s.key, Some(ids[3]), "two objects left: the key stays");
|
||||
s.toggle(ids[2]);
|
||||
s.prune(&d);
|
||||
assert_eq!((s.objects.clone(), s.key), (vec![ids[3]], None), "one object left");
|
||||
// A group key selected with only its own member is cleared too; another selected object
|
||||
// outside the group keeps it.
|
||||
let member = d.alloc_id();
|
||||
d.insert(Some(ids[3]), usize::MAX, crate::Node::group(member, vec![])).unwrap();
|
||||
s.set([member, ids[3]]);
|
||||
s.key = Some(ids[3]);
|
||||
s.prune(&d);
|
||||
assert_eq!(s.key, None, "a group and its own member");
|
||||
s.set([member, ids[3], ids[2]]);
|
||||
s.key = Some(ids[3]);
|
||||
s.prune(&d);
|
||||
assert_eq!(s.key, Some(ids[3]), "another object outside the group");
|
||||
}
|
||||
}
|
||||
@@ -95,6 +95,11 @@ pub struct CharStyle {
|
||||
/// their line line up with it.
|
||||
#[serde(default, rename = "charAlign", skip_serializing_if = "crate::skip::is_default")]
|
||||
pub char_align: CharAlign,
|
||||
/// Proportional Metrics (Character panel menu, East Asian options): full-width glyphs take the
|
||||
/// font's own proportional widths, OpenType `palt` (#966); in vertical type upright glyphs take
|
||||
/// its proportional heights, `vpal`.
|
||||
#[serde(default, rename = "proportionalMetrics", skip_serializing_if = "crate::skip::is_default")]
|
||||
pub proportional_metrics: bool,
|
||||
}
|
||||
|
||||
/// Where a character smaller than the largest on its line lines up with it: on the Roman
|
||||
@@ -205,6 +210,7 @@ impl Default for CharStyle {
|
||||
stroke_dash: None,
|
||||
position: CharPosition::Normal,
|
||||
char_align: CharAlign::RomanBaseline,
|
||||
proportional_metrics: false,
|
||||
small_caps: None,
|
||||
}
|
||||
}
|
||||
@@ -954,7 +960,49 @@ pub struct TextObject {
|
||||
pub cached_baselines: Vec<(Point, Point)>,
|
||||
}
|
||||
|
||||
/// Conversion from stored character attributes to document-space type controls.
|
||||
/// Point size follows the transformed em-height axis; horizontal scale is the ratio of
|
||||
/// the two transformed em axes. Rotation, reflection and shear remain in the affine.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct TextStyleScale {
|
||||
pub points: f64,
|
||||
pub horizontal: f64,
|
||||
}
|
||||
|
||||
impl TextObject {
|
||||
/// None for collapsed or unrepresentable transforms, whose type dimensions cannot
|
||||
/// be edited by compensating the stored attributes.
|
||||
pub fn style_scale(&self) -> Option<TextStyleScale> {
|
||||
let [a, b, c, d, e, f] = self.xf.as_coeffs();
|
||||
if ![a, b, c, d, e, f].iter().all(|v| v.is_finite()) {
|
||||
return None;
|
||||
}
|
||||
let (sx, sy) = (a.hypot(b), c.hypot(d));
|
||||
let determinant = (a / sx) * (d / sy) - (b / sx) * (c / sy);
|
||||
let horizontal = sx / sy;
|
||||
if !sy.is_finite() || sy <= 0.0 || !horizontal.is_finite() || horizontal <= 0.0 || !determinant.is_finite() || determinant.abs() <= 1e-12 {
|
||||
return None;
|
||||
}
|
||||
// Avoid a pure rotation turning otherwise identical fields into mixed values.
|
||||
let unit = |v: f64| if (v - 1.0).abs() <= 1e-12 { 1.0 } else { v };
|
||||
Some(TextStyleScale { points: unit(sy), horizontal: unit(horizontal) })
|
||||
}
|
||||
|
||||
/// Character attributes as presented by controls (stored runs stay local for layout
|
||||
/// and rich-text clipboard operations). Explicit vertical character scale stays separate.
|
||||
pub fn effective_char_style(&self, style: &CharStyle) -> Option<CharStyle> {
|
||||
let scale = self.style_scale()?;
|
||||
let mut style = style.clone();
|
||||
style.size *= scale.points;
|
||||
style.leading = style.leading.map(|v| v * scale.points);
|
||||
style.baseline_shift *= scale.points;
|
||||
style.h_scale *= scale.horizontal;
|
||||
if ![style.size, style.leading.unwrap_or(0.0), style.baseline_shift, style.h_scale].iter().all(|v| v.is_finite()) {
|
||||
return None;
|
||||
}
|
||||
Some(style)
|
||||
}
|
||||
|
||||
pub fn point(origin: Point, text: &str, style: CharStyle) -> Self {
|
||||
Self {
|
||||
vertical: false,
|
||||
@@ -1190,6 +1238,19 @@ impl TextObject {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn character_controls_reject_collapsed_and_nonfinite_transforms() {
|
||||
let mut t = TextObject::point(Point::ZERO, "Text", CharStyle::default());
|
||||
for matrix in [[0.0; 6], [1.0, 1.0, 2.0, 2.0, 0.0, 0.0], [f64::INFINITY, 0.0, 0.0, 1.0, 0.0, 0.0], [1.0, 0.0, 0.0, 1.0, f64::NAN, 0.0]] {
|
||||
t.xf = Affine::new(matrix);
|
||||
assert!(t.effective_char_style(&t.first_style()).is_none());
|
||||
}
|
||||
t.xf = Affine::rotate(0.83);
|
||||
assert_eq!(t.effective_char_style(&t.first_style()).unwrap().size, 12.0);
|
||||
t.xf = Affine::scale(1e308);
|
||||
assert!(t.effective_char_style(&t.first_style()).is_none(), "overflowing point size");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn point_text_basics() {
|
||||
let t = TextObject::point(Point::new(10.0, 20.0), "Hello", CharStyle::default());
|
||||
|
||||
@@ -21,7 +21,7 @@ use vectorcraft_color::Swatch;
|
||||
use vectorcraft_doc::{Document, GraphicStyle, ImageBlob, Node, NodeId, PatternDef, Symbol, TextStyleDef};
|
||||
use vectorcraft_geom::Rect;
|
||||
|
||||
pub use flavours::{BITMAP, EMF, FILE_HEAD, Flavour, PASTE_ORDER, PDF, PNG, SVG, TEXT, file_flavour, is_address};
|
||||
pub use flavours::{BITMAP, EMF, FILE_HEAD, Flavour, PASTE_ORDER, PDF, PNG, SVG, TEXT, file_flavour, is_address, names_a_picture};
|
||||
pub(crate) use resources::SwatchChoices;
|
||||
|
||||
use super::*;
|
||||
@@ -248,6 +248,24 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// The image markup some browsers put as text next to a copied picture names it too (#1089);
|
||||
/// markup with words in it is text.
|
||||
#[test]
|
||||
fn markup_of_only_images_names_a_picture() {
|
||||
for m in [
|
||||
r#"<img src="https://static.example.com/eagle.png" jsaction="" class="sFlh5c" style="max-width: 351px" alt="">"#,
|
||||
r#"<meta http-equiv="content-type" content="text/html; charset=utf-8"><img src="a.png">"#,
|
||||
"<html><body><!--StartFragment--><IMG SRC=\"a.png\"/><!--EndFragment--></body></html>",
|
||||
r#"<a href="https://x.org"><img src="a.png"></a>"#,
|
||||
"https://example.com/cat.png",
|
||||
] {
|
||||
assert!(names_a_picture(m), "{m}");
|
||||
}
|
||||
for t in ["<p>Hello</p>", r#"<img src="a.png"> and a caption"#, "<b>bold</b>", "<imgx>", "<img", "plain words", "<br>", ""] {
|
||||
assert!(!names_a_picture(t), "{t}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copy_exports_svg_that_round_trips_through_paste() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -39,6 +39,30 @@ pub fn is_address(text: &str) -> bool {
|
||||
!t.chars().any(char::is_whitespace) && SCHEMES.iter().any(|s| t.get(..s.len()).is_some_and(|h| h.eq_ignore_ascii_case(s)) && t.len() > s.len())
|
||||
}
|
||||
|
||||
/// Is `text` only a reference to a picture: an address ([`is_address`]), or markup that shows
|
||||
/// nothing but images (`<img …>`)? A browser's Copy Image puts one of them next to the picture
|
||||
/// (some put the image's HTML there as text, #1089): then the picture is what Paste takes.
|
||||
pub fn names_a_picture(text: &str) -> bool {
|
||||
is_address(text) || only_images(text)
|
||||
}
|
||||
|
||||
/// Is `html` markup whose only content is `<img>` elements: no text outside its tags (whitespace
|
||||
/// between them aside), and at least one image?
|
||||
fn only_images(html: &str) -> bool {
|
||||
let mut images = 0;
|
||||
let mut rest = html.trim();
|
||||
while !rest.is_empty() {
|
||||
let Some(tag) = rest.strip_prefix('<') else { return false };
|
||||
let Some(end) = tag.find('>') else { return false };
|
||||
let name = tag.get(..end).unwrap_or_default().split(|c: char| c.is_whitespace() || c == '/').next().unwrap_or_default();
|
||||
if name.eq_ignore_ascii_case("img") {
|
||||
images += 1;
|
||||
}
|
||||
rest = tag.get(end + 1..).unwrap_or_default().trim_start();
|
||||
}
|
||||
images > 0
|
||||
}
|
||||
|
||||
/// How much of a copied file [`file_flavour`] needs to tell what it is.
|
||||
pub const FILE_HEAD: usize = 4096;
|
||||
|
||||
|
||||
@@ -219,11 +219,13 @@ fn weighted_stroke(d: &mut Document, id: NodeId) -> Result<&mut vectorcraft_doc:
|
||||
Ok(st)
|
||||
}
|
||||
|
||||
/// The `index` param, checked against the points of `prof`.
|
||||
/// The `index` param (none when absent or null), checked against the points of `prof`: a malformed
|
||||
/// one is refused like one out of range, never wrapped round or taken as missing.
|
||||
fn point_index(p: &Value, prof: &WidthProfile, cmd: &str) -> Result<Option<usize>> {
|
||||
match p.get("index").and_then(Value::as_u64).map(|v| v as usize) {
|
||||
Some(i) if i >= prof.points.len() => Err(bad(cmd, "no such width point")),
|
||||
i => Ok(i),
|
||||
let Some(v) = p.get("index").filter(|v| !v.is_null()) else { return Ok(None) };
|
||||
match point_indices(&json!([v])).and_then(|i| i.first().copied()) {
|
||||
Some(i) if i < prof.points.len() => Ok(Some(i)),
|
||||
_ => Err(bad(cmd, "no such width point")),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -323,11 +325,13 @@ fn width_point_copy(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
fn width_point_remove(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "stroke.widthPoint.remove";
|
||||
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?;
|
||||
let mut indices: Vec<usize> = match (p.get("indices").and_then(Value::as_array), p.get("index").and_then(Value::as_u64)) {
|
||||
(Some(a), _) => a.iter().filter_map(Value::as_u64).map(|i| i as usize).collect(),
|
||||
(None, Some(i)) => vec![i as usize],
|
||||
let indices = match (p.get("indices"), p.get("index")) {
|
||||
(Some(a), _) => point_indices(a),
|
||||
(None, Some(i)) => point_indices(&json!([i])),
|
||||
(None, None) => return Err(bad(C, "missing index")),
|
||||
};
|
||||
// A malformed index fails the whole delete instead of being dropped while the others go.
|
||||
let mut indices = indices.ok_or_else(|| bad(C, "no such width point"))?;
|
||||
indices.sort_unstable();
|
||||
indices.dedup();
|
||||
s.edit("Delete Width Point", |d, _| {
|
||||
|
||||
@@ -8,12 +8,14 @@ use crate::{DocState, EngineError};
|
||||
pub(super) fn batch(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let mut cmds = p.get("commands").and_then(Value::as_array).ok_or_else(|| bad("command.batch", "missing commands"))?.clone();
|
||||
let label = str_param(p, "label").unwrap_or("Batch").to_string();
|
||||
// The active artboard the app passes, for the steps that act on it.
|
||||
let artboard = p.get("artboard").and_then(Value::as_u64);
|
||||
// What an error rolls back besides the documents: session-level state a step may change
|
||||
// (paint defaults, drawing mode, clipboard, the Untitled-N count).
|
||||
let saved = (s.paint.clone(), s.fill_active, s.draw_mode, s.draw_inside, s.clipboard.clone(), s.untitled_counter);
|
||||
let (start, active): (Vec<u64>, _) = (s.docs.iter().map(|d| d.uid).collect(), s.active);
|
||||
s.batch_stash = Some(vec![]);
|
||||
let r = run_steps(s, &label, &mut cmds);
|
||||
let r = run_steps(s, &label, artboard, &mut cmds);
|
||||
let stash = s.batch_stash.take().unwrap_or_default();
|
||||
let results = match r {
|
||||
Ok(results) => results,
|
||||
@@ -43,7 +45,10 @@ pub(super) fn batch(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
/// Run the steps → their results. Each runs inside an interaction of the document active when it
|
||||
/// starts (one for all the steps in that document), so a step may open, switch or close documents.
|
||||
fn run_steps(s: &mut Session, label: &str, cmds: &mut [Value]) -> Result<Vec<Value>> {
|
||||
/// A step that acts on the active artboard ([`crate::ON_ACTIVE_ARTBOARD`]) and names none gets
|
||||
/// `artboard`, as the Artboards panel shows it in the document the step runs in: past that
|
||||
/// document's last artboard, the last one.
|
||||
fn run_steps(s: &mut Session, label: &str, artboard: Option<u64>, cmds: &mut [Value]) -> Result<Vec<Value>> {
|
||||
let mut results = Vec::with_capacity(cmds.len());
|
||||
for c in cmds {
|
||||
let id = c.get("command").and_then(Value::as_str).unwrap_or("").to_string();
|
||||
@@ -53,7 +58,15 @@ fn run_steps(s: &mut Session, label: &str, cmds: &mut [Value]) -> Result<Vec<Val
|
||||
if s.active().is_some() {
|
||||
s.begin_interaction(label)?;
|
||||
}
|
||||
let params = c.get("params").cloned().unwrap_or(json!({}));
|
||||
let mut params = c.get("params").cloned().unwrap_or(json!({}));
|
||||
if let Some(a) = artboard
|
||||
&& crate::ON_ACTIVE_ARTBOARD.contains(&id.as_str())
|
||||
&& params.get("artboard").is_none()
|
||||
&& let Some(last) = s.active().and_then(|d| d.doc.artboards.len().checked_sub(1))
|
||||
&& let Some(o) = params.as_object_mut()
|
||||
{
|
||||
o.insert("artboard".into(), json!(usize::try_from(a).map_or(last, |a| a.min(last))));
|
||||
}
|
||||
let (v, noted) =
|
||||
s.execute_step(&id, ¶ms).map_err(|e| EngineError::Other(format!("batch step {} (`{id}`) failed: {e}", results.len())))?;
|
||||
// The step's journal entry records what it took from the preferences or the clock (a
|
||||
|
||||
@@ -123,7 +123,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_doc,
|
||||
screens::export_for_screens
|
||||
),
|
||||
cmd!(query "command.batch", "Batch", [], None, "{label?, commands: [{command, params}]} run several commands as ONE undo step (one in each document they edit: steps may open, switch, close or revert documents); stops at the first error and rolls everything back, documents included", has_doc, batch::batch),
|
||||
cmd!(
|
||||
query "command.batch",
|
||||
"Batch",
|
||||
[],
|
||||
None,
|
||||
"{label?, commands: [{command, params}], artboard?: index (the active artboard; the app passes it: each edit.pasteInPlace, edit.pasteInFront, edit.pasteInBack, object.align or select.allOnArtboard step that names none gets it, or the last artboard of a document with fewer)} run several commands as ONE undo step (one in each document they edit: steps may open, switch, close or revert documents); stops at the first error and rolls everything back, documents included",
|
||||
has_doc,
|
||||
batch::batch
|
||||
),
|
||||
cmd!(
|
||||
query "document.formats",
|
||||
"File Formats",
|
||||
|
||||
@@ -148,9 +148,12 @@ pub(crate) fn parse_freeform(v: &Value) -> std::result::Result<Freeform, String>
|
||||
point_fields(pv, &mut pt, Affine::IDENTITY).map_err(|e| format!("freeform point {i}: {e}"))?;
|
||||
f.points.push(pt);
|
||||
}
|
||||
for l in v.get("lines").and_then(Value::as_array).into_iter().flatten() {
|
||||
let ix = l.as_array().map(|a| a.iter().filter_map(Value::as_u64).map(|i| i as usize).collect()).unwrap_or_default();
|
||||
f.add_line(ix)?;
|
||||
if let Some(lines) = v.get("lines") {
|
||||
let lines = lines.as_array().ok_or("`freeform.lines` must be an array")?;
|
||||
for (i, l) in lines.iter().enumerate() {
|
||||
let ix = point_indices(l).ok_or_else(|| format!("freeform line {i} must be an array of point indices"))?;
|
||||
f.add_line(ix).map_err(|e| format!("freeform line {i}: {e}"))?;
|
||||
}
|
||||
}
|
||||
if let Some(m) = str_param(v, "mode") {
|
||||
f.mode = parse_mode(m)?;
|
||||
@@ -279,11 +282,7 @@ fn delete_point(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
fn add_line(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "paint.freeform.addLine";
|
||||
let points: Vec<usize> = p
|
||||
.get("points")
|
||||
.and_then(Value::as_array)
|
||||
.and_then(|a| a.iter().map(|v| v.as_u64().map(|i| i as usize)).collect())
|
||||
.ok_or_else(|| bad(C, "`points` must be an array of point indices"))?;
|
||||
let points = p.get("points").and_then(point_indices).ok_or_else(|| bad(C, "`points` must be an array of point indices"))?;
|
||||
let line = edit(s, p, C, |f, _| f.add_line(points.clone()))?;
|
||||
Ok(json!({ "line": line }))
|
||||
}
|
||||
@@ -323,3 +322,24 @@ fn get(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
v["selected"] = json!(selected);
|
||||
Ok(v)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A line with an index that isn't a point's is refused, not shortened to the indices that are.
|
||||
#[test]
|
||||
fn malformed_freeform_lines_are_not_silently_shortened() {
|
||||
let points = json!([
|
||||
{"at": [0, 0], "color": "#ff0000"},
|
||||
{"at": [50, 0], "color": "#00ff00"},
|
||||
{"at": [100, 0], "color": "#0000ff"}
|
||||
]);
|
||||
for lines in [json!([[0, "broken", 2]]), json!([[0, -1, 2]]), json!([[0, 1.5, 2]]), json!([{"points": [0, 1]}]), json!("not an array")] {
|
||||
let value = json!({"points": points, "lines": lines, "mode": "lines"});
|
||||
assert!(parse_freeform(&value).is_err(), "invalid lines accepted: {value}");
|
||||
}
|
||||
let good = parse_freeform(&json!({"points": points, "lines": [[0, 1, 2]], "mode": "lines"})).unwrap();
|
||||
assert_eq!(good.lines, vec![vec![0, 1, 2]]);
|
||||
}
|
||||
}
|
||||
@@ -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…",
|
||||
@@ -551,11 +559,7 @@ fn set_props(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
for id in &ids {
|
||||
apply_options(d.node_mut(*id).ok_or(EngineError::NoNode(*id))?, p);
|
||||
}
|
||||
// Hidden or locked content can't stay selected.
|
||||
let hidden: Vec<NodeId> = sel.objects.iter().copied().filter(|o| !d.is_editable(*o)).collect();
|
||||
for h in hidden {
|
||||
sel.remove(h);
|
||||
}
|
||||
sel.deselect_uneditable(d);
|
||||
Ok(())
|
||||
})?;
|
||||
ok()
|
||||
@@ -753,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()
|
||||
|
||||
@@ -188,11 +188,7 @@ fn set_flag(s: &mut Session, ids: Vec<NodeId>, flag: Flag, v: bool, label: &str)
|
||||
flag.set(n, v);
|
||||
}
|
||||
}
|
||||
// Hidden or locked art can't stay selected.
|
||||
let out: Vec<NodeId> = sel.objects.iter().copied().filter(|o| !d.is_editable(*o)).collect();
|
||||
for o in out {
|
||||
sel.remove(o);
|
||||
}
|
||||
sel.deselect_uneditable(d);
|
||||
Ok(())
|
||||
})?;
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"All Artwork Above",
|
||||
["Object", "Lock"],
|
||||
None,
|
||||
"{} lock objects in the same layer that are above and overlap the selection → {count}",
|
||||
"{} lock objects in the same layer that are above and overlap the selection, deselecting every selected object left locked or hidden (the key object too) → {count}",
|
||||
has_selection,
|
||||
|s, _| above(s, true)
|
||||
),
|
||||
@@ -57,7 +57,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"All Artwork Above",
|
||||
["Object", "Hide"],
|
||||
None,
|
||||
"{} hide objects in the same layer that are above and overlap the selection → {count}",
|
||||
"{} hide objects in the same layer that are above and overlap the selection, deselecting every selected object left hidden or locked (the key object too) → {count}",
|
||||
has_selection,
|
||||
|s, _| above(s, false)
|
||||
),
|
||||
@@ -228,7 +228,7 @@ fn above(s: &mut Session, lock: bool) -> Result<Value> {
|
||||
}
|
||||
let label = if lock { "Lock" } else { "Hide" };
|
||||
let n = hits.len();
|
||||
s.edit(label, |d, _| {
|
||||
s.edit(label, |d, sel| {
|
||||
for id in &hits {
|
||||
if let Some(n) = d.node_mut(*id) {
|
||||
if lock {
|
||||
@@ -238,6 +238,7 @@ fn above(s: &mut Session, lock: bool) -> Result<Value> {
|
||||
}
|
||||
}
|
||||
}
|
||||
sel.deselect_uneditable(d);
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({ "count": n }))
|
||||
|
||||
@@ -316,6 +316,11 @@ pub(crate) fn checked_ids_param(s: &Session, p: &Value, key: &str, command: &str
|
||||
values.iter().map(|value| checked_id(s, value, command)).collect()
|
||||
}
|
||||
|
||||
/// The objects a command acts on: `ids` when given (every one checked), else the selected objects.
|
||||
pub(crate) fn checked_ids_or_selection(s: &Session, p: &Value, command: &str) -> Result<Vec<NodeId>> {
|
||||
if p.get("ids").is_some() { checked_ids_param(s, p, "ids", command) } else { edit::selected_roots(s) }
|
||||
}
|
||||
|
||||
pub(crate) fn checked_id(s: &Session, value: &Value, command: &str) -> Result<NodeId> {
|
||||
let id = value.as_u64().map(NodeId).ok_or_else(|| bad(command, format!("invalid object id {value}: expected a non-negative integer")))?;
|
||||
if s.doc()?.doc.node(id).is_none() {
|
||||
@@ -335,6 +340,12 @@ pub(crate) fn finite_numbers<const N: usize>(v: &Value) -> Option<[f64; N]> {
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// `v` as a list of point indices: `None` unless it is an array of non-negative integers, so no
|
||||
/// malformed index is dropped or wrapped round.
|
||||
pub(crate) fn point_indices(v: &Value) -> Option<Vec<usize>> {
|
||||
v.as_array()?.iter().map(|i| usize::try_from(i.as_u64()?).ok()).collect()
|
||||
}
|
||||
|
||||
pub(crate) fn point_param(p: &Value, key: &str) -> Option<Point> {
|
||||
let a = p.get(key)?.as_array()?;
|
||||
Some(Point::new(a.first()?.as_f64()?, a.get(1)?.as_f64()?))
|
||||
|
||||
+241
-69
@@ -150,7 +150,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Object Properties",
|
||||
[],
|
||||
None,
|
||||
"{ids?|id?, name?, visible?, locked?, opacity?: 0..100, blend?: \"Multiply\"…, isolate?, knockout?: \"on\"|\"off\"|\"neutral\"|bool (true = on, false = neutral), knockoutShape?: bool, data?: {key: \"value\" | null (removes it)} (the object's own data, SVG data-* attributes: {pivot: \"100,180\"} is data-pivot; document.node → attrs.data)}",
|
||||
"{ids?|id?, name?, visible?, locked? (visible: false or locked: true deselects every selected object hidden or locked after the call, the key object included), opacity?: 0..100, blend?: \"Multiply\"…, isolate?, knockout?: \"on\"|\"off\"|\"neutral\"|bool (true = on, false = neutral), knockoutShape?: bool, data?: {key: \"value\" | null (removes it)} (the object's own data, SVG data-* attributes: {pivot: \"100,180\"} is data-pivot; document.node → attrs.data)}",
|
||||
has_doc,
|
||||
set_props
|
||||
),
|
||||
@@ -159,7 +159,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Align",
|
||||
["Window", "Align"],
|
||||
None,
|
||||
"{horizontal?: \"left\"|\"center\"|\"right\", vertical?: \"top\"|\"center\"|\"bottom\", to?: \"selection\"|\"artboard\"|\"key\" (default: the key object when the selection has one, select.key, else the selection), bounds?: \"preview\"|\"geometric\" (default: the Use Preview Bounds preference; preview bounds take in strokes)}",
|
||||
"{horizontal?: \"left\"|\"center\"|\"right\", vertical?: \"top\"|\"center\"|\"bottom\", to?: \"selection\"|\"artboard\"|\"key\" (default: the key object when the selection has one, select.key, else the selection), artboard?: index (0-based; with to: \"artboard\", the artboard to align to; the app passes the active one; default: the artboard under the center of the selection, else the first), bounds?: \"preview\"|\"geometric\" (default: the Use Preview Bounds preference; preview bounds take in strokes), patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles)}",
|
||||
has_selection,
|
||||
align
|
||||
),
|
||||
@@ -168,7 +168,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Distribute",
|
||||
["Window", "Align"],
|
||||
None,
|
||||
"{horizontal?: \"left\"|\"center\"|\"right\", vertical?: \"top\"|\"center\"|\"bottom\", bounds?: \"preview\"|\"geometric\" (default: the Use Preview Bounds preference; preview bounds take in strokes)}",
|
||||
"{horizontal?: \"left\"|\"center\"|\"right\", vertical?: \"top\"|\"center\"|\"bottom\", bounds?: \"preview\"|\"geometric\" (default: the Use Preview Bounds preference; preview bounds take in strokes), patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles)}",
|
||||
has_multi,
|
||||
distribute
|
||||
),
|
||||
@@ -177,7 +177,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Distribute Spacing",
|
||||
["Window", "Align"],
|
||||
None,
|
||||
"{axis: \"horizontal\"|\"vertical\", spacing?: pt (the key object, select.key, stays put and the others are spaced from it), bounds?: \"preview\"|\"geometric\" (default: the Use Preview Bounds preference; preview bounds take in strokes)}",
|
||||
"{axis: \"horizontal\"|\"vertical\", spacing?: pt (the key object, select.key, stays put and the others are spaced from it), bounds?: \"preview\"|\"geometric\" (default: the Use Preview Bounds preference; preview bounds take in strokes), patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles)}",
|
||||
has_multi,
|
||||
distribute_spacing
|
||||
),
|
||||
@@ -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
|
||||
),
|
||||
@@ -251,17 +251,26 @@ pub(crate) fn apply_transform(s: &mut Session, label: &str, ids: Vec<NodeId>, xf
|
||||
let areas = bool_or(p, "typeAreas", false);
|
||||
let mut sc = if Scaling::factor(xf).is_some() { scaling(s, p) } else { Scaling::default() };
|
||||
sc.patterns = transform_patterns(s, p, &ids)?;
|
||||
let ids = s.edit(label, |d, sel| {
|
||||
let targets = if copy { duplicate_in(d, sel, &ids, Affine::IDENTITY)? } else { ids.clone() };
|
||||
for id in &targets {
|
||||
if let Some(n) = d.node_mut(*id)
|
||||
&& !(areas && resize_type_area(n, xf))
|
||||
{
|
||||
n.transform(xf, sc);
|
||||
// A transform of ids that name no object, or an identity transform without a copy, moves
|
||||
// nothing: the document and its undo history stay as they are, and Transform Again repeats it.
|
||||
// With a copy, the reply lists the copies, and there are none.
|
||||
let d = &s.doc()?.doc;
|
||||
let none = ids.iter().all(|id| d.node(*id).is_none());
|
||||
let ids = if none || (!copy && xf == Affine::IDENTITY) {
|
||||
if copy { vec![] } else { ids }
|
||||
} else {
|
||||
s.edit(label, |d, sel| {
|
||||
let targets = if copy { duplicate_in(d, sel, &ids, Affine::IDENTITY)? } else { ids.clone() };
|
||||
for id in &targets {
|
||||
if let Some(n) = d.node_mut(*id)
|
||||
&& !(areas && resize_type_area(n, xf))
|
||||
{
|
||||
n.transform(xf, sc);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(targets)
|
||||
})?;
|
||||
Ok(targets)
|
||||
})?
|
||||
};
|
||||
let st = s.doc_mut()?;
|
||||
if st.interaction.is_none() {
|
||||
st.last_transform = Some((xf, copy));
|
||||
@@ -300,10 +309,19 @@ fn nudge(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let dx = f64_or(p, "dx", 0.0) * k;
|
||||
let dy = f64_or(p, "dy", 0.0) * k;
|
||||
if !s.doc()?.selection.anchors.is_empty() {
|
||||
// When the anchors move by 0, the document and its undo history stay as they are.
|
||||
if dx == 0.0 && dy == 0.0 {
|
||||
return ok();
|
||||
}
|
||||
return super::path::move_anchors(s, &json!({ "dx": dx, "dy": dy }));
|
||||
}
|
||||
if s.doc()?.selection.is_empty() {
|
||||
return super::docmenu::guide_move(s, &json!({ "dx": dx, "dy": dy, "copy": bool_or(p, "copy", false) }));
|
||||
let copy = bool_or(p, "copy", false);
|
||||
// When guides move by 0 without a copy, the document and its undo history stay as they are.
|
||||
if dx == 0.0 && dy == 0.0 && !copy {
|
||||
return ok();
|
||||
}
|
||||
return super::docmenu::guide_move(s, &json!({ "dx": dx, "dy": dy, "copy": copy }));
|
||||
}
|
||||
let ids = selected_roots(s)?;
|
||||
apply_transform(s, "Move", ids, Affine::translate((dx, dy)), p)
|
||||
@@ -345,7 +363,12 @@ fn reflect(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let t = -n.as_f64().unwrap_or(90.0).to_radians();
|
||||
Affine::rotate(t) * Affine::scale_non_uniform(1.0, -1.0) * Affine::rotate(-t)
|
||||
}
|
||||
_ => Affine::scale_non_uniform(-1.0, 1.0),
|
||||
None | Some(Value::Null) => Affine::scale_non_uniform(-1.0, 1.0),
|
||||
Some(Value::String(a)) if a == "vertical" => Affine::scale_non_uniform(-1.0, 1.0),
|
||||
Some(v) => {
|
||||
let given = v.as_str().map_or_else(|| v.to_string(), str::to_string);
|
||||
return Err(bad("object.reflect", format!("axis must be \"vertical\", \"horizontal\" or an angle in degrees, not `{given}`")));
|
||||
}
|
||||
};
|
||||
apply_transform(s, "Reflect", ids, about(o, m), p)
|
||||
}
|
||||
@@ -354,8 +377,8 @@ fn shear(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids = selected_roots(s)?;
|
||||
let o = origin_of(s, p, &ids)?;
|
||||
let t = f64_or(p, "angle", 0.0).clamp(-89.0, 89.0).to_radians().tan();
|
||||
let m =
|
||||
if str_param(p, "axis") == Some("vertical") { Affine::new([1.0, t, 0.0, 1.0, 0.0, 0.0]) } else { Affine::new([1.0, 0.0, -t, 1.0, 0.0, 0.0]) };
|
||||
let vertical = choice(p, "object.shear", "axis", &["horizontal", "vertical"])? == Some("vertical");
|
||||
let m = if vertical { Affine::new([1.0, t, 0.0, 1.0, 0.0, 0.0]) } else { Affine::new([1.0, 0.0, -t, 1.0, 0.0, 0.0]) };
|
||||
apply_transform(s, "Shear", ids, about(o, m), p)
|
||||
}
|
||||
|
||||
@@ -386,7 +409,9 @@ fn arrange(s: &mut Session, how: Arrange) -> Result<Value> {
|
||||
Arrange::Backward => "Send Backward",
|
||||
Arrange::Back => "Send to Back",
|
||||
};
|
||||
s.edit(label, |d, _| {
|
||||
// When nothing changes order, the document and its undo history stay as they are.
|
||||
super::overprint::edit_counted(s, label, |d| {
|
||||
let mut moved = 0;
|
||||
// Arrange within each parent independently (selections may span layers/groups).
|
||||
let mut by_parent: Vec<(Option<vectorcraft_doc::NodeId>, Vec<vectorcraft_doc::NodeId>)> = vec![];
|
||||
for id in &ids {
|
||||
@@ -411,10 +436,11 @@ fn arrange(s: &mut Session, how: Arrange) -> Result<Value> {
|
||||
};
|
||||
if to != idx {
|
||||
d.move_node(*id, par, to)?;
|
||||
moved += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
Ok(moved)
|
||||
})?;
|
||||
ok()
|
||||
}
|
||||
@@ -424,6 +450,11 @@ fn send_to_current_layer(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
// Ids are reused after undo: the remembered current layer must still be a layer.
|
||||
let st = s.doc()?;
|
||||
let layer = st.current_layer().or_else(|| st.doc.default_layer()).ok_or_else(|| EngineError::Other("no current layer".into()))?;
|
||||
// When the selected objects are already the topmost in the current layer, in paint order, the
|
||||
// document and its undo history stay as they are.
|
||||
if st.doc.children(Some(layer)).is_some_and(|c| c.iter().rev().map(|n| n.id).take(ids.len()).eq(ids.iter().rev().copied())) {
|
||||
return ok();
|
||||
}
|
||||
s.edit("Send to Current Layer", |d, _| {
|
||||
for id in &ids {
|
||||
d.move_node(*id, Some(layer), usize::MAX)?;
|
||||
@@ -457,6 +488,11 @@ pub(crate) fn group_nodes(d: &mut Document, ids: &[NodeId]) -> Result<NodeId> {
|
||||
|
||||
fn ungroup(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let ids = selected_roots(s)?;
|
||||
// With no group selected, the document, the selection and the undo history stay as they are.
|
||||
let d = &s.doc()?.doc;
|
||||
if !ids.iter().any(|id| d.node(*id).is_some_and(|n| matches!(n.kind, NodeKind::Group { .. }))) {
|
||||
return ok();
|
||||
}
|
||||
s.edit("Ungroup", |d, sel| {
|
||||
let mut new_sel = vec![];
|
||||
for id in &ids {
|
||||
@@ -492,6 +528,13 @@ fn ungroup(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
|
||||
fn lock(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let ids = selected_roots(s)?;
|
||||
// With every selected object already locked, the document and its undo history stay as they
|
||||
// are, and the objects are deselected.
|
||||
let d = &s.doc()?.doc;
|
||||
if ids.iter().all(|id| d.node(*id).is_none_or(|n| n.locked)) {
|
||||
s.select(|_, sel| sel.clear())?;
|
||||
return ok();
|
||||
}
|
||||
s.edit("Lock", |d, sel| {
|
||||
for id in &ids {
|
||||
if let Some(n) = d.node_mut(*id) {
|
||||
@@ -516,6 +559,10 @@ fn collect_flag(d: &Document, f: impl Fn(&Node) -> bool) -> Vec<NodeId> {
|
||||
|
||||
fn unlock_all(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let ids = collect_flag(&s.doc()?.doc, |n| n.locked);
|
||||
// With nothing locked, the document, the selection and the undo history stay as they are.
|
||||
if ids.is_empty() {
|
||||
return Ok(json!({ "count": 0 }));
|
||||
}
|
||||
s.edit("Unlock All", |d, sel| {
|
||||
for id in &ids {
|
||||
if let Some(n) = d.node_mut(*id) {
|
||||
@@ -530,6 +577,13 @@ fn unlock_all(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
|
||||
fn hide(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let ids = selected_roots(s)?;
|
||||
// With every selected object already hidden, the document and its undo history stay as they
|
||||
// are, and the objects are deselected.
|
||||
let d = &s.doc()?.doc;
|
||||
if ids.iter().all(|id| d.node(*id).is_none_or(|n| !n.visible)) {
|
||||
s.select(|_, sel| sel.clear())?;
|
||||
return ok();
|
||||
}
|
||||
s.edit("Hide", |d, sel| {
|
||||
for id in &ids {
|
||||
if let Some(n) = d.node_mut(*id) {
|
||||
@@ -544,6 +598,10 @@ fn hide(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
|
||||
fn show_all(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let ids = collect_flag(&s.doc()?.doc, |n| !n.visible);
|
||||
// With nothing hidden, the document, the selection and the undo history stay as they are.
|
||||
if ids.is_empty() {
|
||||
return Ok(json!({ "count": 0 }));
|
||||
}
|
||||
s.edit("Show All", |d, sel| {
|
||||
for id in &ids {
|
||||
if let Some(n) = d.node_mut(*id) {
|
||||
@@ -602,6 +660,12 @@ fn compound_make(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
|
||||
fn compound_release(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let ids = selected_roots(s)?;
|
||||
// With no compound path selected, the document, the selection and the undo history stay as
|
||||
// they are.
|
||||
let d = &s.doc()?.doc;
|
||||
if !ids.iter().any(|id| d.node(*id).is_some_and(|n| matches!(n.kind, NodeKind::Compound { .. }))) {
|
||||
return ok();
|
||||
}
|
||||
s.edit("Release Compound Path", |d, sel| {
|
||||
let mut out = vec![];
|
||||
for id in &ids {
|
||||
@@ -678,6 +742,11 @@ fn clip_make(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
|
||||
fn clip_release(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let ids = selected_roots(s)?;
|
||||
// With no clip group selected, the document and its undo history stay as they are.
|
||||
let d = &s.doc()?.doc;
|
||||
if !ids.iter().any(|id| d.node(*id).is_some_and(Node::clips)) {
|
||||
return ok();
|
||||
}
|
||||
s.edit("Release Clipping Mask", |d, _| {
|
||||
for id in &ids {
|
||||
release_clip(d, *id);
|
||||
@@ -785,7 +854,33 @@ fn set_props(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
),
|
||||
None => None,
|
||||
};
|
||||
s.edit("Object Properties", |d, _| {
|
||||
let hides = p.get("visible").and_then(Value::as_bool) == Some(false) || p.get("locked").and_then(Value::as_bool) == Some(true);
|
||||
// When every object already has every value given, the document and its undo history stay as
|
||||
// they are.
|
||||
let flag = |k: &str| p.get(k).and_then(Value::as_bool);
|
||||
let has_values = |n: &Node| {
|
||||
str_param(p, "name").is_none_or(|v| n.name.as_deref() == Some(v).filter(|v| !v.is_empty()))
|
||||
&& flag("visible").is_none_or(|v| n.visible == v)
|
||||
&& flag("locked").is_none_or(|v| n.locked == v)
|
||||
&& opacity.is_none_or(|v| n.opacity == v)
|
||||
&& blend.is_none_or(|b| n.blend == b)
|
||||
&& flag("isolate").is_none_or(|v| n.isolate == v)
|
||||
&& knockout.is_none_or(|k| n.knockout == k)
|
||||
&& flag("knockoutShape").is_none_or(|v| n.knockout_shape == v)
|
||||
&& data
|
||||
.iter()
|
||||
.flatten()
|
||||
.all(|(k, v)| n.attrs.as_ref().and_then(|a| a.data.iter().find(|(key, _)| key == k)).map(|(_, now)| now.as_str()) == v.as_deref())
|
||||
};
|
||||
let d = &s.doc()?.doc;
|
||||
if ids.iter().all(|id| d.node(*id).is_some_and(has_values)) {
|
||||
// Hidden or locked objects can't stay selected.
|
||||
if hides {
|
||||
s.select(|d, sel| sel.deselect_uneditable(d))?;
|
||||
}
|
||||
return ok();
|
||||
}
|
||||
s.edit("Object Properties", |d, sel| {
|
||||
for id in &ids {
|
||||
let n = d.node_mut(*id).ok_or(EngineError::NoNode(*id))?;
|
||||
if let Some(v) = str_param(p, "name") {
|
||||
@@ -831,6 +926,10 @@ fn set_props(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
});
|
||||
}
|
||||
}
|
||||
// Hidden or locked objects can't stay selected.
|
||||
if hides {
|
||||
sel.deselect_uneditable(d);
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
ok()
|
||||
@@ -851,19 +950,51 @@ impl Session {
|
||||
|
||||
/// Measure with preview (visual) bounds? The `bounds` param, else Use Preview Bounds.
|
||||
fn preview_bounds(s: &Session, p: &Value, cmd: &str) -> Result<bool> {
|
||||
match str_param(p, "bounds") {
|
||||
match choice(p, cmd, "bounds", &["preview", "geometric"])? {
|
||||
None => Ok(s.prefs.use_preview_bounds),
|
||||
Some("preview") => Ok(true),
|
||||
Some("geometric") => Ok(false),
|
||||
Some(b) => Err(bad(cmd, format!("bounds must be \"preview\" or \"geometric\", not `{b}`"))),
|
||||
Some(b) => Ok(b == "preview"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Param `key` of the Align commands and Shear: one of `values`, or `None` when it is missing or
|
||||
/// null. Any other value is an error that lists `values`.
|
||||
fn choice<'a>(p: &'a Value, cmd: &str, key: &str, values: &[&str]) -> Result<Option<&'a str>> {
|
||||
let Some(v) = p.get(key).filter(|v| !v.is_null()) else { return Ok(None) };
|
||||
match v.as_str() {
|
||||
Some(s) if values.contains(&s) => Ok(Some(s)),
|
||||
_ => Err(bad(cmd, format!("`{key}` must be one of {}", values.join(", ")))),
|
||||
}
|
||||
}
|
||||
|
||||
/// `horizontal` and `vertical` of `object.align` and `object.distribute`, each checked by [`choice`].
|
||||
fn direction<'a>(p: &'a Value, cmd: &str) -> Result<(Option<&'a str>, Option<&'a str>)> {
|
||||
Ok((choice(p, cmd, "horizontal", &["left", "center", "right"])?, choice(p, cmd, "vertical", &["top", "center", "bottom"])?))
|
||||
}
|
||||
|
||||
/// `ids` with their bounds (`preview`: visual bounds).
|
||||
fn items_bounds(d: &Document, ids: &[NodeId], preview: bool) -> Vec<(NodeId, Rect)> {
|
||||
ids.iter().filter_map(|id| Some((*id, d.bounds_of(&[*id], preview)?))).collect()
|
||||
}
|
||||
|
||||
/// Move each object of `moves` by its offset, as undo step `label`; pattern tiles move too when
|
||||
/// [`transform_patterns`] says so for `ids`. When every move is a rounding error (1e-9 pt or less
|
||||
/// on both axes), the document and its undo history stay as they are.
|
||||
fn apply_moves(s: &mut Session, label: &str, p: &Value, ids: &[NodeId], moves: &[(NodeId, Vec2)]) -> Result<Value> {
|
||||
let sc = Scaling { patterns: transform_patterns(s, p, ids)?, ..Scaling::default() };
|
||||
if moves.iter().all(|(_, dv)| dv.x.abs() <= 1e-9 && dv.y.abs() <= 1e-9) {
|
||||
return ok();
|
||||
}
|
||||
s.edit(label, |d, _| {
|
||||
for (id, dv) in moves {
|
||||
if let Some(n) = d.node_mut(*id) {
|
||||
n.transform(Affine::translate(*dv), sc);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
ok()
|
||||
}
|
||||
|
||||
/// What `object.align` aligns to: `to`, else the key object when there is one, else the
|
||||
/// selection.
|
||||
fn align_to<'a>(s: &Session, p: &'a Value) -> Option<&'a str> {
|
||||
@@ -875,8 +1006,12 @@ fn reference_rect(s: &Session, p: &Value, ids: &[NodeId], preview: bool) -> Resu
|
||||
match align_to(s, p) {
|
||||
Some("artboard") => {
|
||||
let b = st.doc.bounds_of(ids, preview).unwrap_or_default();
|
||||
// Align to Artboard uses artboard `artboard` (the app passes the active one). Without
|
||||
// it, or with an index the document doesn't have, it uses the artboard under the center
|
||||
// of the selection, or the first one when no artboard is there.
|
||||
let named = p.get("artboard").and_then(Value::as_u64).and_then(|i| usize::try_from(i).ok()).and_then(|i| st.doc.artboards.get(i));
|
||||
let i = st.doc.artboard_at(b.center()).unwrap_or(0);
|
||||
Ok(st.doc.artboards.get(i).map(|a| a.rect).unwrap_or(b))
|
||||
Ok(named.or(st.doc.artboards.get(i)).map(|a| a.rect).unwrap_or(b))
|
||||
}
|
||||
Some("key") => {
|
||||
let k = st.selection.key.ok_or_else(|| EngineError::Other("no key object".into()))?;
|
||||
@@ -886,13 +1021,25 @@ fn reference_rect(s: &Session, p: &Value, ids: &[NodeId], preview: bool) -> Resu
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the selected root (one of `ids`) that is the key object or contains it, such as the
|
||||
/// compound path or selected group the key is in. Align to the key object and Distribute Spacing
|
||||
/// with a spacing keep it in place.
|
||||
fn key_root(s: &Session, ids: &[NodeId]) -> Option<NodeId> {
|
||||
let st = s.doc().ok()?;
|
||||
let ancestry = st.doc.ancestry(st.selection.key?)?;
|
||||
ids.iter().copied().find(|id| ancestry.contains(id))
|
||||
}
|
||||
|
||||
fn align(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids = selected_roots(s)?;
|
||||
let preview = preview_bounds(s, p, "object.align")?;
|
||||
let (h, v) = direction(p, "object.align")?;
|
||||
choice(p, "object.align", "to", &["selection", "artboard", "key"])?;
|
||||
if h.is_none() && v.is_none() {
|
||||
return Err(bad("object.align", "give horizontal or vertical"));
|
||||
}
|
||||
let r = reference_rect(s, p, &ids, preview)?;
|
||||
let h = str_param(p, "horizontal");
|
||||
let v = str_param(p, "vertical");
|
||||
let key = s.doc()?.selection.key;
|
||||
let key = key_root(s, &ids);
|
||||
let moves: Vec<(NodeId, Vec2)> = {
|
||||
let d = &s.doc()?.doc;
|
||||
items_bounds(d, &ids, preview)
|
||||
@@ -915,22 +1062,14 @@ fn align(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
s.edit("Align", |d, _| {
|
||||
for (id, dv) in &moves {
|
||||
if let Some(n) = d.node_mut(*id) {
|
||||
n.transform(Affine::translate(*dv), false);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
ok()
|
||||
apply_moves(s, "Align", p, &ids, &moves)
|
||||
}
|
||||
|
||||
fn distribute(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids = selected_roots(s)?;
|
||||
let preview = preview_bounds(s, p, "object.distribute")?;
|
||||
let mut items = items_bounds(&s.doc()?.doc, &ids, preview);
|
||||
let (horiz, key): (bool, fn(&Rect, bool) -> f64) = match (str_param(p, "horizontal"), str_param(p, "vertical")) {
|
||||
let (horiz, key): (bool, fn(&Rect, bool) -> f64) = match direction(p, "object.distribute")? {
|
||||
(Some(h), _) => (
|
||||
true,
|
||||
match h {
|
||||
@@ -965,20 +1104,12 @@ fn distribute(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
(*id, if horiz { Vec2::new(delta, 0.0) } else { Vec2::new(0.0, delta) })
|
||||
})
|
||||
.collect();
|
||||
s.edit("Distribute", |d, _| {
|
||||
for (id, dv) in &moves {
|
||||
if let Some(n) = d.node_mut(*id) {
|
||||
n.transform(Affine::translate(*dv), false);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
ok()
|
||||
apply_moves(s, "Distribute", p, &ids, &moves)
|
||||
}
|
||||
|
||||
fn distribute_spacing(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids = selected_roots(s)?;
|
||||
let horiz = str_param(p, "axis") != Some("vertical");
|
||||
let horiz = choice(p, "object.distributeSpacing", "axis", &["horizontal", "vertical"])? != Some("vertical");
|
||||
let preview = preview_bounds(s, p, "object.distributeSpacing")?;
|
||||
let mut items = items_bounds(&s.doc()?.doc, &ids, preview);
|
||||
items.sort_by(|a, b| if horiz { a.1.x0.total_cmp(&b.1.x0) } else { a.1.y0.total_cmp(&b.1.y0) });
|
||||
@@ -988,7 +1119,11 @@ fn distribute_spacing(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
};
|
||||
let size = |r: &Rect| if horiz { r.width() } else { r.height() };
|
||||
let start = |r: &Rect| if horiz { r.x0 } else { r.y0 };
|
||||
let spacing = p.get("spacing").and_then(Value::as_f64).filter(|g| g.is_finite());
|
||||
let spacing = p
|
||||
.get("spacing")
|
||||
.filter(|v| !v.is_null())
|
||||
.map(|v| v.as_f64().filter(|g| g.is_finite()).ok_or_else(|| bad("object.distributeSpacing", "`spacing` must be a number")))
|
||||
.transpose()?;
|
||||
let gap = match spacing {
|
||||
Some(g) => g,
|
||||
None => {
|
||||
@@ -1002,21 +1137,13 @@ fn distribute_spacing(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
deltas.push((*id, pos - start(r)));
|
||||
pos += size(r) + gap;
|
||||
}
|
||||
// With a spacing (aligning to a key object) the key object stays where it is: the others
|
||||
// are spaced from it.
|
||||
let key = s.doc()?.selection.key;
|
||||
// With a spacing (aligning to a key object) the key object stays where it is, with the
|
||||
// compound path or selected group it is in: the others are spaced from it.
|
||||
let key = key_root(s, &ids);
|
||||
let fixed = spacing.and_then(|_| deltas.iter().find(|(id, _)| Some(*id) == key)).map_or(0.0, |k| k.1);
|
||||
let moves: Vec<(NodeId, Vec2)> =
|
||||
deltas.into_iter().map(|(id, d)| (id, if horiz { Vec2::new(d - fixed, 0.0) } else { Vec2::new(0.0, d - fixed) })).collect();
|
||||
s.edit("Distribute Spacing", |d, _| {
|
||||
for (id, dv) in &moves {
|
||||
if let Some(n) = d.node_mut(*id) {
|
||||
n.transform(Affine::translate(*dv), false);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
ok()
|
||||
apply_moves(s, "Distribute Spacing", p, &ids, &moves)
|
||||
}
|
||||
|
||||
fn set_bounds(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -1057,12 +1184,23 @@ fn set_bounds(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let new_rp = scale * gp + (rp - gp) * f(sx, sy);
|
||||
// x, y place the reference point on the page.
|
||||
let page = frame.to_doc() * rp;
|
||||
// When every value given is the current one, the transform is the identity. Computing it can
|
||||
// leave a rounding error in it on a rotated object or with Use Preview Bounds.
|
||||
let current = |k: &str, now: f64| p.get(k).and_then(Value::as_f64).is_none_or(|x| x == now);
|
||||
if current("x", page.x) && current("y", page.y) && current("width", v.width()) && current("height", v.height()) {
|
||||
return apply_transform(s, "Transform", ids, Affine::IDENTITY, p);
|
||||
}
|
||||
let to = frame.to_local(Point::new(f64_or(p, "x", page.x), f64_or(p, "y", page.y)));
|
||||
apply_transform(s, "Transform", ids, frame.conjugate(Affine::translate(to - new_rp) * scale), p)
|
||||
}
|
||||
|
||||
fn expand_shape(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let ids = selected_roots(s)?;
|
||||
// With no live shape selected, the document and its undo history stay as they are.
|
||||
let d = &s.doc()?.doc;
|
||||
if !ids.iter().any(|id| d.node(*id).is_some_and(|n| matches!(n.kind, NodeKind::Path { live: Some(_), .. }))) {
|
||||
return ok();
|
||||
}
|
||||
s.edit("Expand Shape", |d, _| {
|
||||
for id in &ids {
|
||||
if let Some(NodeKind::Path { live, .. }) = d.node_mut(*id).map(|n| &mut n.kind) {
|
||||
@@ -1138,6 +1276,45 @@ fn set_live_shape(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// A polygon's Polygon Properties: its angle, radius or side length, Make Sides Equal.
|
||||
let (polygon_angle, polygon_radius, side) = (angle("polygonAngle")?, length("polygonRadius")?, length("sideLength")?);
|
||||
let equal = p.get("makeSidesEqual").and_then(Value::as_bool) == Some(true);
|
||||
// An ellipse's pie becomes `pie_of(pie)`. An end of 0 is a full turn (Illustrator shows a whole
|
||||
// ellipse as 0° to 360°).
|
||||
let end_of = |a: f64| if a == 0.0 { 360.0 } else { a };
|
||||
let pie_of = |pie: (f64, f64)| {
|
||||
let pie = (pie_start.unwrap_or(pie.0), pie_end.map_or(pie.1, end_of));
|
||||
if invert { (pie.1.rem_euclid(360.0), end_of(pie.0)) } else { pie }
|
||||
};
|
||||
// When every value given is the current one, the document and its undo history stay as they
|
||||
// are.
|
||||
let st = s.doc()?;
|
||||
let has_values = |id: &NodeId| {
|
||||
let Some(NodeKind::Path { path, live, .. }) = st.doc.node(*id).map(|n| &n.kind) else { return true };
|
||||
match live {
|
||||
Some(l @ LiveShape::Polygon { sides: now, .. })
|
||||
if !(sides.is_none_or(|n| n.clamp(3, 1000) == u64::from(*now))
|
||||
&& (!equal || l.polygon_sides_equal())
|
||||
&& polygon_angle.is_none_or(|a| l.polygon_angle() == Some(a))
|
||||
&& side.and_then(|len| l.polygon_radius_for_side(len)).or(polygon_radius).is_none_or(|r| l.polygon_radius() == Some(r))) =>
|
||||
{
|
||||
return false;
|
||||
}
|
||||
Some(LiveShape::Ellipse { pie, .. }) if pie_of(*pie) != *pie => return false,
|
||||
_ => {}
|
||||
}
|
||||
if radius.is_none() && kind.is_none() {
|
||||
return true;
|
||||
}
|
||||
let Some(c) = LiveCorners::new(path, live.as_ref()) else { return true };
|
||||
let picked = corners_of(*id).unwrap_or_else(|| c.picked(st.selection.anchors.get(id)));
|
||||
// Setting corners folds a scale left in a rectangle's transform into its size, which
|
||||
// changes it.
|
||||
live.as_ref().is_none_or(|l| l.folded() == *l)
|
||||
&& picked
|
||||
.iter()
|
||||
.all(|k| *k < c.base.anchor_count() && radius.is_none_or(|r| c.radius(*k) == r.max(0.0)) && kind.is_none_or(|kd| c.kind(*k) == kd))
|
||||
};
|
||||
if ids.iter().all(has_values) {
|
||||
return ok();
|
||||
}
|
||||
s.edit("Live Shape", |d, sel| {
|
||||
for id in &ids {
|
||||
let Some(NodeKind::Path { path, live, .. }) = d.node_mut(*id).map(|n| &mut n.kind) else { continue };
|
||||
@@ -1163,12 +1340,7 @@ fn set_live_shape(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
&& (pie_start.is_some() || pie_end.is_some() || invert)
|
||||
{
|
||||
if let LiveShape::Ellipse { pie, .. } = l {
|
||||
// An end of 0 is a full turn (Illustrator shows a whole ellipse as 0° to 360°).
|
||||
let end_of = |a: f64| if a == 0.0 { 360.0 } else { a };
|
||||
*pie = (pie_start.unwrap_or(pie.0), pie_end.map_or(pie.1, end_of));
|
||||
if invert {
|
||||
*pie = (pie.1.rem_euclid(360.0), end_of(pie.0));
|
||||
}
|
||||
*pie = pie_of(*pie);
|
||||
}
|
||||
*path = l.to_path();
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Fill",
|
||||
[],
|
||||
None,
|
||||
"{color?: \"#rrggbb\"|[r,g,b]|{c,m,y,k}|{gray}|{l,a,b} (CIE Lab), none?: true, swatch?: name (a global or spot colour stays linked, so swatch edits recolour it; a tint swatch links to its base at its tint; a gradient swatch is recorded as the gradient's swatch and fits each object, keeping its aspect; the built-in \"[Registration]\" prints on every plate), tint?: 0..100 (% of a global or spot `swatch`; default 100, or a tint swatch's own), gradient?: {kind?: linear|radial|freeform, stops?: [{offset 0..1, color, opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: global or spot colour (or tint) swatch the stop links to (its colour comes from the swatch), tint?: 0..100}] (at least 2; default white→black), angle?: deg, start?: [x,y], end?: [x,y] (the vector in document coordinates, both or neither; type objects keep it in text space), aspect?: % (radial; without start/end the gradient is placed on each object's bounds), focal?: [x,y] (radial, with start/end: the focal point, where the first stop sits), swatch?: linked gradient swatch name}, item?: appearance item index|null (omitted: the Appearance panel's active item if it is a fill, else the top fill), ids?, focus?: true (false keeps the active proxy), keepModel?: false (in a CMYK document, RGB colours and gradient stops are stored as CMYK unless true; Gray stays Gray)} sets the selection's fill and the default (new art fits a gradient to itself)",
|
||||
"{color?: \"#rrggbb\"|[r,g,b]|{c,m,y,k}|{gray}|{l,a,b} (CIE Lab), none?: true, swatch?: name (a global or spot colour stays linked, so swatch edits recolour it; a tint swatch links to its base at its tint; a gradient swatch is recorded as the gradient's swatch and fits each object, keeping its aspect; the built-in \"[Registration]\" prints on every plate), tint?: 0..100 (% of a global or spot `swatch`; default 100, or a tint swatch's own), gradient?: {kind?: linear|radial|freeform, stops?: [{offset 0..1, color, opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: global or spot colour (or tint) swatch the stop links to (its colour comes from the swatch), tint?: 0..100}] (at least 2; default white→black), angle?: deg, start?: [x,y], end?: [x,y] (the vector in document coordinates, both or neither; type objects keep it in text space), aspect?: % (radial; without start/end the gradient is placed on each object's bounds), focal?: [x,y] (radial, with start/end: the focal point, where the first stop sits), swatch?: linked gradient swatch name}, item?: appearance item index|null (omitted: the Appearance panel's active item if it is a fill, else the top fill), ids?, focus?: true (false keeps the active proxy), keepModel?: false (in a CMYK document, RGB colours and gradient stops are stored as CMYK unless true; Gray stays Gray)} sets the selection's fill and the default (new art fits a gradient to itself); a solid colour recolours only the gradient mesh point last clicked with the Mesh tool or Direct Selection; while the Type tool has characters selected (and no ids or item are given), the paint goes to those characters only",
|
||||
has_doc,
|
||||
|s, p| set_paint(s, p, true)
|
||||
),
|
||||
@@ -375,10 +375,41 @@ fn apply_to_proxy(s: &mut Session, p: &Value, paint: Paint) -> Result<Value> {
|
||||
/// remember the paint.
|
||||
fn apply_paint(s: &mut Session, p: &Value, paint: Paint, fill: bool) -> Result<Value> {
|
||||
let cmd = if fill { "paint.setFill" } else { "paint.setStroke" };
|
||||
// Characters selected with the Type tool take the paint, not the whole type object (#1063).
|
||||
if p.get("ids").is_none()
|
||||
&& p.get("item").is_none()
|
||||
&& let Some((id, a, b)) = super::textedit::editing_range(s)?
|
||||
&& a != b
|
||||
{
|
||||
s.edit(if fill { "Fill Color" } else { "Stroke Color" }, |d, _| {
|
||||
let Some(NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind) else { return Err(EngineError::NoNode(id)) };
|
||||
let (xf, lb) = (t.xf, t.local_bounds());
|
||||
vectorcraft_text::edit::style_range(&mut t.runs, a, b, |st| {
|
||||
if fill {
|
||||
st.fill = place_run_paint(&paint, p, xf, lb);
|
||||
} else {
|
||||
if !paint.is_none() {
|
||||
run_stroke_weight(st);
|
||||
}
|
||||
st.stroke = place_run_paint(&paint, p, xf, vectorcraft_doc::appearance::stroke_paint_bounds(lb, st.stroke_width));
|
||||
}
|
||||
});
|
||||
super::typecmd::refresh_bounds(t);
|
||||
Ok(())
|
||||
})?;
|
||||
if bool_or(p, "focus", true) {
|
||||
s.fill_active = fill;
|
||||
}
|
||||
s.remember_paint(&paint);
|
||||
return ok();
|
||||
}
|
||||
// Resolve the requested targets before mutating defaults or the active proxy.
|
||||
let item = item_target(s, p, cmd)?.of_kind(s, fill);
|
||||
let selected = item.targets(s, p)?;
|
||||
let ids = super::graph::paint_targets(&s.doc()?.doc, &selected);
|
||||
// The gradient mesh point last clicked with the Mesh tool or Direct Selection: a solid fill
|
||||
// recolours just that point of its mesh.
|
||||
let mesh_point = if fill && matches!(paint, Paint::Solid { .. }) { focused_mesh_point(s) } else { None };
|
||||
let label = if fill { "Fill Color" } else { "Stroke Color" };
|
||||
edit_items_then(
|
||||
s,
|
||||
@@ -388,6 +419,15 @@ fn apply_paint(s: &mut Session, p: &Value, paint: Paint, fill: bool) -> Result<V
|
||||
label,
|
||||
fill,
|
||||
|n, index| {
|
||||
if index.is_none()
|
||||
&& let Paint::Solid { color, .. } = &paint
|
||||
&& let Some((_, i)) = mesh_point.filter(|(id, _)| *id == n.id)
|
||||
&& let NodeKind::Mesh(m) = &mut n.kind
|
||||
&& let Some(q) = m.points.get_mut(i)
|
||||
{
|
||||
q.color = *color;
|
||||
return Ok(());
|
||||
}
|
||||
if index.is_none()
|
||||
&& let NodeKind::Text(t) = &mut n.kind
|
||||
{
|
||||
@@ -429,6 +469,14 @@ fn apply_paint(s: &mut Session, p: &Value, paint: Paint, fill: bool) -> Result<V
|
||||
ok()
|
||||
}
|
||||
|
||||
/// The mesh point the active tool has focused (its `meshPoint` option: the Mesh tool's or Direct
|
||||
/// Selection's point last clicked).
|
||||
fn focused_mesh_point(s: &Session) -> Option<(NodeId, usize)> {
|
||||
let o = s.tool_options();
|
||||
let pt = o.get("meshPoint")?;
|
||||
Some((NodeId(pt.get("id")?.as_u64()?), usize::try_from(pt.get("index")?.as_u64()?).ok()?))
|
||||
}
|
||||
|
||||
/// A type run's stroke; a painted stroke on a run without one gets 1 pt.
|
||||
fn set_run_stroke(style: &mut CharStyle, paint: Paint) {
|
||||
if !paint.is_none() {
|
||||
|
||||
@@ -43,7 +43,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Character / Paragraph",
|
||||
[],
|
||||
None,
|
||||
"{ids?|id?, kerning?: 1/1000 em|\"auto\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, underline?, strikethrough?, allCaps?, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), leftIndent?, rightIndent?, firstLineIndent?, spaceBefore?, spaceAfter?: pt (±1296), hyphenate?: bool, mojikumi?: \"none\"|\"lineEndHalf\" (Japanese punctuation spacing), kinsoku?: \"none\"|\"hard\"|\"soft\" (Kinsoku Set: Soft lets 々, ー and small kana start a line), direction?: \"auto\"|\"leftToRight\"|\"rightToLeft\" (paragraph direction; auto: from each paragraph's first strong character), leadingModel?: \"romanBaseline\"|\"emBoxTop\" (leading measured baseline to baseline, or em box top to top), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (where characters smaller than the largest on their line line up with it), burasagari?: \"none\"|\"standard\"|\"forced\" (Paragraph panel menu › Burasagari None/Regular/Force: an East Asian comma or full stop ending an area type line hangs outside it: when it doesn't fit, or always; new type: \"standard\"), composer?: \"singleLine\"|\"everyLine\" (line breaking, Every-line by default), start?: byte, end?: byte} (with a range: the character attributes style that range and the paragraph attributes apply to the paragraphs it touches; without: all the text)",
|
||||
"{ids?|id?, kerning?: 1/1000 em|\"auto\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, underline?, strikethrough?, allCaps?, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), leftIndent?, rightIndent?, firstLineIndent?, spaceBefore?, spaceAfter?: pt (±1296), hyphenate?: bool, mojikumi?: \"none\"|\"lineEndHalf\" (Japanese punctuation spacing), kinsoku?: \"none\"|\"hard\"|\"soft\" (Kinsoku Set: Soft lets 々, ー and small kana start a line), direction?: \"auto\"|\"leftToRight\"|\"rightToLeft\" (paragraph direction; auto: from each paragraph's first strong character), leadingModel?: \"romanBaseline\"|\"emBoxTop\" (leading measured baseline to baseline, or em box top to top), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (where characters smaller than the largest on their line line up with it), proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), burasagari?: \"none\"|\"standard\"|\"forced\" (Paragraph panel menu › Burasagari None/Regular/Force: an East Asian comma or full stop ending an area type line hangs outside it: when it doesn't fit, or always; new type: \"standard\"), composer?: \"singleLine\"|\"everyLine\" (line breaking, Every-line by default), start?: byte, end?: byte} (baselineShift is in document points and hScale includes the object transform; groups include their text descendants; with a range: the character attributes style that range and the paragraph attributes apply to the paragraphs it touches; without: all the text)",
|
||||
has_doc,
|
||||
set_format
|
||||
),
|
||||
@@ -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()))?;
|
||||
@@ -185,14 +200,7 @@ const MAX_PARA: f64 = 1296.0;
|
||||
|
||||
fn set_format(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "text.setFormat";
|
||||
let ids: Vec<NodeId> = {
|
||||
let ids = targets(s, p)?;
|
||||
let d = &s.doc()?.doc;
|
||||
ids.into_iter().filter(|id| matches!(d.node(*id).map(|n| &n.kind), Some(NodeKind::Text(_)))).collect()
|
||||
};
|
||||
if ids.is_empty() {
|
||||
return Err(bad(C, "no text objects selected"));
|
||||
}
|
||||
let ids = super::typecmd::text_targets(s, p, C)?;
|
||||
let num = |k: &str| p.get(k).and_then(Value::as_f64);
|
||||
let flag = |k: &str| p.get(k).and_then(Value::as_bool);
|
||||
let kerning = match p.get("kerning") {
|
||||
@@ -222,6 +230,7 @@ fn set_format(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
"direction",
|
||||
"leadingModel",
|
||||
"charAlign",
|
||||
"proportionalMetrics",
|
||||
"burasagari",
|
||||
"composer",
|
||||
];
|
||||
@@ -285,15 +294,17 @@ fn set_format(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
s.edit("Character", |d, _| {
|
||||
for id in &ids {
|
||||
let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) else { continue };
|
||||
let baseline_shift = num("baselineShift").map(|v| super::typecmd::local_type_value(t, v, (-1296.0, 1296.0), false, C)).transpose()?;
|
||||
let h_scale = num("hScale").map(|v| super::typecmd::local_type_value(t, v, (1.0, 10000.0), true, C)).transpose()?;
|
||||
super::typecmd::style_chars(t, range, |st| {
|
||||
if let Some(k) = kerning {
|
||||
st.kerning = k;
|
||||
}
|
||||
if let Some(v) = num("baselineShift") {
|
||||
st.baseline_shift = v.clamp(-1296.0, 1296.0);
|
||||
if let Some(v) = baseline_shift {
|
||||
st.baseline_shift = v;
|
||||
}
|
||||
if let Some(v) = num("hScale") {
|
||||
st.h_scale = v.clamp(1.0, 10000.0);
|
||||
if let Some(v) = h_scale {
|
||||
st.h_scale = v;
|
||||
}
|
||||
if let Some(v) = num("vScale") {
|
||||
st.v_scale = v.clamp(1.0, 10000.0);
|
||||
@@ -319,6 +330,9 @@ fn set_format(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(v) = char_align {
|
||||
st.char_align = v;
|
||||
}
|
||||
if let Some(v) = flag("proportionalMetrics") {
|
||||
st.proportional_metrics = v;
|
||||
}
|
||||
});
|
||||
let span = super::typecmd::para_span(range, t);
|
||||
t.edit_paras(span, |para| {
|
||||
|
||||
@@ -57,7 +57,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Place…",
|
||||
["File"],
|
||||
Some("Cmd+Shift+P"),
|
||||
"{path | name+dataBase64, link?: true (a raster image keeps its file's path; a VectorCraft document at path becomes one locked placed document showing artboard `page`, linked to the file and read again when it changes (links.update; saving it in this app updates it at once), drawn as vectors in every output; false: an editable copy of its art, as from dataBase64; other files are embedded), text?: {characterSet?: \"unicode\" (UTF-8, or UTF-16 with a byte-order mark; other bytes as the platform's 8-bit set) | \"ansi\" (the platform's 8-bit set), platform?: \"windows\" (Windows-1252) | \"mac\" (Mac Roman), removeLineReturns?: false (each block of lines becomes one paragraph; blank lines end paragraphs), removeParagraphReturns?: false (drop blank lines), replaceSpaces?: n (runs of n ≥ 2 spaces become a tab)} (a .txt file, placed as area type filling rect, the replaced object's bounds, or else the artboard less a 36 pt margin), template?: false (onto a new locked template layer below the current layer), replace?: false (swap the one selected object, keeping its stacking place and transform; no at/rect), at?: [x, y] centre (default: the first artboard's centre), rect?: [x, y, width, height] fit inside, aspect kept (wins over at), page?: 1 (PDF/.ai page, or a native document's artboard), crop?: \"crop\" (clipped to that page or artboard, default) | \"bounding\" (the art's bounds) | \"art\" | \"trim\" | \"bleed\" | \"media\" (a PDF page's boxes), password? (an encrypted PDF), dxf?: {…the DXF options of document.open} (a .dxf drawing: fit fills the artboard under at, else the first; fitted or uncentred art lands with its drawing's artboard on that artboard, else it is centred on at)} → {ids, name, format, linked, width, height, warnings}. Raster images come in at 100% of their physical size (the file's ppi, else 72); SVG, DXF, PDF/.ai, EMF/WMF (clipped to the picture's frame), EPS (clipped to its bounding box; as document.open reads it) and native documents as one group, with the images, symbols, patterns and swatches they use. One undo step; selects what it placed (unless on a template layer); never touches the clipboard",
|
||||
"{path | name+dataBase64, link?: true (a raster image keeps its file's path; a VectorCraft document at path becomes one locked placed document showing artboard `page`, linked to the file and read again when it changes (links.update; saving it in this app updates it at once), drawn as vectors in every output; false: an editable copy of its art, as from dataBase64; other files are embedded), text?: {characterSet?: \"unicode\" (UTF-8, or UTF-16 with a byte-order mark; other bytes as the platform's 8-bit set) | \"ansi\" (the platform's 8-bit set), platform?: \"windows\" (Windows-1252) | \"mac\" (Mac Roman), removeLineReturns?: false (each block of lines becomes one paragraph; blank lines end paragraphs), removeParagraphReturns?: false (drop blank lines), replaceSpaces?: n (runs of n ≥ 2 spaces become a tab)} (a .txt file, placed as area type filling rect, the replaced object's bounds, or else the artboard less a 36 pt margin), template?: false (onto a new locked template layer below the current layer), replace?: false (swap the one selected object, keeping its stacking place and transform; no at/rect), at?: [x, y] centre (default: the first artboard's centre), rect?: [x, y, width, height] fit inside, aspect kept (wins over at), page?: 1 (PDF/.ai page, or a native document's artboard), crop?: \"crop\" (clipped to that page or artboard, default; an SVG, only when given: framed and clipped by its viewBox, else by its art's bounds) | \"bounding\" (the art's bounds) | \"art\" | \"trim\" | \"bleed\" | \"media\" (a PDF page's boxes), password? (an encrypted PDF), dxf?: {…the DXF options of document.open} (a .dxf drawing: fit fills the artboard under at, else the first; fitted or uncentred art lands with its drawing's artboard on that artboard, else it is centred on at)} → {ids, name, format, linked, width, height, warnings}. Raster images come in at 100% of their physical size (the file's ppi, else 72); SVG, DXF, PDF/.ai, EMF/WMF (clipped to the picture's frame), EPS (clipped to its bounding box; as document.open reads it) and native documents as one group, with the images, symbols, patterns and swatches they use. One undo step; selects what it placed (unless on a template layer); never touches the clipboard",
|
||||
has_doc,
|
||||
place
|
||||
),
|
||||
@@ -216,7 +216,12 @@ fn load(p: &Value, cmd: &str, board: Option<Rect>) -> Result<Loaded> {
|
||||
let board = doc.artboards.first().map(|a| a.rect).filter(|_| anchored);
|
||||
let layers = std::mem::take(&mut doc.layers);
|
||||
let (nodes, clip) = if matches!(format.id, "svg" | "svgz" | "dxf") {
|
||||
(art_of(layers.iter().filter(|l| placeable(l)).flat_map(|l| l.children().into_iter().flatten())), None)
|
||||
// An SVG with `crop: "crop"` asked for is framed and clipped by its viewBox (its artboard),
|
||||
// as a PDF by its page: art drawn on a shared canvas keeps its place and scale (#788). By
|
||||
// default, as Illustrator places one, it is its art's bounds.
|
||||
let framed = matches!(format.id, "svg" | "svgz") && crop && p.get("crop").is_some();
|
||||
let frame = doc.artboards.first().map(|a| a.rect).filter(|_| framed);
|
||||
(art_of(layers.iter().filter(|l| placeable(l)).flat_map(|l| l.children().into_iter().flatten())), frame)
|
||||
} else {
|
||||
// The page placed is a PDF import's only one.
|
||||
let index = if pdf { 0 } else { page - 1 };
|
||||
|
||||
@@ -19,7 +19,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_doc,
|
||||
all
|
||||
),
|
||||
cmd!("select.allOnArtboard", "All on Active Artboard", ["Select"], Some("Cmd+Alt+A"), "{artboard?: index}", has_doc, all_on_artboard),
|
||||
cmd!(
|
||||
"select.allOnArtboard",
|
||||
"All on Active Artboard",
|
||||
["Select"],
|
||||
Some("Cmd+Alt+A"),
|
||||
"{artboard?: index (0-based; the app passes the active one; default: the first)}",
|
||||
has_doc,
|
||||
all_on_artboard
|
||||
),
|
||||
cmd!("select.none", "Deselect", ["Select"], Some("Cmd+Shift+A"), "{}", has_doc, none),
|
||||
cmd!("select.reselect", "Reselect", ["Select"], Some("Cmd+6"), "{}", has_doc, reselect),
|
||||
cmd!("select.inverse", "Inverse", ["Select"], None, "{}", has_doc, inverse),
|
||||
@@ -57,7 +65,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Set Key Object",
|
||||
[],
|
||||
None,
|
||||
"{id?: a selected object (the Selection tool: a click on one object of a selection of several; on the key again: none)} the key object Align aligns to and Distribute Spacing spaces from; none clears it",
|
||||
"{id?: a selected object, one of two or more (the Selection tool: a click on one object of a selection of several; on the key again: none)} the key object Align aligns to and Distribute Spacing spaces from; none clears it, as does a selection that holds only the key and objects inside it (one object, or a group key and its own members)",
|
||||
has_doc,
|
||||
key
|
||||
),
|
||||
|
||||
@@ -500,7 +500,7 @@ pub(crate) fn refresh(before: &Document, d: &mut Document) -> Result<()> {
|
||||
fn candidates(s: &Session, p: &Value, points: &[Point], tolerance: f64) -> Result<Vec<NodeId>> {
|
||||
let st = s.doc()?;
|
||||
let area = points.iter().fold(Rect::new(f64::MAX, f64::MAX, f64::MIN, f64::MIN), |b, p| b.union_pt(*p)).inflate(tolerance, tolerance);
|
||||
let supplied = p.get("ids").and_then(Value::as_array).map(|v| v.iter().filter_map(Value::as_u64).map(NodeId).collect::<Vec<_>>());
|
||||
let supplied = p.get("ids").map(|_| checked_ids_param(s, p, "ids", "shaper.scribble")).transpose()?;
|
||||
let expand = supplied.is_none() && st.selection.objects.len() < 2;
|
||||
let ids = supplied.unwrap_or_else(|| if st.selection.objects.len() > 1 { st.selection.objects.clone() } else { st.doc.selectable_art() });
|
||||
let eligible: Vec<NodeId> = ids
|
||||
|
||||
@@ -63,7 +63,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Save Swatch Library…",
|
||||
["Window", "Swatches"],
|
||||
None,
|
||||
"{path?, format?: \"vcswatches\" (lossless JSON: colour models, global, spot, gradients, groups) | \"gpl\" (8-bit RGB palette; groups as `# Group:` comments) | \"ase\" (swatch exchange, binary: solid colors in their own model (RGB, CMYK, Lab, Gray) as global, spot or process colors, and color groups; gradients are left out, and a tint swatch is saved as the color it shows) | \"css\" (custom properties on :root) (default: the path's extension, else vcswatches), names?: [swatch or colour group names] (default: all; None and patterns are never saved), name?: library name (default: the document's), user?: false (save into the user library folder, listed under User Defined)} save the document's swatches as a library → {path, format, count, library?: id when saved to the user folder}; without path or user → {data: the file's text, format, count}, or {dataBase64, format, count} for ase",
|
||||
"{path?, format?: \"vcswatches\" (lossless JSON: colour models, global, spot, gradients, groups) | \"gpl\" (8-bit RGB palette; groups as `# Group:` comments) | \"ase\" (swatch exchange, binary: solid colors in their own model (RGB, CMYK, Lab, Gray) as global, spot or process colors, and color groups; gradients are left out, and a tint swatch is saved as the color it shows) | \"css\" (custom properties on :root) (default: the path's extension, else vcswatches), names?: [swatch or colour group names] (default: all; None and patterns are never saved), name?: library name (default: the document's), user?: false (save into the user library folder, listed under User Defined)} save the document's swatches as a library → {path, format, count: the swatches written (gpl and ase hold solid colors only, and css leaves freeform gradients out), library?: id when saved to the user folder}; without path or user → {data: the file's text, format, count}, or {dataBase64, format, count} for ase",
|
||||
has_doc,
|
||||
save
|
||||
),
|
||||
@@ -454,7 +454,7 @@ fn save(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let lib = document_library(&st.doc, &str_list(p, "names"), name, C)?;
|
||||
let bytes = palette_io::write(&lib, format).map_err(|e| bad(C, e))?;
|
||||
let data = if format.binary() { FileData::Binary(bytes) } else { FileData::Text(String::from_utf8_lossy(&bytes).into_owned()) };
|
||||
let mut out = json!({"format": format.id(), "count": lib.len()});
|
||||
let mut out = json!({"format": format.id(), "count": lib.iter().filter(|w| format.holds(&w.paint)).count()});
|
||||
s.swatch_libraries.write(p, &lib.name, format.id(), data, &mut out, C)?;
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Character",
|
||||
[],
|
||||
None,
|
||||
"{id? (default: the one selected type object), start?: byte, end?: byte (default: all text), font?, style?, size?: pt, leading?: pt|\"auto\", tracking?, kerning?: 1/1000 em|\"auto\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, fill?: colour|\"none\", stroke?: colour|\"none\", strokeWidth?: pt, strokeOptions?: {weight?, cap?, join?, miterLimit?, dash?, dashOffset?, alignDashes?} (as stroke.set: the character stroke), underline?, strikethrough?, allCaps?: bool, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), features?: [\"dlig\", \"-liga\", …], charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\"} style a character range (runs are split at the range ends) → {id, runs}",
|
||||
"{id? (default: the one selected type object), start?: byte, end?: byte (default: all text), font?, style?, size?: pt, leading?: pt|\"auto\", tracking?, kerning?: 1/1000 em|\"auto\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, fill?: colour|\"none\", stroke?: colour|\"none\", strokeWidth?: pt, strokeOptions?: {weight?, cap?, join?, miterLimit?, dash?, dashOffset?, alignDashes?} (as stroke.set: the character stroke), underline?, strikethrough?, allCaps?: bool, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), features?: [\"dlig\", \"-liga\", …], charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\", proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type)} style a character range (size/leading/baselineShift are document points and hScale includes the object transform; runs are split at the range ends) → {id, runs}",
|
||||
has_doc,
|
||||
set_range_style
|
||||
),
|
||||
@@ -36,7 +36,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Get Text Range",
|
||||
[],
|
||||
None,
|
||||
"{id, start?, end?} → {text, runs: [{text, style}], length}",
|
||||
"{id, start?, end?, effective?: bool = false} → {text, runs: [{text, style}], length}; effective uses document-point size/leading/baseline shift and transformed horizontal scale; default returns stored local styles for rich-text copying",
|
||||
has_doc,
|
||||
get_range
|
||||
),
|
||||
@@ -174,7 +174,12 @@ fn get_range(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let t = text_ref(s, id)?;
|
||||
let len = edit::runs_len(&t.runs);
|
||||
let (a, b) = range_of(p, len);
|
||||
let runs = edit::slice_runs(&t.runs, a, b);
|
||||
let mut runs = edit::slice_runs(&t.runs, a, b);
|
||||
if p.get("effective").and_then(Value::as_bool).unwrap_or(false) {
|
||||
for run in &mut runs {
|
||||
run.style = t.effective_char_style(&run.style).ok_or_else(|| bad("text.getRange", "text transform is collapsed or unrepresentable"))?;
|
||||
}
|
||||
}
|
||||
let text: String = runs.iter().map(|r| r.text.as_str()).collect();
|
||||
Ok(json!({"text": text, "runs": runs, "length": len}))
|
||||
}
|
||||
@@ -203,6 +208,7 @@ pub(crate) struct CharChange {
|
||||
position: Option<vectorcraft_doc::CharPosition>,
|
||||
small_caps: Option<Option<f64>>,
|
||||
char_align: Option<vectorcraft_doc::CharAlign>,
|
||||
proportional_metrics: Option<bool>,
|
||||
}
|
||||
|
||||
/// `features: ["dlig", "-liga", …]` → the canonical tag list (differences from the defaults).
|
||||
@@ -286,6 +292,7 @@ impl CharChange {
|
||||
position,
|
||||
small_caps,
|
||||
char_align: char_align_param(p, cmd)?,
|
||||
proportional_metrics: flag("proportionalMetrics"),
|
||||
};
|
||||
if c.size.is_some_and(|v| v <= 0.0) {
|
||||
return Err(bad(cmd, "size must be positive"));
|
||||
@@ -314,6 +321,16 @@ impl CharChange {
|
||||
&& self.position.is_none()
|
||||
&& self.small_caps.is_none()
|
||||
&& self.char_align.is_none()
|
||||
&& self.proportional_metrics.is_none()
|
||||
}
|
||||
|
||||
fn localize(&mut self, t: &TextObject, cmd: &str) -> Result<()> {
|
||||
use super::typecmd::local_type_value;
|
||||
self.size = self.size.map(|v| local_type_value(t, v, (0.1, 1296.0), false, cmd)).transpose()?;
|
||||
self.leading = self.leading.map(|v| v.map(|v| local_type_value(t, v, (0.1, 5000.0), false, cmd)).transpose()).transpose()?;
|
||||
self.baseline_shift = self.baseline_shift.map(|v| local_type_value(t, v, (-1296.0, 1296.0), false, cmd)).transpose()?;
|
||||
self.h_scale = self.h_scale.map(|v| local_type_value(t, v, (1.0, 10000.0), true, cmd)).transpose()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn apply(&self, st: &mut CharStyle) {
|
||||
@@ -338,10 +355,10 @@ impl CharChange {
|
||||
st.font_version = None;
|
||||
}
|
||||
if let Some(v) = self.size {
|
||||
st.size = v.clamp(0.1, 1296.0);
|
||||
st.size = v;
|
||||
}
|
||||
if let Some(v) = self.leading {
|
||||
st.leading = v.map(|l| l.clamp(0.1, 5000.0));
|
||||
st.leading = v;
|
||||
}
|
||||
if let Some(v) = self.tracking {
|
||||
st.tracking = v.clamp(-1000.0, 10000.0);
|
||||
@@ -350,10 +367,10 @@ impl CharChange {
|
||||
st.kerning = v.map(|k| k.clamp(-1000.0, 10000.0));
|
||||
}
|
||||
if let Some(v) = self.baseline_shift {
|
||||
st.baseline_shift = v.clamp(-1296.0, 1296.0);
|
||||
st.baseline_shift = v;
|
||||
}
|
||||
if let Some(v) = self.h_scale {
|
||||
st.h_scale = v.clamp(1.0, 10000.0);
|
||||
st.h_scale = v;
|
||||
}
|
||||
if let Some(v) = self.v_scale {
|
||||
st.v_scale = v.clamp(1.0, 10000.0);
|
||||
@@ -391,6 +408,9 @@ impl CharChange {
|
||||
if let Some(v) = self.char_align {
|
||||
st.char_align = v;
|
||||
}
|
||||
if let Some(v) = self.proportional_metrics {
|
||||
st.proportional_metrics = v;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -445,12 +465,13 @@ fn set_range_style(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
_ => return Err(bad(C, "missing `id`: give one, or select one type object")),
|
||||
},
|
||||
};
|
||||
let change = CharChange::parse(p, C, &s.doc()?.doc.setup)?;
|
||||
let mut change = CharChange::parse(p, C, &s.doc()?.doc.setup)?;
|
||||
if change.is_empty() {
|
||||
return Err(bad(C, "nothing to change"));
|
||||
}
|
||||
let protect = s.prefs.missing_glyph_protection && (change.font.is_some() || change.style.is_some());
|
||||
let t = text_ref(s, id)?;
|
||||
change.localize(&t, C)?;
|
||||
let (a, b) = range_of(p, edit::runs_len(&t.runs));
|
||||
let n = s.edit("Character", |d, _| {
|
||||
let t = text_mut(d, id).ok_or(EngineError::NoNode(id))?;
|
||||
@@ -542,7 +563,7 @@ enum Step {
|
||||
}
|
||||
|
||||
/// The Type tool's selected text while it edits (its typing session ends first: one undo step).
|
||||
fn editing_range(s: &mut Session) -> Result<Option<(NodeId, usize, usize)>> {
|
||||
pub(crate) fn editing_range(s: &mut Session) -> Result<Option<(NodeId, usize, usize)>> {
|
||||
let o = s.tool_options();
|
||||
let Some(id) = o.get("editing").and_then(Value::as_u64).map(NodeId) else { return Ok(None) };
|
||||
if super::edit::typing_in_progress(s) {
|
||||
@@ -605,13 +626,27 @@ fn type_step(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
s.edit("Character", |d, _| {
|
||||
for &(id, a, b) in &ranges {
|
||||
let t = text_mut(d, id).ok_or(EngineError::NoNode(id))?;
|
||||
edit::style_range(&mut t.runs, a, b, |st| match step {
|
||||
Step::Size => st.size = (st.size + inc).clamp(0.1, 1296.0),
|
||||
Step::Leading => st.leading = Some((st.effective_leading() + inc).clamp(0.1, 5000.0)),
|
||||
Step::Tracking => st.tracking = (st.tracking + inc).clamp(-1000.0, 10000.0),
|
||||
Step::Kerning => st.kerning = Some((st.kerning.unwrap_or(0.0) + inc).clamp(-1000.0, 10000.0)),
|
||||
Step::BaselineShift => st.baseline_shift = (st.baseline_shift + inc).clamp(-1296.0, 1296.0),
|
||||
});
|
||||
let points = if matches!(step, Step::Size | Step::Leading | Step::BaselineShift) {
|
||||
t.style_scale().ok_or_else(|| bad(C, "text transform is collapsed or unrepresentable"))?.points
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
let mut runs = edit::slice_runs(&t.runs, a, b);
|
||||
for run in &mut runs {
|
||||
let st = &mut run.style;
|
||||
match step {
|
||||
Step::Size => st.size = super::typecmd::local_type_value(t, st.size * points + inc, (0.1, 1296.0), false, C)?,
|
||||
Step::Leading => {
|
||||
st.leading = Some(super::typecmd::local_type_value(t, st.effective_leading() * points + inc, (0.1, 5000.0), false, C)?);
|
||||
}
|
||||
Step::Tracking => st.tracking = (st.tracking + inc).clamp(-1000.0, 10000.0),
|
||||
Step::Kerning => st.kerning = Some((st.kerning.unwrap_or(0.0) + inc).clamp(-1000.0, 10000.0)),
|
||||
Step::BaselineShift => {
|
||||
st.baseline_shift = super::typecmd::local_type_value(t, st.baseline_shift * points + inc, (-1296.0, 1296.0), false, C)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
edit::replace_range_styled(&mut t.runs, a, b, &runs);
|
||||
refresh_bounds(t);
|
||||
}
|
||||
Ok(())
|
||||
|
||||
@@ -8,7 +8,6 @@ use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{Document, Node, NodeId, NodeKind, TextKind, TextWrap, WrapShape};
|
||||
use vectorcraft_geom::{PathData, shapes};
|
||||
|
||||
use super::edit::selected_roots;
|
||||
use super::typecmd::refresh_bounds;
|
||||
use super::*;
|
||||
|
||||
@@ -36,19 +35,12 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
]
|
||||
}
|
||||
|
||||
fn targets(s: &Session, p: &Value) -> Result<Vec<NodeId>> {
|
||||
match p.get("ids").and_then(Value::as_array) {
|
||||
Some(a) => Ok(a.iter().filter_map(Value::as_u64).map(NodeId).collect()),
|
||||
None => selected_roots(s),
|
||||
}
|
||||
}
|
||||
|
||||
fn wrap_param(p: &Value, base: TextWrap) -> TextWrap {
|
||||
TextWrap { offset: f64_or(p, "offset", base.offset).clamp(-1000.0, 1000.0), invert: bool_or(p, "invert", base.invert) }
|
||||
}
|
||||
|
||||
fn make(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids = targets(s, p)?;
|
||||
let ids = checked_ids_or_selection(s, p, "object.textWrap.make")?;
|
||||
let w = wrap_param(p, TextWrap::default());
|
||||
s.edit("Make Text Wrap", |d, _| {
|
||||
for id in &ids {
|
||||
@@ -66,7 +58,7 @@ fn make(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
fn release(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids: Vec<NodeId> = {
|
||||
let d = &s.doc()?.doc;
|
||||
targets(s, p)?.into_iter().filter(|i| d.node(*i).is_some_and(|n| n.wrap.is_some())).collect()
|
||||
checked_ids_or_selection(s, p, "object.textWrap.release")?.into_iter().filter(|i| d.node(*i).is_some_and(|n| n.wrap.is_some())).collect()
|
||||
};
|
||||
if ids.is_empty() {
|
||||
return Err(EngineError::Other("Text Wrap: select a wrap object".into()));
|
||||
@@ -83,7 +75,7 @@ fn release(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
fn options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids = targets(s, p)?;
|
||||
let ids = checked_ids_or_selection(s, p, "object.textWrap.options")?;
|
||||
let current = {
|
||||
let d = &s.doc()?.doc;
|
||||
ids.iter().find_map(|i| d.node(*i).and_then(|n| n.wrap)).unwrap_or_default()
|
||||
@@ -212,6 +204,35 @@ mod tests {
|
||||
vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t).lines.iter().map(|l| l.avail).collect()
|
||||
}
|
||||
|
||||
/// Invalid explicit ids must not turn a requested multi-object edit into a partial one.
|
||||
#[test]
|
||||
fn text_wrap_commands_reject_malformed_target_lists_without_editing() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 300, "height": 300})).unwrap();
|
||||
let id = s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 80, "height": 80})).unwrap()["id"].as_u64().unwrap();
|
||||
let other = s.execute("shape.rectangle", &json!({"x": 120, "y": 10, "width": 80, "height": 80})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("select.set", &json!({"ids": [id]})).unwrap();
|
||||
|
||||
let before = s.doc().unwrap().history.undo.len();
|
||||
for command in ["object.textWrap.make", "object.textWrap.release", "object.textWrap.options"] {
|
||||
for ids in [json!([id, "invalid"]), json!([id, 99999]), json!({"not": "an array"})] {
|
||||
let e = s.execute(command, &json!({"ids": ids, "offset": 12})).unwrap_err().to_string();
|
||||
assert!(e.contains("id") || e.contains("array"), "{command}: {e}");
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), before, "{command} added history on error");
|
||||
assert!(s.doc().unwrap().doc.node(NodeId(id)).unwrap().wrap.is_none());
|
||||
assert!(s.doc().unwrap().doc.node(NodeId(other)).unwrap().wrap.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
s.execute("object.textWrap.make", &json!({"ids": [id]})).unwrap();
|
||||
assert!(s.doc().unwrap().doc.node(NodeId(id)).unwrap().wrap.is_some());
|
||||
assert!(s.doc().unwrap().doc.node(NodeId(other)).unwrap().wrap.is_none());
|
||||
s.execute("object.textWrap.options", &json!({"ids": [id], "offset": 12})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc.node(NodeId(id)).unwrap().wrap.unwrap().offset, 12.0);
|
||||
s.execute("object.textWrap.release", &json!({"ids": [id]})).unwrap();
|
||||
assert!(s.doc().unwrap().doc.node(NodeId(id)).unwrap().wrap.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn area_type_wraps_around_objects_above_and_follows_edits() {
|
||||
let mut s = Session::new();
|
||||
|
||||
@@ -75,7 +75,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Character",
|
||||
[],
|
||||
None,
|
||||
"{ids?|id?, font?, style?, size?: pt, leading?: pt|\"auto\", tracking?: 1/1000 em, justify?: \"auto\" (the start of each paragraph's direction)|\"left\"|\"center\"|\"right\"|\"justifyAll\", fill?: colour, features?: [\"dlig\", \"-liga\", …] OpenType, start?: byte, end?: byte} (with a range: the character attributes style that range and justify the paragraphs it touches; without: all the text)",
|
||||
"{ids?|id?, font?, style?, size?: pt, leading?: pt|\"auto\", tracking?: 1/1000 em, justify?: \"auto\" (the start of each paragraph's direction)|\"left\"|\"center\"|\"right\"|\"justifyAll\", fill?: colour, features?: [\"dlig\", \"-liga\", …] OpenType, start?: byte, end?: byte} (size/leading are document points after object scaling; with a range: the character attributes style that range and justify the paragraphs it touches; without: all the text)",
|
||||
has_doc,
|
||||
set_style
|
||||
),
|
||||
@@ -289,6 +289,22 @@ pub(crate) fn text_targets(s: &Session, p: &Value, cmd: &str) -> Result<Vec<Node
|
||||
Ok(t)
|
||||
}
|
||||
|
||||
/// Clamp in the units shown to the user, then compensate the text's affine. Clamping
|
||||
/// the local result would make small sizes impossible after enlarging an object.
|
||||
pub(crate) fn local_type_value(t: &TextObject, value: f64, limits: (f64, f64), horizontal: bool, cmd: &str) -> Result<f64> {
|
||||
if !value.is_finite() {
|
||||
return Err(bad(cmd, "type dimension must be finite"));
|
||||
}
|
||||
let scale = t.style_scale().ok_or_else(|| bad(cmd, "text transform is collapsed or unrepresentable"))?;
|
||||
let local = value.clamp(limits.0, limits.1) / if horizontal { scale.horizontal } else { scale.points };
|
||||
// Bound local dimensions too: an extreme inverse scale can produce a finite size
|
||||
// whose leading, glyph advances or layout bounds overflow before the edit's geometry check.
|
||||
if !local.is_finite() || local.abs() > crate::MAX_COORD || (value != 0.0 && local == 0.0) {
|
||||
return Err(bad(cmd, "type dimension cannot be represented with this text transform"));
|
||||
}
|
||||
Ok(local)
|
||||
}
|
||||
|
||||
/// Optional `start`/`end` byte offsets of a text command: the character range it styles and the
|
||||
/// paragraphs it touches. Without either, the command applies to all the text.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
@@ -415,6 +431,8 @@ fn set_style(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
s.edit("Character", |d, _| {
|
||||
for id in &ids {
|
||||
let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) else { continue };
|
||||
let size = size.map(|v| local_type_value(t, v, (0.1, 1296.0), false, C)).transpose()?;
|
||||
let leading = leading.map(|v| v.map(|v| local_type_value(t, v, (0.1, 5000.0), false, C)).transpose()).transpose()?;
|
||||
let before = protect.then(|| t.runs.clone());
|
||||
style_chars(t, range, |st| {
|
||||
if let Some(f) = &font {
|
||||
@@ -428,7 +446,7 @@ fn set_style(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
st.font_version = None;
|
||||
}
|
||||
if let Some(v) = size {
|
||||
st.size = v.clamp(0.1, 1296.0);
|
||||
st.size = v;
|
||||
}
|
||||
if let Some(l) = leading {
|
||||
st.leading = l;
|
||||
@@ -841,6 +859,34 @@ mod area_tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Proportional Metrics (#966): a character attribute, saved only when on.
|
||||
#[test]
|
||||
fn proportional_metrics_is_a_character_attribute_saved_only_when_on() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap();
|
||||
let id = s.execute("text.create", &json!({"x": 10, "y": 50, "text": "雅楽"})).unwrap()["id"].as_u64().unwrap();
|
||||
let runs = |s: &Session| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind {
|
||||
NodeKind::Text(t) => t.runs.clone(),
|
||||
_ => panic!("text"),
|
||||
};
|
||||
assert!(!runs(&s)[0].style.proportional_metrics);
|
||||
assert!(serde_json::to_value(&runs(&s)[0].style).unwrap().get("proportionalMetrics").is_none());
|
||||
s.execute("select.set", &json!({"ids": [id]})).unwrap();
|
||||
s.execute("text.setFormat", &json!({"proportionalMetrics": true})).unwrap();
|
||||
assert!(runs(&s).iter().all(|r| r.style.proportional_metrics));
|
||||
assert_eq!(serde_json::to_value(&runs(&s)[0].style).unwrap()["proportionalMetrics"], true);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(!runs(&s)[0].style.proportional_metrics);
|
||||
// Characters selected with the Type tool: only the range takes it (雅 is 3 bytes).
|
||||
s.execute("text.setRangeStyle", &json!({"id": id, "start": 3, "end": 6, "proportionalMetrics": true})).unwrap();
|
||||
let on: Vec<_> = runs(&s).iter().map(|r| (r.text.clone(), r.style.proportional_metrics)).collect();
|
||||
assert_eq!(on, [("雅".to_string(), false), ("楽".to_string(), true)]);
|
||||
// Styles written without it (documents from before it) read as off.
|
||||
let old = serde_json::to_value(&runs(&s)[0].style).unwrap();
|
||||
assert!(old.get("proportionalMetrics").is_none());
|
||||
assert!(!serde_json::from_value::<vectorcraft_doc::CharStyle>(old).unwrap().proportional_metrics);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_type_is_composed_with_line_end_half_width_punctuation() {
|
||||
use vectorcraft_doc::Mojikumi;
|
||||
|
||||
@@ -49,11 +49,6 @@ fn names_param(p: &Value, cmd: &str, fallback: Option<String>, nothing: &str) ->
|
||||
}
|
||||
}
|
||||
|
||||
/// The objects a bind acts on: `ids` when given (every one checked), else the selection.
|
||||
fn target_ids(s: &Session, p: &Value, cmd: &str) -> Result<Vec<NodeId>> {
|
||||
if p.get("ids").is_some() { checked_ids_param(s, p, "ids", cmd) } else { selected_roots(s) }
|
||||
}
|
||||
|
||||
/// Refuse what a variable of `kind` can't drive: art that is locked or on a hidden or locked
|
||||
/// layer is not the document's to rewrite, and a text variable only drives type.
|
||||
fn check_bindable(doc: &vectorcraft_doc::Document, ids: &[NodeId], kind: VariableKind, cmd: &str) -> Result<()> {
|
||||
@@ -203,7 +198,7 @@ fn list(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
fn bind(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "variable.bind";
|
||||
let name = named(str_param(p, "variable"), "variable", C)?;
|
||||
let ids = target_ids(s, p, C)?;
|
||||
let ids = checked_ids_or_selection(s, p, C)?;
|
||||
if ids.is_empty() {
|
||||
return Err(bad(C, "nothing to bind"));
|
||||
}
|
||||
@@ -218,7 +213,7 @@ fn bind(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
fn unbind(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "variable.unbind";
|
||||
let ids = target_ids(s, p, C)?;
|
||||
let ids = checked_ids_or_selection(s, p, C)?;
|
||||
let name = defined_variable(s, p, C)?;
|
||||
let n = s.edit("Unbind Variable", |d, _| {
|
||||
let mut n = 0;
|
||||
|
||||
@@ -55,12 +55,16 @@ fn list_views(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
|
||||
fn new_view(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "view.saved.new";
|
||||
let center = p.get("center").and_then(Value::as_array).and_then(|a| Some(Point::new(a.first()?.as_f64()?, a.get(1)?.as_f64()?)));
|
||||
let center = center.ok_or_else(|| bad(C, "missing center [x, y]"))?;
|
||||
let [x, y] = p.get("center").and_then(finite_numbers::<2>).ok_or_else(|| bad(C, "center must be [x, y]"))?;
|
||||
let zoom = f64_req(p, "zoom", C)?;
|
||||
if !(zoom.is_finite() && zoom > 0.0) {
|
||||
return Err(bad(C, "zoom must be positive"));
|
||||
}
|
||||
// Given, the rotation is a number: nothing malformed falls back to 0°.
|
||||
let rotation = match p.get("rotation") {
|
||||
Some(r) => r.as_f64().filter(|r| r.is_finite()).ok_or_else(|| bad(C, "rotation must be a number of degrees"))?,
|
||||
None => 0.0,
|
||||
};
|
||||
let st = s.doc()?;
|
||||
if st.doc.views.len() >= MAX_VIEWS {
|
||||
return Err(bad(C, format!("a document keeps at most {MAX_VIEWS} views")));
|
||||
@@ -72,7 +76,7 @@ fn new_view(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if st.doc.views.iter().any(|v| v.name == name) {
|
||||
return Err(bad(C, format!("a view named `{name}` exists")));
|
||||
}
|
||||
let view = SavedView { name: name.clone(), center, zoom, rotation: f64_or(p, "rotation", 0.0) };
|
||||
let view = SavedView { name: name.clone(), center: Point::new(x, y), zoom, rotation };
|
||||
let index = s.edit("New View", |d, _| {
|
||||
d.views.push(view);
|
||||
Ok(d.views.len() - 1)
|
||||
@@ -108,6 +112,29 @@ mod tests {
|
||||
|
||||
use crate::Session;
|
||||
|
||||
/// Malformed dimensions are rejected rather than saving an arbitrary or partial viewport.
|
||||
#[test]
|
||||
fn saved_views_require_exact_finite_coordinates_and_rotation() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let before = s.doc().unwrap().history.undo.len();
|
||||
for params in [
|
||||
json!({"center": [10], "zoom": 2}),
|
||||
json!({"center": [10, 20, 30], "zoom": 2}),
|
||||
json!({"center": [10, "bad"], "zoom": 2}),
|
||||
json!({"center": [10, 20], "zoom": 2, "rotation": "bad"}),
|
||||
json!({"center": [10, 20], "zoom": 2, "rotation": null}),
|
||||
] {
|
||||
assert!(s.execute("view.saved.new", ¶ms).is_err(), "accepted {params}");
|
||||
assert!(s.doc().unwrap().doc.views.is_empty());
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), before);
|
||||
}
|
||||
s.execute("view.saved.new", &json!({"center": [10, 20], "zoom": 2, "rotation": -15})).unwrap();
|
||||
let views = &s.doc().unwrap().doc.views;
|
||||
assert_eq!(views.len(), 1);
|
||||
assert_eq!(views[0].rotation, -15.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_rename_delete_views() {
|
||||
let mut s = Session::new();
|
||||
|
||||
@@ -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<_>>() }))
|
||||
}
|
||||
@@ -369,10 +369,27 @@ impl DocState {
|
||||
/// Coordinates beyond this (points) are rejected: ~1,400 m, far past Illustrator's large canvas.
|
||||
pub const MAX_COORD: f64 = 4.0e6;
|
||||
|
||||
/// The commands that act on the active artboard (#693), which the app passes as their `artboard`:
|
||||
/// Paste in Place, in Front and in Back paste onto it, Align to Artboard aligns to it, and All on
|
||||
/// Active Artboard selects on it. A `command.batch` given `artboard` passes it on to each of their
|
||||
/// steps that names none.
|
||||
pub const ON_ACTIVE_ARTBOARD: [&str; 5] = ["edit.pasteInPlace", "edit.pasteInFront", "edit.pasteInBack", "object.align", "select.allOnArtboard"];
|
||||
|
||||
/// `(command, param)` pairs the journal records only so a replay reproduces the original run
|
||||
/// (dates from the clock: `cmd::clock_date`); an action leaves them out
|
||||
/// (dates from the clock: `cmd::clock_date`; the active artboard the app passes to the
|
||||
/// [`ON_ACTIVE_ARTBOARD`] commands and to a batch); an action leaves them out
|
||||
/// ([`Session::journal_for_action`]).
|
||||
const REPLAY_ONLY: [(&str, &str); 3] = [("file.new", "created"), ("document.save", "modified"), ("file.saveAs", "modified")];
|
||||
const REPLAY_ONLY: [(&str, &str); 9] = [
|
||||
("file.new", "created"),
|
||||
("document.save", "modified"),
|
||||
("file.saveAs", "modified"),
|
||||
("edit.pasteInPlace", "artboard"),
|
||||
("edit.pasteInFront", "artboard"),
|
||||
("edit.pasteInBack", "artboard"),
|
||||
("object.align", "artboard"),
|
||||
("select.allOnArtboard", "artboard"),
|
||||
("command.batch", "artboard"),
|
||||
];
|
||||
|
||||
/// `p`, the params of command `id`, without its [`REPLAY_ONLY`] values (a batch: its steps').
|
||||
fn strip_replay_only(id: &str, p: &mut Value) {
|
||||
@@ -1324,6 +1341,11 @@ impl Session {
|
||||
{
|
||||
let st = self.doc_mut()?;
|
||||
let Some(it) = &st.interaction else { return Err(EngineError::Other("no interaction in progress".into())) };
|
||||
// Undoing the previous preview counts as a change, so views redraw even when the command
|
||||
// below returns without an edit.
|
||||
if !Arc::ptr_eq(&st.doc, &it.doc) || st.selection != it.selection {
|
||||
st.revision += 1;
|
||||
}
|
||||
st.doc = it.doc.clone();
|
||||
st.selection = it.selection.clone();
|
||||
}
|
||||
@@ -1344,10 +1366,17 @@ impl Session {
|
||||
let Some(mut it) = st.interaction.take() else { return Ok(()) };
|
||||
let Some(preview) = it.preview.take() else { return Ok(()) };
|
||||
let perspective_again = it.perspective_again.take();
|
||||
let copy = preview.0 == "object.transform" && preview.1.get("copy").and_then(Value::as_bool).unwrap_or(false);
|
||||
// Alt pressed or released during a move drag turns it into a copy or back: name it for
|
||||
// what it did in the end.
|
||||
match (it.label.as_str(), copy) {
|
||||
("Move", true) => it.label = "Copy".into(),
|
||||
("Copy", false) if preview.0 == "object.transform" => it.label = "Move".into(),
|
||||
_ => {}
|
||||
}
|
||||
st.keep_interaction(it);
|
||||
if preview.0 == "object.transform" {
|
||||
let m = cmd::matrix_param(&preview.1, "matrix");
|
||||
let copy = preview.1.get("copy").and_then(Value::as_bool).unwrap_or(false);
|
||||
if let Some(m) = m {
|
||||
st.last_transform = Some((m, copy));
|
||||
st.last_perspective = None;
|
||||
@@ -1686,6 +1715,8 @@ mod tests_transparencygrid;
|
||||
#[cfg(test)]
|
||||
mod tests_typearea;
|
||||
#[cfg(test)]
|
||||
mod tests_typescale;
|
||||
#[cfg(test)]
|
||||
mod tests_units;
|
||||
#[cfg(test)]
|
||||
mod tests_variables;
|
||||
|
||||
@@ -52,6 +52,67 @@ fn group_ungroup() {
|
||||
assert_eq!(s.doc().unwrap().selection.len(), 2);
|
||||
}
|
||||
|
||||
/// Makes 10 pt squares at (0, 90) and (50, 100) into a compound path or group with the commands in
|
||||
/// `make`, each run on the selection (the last one makes `outer`), draws a 10 pt square `r` at
|
||||
/// (200, 0), selects the second square and `r`, and sets the selection's `key` to that square
|
||||
/// → (session, key, outer, r).
|
||||
fn key_inside(make: &[&str]) -> (Session, NodeId, NodeId, NodeId) {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 0.0, 90.0, 10.0, 10.0);
|
||||
let key = rect(&mut s, 50.0, 100.0, 10.0, 10.0);
|
||||
s.execute("select.set", &json!({"ids": [a.0, key.0]})).unwrap();
|
||||
let mut outer = key;
|
||||
for cmd in make {
|
||||
outer = NodeId(s.execute(cmd, &json!({})).unwrap()["id"].as_u64().unwrap());
|
||||
}
|
||||
let r = rect(&mut s, 200.0, 0.0, 10.0, 10.0);
|
||||
s.execute("select.set", &json!({"ids": [key.0, r.0]})).unwrap();
|
||||
s.select(|_, sel| sel.key = Some(key)).unwrap();
|
||||
(s, key, outer, r)
|
||||
}
|
||||
|
||||
/// Aligns the selection left to its key object and, after Undo, spaces it 5 pt apart vertically
|
||||
/// → the top-left corners of `outer` and `r` after each.
|
||||
fn aligned_then_spaced(s: &mut Session, outer: NodeId, r: NodeId) -> [((f64, f64), (f64, f64)); 2] {
|
||||
let at = |s: &Session, id| {
|
||||
let b = s.doc().unwrap().doc.node(id).unwrap().geometric_bounds().unwrap();
|
||||
(b.x0, b.y0)
|
||||
};
|
||||
s.execute("object.align", &json!({"horizontal": "left", "bounds": "geometric"})).unwrap();
|
||||
let aligned = (at(s, outer), at(s, r));
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.execute("object.distributeSpacing", &json!({"axis": "vertical", "spacing": 5, "bounds": "geometric"})).unwrap();
|
||||
[aligned, (at(s, outer), at(s, r))]
|
||||
}
|
||||
|
||||
/// Align and Distribute Spacing move a compound path as one object. With one of its members as the
|
||||
/// key object, the compound path stays where it is: Align moves the others to the member's edge,
|
||||
/// and Distribute Spacing spaces them from the compound path.
|
||||
#[test]
|
||||
fn a_compound_path_that_contains_the_key_object_stays_in_place() {
|
||||
let (mut s, key, outer, r) = key_inside(&["object.compoundPath.make"]);
|
||||
assert_eq!(s.doc().unwrap().selection.key, Some(key));
|
||||
let [aligned, spaced] = aligned_then_spaced(&mut s, outer, r);
|
||||
assert_eq!(aligned, ((0.0, 90.0), (50.0, 0.0)), "it stays, the other moved to the key");
|
||||
assert_eq!(spaced, ((0.0, 90.0), (200.0, 75.0)), "5 pt apart, it stays");
|
||||
}
|
||||
|
||||
/// The key object stays the key when the group that contains it is added to the selection, by
|
||||
/// `select.add` or by the Group Selection tool's click on a selected member. Align and Distribute
|
||||
/// Spacing move that group as one object, and the group stays where it is, also when the key is in
|
||||
/// a group inside it.
|
||||
#[test]
|
||||
fn a_selected_group_that_contains_the_key_object_stays_in_place() {
|
||||
for make in [&["object.group"][..], &["object.group", "object.group"]] {
|
||||
let (mut s, key, outer, r) = key_inside(make);
|
||||
s.execute("select.add", &json!({"ids": [outer.0]})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().selection.key, Some(key), "{make:?}");
|
||||
let [aligned, spaced] = aligned_then_spaced(&mut s, outer, r);
|
||||
assert_eq!(aligned, ((0.0, 90.0), (50.0, 0.0)), "{make:?}: it stays, the other moved to the key");
|
||||
assert_eq!(spaced, ((0.0, 90.0), (200.0, 75.0)), "{make:?}: 5 pt apart, it stays");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arrange_order() {
|
||||
let mut s = session();
|
||||
@@ -250,6 +311,57 @@ fn layers_and_lock_hide() {
|
||||
assert!(s.doc().unwrap().doc.node(b).is_none());
|
||||
}
|
||||
|
||||
/// A key object is one object of a selection of several: a Shift-click (`select.toggle`) or a
|
||||
/// hidden or deleted row that leaves one object selected clears the key, and `select.key` sets
|
||||
/// none on a single selected object. A Shift-drag marquee that leaves two or more objects selected
|
||||
/// keeps the key in either order of its ids. `select.key` also sets none on a group selected with
|
||||
/// only its own member, and sets it once another object is selected.
|
||||
#[test]
|
||||
fn a_lone_selected_object_is_not_the_key_object() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
|
||||
let b = rect(&mut s, 100.0, 30.0, 20.0, 20.0);
|
||||
let c = rect(&mut s, 200.0, 60.0, 10.0, 10.0);
|
||||
let key = |s: &Session| s.doc().unwrap().selection.key;
|
||||
let objects = |s: &Session| s.doc().unwrap().selection.objects.clone();
|
||||
s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap();
|
||||
s.execute("select.key", &json!({"id": b.0})).unwrap();
|
||||
assert_eq!(key(&s), Some(b));
|
||||
s.execute("select.toggle", &json!({"id": a.0})).unwrap();
|
||||
assert_eq!(objects(&s), vec![b]);
|
||||
assert_eq!(key(&s), None, "the Shift-click left one object");
|
||||
s.execute("select.key", &json!({"id": b.0})).unwrap();
|
||||
assert_eq!(key(&s), None, "one object selected");
|
||||
for ids in [[a.0, c.0], [c.0, a.0]] {
|
||||
s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap();
|
||||
s.execute("select.key", &json!({"id": b.0})).unwrap();
|
||||
s.execute("select.toggle", &json!({"ids": ids})).unwrap();
|
||||
assert_eq!((objects(&s), key(&s)), (vec![b, c], Some(b)), "the marquee toggled {ids:?}");
|
||||
}
|
||||
// The Group Selection tool's second click on a member adds its group.
|
||||
s.execute("select.set", &json!({"ids": [b.0, c.0]})).unwrap();
|
||||
let g = NodeId(s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap());
|
||||
s.execute("select.set", &json!({"ids": [b.0]})).unwrap();
|
||||
s.execute("select.add", &json!({"ids": [g.0]})).unwrap();
|
||||
s.execute("select.key", &json!({"id": g.0})).unwrap();
|
||||
assert_eq!(key(&s), None, "a group and its own member");
|
||||
s.execute("select.add", &json!({"ids": [a.0]})).unwrap();
|
||||
s.execute("select.key", &json!({"id": g.0})).unwrap();
|
||||
assert_eq!(key(&s), Some(g), "another object outside the group");
|
||||
s.execute("select.set", &json!({"ids": [a.0, g.0]})).unwrap();
|
||||
s.execute("select.key", &json!({"id": g.0})).unwrap();
|
||||
assert_eq!(key(&s), Some(g));
|
||||
s.execute("layer.setProps", &json!({"id": a.0, "visible": false})).unwrap();
|
||||
assert_eq!((objects(&s), key(&s)), (vec![g], None), "hiding the other object's row left one object");
|
||||
s.execute("layer.setProps", &json!({"id": a.0, "visible": true})).unwrap();
|
||||
s.execute("select.set", &json!({"ids": [a.0, g.0]})).unwrap();
|
||||
s.execute("select.key", &json!({"id": g.0})).unwrap();
|
||||
assert_eq!(key(&s), Some(g));
|
||||
s.execute("layer.delete", &json!({"id": a.0})).unwrap();
|
||||
assert_eq!(objects(&s), vec![g]);
|
||||
assert_eq!(key(&s), None, "deleting the other object's row left one object");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clipping_and_compound() {
|
||||
let mut s = session();
|
||||
@@ -269,6 +381,65 @@ fn clipping_and_compound() {
|
||||
assert_eq!(s.doc().unwrap().selection.objects, vec![b]);
|
||||
}
|
||||
|
||||
/// `object.align` without `horizontal` or `vertical` (an unknown key such as `align` counts as
|
||||
/// neither) returns an error: nothing moves, and the undo and redo stacks stay as they were.
|
||||
#[test]
|
||||
fn align_without_a_direction_is_refused() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
|
||||
let b = rect(&mut s, 50.0, 30.0, 10.0, 10.0);
|
||||
s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap();
|
||||
s.execute("object.move", &json!({"dx": 5, "dy": 0})).unwrap();
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
let stacks = |s: &Session| (s.doc().unwrap().history.undo.len(), s.doc().unwrap().history.redo.len());
|
||||
let before = stacks(&s);
|
||||
for p in [json!({}), json!({"align": "left"}), json!({"to": "artboard"}), json!({"horizontal": null, "vertical": null})] {
|
||||
let e = s.execute("object.align", &p).expect_err(&p.to_string());
|
||||
assert_eq!(e.to_string(), "invalid parameters for `object.align`: give horizontal or vertical", "{p}");
|
||||
}
|
||||
assert_eq!(stacks(&s), before, "no undo step, and the redo step is kept");
|
||||
assert_eq!(s.doc().unwrap().doc.node(b).unwrap().geometric_bounds().unwrap().x0, 50.0, "nothing moved");
|
||||
}
|
||||
|
||||
/// Align, Distribute and Distribute Spacing return an error that lists the choices of a param
|
||||
/// given a value outside them: nothing moves and no undo step is recorded. A null value counts as
|
||||
/// not given, and Distribute Spacing without an `axis` spaces the objects horizontally.
|
||||
#[test]
|
||||
fn align_and_distribute_reject_bad_params_without_changing_state() {
|
||||
let mut s = session();
|
||||
let ids: Vec<NodeId> = [0.0, 50.0, 200.0].into_iter().map(|x| rect(&mut s, x, x / 2.0, 10.0, 10.0)).collect();
|
||||
s.execute("select.set", &json!({"ids": ids.iter().map(|id| id.0).collect::<Vec<_>>()})).unwrap();
|
||||
let bounds = |s: &Session| ids.iter().map(|id| s.doc().unwrap().doc.node(*id).unwrap().geometric_bounds().unwrap()).collect::<Vec<_>>();
|
||||
let before = bounds(&s);
|
||||
let undo = s.doc().unwrap().history.undo.len();
|
||||
let h = "`horizontal` must be one of left, center, right";
|
||||
let v = "`vertical` must be one of top, center, bottom";
|
||||
let to = "`to` must be one of selection, artboard, key";
|
||||
for (cmd, p, msg) in [
|
||||
("object.align", json!({"horizontal": "middle"}), h),
|
||||
("object.align", json!({"vertical": "centre"}), v),
|
||||
("object.align", json!({"horizontal": true}), h),
|
||||
("object.align", json!({"horizontal": "left", "to": "page"}), to),
|
||||
("object.align", json!({"horizontal": "left", "to": 0}), to),
|
||||
("object.align", json!({"horizontal": "left", "bounds": false}), "`bounds` must be one of preview, geometric"),
|
||||
("object.align", json!({"horizontal": "left", "vertical": "middle"}), v),
|
||||
("object.distribute", json!({"horizontal": "middle"}), h),
|
||||
("object.distribute", json!({"vertical": "centre"}), v),
|
||||
("object.distribute", json!({"horizontal": "left", "vertical": "middle"}), v),
|
||||
("object.distributeSpacing", json!({"axis": "diagonal"}), "`axis` must be one of horizontal, vertical"),
|
||||
("object.distributeSpacing", json!({"axis": "vertical", "spacing": "5"}), "`spacing` must be a number"),
|
||||
] {
|
||||
let e = s.execute(cmd, &p).expect_err(&format!("{cmd} {p}"));
|
||||
assert_eq!(e.to_string(), format!("invalid parameters for `{cmd}`: {msg}"));
|
||||
}
|
||||
assert_eq!(bounds(&s), before, "nothing moved");
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo, "no undo step");
|
||||
s.execute("object.align", &json!({"horizontal": null, "vertical": "top", "to": null, "bounds": null})).unwrap();
|
||||
assert!(bounds(&s).iter().all(|b| b.y0 == 0.0), "aligned to the top");
|
||||
s.execute("object.distributeSpacing", &json!({"axis": null, "spacing": null})).unwrap();
|
||||
assert_eq!(bounds(&s).iter().map(|b| b.x0).collect::<Vec<_>>(), [0.0, 100.0, 200.0], "equal horizontal gaps");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn align_left() {
|
||||
let mut s = session();
|
||||
@@ -279,6 +450,37 @@ fn align_left() {
|
||||
assert_eq!(s.doc().unwrap().doc.node(b).unwrap().geometric_bounds().unwrap().x0, 0.0);
|
||||
}
|
||||
|
||||
/// Align to Artboard aligns to artboard `artboard` (the app passes the active one). Without it, or
|
||||
/// with one the document doesn't have, it aligns to the artboard under the center of the
|
||||
/// selection, or to the first artboard when no artboard is there. Align to Selection does not use
|
||||
/// `artboard`.
|
||||
#[test]
|
||||
fn align_to_artboard_uses_the_artboard_named() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 200, "height": 100, "artboards": 3})).unwrap();
|
||||
let boards: Vec<_> = s.doc().unwrap().doc.artboards.iter().map(|a| a.rect).collect();
|
||||
let a = rect(&mut s, boards[1].x0 + 10.0, boards[1].y0 + 10.0, 20.0, 20.0);
|
||||
let b = rect(&mut s, boards[1].x0 + 50.0, boards[1].y0 + 40.0, 20.0, 20.0);
|
||||
s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap();
|
||||
let x0 = |s: &Session, id| s.doc().unwrap().doc.node(id).unwrap().geometric_bounds().unwrap().x0;
|
||||
s.execute("object.align", &json!({"horizontal": "left", "to": "artboard", "artboard": 2})).unwrap();
|
||||
assert_eq!((x0(&s, a), x0(&s, b)), (boards[2].x0, boards[2].x0), "the left edges are on artboard 2's");
|
||||
for (p, left) in [
|
||||
(json!({"horizontal": "left", "to": "artboard"}), boards[1].x0),
|
||||
(json!({"horizontal": "left", "to": "artboard", "artboard": 9}), boards[1].x0),
|
||||
(json!({"horizontal": "left", "to": "selection", "artboard": 2}), boards[1].x0 + 10.0),
|
||||
] {
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.execute("object.align", &p).unwrap();
|
||||
assert_eq!((x0(&s, a), x0(&s, b)), (left, left), "{p}");
|
||||
}
|
||||
// A selection whose center is on no artboard aligns to the first.
|
||||
let c = rect(&mut s, boards[1].x0 + 10.0, boards[1].y1 + 50.0, 20.0, 20.0);
|
||||
s.execute("select.set", &json!({"ids": [c.0]})).unwrap();
|
||||
s.execute("object.align", &json!({"horizontal": "left", "to": "artboard"})).unwrap();
|
||||
assert_eq!(x0(&s, c), boards[0].x0, "off every artboard: the first");
|
||||
}
|
||||
|
||||
/// #541: with the Selection tool, a click on one object of the selection makes it the key object:
|
||||
/// aligning to the key moves the others to it, Distribute Spacing spaces them from it, and a click
|
||||
/// on the key again lets it go.
|
||||
@@ -330,6 +532,426 @@ fn selection_tool_drag_is_one_undo_step() {
|
||||
assert_eq!(s.doc().unwrap().doc.node(a).unwrap().geometric_bounds().unwrap().x0, 100.0);
|
||||
}
|
||||
|
||||
/// How many steps there are to undo and to redo, and whether the document has unsaved changes.
|
||||
fn history(s: &Session) -> (usize, usize, bool) {
|
||||
let st = s.doc().unwrap();
|
||||
(st.history.undo.len(), st.history.redo.len(), st.is_dirty())
|
||||
}
|
||||
|
||||
/// Select `ids` with a move of them left to redo, and mark the document saved.
|
||||
fn select_with_a_step_to_redo(s: &mut Session, ids: &[NodeId]) {
|
||||
s.execute("select.set", &json!({"ids": ids.iter().map(|i| i.0).collect::<Vec<_>>()})).unwrap();
|
||||
s.execute("object.move", &json!({"dx": 5, "dy": 0})).unwrap();
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.doc_mut().unwrap().mark_saved();
|
||||
}
|
||||
|
||||
/// An Align that moves nothing (one object on the artboard's left edge, a pair already aligned, a
|
||||
/// pair that an earlier Align left a rounding error apart) records no undo step: the step to redo
|
||||
/// stays, the document stays unmodified, and the journal, which the Actions panel records from,
|
||||
/// still has the command.
|
||||
#[test]
|
||||
fn an_align_that_moves_nothing_records_no_undo_step() {
|
||||
let mut s = session();
|
||||
let x0 = |s: &Session, id| s.doc().unwrap().doc.node(id).unwrap().geometric_bounds().unwrap().x0;
|
||||
let left = json!({"horizontal": "left"});
|
||||
let a = rect(&mut s, 0.0, 50.0, 40.0, 40.0);
|
||||
select_with_a_step_to_redo(&mut s, &[a]);
|
||||
let before = history(&s);
|
||||
assert_eq!((before.1, before.2), (1, false), "a step to redo, and nothing unsaved");
|
||||
// Object › Align sends no `to`, and the Align panel aligns one object to the artboard.
|
||||
for p in [left.clone(), json!({"horizontal": "left", "to": "artboard"})] {
|
||||
s.execute("object.align", &p).unwrap();
|
||||
assert_eq!(history(&s), before, "one object and no key: {p}");
|
||||
assert_eq!(s.journal.last().unwrap(), &("object.align".to_string(), p));
|
||||
}
|
||||
let b = rect(&mut s, 0.0, 200.0, 80.0, 20.0);
|
||||
select_with_a_step_to_redo(&mut s, &[a, b]);
|
||||
let before = history(&s);
|
||||
s.execute("object.align", &left).unwrap();
|
||||
assert_eq!(history(&s), before, "a pair already aligned");
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
assert_eq!((x0(&s, a), x0(&s, b)), (5.0, 5.0), "Redo brings back the move");
|
||||
// An Align that moves something is one undo step, and it clears Redo.
|
||||
let c = rect(&mut s, 120.0, 300.0, 20.0, 20.0);
|
||||
select_with_a_step_to_redo(&mut s, &[a, c]);
|
||||
let undo = history(&s).0;
|
||||
s.execute("object.align", &left).unwrap();
|
||||
assert_eq!(history(&s), (undo + 1, 0, true));
|
||||
assert_eq!(x0(&s, c), 5.0);
|
||||
// Aligning a rectangle at 51.8 to one at 10.7 leaves it a rounding error off 10.7, and a
|
||||
// second Align records no step.
|
||||
let d = rect(&mut s, 10.7, 400.0, 20.0, 20.0);
|
||||
let e = rect(&mut s, 51.8, 450.0, 20.0, 20.0);
|
||||
s.execute("select.set", &json!({"ids": [d.0, e.0]})).unwrap();
|
||||
s.execute("object.align", &left).unwrap();
|
||||
assert!(x0(&s, e) != 10.7 && (x0(&s, e) - 10.7).abs() < 1e-12, "{}", x0(&s, e));
|
||||
s.doc_mut().unwrap().mark_saved();
|
||||
let before = history(&s);
|
||||
s.execute("object.align", &left).unwrap();
|
||||
assert_eq!(history(&s), before, "a pair the first Align aligned");
|
||||
}
|
||||
|
||||
/// Distribute and Distribute Spacing over objects already evenly spaced move nothing and record no
|
||||
/// undo step either.
|
||||
#[test]
|
||||
fn a_distribute_that_moves_nothing_records_no_undo_step() {
|
||||
let mut s = session();
|
||||
// The rectangles are 20 pt wide and 30 pt apart.
|
||||
let ids = [0.0, 50.0, 100.0].map(|x| rect(&mut s, x, 0.0, 20.0, 20.0));
|
||||
select_with_a_step_to_redo(&mut s, &ids);
|
||||
let before = history(&s);
|
||||
let calls = [
|
||||
("object.distribute", json!({"horizontal": "left"})),
|
||||
("object.distribute", json!({"horizontal": "center"})),
|
||||
("object.distribute", json!({"horizontal": "right"})),
|
||||
("object.distributeSpacing", json!({"axis": "horizontal"})),
|
||||
("object.distributeSpacing", json!({"axis": "horizontal", "spacing": 30})),
|
||||
];
|
||||
for (id, p) in calls {
|
||||
s.execute(id, &p).unwrap();
|
||||
assert_eq!(history(&s), before, "{id} {p}");
|
||||
assert_eq!(s.journal.last().unwrap(), &(id.to_string(), p));
|
||||
}
|
||||
// Distribute Spacing keeps the key object still and spaces the others from it.
|
||||
s.execute("select.key", &json!({"id": ids[1].0})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().selection.key, Some(ids[1]));
|
||||
s.execute("object.distributeSpacing", &json!({"axis": "horizontal", "spacing": 30})).unwrap();
|
||||
assert_eq!(history(&s), before, "spaced 30 pt from the key object");
|
||||
// After the middle one moves 10 pt right, Distribute moves it back in one undo step.
|
||||
s.execute("select.set", &json!({"ids": [ids[1].0]})).unwrap();
|
||||
s.execute("object.move", &json!({"dx": 10, "dy": 0})).unwrap();
|
||||
s.execute("select.set", &json!({"ids": ids.map(|i| i.0)})).unwrap();
|
||||
let undo = history(&s).0;
|
||||
s.execute("object.distribute", &json!({"horizontal": "left"})).unwrap();
|
||||
assert_eq!(history(&s).0, undo + 1);
|
||||
assert_eq!(s.doc().unwrap().doc.node(ids[1]).unwrap().geometric_bounds().unwrap().x0, 50.0);
|
||||
// For rectangles 30.1 pt apart, the gap Distribute Spacing computes moves the third one by a
|
||||
// rounding error.
|
||||
let ids = [0.1, 50.2, 100.3].map(|x| rect(&mut s, x, 100.0, 20.0, 20.0));
|
||||
select_with_a_step_to_redo(&mut s, &ids);
|
||||
let before = history(&s);
|
||||
s.execute("object.distributeSpacing", &json!({"axis": "horizontal"})).unwrap();
|
||||
assert_eq!(history(&s), before, "30.1 pt apart");
|
||||
// Distributing these three leaves the middle one a rounding error off halfway between the
|
||||
// others, and a second Distribute records no step.
|
||||
let ids = [40.045, 433.482, 1935.587].map(|x| rect(&mut s, x, 200.0, 20.0, 20.0));
|
||||
s.execute("select.set", &json!({"ids": ids.map(|i| i.0)})).unwrap();
|
||||
s.execute("object.distribute", &json!({"horizontal": "left"})).unwrap();
|
||||
s.doc_mut().unwrap().mark_saved();
|
||||
let before = history(&s);
|
||||
s.execute("object.distribute", &json!({"horizontal": "left"})).unwrap();
|
||||
assert_eq!(history(&s), before, "three objects the first Distribute spaced");
|
||||
}
|
||||
|
||||
/// Move by 0 pt, Rotate and Shear by 0°, Scale to 100%, an identity Transform, Set Bounds with the
|
||||
/// current position and size, a Nudge of 0 (of objects, anchors or ruler guides), a Transform with
|
||||
/// no object to transform and a Selection tool drag that ends where it started record no undo
|
||||
/// step. Transform Again then repeats the identity, and a Nudge of 0 of a live rectangle's anchor
|
||||
/// leaves the rectangle a live shape.
|
||||
#[test]
|
||||
fn a_transform_that_moves_nothing_records_no_undo_step() {
|
||||
let mut s = session();
|
||||
// The Nudges of a guide and of anchors below use this ruler guide and this plain path.
|
||||
s.execute("guide.add", &json!({"vertical": true, "pos": 600})).unwrap();
|
||||
let made = s.execute("path.create", &json!({"anchors": [{"x": 300, "y": 300}, {"x": 400, "y": 300}]})).unwrap();
|
||||
let path = NodeId(made["id"].as_u64().unwrap());
|
||||
let a = rect(&mut s, 10.0, 20.0, 40.0, 30.0);
|
||||
select_with_a_step_to_redo(&mut s, &[a]);
|
||||
let before = history(&s);
|
||||
let calls = [
|
||||
("object.move", json!({"dx": 0, "dy": 0})),
|
||||
("object.rotate", json!({"angle": 0})),
|
||||
("object.scale", json!({"sx": 100})),
|
||||
("object.shear", json!({"angle": 0})),
|
||||
("object.transform", json!({"matrix": [1, 0, 0, 1, 0, 0]})),
|
||||
("object.nudge", json!({"dx": 0, "dy": 0})),
|
||||
("object.setBounds", json!({})),
|
||||
("object.transformAgain", json!({})),
|
||||
];
|
||||
for (id, p) in calls {
|
||||
assert_eq!(s.execute(id, &p).unwrap(), json!({"ids": [a.0]}), "{id} {p}");
|
||||
assert_eq!(history(&s), before, "{id} {p}");
|
||||
assert_eq!(s.journal.last().unwrap(), &(id.to_string(), p));
|
||||
}
|
||||
assert_eq!(s.doc().unwrap().last_transform, Some((Affine::IDENTITY, false)));
|
||||
// A Nudge of 0 with an anchor of the rectangle direct-selected keeps it a live shape.
|
||||
let is_live = |s: &Session| matches!(s.doc().unwrap().doc.node(a).unwrap().kind, NodeKind::Path { live: Some(_), .. });
|
||||
assert!(is_live(&s), "a rectangle is a live shape");
|
||||
s.execute("select.anchors", &json!({"id": a.0, "anchors": [[0, 0]]})).unwrap();
|
||||
s.execute("object.nudge", &json!({"dx": 0, "dy": 0})).unwrap();
|
||||
assert!(is_live(&s), "a Nudge of 0 keeps it a live shape");
|
||||
assert_eq!(history(&s), before, "an anchor of the live rectangle, then object.nudge");
|
||||
// A Nudge of 0 with anchors direct-selected or with only a ruler guide selected, and a
|
||||
// Transform with nothing selected or with ids that name no object, with or without a copy,
|
||||
// record no undo step either.
|
||||
let calls = [
|
||||
("select.anchors", json!({"id": path.0, "anchors": [[0, 0]]}), "object.nudge", json!({"dx": 0, "dy": 0})),
|
||||
("guide.select", json!({"indexes": [0]}), "object.nudge", json!({"dx": 0, "dy": 0})),
|
||||
("select.set", json!({"ids": []}), "object.transform", json!({"matrix": [1, 0, 0, 1, 5, 0]})),
|
||||
("select.set", json!({"ids": []}), "object.transform", json!({"matrix": [1, 0, 0, 1, 5, 0], "copy": true})),
|
||||
("select.set", json!({"ids": [a.0]}), "object.transform", json!({"matrix": [1, 0, 0, 1, 5, 0], "ids": [999999]})),
|
||||
("select.set", json!({"ids": [a.0]}), "object.transform", json!({"matrix": [1, 0, 0, 1, 5, 0], "ids": [999999], "copy": true})),
|
||||
];
|
||||
for (select, sp, id, p) in calls {
|
||||
s.execute(select, &sp).unwrap();
|
||||
s.execute(id, &p).unwrap();
|
||||
assert_eq!(history(&s), before, "{select} {sp}, then {id} {p}");
|
||||
}
|
||||
// A copy of no object lists no copies and keeps the selection.
|
||||
let p = json!({"matrix": [1, 0, 0, 1, 5, 0], "ids": [999999], "copy": true});
|
||||
assert_eq!(s.execute("object.transform", &p).unwrap(), json!({"ids": []}));
|
||||
assert_eq!(s.doc().unwrap().selection.objects, [a]);
|
||||
// A Selection tool drag that ends where it started records no undo step either. Back at the
|
||||
// start, the revision advances, so views redraw the object where it was.
|
||||
let v = ViewInfo::default();
|
||||
s.select_tool("selection", v).unwrap();
|
||||
let x0 = |s: &Session| s.doc().unwrap().doc.node(a).unwrap().geometric_bounds().unwrap().x0;
|
||||
let revision = |s: &Session| s.doc().unwrap().revision;
|
||||
s.pointer(&PointerEvent::new(PointerKind::Down, 30.0, 35.0), v).unwrap();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Drag, 60.0, 35.0), v).unwrap();
|
||||
assert_ne!(x0(&s), 10.0, "the preview moves it");
|
||||
let moved = revision(&s);
|
||||
s.pointer(&PointerEvent::new(PointerKind::Drag, 30.0, 35.0), v).unwrap();
|
||||
assert_eq!(x0(&s), 10.0);
|
||||
assert!(revision(&s) > moved, "views redraw it back at the start");
|
||||
s.pointer(&PointerEvent::new(PointerKind::Up, 30.0, 35.0), v).unwrap();
|
||||
assert_eq!(history(&s), before, "a drag back to the start");
|
||||
assert_eq!(s.journal.last().unwrap().0, "object.transform");
|
||||
// With a copy, a Move by 0 pt duplicates the object in one undo step.
|
||||
let count = |s: &Session| s.doc().unwrap().doc.layers[0].children().unwrap().len();
|
||||
let n = count(&s);
|
||||
s.execute("object.move", &json!({"dx": 0, "dy": 0, "copy": true})).unwrap();
|
||||
assert_eq!(history(&s), (before.0 + 1, 0, true));
|
||||
assert_eq!(count(&s), n + 1);
|
||||
}
|
||||
|
||||
/// Arrange commands that leave the stacking order as it is record no undo step: Bring to Front and
|
||||
/// Bring Forward on the front objects, Send to Back and Send Backward on the back ones, and Send to
|
||||
/// Current Layer on objects already on top of the current layer.
|
||||
#[test]
|
||||
fn an_arrange_that_changes_no_order_records_no_undo_step() {
|
||||
let mut s = session();
|
||||
let ids = [0.0, 30.0, 60.0].map(|x| rect(&mut s, x, 0.0, 20.0, 20.0));
|
||||
let order = |s: &Session| s.doc().unwrap().doc.layers[0].children().unwrap().iter().map(|n| n.id).collect::<Vec<_>>();
|
||||
let calls = [
|
||||
(vec![ids[2]], "object.arrange.bringToFront"),
|
||||
(vec![ids[2]], "object.arrange.bringForward"),
|
||||
(vec![ids[0]], "object.arrange.sendToBack"),
|
||||
(vec![ids[0]], "object.arrange.sendBackward"),
|
||||
(vec![ids[2]], "object.arrange.sendToCurrentLayer"),
|
||||
(vec![ids[1], ids[2]], "object.arrange.bringToFront"),
|
||||
(vec![ids[1], ids[2]], "object.arrange.bringForward"),
|
||||
(vec![ids[0], ids[1]], "object.arrange.sendToBack"),
|
||||
(vec![ids[0], ids[1]], "object.arrange.sendBackward"),
|
||||
(vec![ids[1], ids[2]], "object.arrange.sendToCurrentLayer"),
|
||||
];
|
||||
for (sel, c) in calls {
|
||||
select_with_a_step_to_redo(&mut s, &sel);
|
||||
let before = history(&s);
|
||||
s.execute(c, &json!({})).unwrap();
|
||||
assert_eq!(history(&s), before, "{c} {sel:?}");
|
||||
assert_eq!(order(&s), ids, "{c} {sel:?}");
|
||||
}
|
||||
// Bring to Front on the back object is one undo step, and it clears Redo.
|
||||
select_with_a_step_to_redo(&mut s, &[ids[0]]);
|
||||
let undo = history(&s).0;
|
||||
s.execute("object.arrange.bringToFront", &json!({})).unwrap();
|
||||
assert_eq!(history(&s), (undo + 1, 0, true));
|
||||
assert_eq!(order(&s), [ids[1], ids[2], ids[0]]);
|
||||
}
|
||||
|
||||
/// Ungroup, Release Compound Path and Release Clipping Mask with none of their objects selected,
|
||||
/// and Expand Shape with no live shape selected, record no undo step and keep the selection and its
|
||||
/// key object.
|
||||
#[test]
|
||||
fn ungroup_release_and_expand_shape_with_nothing_to_act_on_record_no_undo_step() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 0.0, 0.0, 20.0, 20.0);
|
||||
let b = rect(&mut s, 40.0, 0.0, 20.0, 20.0);
|
||||
s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap();
|
||||
// Expand Shape makes the two rectangles plain paths in one undo step.
|
||||
let undo = history(&s).0;
|
||||
s.execute("object.expandShape", &json!({})).unwrap();
|
||||
assert_eq!(history(&s).0, undo + 1);
|
||||
select_with_a_step_to_redo(&mut s, &[a, b]);
|
||||
s.execute("select.key", &json!({"id": a.0})).unwrap();
|
||||
let (before, selection) = (history(&s), s.doc().unwrap().selection.clone());
|
||||
assert_eq!(selection.key, Some(a));
|
||||
for c in ["object.ungroup", "object.compoundPath.release", "object.clippingMask.release", "object.expandShape"] {
|
||||
s.execute(c, &json!({})).unwrap();
|
||||
assert_eq!(history(&s), before, "{c}");
|
||||
assert_eq!(s.doc().unwrap().selection, selection, "{c}");
|
||||
assert_eq!(s.journal.last().unwrap().0, c);
|
||||
}
|
||||
// Ungroup with a group selected is one undo step.
|
||||
s.execute("object.group", &json!({})).unwrap();
|
||||
let undo = history(&s).0;
|
||||
s.execute("object.ungroup", &json!({})).unwrap();
|
||||
assert_eq!(history(&s).0, undo + 1);
|
||||
}
|
||||
|
||||
/// Unlock All with nothing locked and Show All with nothing hidden record no undo step and keep the
|
||||
/// selection. Lock with every selected object already locked and Hide with every one already
|
||||
/// hidden record no undo step and deselect them.
|
||||
#[test]
|
||||
fn lock_hide_unlock_all_and_show_all_with_nothing_to_do_record_no_undo_step() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 0.0, 0.0, 20.0, 20.0);
|
||||
select_with_a_step_to_redo(&mut s, &[a]);
|
||||
let before = history(&s);
|
||||
for c in ["object.unlockAll", "object.showAll"] {
|
||||
assert_eq!(s.execute(c, &json!({})).unwrap(), json!({"count": 0}), "{c}");
|
||||
assert_eq!(history(&s), before, "{c}");
|
||||
assert_eq!(s.doc().unwrap().selection.objects, [a], "{c}");
|
||||
}
|
||||
// With the object locked or hidden, each is one undo step that selects it.
|
||||
for (off, on) in [("object.lock", "object.unlockAll"), ("object.hide", "object.showAll")] {
|
||||
s.execute(off, &json!({})).unwrap();
|
||||
let undo = history(&s).0;
|
||||
assert_eq!(s.execute(on, &json!({})).unwrap(), json!({"count": 1}), "{on}");
|
||||
assert_eq!(history(&s).0, undo + 1, "{on}");
|
||||
assert_eq!(s.doc().unwrap().selection.objects, [a], "{on}");
|
||||
}
|
||||
// Object Properties locks (then hides) a new rectangle `b`, and `select.set` selects it.
|
||||
// Lock (then Hide) deselects it and records no undo step. With `a` selected too, it is one
|
||||
// undo step.
|
||||
for (c, props) in [("object.lock", json!({"locked": true})), ("object.hide", json!({"visible": false}))] {
|
||||
let b = rect(&mut s, 40.0, 0.0, 20.0, 20.0);
|
||||
s.execute("object.setProps", &props).unwrap();
|
||||
select_with_a_step_to_redo(&mut s, &[a]);
|
||||
s.execute("select.set", &json!({"ids": [b.0]})).unwrap();
|
||||
let before = history(&s);
|
||||
s.execute(c, &json!({})).unwrap();
|
||||
assert_eq!(history(&s), before, "{c}");
|
||||
assert!(s.doc().unwrap().selection.objects.is_empty(), "{c}");
|
||||
s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap();
|
||||
s.execute(c, &json!({})).unwrap();
|
||||
assert_eq!(history(&s), (before.0 + 1, 0, true), "{c} with a");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
/// Set Bounds with the current position and size records no undo step on a rotated object, with
|
||||
/// and without Use Preview Bounds, and on an unrotated object with Use Preview Bounds. In these
|
||||
/// cases computing the transform can leave a rounding error in it.
|
||||
#[test]
|
||||
fn set_bounds_with_the_current_position_and_size_records_no_undo_step() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 10.0, 20.0, 40.0, 30.0);
|
||||
s.execute("stroke.set", &json!({"weight": 5})).unwrap();
|
||||
s.execute("object.rotate", &json!({"angle": 30})).unwrap();
|
||||
let c = rect(&mut s, 0.1, 0.05, 10.1, 6.06);
|
||||
s.execute("stroke.set", &json!({"weight": 0.3})).unwrap();
|
||||
select_with_a_step_to_redo(&mut s, &[a]);
|
||||
let before = history(&s);
|
||||
for (id, preview) in [(a, false), (a, true), (c, true)] {
|
||||
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
|
||||
s.execute("prefs.set", &json!({"key": "usePreviewBounds", "value": preview})).unwrap();
|
||||
// The Transform panel's values: X and Y place the reference point, W and H are the sides.
|
||||
let b = s.transform_box(&[id]).unwrap();
|
||||
let (rp, w, h) = (b.reference_point(4), b.rect.width(), b.rect.height());
|
||||
for p in [json!({}), json!({"x": rp.x, "y": rp.y, "width": w, "height": h}), json!({"width": w, "proportional": true})] {
|
||||
assert_eq!(s.execute("object.setBounds", &p).unwrap(), json!({"ids": [id.0]}), "{p}");
|
||||
assert_eq!(history(&s), before, "{id:?}, Use Preview Bounds {preview}: {p}");
|
||||
assert_eq!(s.journal.last().unwrap().0, "object.setBounds");
|
||||
}
|
||||
}
|
||||
// A new width is one undo step, and it clears Redo.
|
||||
s.execute("object.setBounds", &json!({"width": 50})).unwrap();
|
||||
assert_eq!(history(&s), (before.0 + 1, 0, true));
|
||||
}
|
||||
|
||||
/// Object Properties with the values the objects already have records no undo step: every
|
||||
/// property, data already set or already absent, and no object. A value that differs on one of
|
||||
/// the objects is one undo step, and an id that names no object is still an error.
|
||||
#[test]
|
||||
fn object_properties_already_set_record_no_undo_step() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 0.0, 0.0, 20.0, 20.0);
|
||||
let b = rect(&mut s, 40.0, 0.0, 20.0, 20.0);
|
||||
let set = json!({"ids": [a.0], "name": "A", "opacity": 50, "blend": "Multiply", "knockout": "on", "data": {"pivot": "1,2"}});
|
||||
s.execute("object.setProps", &set).unwrap();
|
||||
select_with_a_step_to_redo(&mut s, &[a, b]);
|
||||
let before = history(&s);
|
||||
let defaults = json!({
|
||||
"id": b.0, "name": "", "visible": true, "locked": false, "opacity": 100, "blend": "Normal", "isolate": false,
|
||||
"knockout": "neutral", "knockoutShape": false, "data": {"pivot": null}
|
||||
});
|
||||
let calls =
|
||||
[set, defaults, json!({"id": a.0, "data": {"data-pivot": "1,2", "other": null}}), json!({"visible": true}), json!({}), json!({"ids": []})];
|
||||
for p in calls {
|
||||
s.execute("object.setProps", &p).unwrap();
|
||||
assert_eq!(history(&s), before, "{p}");
|
||||
assert_eq!(s.journal.last().unwrap(), &("object.setProps".to_string(), p));
|
||||
}
|
||||
// `a` already has 50% opacity, and `b` does not.
|
||||
s.execute("object.setProps", &json!({"opacity": 50})).unwrap();
|
||||
assert_eq!(history(&s), (before.0 + 1, 0, true));
|
||||
assert!(s.execute("object.setProps", &json!({"ids": [999999], "visible": true})).is_err());
|
||||
}
|
||||
|
||||
/// Live Shape Properties that a shape already has record no undo step: a polygon's sides, angle,
|
||||
/// radius, side length and equal sides, an ellipse's pie, Invert Pie on a whole ellipse, and the
|
||||
/// radius and kind its corners have (given corners, the Direct-Selected one, or all). A value that
|
||||
/// differs is one undo step. Setting the radius of a rectangle that kept a scale in its transform
|
||||
/// is one undo step too, because it folds the scale into the rectangle's size.
|
||||
#[test]
|
||||
fn live_shape_properties_already_set_record_no_undo_step() {
|
||||
let mut s = session();
|
||||
let id = |v: Value| NodeId(v["id"].as_u64().unwrap());
|
||||
let r = rect(&mut s, 0.0, 0.0, 100.0, 60.0);
|
||||
let polygon = id(s.execute("shape.polygon", &json!({"cx": 300, "cy": 100, "radius": 50, "sides": 6})).unwrap());
|
||||
let ellipse = id(s.execute("shape.ellipse", &json!({"x": 0, "y": 200, "width": 80, "height": 40})).unwrap());
|
||||
let star = id(s.execute("shape.star", &json!({"cx": 300, "cy": 300, "radius1": 60, "radius2": 30})).unwrap());
|
||||
// The rectangle's corners get 8 pt radii, its top-right corner gets 12 pt, and the polygon gets
|
||||
// 30 pt sides.
|
||||
for p in [json!({"id": r.0, "radius": 8}), json!({"id": r.0, "corners": [1], "radius": 12}), json!({"id": polygon.0, "sideLength": 30})] {
|
||||
s.execute("object.setLiveShape", &p).unwrap();
|
||||
}
|
||||
let NodeKind::Path { live: Some(l), .. } = &s.doc().unwrap().doc.node(polygon).unwrap().kind else { panic!("not a live polygon") };
|
||||
let (angle, radius) = (l.polygon_angle().unwrap(), l.polygon_radius().unwrap());
|
||||
select_with_a_step_to_redo(&mut s, &[r, polygon, ellipse, star]);
|
||||
let before = history(&s);
|
||||
let calls = [
|
||||
json!({"id": polygon.0, "sides": 6, "polygonAngle": angle, "polygonRadius": radius, "makeSidesEqual": true}),
|
||||
json!({"id": polygon.0, "sideLength": 30, "kind": "round"}),
|
||||
json!({"id": ellipse.0, "pieStart": 0, "pieEnd": 360}),
|
||||
json!({"id": ellipse.0, "invertPie": true}),
|
||||
json!({"id": r.0, "corners": [0, 2, 3], "radius": 8, "kind": "round"}),
|
||||
json!({"items": [{"id": r.0, "corners": [1]}], "radius": 12}),
|
||||
json!({"id": star.0, "radius": 0}),
|
||||
];
|
||||
for p in calls {
|
||||
s.execute("object.setLiveShape", &p).unwrap();
|
||||
assert_eq!(history(&s), before, "{p}");
|
||||
assert_eq!(s.journal.last().unwrap(), &("object.setLiveShape".to_string(), p));
|
||||
}
|
||||
// With an anchor of the top-left corner direct-selected, the radius applies to that corner.
|
||||
s.execute("select.anchors", &json!({"id": r.0, "anchors": [[0, 0]]})).unwrap();
|
||||
s.execute("object.setLiveShape", &json!({"radius": 8})).unwrap();
|
||||
assert_eq!(history(&s), before, "the Direct-Selected corner");
|
||||
for p in [json!({"id": polygon.0, "sides": 7}), json!({"id": ellipse.0, "pieEnd": 90}), json!({"radius": 9})] {
|
||||
let undo = history(&s).0;
|
||||
s.execute("object.setLiveShape", &p).unwrap();
|
||||
assert_eq!(history(&s).0, undo + 1, "{p}");
|
||||
}
|
||||
// The rectangle is now 50 × 60 pt with 10 pt corners, stretched to 100 × 60 pt by its
|
||||
// transform, as files from before #291 saved it. Setting the radius its corners show makes the
|
||||
// corners circles.
|
||||
{
|
||||
let d = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc);
|
||||
let NodeKind::Path { path, live: Some(live), .. } = &mut d.node_mut(r).unwrap().kind else { panic!("not a live rectangle") };
|
||||
let xf = Affine::scale_non_uniform(2.0, 1.0);
|
||||
*live = vectorcraft_doc::LiveShape::Rectangle { w: 50.0, h: 60.0, radii: [10.0; 4], kinds: Default::default(), xf };
|
||||
*path = live.to_path();
|
||||
}
|
||||
let shown = vectorcraft_doc::LiveCorners::of(s.doc().unwrap().doc.node(r).unwrap()).unwrap().radius(0);
|
||||
let undo = history(&s).0;
|
||||
s.execute("object.setLiveShape", &json!({"id": r.0, "radius": shown})).unwrap();
|
||||
assert_eq!(history(&s).0, undo + 1, "a stretched rectangle");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rectangle_tool_draws() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -3,8 +3,10 @@
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
use vectorcraft_doc::Node;
|
||||
use vectorcraft_tools::{PointerEvent, PointerKind};
|
||||
|
||||
use super::*;
|
||||
use crate::tooling::ViewInfo;
|
||||
|
||||
fn session() -> Session {
|
||||
let mut s = Session::new();
|
||||
@@ -99,6 +101,38 @@ fn edit_colors_reach_mesh_points() {
|
||||
assert_eq!(mesh_colors(&s, m), pts, "a mesh's points are fill colours");
|
||||
}
|
||||
|
||||
/// Clicking a gradient mesh's point with the Mesh tool or Direct Selection picks it: a solid fill
|
||||
/// colour then recolours that point alone (#1050).
|
||||
#[test]
|
||||
fn a_clicked_mesh_point_takes_the_fill_colour() {
|
||||
let mut s = session();
|
||||
let m = mesh(&mut s);
|
||||
let before = mesh_colors(&s, m);
|
||||
let v = ViewInfo::default();
|
||||
let click = |s: &mut Session, i: usize| {
|
||||
let NodeKind::Mesh(g) = &s.doc().unwrap().doc.node(m).unwrap().kind else { panic!("not a mesh") };
|
||||
let q = g.points[i].p;
|
||||
for kind in [PointerKind::Down, PointerKind::Up] {
|
||||
s.pointer(&PointerEvent::new(kind, q.x, q.y), v).unwrap();
|
||||
}
|
||||
};
|
||||
// The Mesh tool selects the mesh whose point it clicks.
|
||||
s.execute("select.none", &json!({})).unwrap();
|
||||
s.select_tool("mesh", v).unwrap();
|
||||
click(&mut s, 1);
|
||||
assert_eq!(s.doc().unwrap().selection.objects, [m]);
|
||||
s.execute("paint.setFill", &json!({"color": "#0000ff"})).unwrap();
|
||||
let after = mesh_colors(&s, m);
|
||||
assert_eq!(after[1].to_hex(), "#0000ff", "the clicked point takes the colour");
|
||||
assert_eq!((after[0], after[2], after[3]), (before[0], before[2], before[3]), "the other points keep theirs");
|
||||
s.select_tool("directSelection", v).unwrap();
|
||||
click(&mut s, 2);
|
||||
s.execute("paint.setFill", &json!({"color": "#ffff00"})).unwrap();
|
||||
let after = mesh_colors(&s, m);
|
||||
assert_eq!((after[1].to_hex(), after[2].to_hex()), ("#0000ff".to_string(), "#ffff00".to_string()));
|
||||
assert_eq!((after[0], after[3]), (before[0], before[3]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blend_moves_a_mesh_to_the_blended_colour() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -66,6 +66,65 @@ fn recorded_actions_leave_out_replay_only_values() {
|
||||
assert_eq!(s.journal_for_action(1), s.journal[1..].to_vec());
|
||||
}
|
||||
|
||||
/// The artboard the app passes to Paste in Place, in Front and in Back, to Align to Artboard, to
|
||||
/// All on Active Artboard and to a batch (its active one) is journaled and left out of recorded
|
||||
/// actions, so a played action uses the artboard active when it plays.
|
||||
#[test]
|
||||
fn recorded_actions_leave_out_the_artboard_the_app_passes() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 200, "height": 100, "artboards": 2, "created": null})).unwrap();
|
||||
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap();
|
||||
s.execute("edit.copy", &json!({})).unwrap();
|
||||
let start = s.journal.len();
|
||||
let steps = [
|
||||
("edit.pasteInPlace", json!({"artboard": 1})),
|
||||
("edit.pasteInFront", json!({"artboard": 1})),
|
||||
("edit.pasteInBack", json!({"artboard": 1})),
|
||||
("select.allOnArtboard", json!({"artboard": 1})),
|
||||
("object.align", json!({"horizontal": "left", "to": "artboard", "artboard": 1})),
|
||||
("command.batch", json!({"artboard": 1, "commands": [{"command": "select.allOnArtboard", "params": {}}]})),
|
||||
];
|
||||
for (id, p) in &steps {
|
||||
s.execute(id, p).unwrap();
|
||||
}
|
||||
assert!(s.journal[start..].iter().all(|(_, p)| p["artboard"] == json!(1)), "the journal keeps it: {:?}", &s.journal[start..]);
|
||||
let action = s.journal_for_action(start);
|
||||
assert_eq!(action.len(), steps.len());
|
||||
for ((id, mut p), (step, recorded)) in steps.into_iter().zip(action) {
|
||||
p.as_object_mut().unwrap().remove("artboard");
|
||||
assert_eq!((step.as_str(), recorded), (id, p));
|
||||
}
|
||||
}
|
||||
|
||||
/// A batch passes its `artboard` (the app's active one) to each step that acts on the active
|
||||
/// artboard and names none, when the step runs: once a step deletes the last artboard, the new
|
||||
/// last one, and in a new document with fewer artboards, its last one. A step that names an
|
||||
/// artboard keeps it.
|
||||
#[test]
|
||||
fn a_batch_passes_its_artboard_to_each_step_when_it_runs() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 200, "height": 100, "artboards": 3})).unwrap();
|
||||
let boards: Vec<_> = s.doc().unwrap().doc.artboards.iter().map(|a| a.rect).collect();
|
||||
let ids: Vec<u64> = boards
|
||||
.iter()
|
||||
.map(|b| {
|
||||
s.execute("shape.rectangle", &json!({"x": b.x0 + 10.0, "y": b.y0 + 10.0, "width": 20, "height": 20})).unwrap()["id"].as_u64().unwrap()
|
||||
})
|
||||
.collect();
|
||||
let picked = |s: &Session| s.doc().unwrap().selection.objects.iter().map(|id| id.0).collect::<Vec<_>>();
|
||||
let step = |command: &str, params: Value| json!({"command": command, "params": params});
|
||||
let all_on_artboard = step("select.allOnArtboard", json!({}));
|
||||
s.execute("command.batch", &json!({"artboard": 2, "commands": [all_on_artboard]})).unwrap();
|
||||
assert_eq!(picked(&s), [ids[2]], "the batch's artboard 3");
|
||||
s.execute("command.batch", &json!({"artboard": 2, "commands": [step("select.allOnArtboard", json!({"artboard": 0}))]})).unwrap();
|
||||
assert_eq!(picked(&s), [ids[0]], "an artboard the step names is kept");
|
||||
s.execute("command.batch", &json!({"artboard": 2, "commands": [step("artboard.delete", json!({"index": 2})), all_on_artboard]})).unwrap();
|
||||
assert_eq!(picked(&s), [ids[1]], "artboard 2, the last one once artboard 3 is deleted");
|
||||
let new = step("file.new", json!({"width": 100, "height": 100}));
|
||||
s.execute("command.batch", &json!({"artboard": 2, "commands": [new, all_on_artboard]})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc.artboards.len(), 1, "in the new document");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_save_records_its_date_and_replays_it() {
|
||||
let path = vectorcraft_testkit::temp_dir("journal-save").join("a.vectorcraft").to_string_lossy().into_owned();
|
||||
@@ -296,3 +355,19 @@ fn an_undo_group_is_one_undo_step() {
|
||||
assert!((width(&s) - 120.0).abs() < 1e-9);
|
||||
assert_eq!(undo_len(&s), undo + 2);
|
||||
}
|
||||
|
||||
/// A move drag is named for what its last preview did: Alt pressed or released during the drag
|
||||
/// turns it into a copy or back (the Selection and Direct Selection tools).
|
||||
#[test]
|
||||
fn a_move_drag_is_named_for_whether_it_copied() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap();
|
||||
let last = |s: &Session| s.doc().unwrap().history.undo.last().map(|e| e.label.clone());
|
||||
for (begin, copy, label) in [("Move", true, "Copy"), ("Copy", false, "Move"), ("Move", false, "Move")] {
|
||||
s.begin_interaction(begin).unwrap();
|
||||
s.preview("object.transform", &json!({"matrix": [1, 0, 0, 1, 5, 0], "copy": copy})).unwrap();
|
||||
s.commit_interaction().unwrap();
|
||||
assert_eq!(last(&s).as_deref(), Some(label), "begun as {begin}, copy: {copy}");
|
||||
}
|
||||
}
|
||||
@@ -206,6 +206,89 @@ fn visibility_and_lock_per_row_are_undoable() {
|
||||
assert_eq!(node(&s, sub).display_name(), "Sub", "a layer keeps its name");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn object_set_props_deselects_the_objects_it_hides_or_locks() {
|
||||
let mut s = session();
|
||||
let (a, b) = (rect(&mut s, 0.0), rect(&mut s, 100.0));
|
||||
for (prop, value) in [("visible", false), ("locked", true)] {
|
||||
run(&mut s, "select.set", json!({"ids": [a.0, b.0]}));
|
||||
run(&mut s, "select.key", json!({"id": b.0}));
|
||||
let mut p = json!({"ids": [b.0]});
|
||||
p[prop] = json!(value);
|
||||
run(&mut s, "object.setProps", p);
|
||||
assert!(!s.doc().unwrap().doc.is_editable(b));
|
||||
let sel = s.doc().unwrap().selection.clone();
|
||||
assert_eq!((sel.objects, sel.key), (vec![a], None), "{prop}: {value} deselects the key object");
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert!(s.doc().unwrap().doc.is_editable(b));
|
||||
let sel = s.doc().unwrap().selection.clone();
|
||||
assert_eq!((sel.objects, sel.key), (vec![a, b], Some(b)), "undoing {prop}: {value} selects it again as the key");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn object_set_props_deselects_the_objects_inside_a_group_or_layer_it_hides_or_locks() {
|
||||
let mut s = session();
|
||||
let (_, sub, a, g, b, c) = tree(&mut s);
|
||||
run(&mut s, "select.set", json!({"ids": [a.0, b.0, c.0]}));
|
||||
run(&mut s, "object.setProps", json!({"ids": [g.0], "locked": true}));
|
||||
assert_eq!(s.doc().unwrap().selection.objects, vec![a, c], "b is in the locked group");
|
||||
run(&mut s, "select.key", json!({"id": a.0}));
|
||||
assert_eq!(s.doc().unwrap().selection.key, Some(a));
|
||||
run(&mut s, "object.setProps", json!({"ids": [sub.0], "visible": false}));
|
||||
let sel = s.doc().unwrap().selection.clone();
|
||||
assert_eq!((sel.objects, sel.key), (vec![c], None), "a, the key object, is in the hidden sublayer");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn object_set_props_that_hides_or_locks_nothing_keeps_the_selection() {
|
||||
let mut s = session();
|
||||
let layer = s.doc().unwrap().doc.layers[0].id;
|
||||
let x = rect(&mut s, 0.0);
|
||||
run(&mut s, "select.set", json!({"ids": [x.0]}));
|
||||
run(&mut s, "object.lock", json!({}));
|
||||
run(&mut s, "layer.setProps", json!({"id": layer.0, "locked": true}));
|
||||
run(&mut s, "object.unlockAll", json!({}));
|
||||
let st = s.doc().unwrap();
|
||||
assert!(st.selection.objects == vec![x] && !st.doc.is_editable(x), "Unlock All selects x, and its layer still locks it");
|
||||
for p in [json!({"opacity": 50}), json!({"visible": true, "locked": false})] {
|
||||
run(&mut s, "object.setProps", p.clone());
|
||||
assert_eq!(s.doc().unwrap().selection.objects, vec![x], "{p} keeps x selected");
|
||||
}
|
||||
assert_eq!(node(&s, x).opacity, 0.5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn object_set_props_that_hides_or_locks_deselects_objects_hidden_or_locked_before() {
|
||||
let mut s = session();
|
||||
let (x, y, z) = (rect(&mut s, 0.0), rect(&mut s, 100.0), rect(&mut s, 200.0));
|
||||
run(&mut s, "object.setProps", json!({"ids": [x.0], "visible": false}));
|
||||
run(&mut s, "select.set", json!({"ids": [x.0, y.0, z.0]}));
|
||||
assert_eq!(s.doc().unwrap().selection.objects, vec![x, y, z], "select.set selects the hidden x");
|
||||
run(&mut s, "object.setProps", json!({"ids": [y.0], "locked": true}));
|
||||
assert_eq!(s.doc().unwrap().selection.objects, vec![z], "locking y deselects x, hidden before the call, too");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn object_set_props_that_changes_nothing_deselects_what_it_leaves_hidden_or_locked() {
|
||||
let mut s = session();
|
||||
let (x, y) = (rect(&mut s, 0.0), rect(&mut s, 100.0));
|
||||
for (prop, value) in [("visible", false), ("locked", true)] {
|
||||
let mut p = json!({"ids": [x.0]});
|
||||
p[prop] = json!(value);
|
||||
run(&mut s, "object.setProps", p.clone());
|
||||
run(&mut s, "select.set", json!({"ids": [x.0, y.0]}));
|
||||
run(&mut s, "select.key", json!({"id": x.0}));
|
||||
let before = undos(&s);
|
||||
run(&mut s, "object.setProps", p);
|
||||
assert_eq!(undos(&s), before, "{prop}: {value} again records no undo step");
|
||||
let sel = s.doc().unwrap().selection.clone();
|
||||
assert_eq!((sel.objects, sel.key), (vec![y], None), "{prop}: {value} again deselects x, the key object");
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert!(s.doc().unwrap().doc.is_editable(x));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layer_options_validate_and_template_locks_and_dims() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -94,6 +94,49 @@ fn hide_all_artwork_above() {
|
||||
assert!(node(&s, over).visible);
|
||||
}
|
||||
|
||||
fn key(s: &Session) -> Option<NodeId> {
|
||||
s.doc().unwrap().selection.key
|
||||
}
|
||||
|
||||
/// Lock › All Artwork Above deselects the selected objects it locks: here the key object, in a
|
||||
/// group above the other selected object. Undo selects it again as the key object.
|
||||
#[test]
|
||||
fn lock_all_artwork_above_deselects_the_objects_it_locks() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 0.0, 0.0, 100.0, 100.0);
|
||||
let b = rect(&mut s, 50.0, 50.0, 100.0, 100.0);
|
||||
sel(&mut s, &[b]);
|
||||
let g = id_of(&s.execute("object.group", &json!({})).unwrap());
|
||||
sel(&mut s, &[a, b]);
|
||||
s.execute("select.key", &json!({"id": b.0})).unwrap();
|
||||
s.execute("object.lock.above", &json!({})).unwrap();
|
||||
assert!(node(&s, g).locked);
|
||||
assert_eq!((selected(&s), key(&s)), (vec![a], None));
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(!node(&s, g).locked);
|
||||
assert_eq!((selected(&s), key(&s)), (vec![a, b], Some(b)));
|
||||
}
|
||||
|
||||
/// Hide › All Artwork Above deselects the selected objects it hides (here one in a sublayer above)
|
||||
/// and selected objects that were hidden before. The one object left can't be the key object, so
|
||||
/// the key goes too; undo brings both back.
|
||||
#[test]
|
||||
fn hide_all_artwork_above_deselects_the_objects_it_hides() {
|
||||
let mut s = session();
|
||||
let hidden = rect(&mut s, 400.0, 400.0, 20.0, 20.0);
|
||||
s.execute("layer.setProps", &json!({"id": hidden.0, "visible": false})).unwrap();
|
||||
let a = rect(&mut s, 0.0, 0.0, 100.0, 100.0);
|
||||
let sub = id_of(&s.execute("layer.newSublayer", &json!({})).unwrap());
|
||||
let b = rect(&mut s, 50.0, 50.0, 100.0, 100.0);
|
||||
sel(&mut s, &[hidden, a, b]);
|
||||
s.execute("select.key", &json!({"id": a.0})).unwrap();
|
||||
s.execute("object.hide.above", &json!({})).unwrap();
|
||||
assert!(!node(&s, sub).visible);
|
||||
assert_eq!((selected(&s), key(&s)), (vec![a], None));
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!((selected(&s), key(&s)), (vec![hidden, a, b], Some(a)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lock_and_hide_other_layers() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -346,3 +346,32 @@ fn bad_place_params_are_errors() {
|
||||
assert_eq!(s.tool_id(), "selection", "a failed queue leaves the tool alone");
|
||||
assert!(top_children(&s).is_empty());
|
||||
}
|
||||
|
||||
/// An SVG placed with `crop: "crop"` given is framed and clipped by its viewBox, as a PDF by its
|
||||
/// page, so art drawn on a shared canvas keeps its place and scale; by default (and with
|
||||
/// "bounding") it is fitted by its art's bounds (#788).
|
||||
#[test]
|
||||
fn an_svg_placed_with_crop_is_framed_by_its_viewbox() {
|
||||
let svg = br#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1000 1000"><path d="M200 300 L600 300 L600 800 L200 800 Z"/></svg>"#;
|
||||
let path_bounds = |s: &Session, id: &Value| {
|
||||
let mut b = None;
|
||||
node(s, id).walk(&mut |n| {
|
||||
if let NodeKind::Path { clipping: false, .. } = n.kind {
|
||||
b = n.visual_bounds();
|
||||
}
|
||||
});
|
||||
b.unwrap()
|
||||
};
|
||||
let near = |a: Rect, b: Rect| [a.x0 - b.x0, a.y0 - b.y0, a.x1 - b.x1, a.y1 - b.y1].iter().all(|d| d.abs() < 1e-6);
|
||||
let mut s = session();
|
||||
let r = place(&mut s, "glyph.svg", svg, json!({"rect": [100, 100, 200, 200], "crop": "crop"}));
|
||||
assert_eq!((r["width"].as_f64(), r["height"].as_f64()), (Some(200.0), Some(200.0)), "{r}");
|
||||
let id = &r["ids"][0];
|
||||
assert!(near(path_bounds(&s, id), Rect::new(140.0, 160.0, 220.0, 260.0)), "{:?}", path_bounds(&s, id));
|
||||
assert!(matches!(node(&s, id).kind, NodeKind::Group { clip: true, .. }), "clipped to the viewBox");
|
||||
for extra in [json!({"rect": [100, 100, 200, 200]}), json!({"rect": [100, 100, 200, 200], "crop": "bounding"})] {
|
||||
let mut s = session();
|
||||
let r = place(&mut s, "glyph.svg", svg, extra);
|
||||
assert!(near(path_bounds(&s, &r["ids"][0]), Rect::new(120.0, 100.0, 280.0, 300.0)), "{r}");
|
||||
}
|
||||
}
|
||||
@@ -452,6 +452,50 @@ fn transform_pattern_tiles_is_the_transforms_default() {
|
||||
assert_eq!(tiles(&s), [0.5, 0.0, 0.0, 0.5, 5.0, 0.0]);
|
||||
}
|
||||
|
||||
/// General › Transform Pattern Tiles (#394) applies to Align and Distribute as it does to the
|
||||
/// transforms: off, the tiles of a pattern-filled square stay in place when the square moves;
|
||||
/// on, they move with it, and `patterns: false` keeps them in place for one call.
|
||||
#[test]
|
||||
fn align_and_distribute_follow_transform_pattern_tiles() {
|
||||
use vectorcraft_color::Paint;
|
||||
let mut s = new_doc();
|
||||
let plain = square(&mut s, 0.0, 0.0);
|
||||
s.execute("select.set", &json!({"ids": [plain.0]})).unwrap();
|
||||
s.execute("object.pattern.make", &json!({"name": "Dots", "width": 20, "height": 20})).unwrap();
|
||||
s.execute("object.pattern.done", &json!({})).unwrap();
|
||||
let far = square(&mut s, 300.0, 0.0);
|
||||
let filled = square(&mut s, 100.0, 0.0);
|
||||
s.execute("paint.setFill", &json!({"ids": [filled.0], "swatch": "Dots"})).unwrap();
|
||||
s.execute("select.set", &json!({"ids": [plain.0, filled.0, far.0]})).unwrap();
|
||||
let tiles = |s: &Session| match s.doc().unwrap().doc.node(filled).unwrap().appearance.fill_paint() {
|
||||
Paint::Pattern { xf, .. } => xf.as_coeffs(),
|
||||
p => panic!("not a pattern: {p:?}"),
|
||||
};
|
||||
// Align left moves the square 100 pt left, to the left edge of `plain`. Both Distribute
|
||||
// commands move it 50 pt right, halfway between the other two.
|
||||
for (cmd, p, dx) in [
|
||||
("object.align", json!({"horizontal": "left"}), -100.0),
|
||||
("object.distribute", json!({"horizontal": "left"}), 50.0),
|
||||
("object.distributeSpacing", json!({"axis": "horizontal"}), 50.0),
|
||||
] {
|
||||
s.execute(cmd, &p).unwrap();
|
||||
assert_eq!(tiles(&s), [1.0, 0.0, 0.0, 1.0, 0.0, 0.0], "{cmd}: off, the tiles stay");
|
||||
assert_eq!(s.journal.last().unwrap().1["patterns"], json!(false), "{cmd}: the journal keeps the choice");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
set_pref(&mut s, "transformPatternTiles", json!(true));
|
||||
s.execute(cmd, &p).unwrap();
|
||||
assert_eq!(tiles(&s), [1.0, 0.0, 0.0, 1.0, dx, 0.0], "{cmd}: on, they move with the square");
|
||||
assert_eq!(s.journal.last().unwrap().1["patterns"], json!(true), "{cmd}: the journal keeps the choice");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
let mut p = p;
|
||||
p["patterns"] = json!(false);
|
||||
s.execute(cmd, &p).unwrap();
|
||||
assert_eq!(tiles(&s), [1.0, 0.0, 0.0, 1.0, 0.0, 0.0], "{cmd}: the param wins");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
set_pref(&mut s, "transformPatternTiles", json!(false));
|
||||
}
|
||||
}
|
||||
|
||||
/// General › Select Same Tint % (#394): off, Select › Same › Fill Color takes every tint of the
|
||||
/// swatch; on, only the same tint.
|
||||
#[test]
|
||||
|
||||
@@ -42,6 +42,22 @@ fn stroke(s: &Session, id: NodeId, x: f64, y: f64) -> bool {
|
||||
})
|
||||
}
|
||||
|
||||
/// An invalid explicit Shaper target must never silently change which shapes get edited.
|
||||
#[test]
|
||||
fn shaper_scribble_rejects_malformed_explicit_targets() {
|
||||
let mut s = session();
|
||||
let a = rectangle(&mut s, 0.0, "#ff0000");
|
||||
let untouched = s.doc().unwrap().doc.layers.clone();
|
||||
let undo = s.doc().unwrap().history.undo.len();
|
||||
for ids in [json!([a.0, "bad"]), json!([a.0, 999999]), json!({"id": a.0}), json!("bad")] {
|
||||
let err = s.execute("shaper.scribble", &json!({"points": [[25, 20], [35, 30]], "ids": ids}));
|
||||
assert!(err.is_err(), "malformed targets were accepted");
|
||||
assert_eq!(s.doc().unwrap().doc.layers, untouched);
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo);
|
||||
}
|
||||
s.execute("shaper.scribble", &json!({"points": [[25, 20], [35, 30]], "ids": [a.0]})).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shaper_merges_with_origin_color_keeps_live_originals_and_undoes_once() {
|
||||
for reverse in [false, true] {
|
||||
|
||||
@@ -283,6 +283,26 @@ fn swatch_exchange_files_save_with_models_kinds_and_groups() {
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_counts_the_swatches_the_file_holds() {
|
||||
let mut s = session();
|
||||
// A new document has 46 solid colors and 4 linear or radial gradients besides None. Mesh is a freeform gradient.
|
||||
run(&mut s, "swatch.new", json!({"name": "Mesh", "gradient": {"kind": "freeform"}}));
|
||||
let mut counts = vec![];
|
||||
for format in ["vcswatches", "gpl", "ase", "css"] {
|
||||
let r = run(&mut s, "swatch.library.save", json!({ "format": format }));
|
||||
// The libraries load back with that many swatches, and the CSS file has one property for each.
|
||||
let held = if format == "css" {
|
||||
r["data"].as_str().unwrap().lines().filter(|l| l.starts_with(" --")).count() as u64
|
||||
} else {
|
||||
let load = json!({"data": r["data"], "dataBase64": r["dataBase64"], "name": format!("Saved.{format}")});
|
||||
run(&mut s, "swatch.library.load", load)["count"].as_u64().unwrap()
|
||||
};
|
||||
counts.push((format, r["count"].as_u64().unwrap(), held));
|
||||
}
|
||||
assert_eq!(counts, [("vcswatches", 51, 51), ("gpl", 46, 46), ("ase", 46, 46), ("css", 50, 50)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loaded_libraries_copy_into_the_user_folder_without_replacing_files() {
|
||||
use vectorcraft_testkit::ase::{Block, ase, sample};
|
||||
|
||||
@@ -563,3 +563,35 @@ fn automatic_alignment_updates_during_edits_and_can_be_overridden() {
|
||||
legacy["para"]["justify"] = json!("Left");
|
||||
assert_eq!(serde_json::from_value::<TextObject>(legacy).unwrap().para.justify, vectorcraft_doc::Justify::Left);
|
||||
}
|
||||
|
||||
/// #1063: a fill (or stroke) chosen while the Type tool has characters selected colours just
|
||||
/// those characters, as one undo step; the rest of the type keeps its colour.
|
||||
#[test]
|
||||
fn a_fill_chosen_with_characters_selected_colours_only_them() {
|
||||
let mut s = session();
|
||||
let v = ViewInfo::default();
|
||||
s.select_tool("type", v).unwrap();
|
||||
click(&mut s, 50.0, 300.0);
|
||||
let id = s.doc().unwrap().selection.objects[0];
|
||||
s.tool_text("Sample Text", v).unwrap();
|
||||
let before = obj(&s, id).runs[0].style.fill.clone();
|
||||
s.set_tool_option("select", &json!({"start": 0, "end": 6}));
|
||||
s.execute("paint.setFill", &json!({"color": "#00a000"})).unwrap();
|
||||
let t = obj(&s, id);
|
||||
assert_eq!(t.runs.len(), 2);
|
||||
assert_eq!(t.runs[0].text, "Sample");
|
||||
assert_eq!(t.runs[0].style.fill, Paint::solid(vectorcraft_color::Color::rgb8(0, 160, 0)));
|
||||
assert_eq!(t.runs[1].text, " Text");
|
||||
assert_eq!(t.runs[1].style.fill, before);
|
||||
// A stroke goes on the selected characters only, with a weight to show.
|
||||
s.execute("paint.setStroke", &json!({"color": "#ff0000"})).unwrap();
|
||||
let t = obj(&s, id);
|
||||
assert!(t.runs[0].style.stroke_width > 0.0 && !t.runs[0].style.stroke.is_none());
|
||||
assert!(t.runs[1].style.stroke.is_none());
|
||||
// One undo step each.
|
||||
let labels = undo_labels(&s);
|
||||
assert_eq!(&labels[labels.len() - 2..], ["Fill Color", "Stroke Color"]);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(obj(&s, id).runs.iter().all(|r| r.style.fill == before));
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
//! #1034: Character sizes and horizontal scales account for the object's affine transform.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{NodeId, NodeKind, TextObject};
|
||||
use vectorcraft_geom::Point;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn session() -> Session {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn create(s: &mut Session, size: f64) -> NodeId {
|
||||
NodeId(s.execute("text.create", &json!({"x": 40, "y": 60, "text": "Axis label", "size": size})).unwrap()["id"].as_u64().unwrap())
|
||||
}
|
||||
|
||||
fn text(s: &Session, id: NodeId) -> TextObject {
|
||||
match &s.doc().unwrap().doc.node(id).unwrap().kind {
|
||||
NodeKind::Text(t) => (**t).clone(),
|
||||
_ => panic!("not text"),
|
||||
}
|
||||
}
|
||||
|
||||
fn em(t: &TextObject, run: usize) -> (f64, f64) {
|
||||
let st = &t.runs[run].style;
|
||||
let origin = t.xf * Point::ZERO;
|
||||
((t.xf * Point::new(st.size * st.h_scale / 100.0, 0.0) - origin).hypot(), (t.xf * Point::new(0.0, st.size * st.v_scale / 100.0) - origin).hypot())
|
||||
}
|
||||
|
||||
fn near(actual: f64, expected: f64) {
|
||||
assert!((actual - expected).abs() < 1e-8, "{actual} != {expected}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scaled_font_size_setter_sets_document_points_and_undoes() {
|
||||
let mut s = session();
|
||||
let id = create(&mut s, 12.0);
|
||||
s.execute("object.scale", &json!({"sx": 200})).unwrap();
|
||||
s.execute("object.scale", &json!({"sx": 150})).unwrap();
|
||||
let before = text(&s, id);
|
||||
near(em(&before, 0).1, 36.0);
|
||||
s.execute("text.setStyle", &json!({"size": 10})).unwrap();
|
||||
let after = text(&s, id);
|
||||
near(em(&after, 0).1, 10.0);
|
||||
assert_eq!(after.xf, before.xf, "font editing preserves placement and rotation");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(text(&s, id), before);
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
assert_eq!(text(&s, id), after);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn group_font_size_setter_handles_different_accumulated_scales() {
|
||||
let mut s = session();
|
||||
let a = create(&mut s, 12.0);
|
||||
s.execute("object.scale", &json!({"sx": 200})).unwrap();
|
||||
let b = create(&mut s, 18.0);
|
||||
s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap();
|
||||
s.execute("object.group", &json!({})).unwrap();
|
||||
s.execute("object.group", &json!({})).unwrap();
|
||||
s.execute("object.scale", &json!({"sx": 200})).unwrap();
|
||||
near(em(&text(&s, a), 0).1, 48.0);
|
||||
near(em(&text(&s, b), 0).1, 36.0);
|
||||
s.execute("text.setStyle", &json!({"size": 10})).unwrap();
|
||||
near(em(&text(&s, a), 0).1, 10.0);
|
||||
near(em(&text(&s, b), 0).1, 10.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scaled_range_size_and_keyboard_steps_use_document_points() {
|
||||
let mut s = session();
|
||||
let id = create(&mut s, 12.0);
|
||||
s.execute("object.transform", &json!({"matrix": [2, 0, 0, 2, 0, 0]})).unwrap();
|
||||
s.execute("text.setRangeStyle", &json!({"id": id.0, "start": 0, "end": 4, "size": 10})).unwrap();
|
||||
near(em(&text(&s, id), 0).1, 10.0);
|
||||
near(em(&text(&s, id), 1).1, 24.0);
|
||||
s.prefs.type_size_increment = 2.0;
|
||||
s.execute("type.step", &json!({"attribute": "size", "id": id.0, "start": 0, "end": 4})).unwrap();
|
||||
near(em(&text(&s, id), 0).1, 12.0);
|
||||
near(em(&text(&s, id), 1).1, 24.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn horizontal_scale_reset_removes_object_stretch_including_groups() {
|
||||
for grouped in [false, true] {
|
||||
let mut s = session();
|
||||
let id = create(&mut s, 12.0);
|
||||
if grouped {
|
||||
s.execute("object.group", &json!({})).unwrap();
|
||||
}
|
||||
s.execute("object.scale", &json!({"sx": 200, "sy": 100})).unwrap();
|
||||
let before = text(&s, id);
|
||||
s.execute("text.setFormat", &json!({"hScale": 100, "vScale": 100})).unwrap();
|
||||
let after = text(&s, id);
|
||||
near(em(&after, 0).0, 12.0);
|
||||
near(em(&after, 0).1, 12.0);
|
||||
assert_eq!(after.xf, before.xf);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rotated_reflected_nonuniform_range_edits_preserve_the_affine() {
|
||||
let mut s = session();
|
||||
let id = create(&mut s, 12.0);
|
||||
// A quarter turn, reflected in the baseline axis: horizontal ×2, vertical ×3.
|
||||
s.execute("object.transform", &json!({"matrix": [0, -2, -3, 0, 0, 0]})).unwrap();
|
||||
let before = text(&s, id);
|
||||
s.execute("text.setRangeStyle", &json!({"id": id.0, "size": 10, "hScale": 100, "vScale": 100})).unwrap();
|
||||
let after = text(&s, id);
|
||||
near(em(&after, 0).0, 10.0);
|
||||
near(em(&after, 0).1, 10.0);
|
||||
assert_eq!(after.xf, before.xf);
|
||||
// The native document representation retains both the compensation and the affine.
|
||||
let bytes = vectorcraft_format::save_file(&s.doc().unwrap().doc);
|
||||
let reopened = vectorcraft_format::load(&bytes).unwrap();
|
||||
let NodeKind::Text(t) = &reopened.node(id).unwrap().kind else { panic!("not text") };
|
||||
near(em(t, 0).0, 10.0);
|
||||
near(em(t, 0).1, 10.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effective_range_query_leaves_raw_runs_available_for_clipboard() {
|
||||
let mut s = session();
|
||||
let id = create(&mut s, 12.0);
|
||||
s.execute("object.scale", &json!({"sx": 300, "sy": 200})).unwrap();
|
||||
let query = |s: &mut Session, effective| -> Value {
|
||||
s.execute("text.getRange", &json!({"id": id.0, "effective": effective})).unwrap()["runs"][0]["style"].clone()
|
||||
};
|
||||
let effective = query(&mut s, true);
|
||||
assert_eq!(effective["size"], 24.0);
|
||||
assert_eq!(effective["h_scale"], 150.0);
|
||||
let raw = query(&mut s, false);
|
||||
assert_eq!(raw["size"], 12.0);
|
||||
assert_eq!(raw["h_scale"], 100.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scaled_point_units_and_small_sizes_are_converted_before_local_storage() {
|
||||
let mut s = session();
|
||||
let id = create(&mut s, 12.0);
|
||||
s.execute("object.scale", &json!({"sx": 200})).unwrap();
|
||||
s.execute("text.setStyle", &json!({"size": 0.1, "leading": 14})).unwrap();
|
||||
let t = text(&s, id);
|
||||
near(em(&t, 0).1, 0.1);
|
||||
near(t.runs[0].style.leading.unwrap() * 2.0, 14.0);
|
||||
s.execute("text.setRangeStyle", &json!({"id": id.0, "size": 12, "baselineShift": 4, "leading": 16})).unwrap();
|
||||
let t = text(&s, id);
|
||||
near(t.runs[0].style.baseline_shift * 2.0, 4.0);
|
||||
near(t.runs[0].style.leading.unwrap() * 2.0, 16.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn singular_text_size_edit_returns_an_error_and_rolls_back_the_group() {
|
||||
let mut s = session();
|
||||
let a = create(&mut s, 12.0);
|
||||
let b = create(&mut s, 18.0);
|
||||
s.execute("object.transform", &json!({"matrix": [0, 0, 0, 0, 0, 0]})).unwrap();
|
||||
s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap();
|
||||
s.execute("object.group", &json!({})).unwrap();
|
||||
let before = s.doc().unwrap().doc.clone();
|
||||
let history = s.doc().unwrap().history.undo.len();
|
||||
assert!(s.execute("text.setStyle", &json!({"size": 10})).is_err());
|
||||
assert_eq!(s.doc().unwrap().doc, before);
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), history);
|
||||
assert!(s.execute("text.setRangeStyle", &json!({"id": b.0, "size": 10})).is_err());
|
||||
assert!(s.execute("text.setFormat", &json!({"id": b.0, "hScale": 100})).is_err());
|
||||
// Non-dimensional edits remain possible, including on imported collapsed text.
|
||||
s.execute("text.setStyle", &json!({"fill": "#123456"})).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sheared_text_keeps_its_affine_and_accepts_exact_em_dimensions() {
|
||||
let mut s = session();
|
||||
let id = create(&mut s, 12.0);
|
||||
s.execute("object.transform", &json!({"matrix": [2, 0, 1, 2, 0, 0]})).unwrap();
|
||||
let before = text(&s, id);
|
||||
s.execute("text.setRangeStyle", &json!({"id": id.0, "size": 10, "hScale": 100})).unwrap();
|
||||
let after = text(&s, id);
|
||||
near(em(&after, 0).0, 10.0);
|
||||
near(em(&after, 0).1, 10.0);
|
||||
assert_eq!(after.xf, before.xf);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn area_frame_resize_reflows_at_same_size_and_object_scaling_reports_scaled_size() {
|
||||
let mut s = session();
|
||||
let id = NodeId(
|
||||
s.execute("text.create", &json!({"x": 40, "y": 60, "text": "Area label", "size": 12, "area": {"width": 120, "height": 80}})).unwrap()["id"]
|
||||
.as_u64()
|
||||
.unwrap(),
|
||||
);
|
||||
s.execute("object.transform", &json!({"matrix": [2, 0, 0, 2, 0, 0], "typeAreas": true})).unwrap();
|
||||
near(em(&text(&s, id), 0).1, 12.0);
|
||||
s.execute("object.scale", &json!({"sx": 200})).unwrap();
|
||||
near(em(&text(&s, id), 0).1, 24.0);
|
||||
s.execute("text.setStyle", &json!({"size": 10})).unwrap();
|
||||
near(em(&text(&s, id), 0).1, 10.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn point_based_keyboard_increments_and_format_fields_follow_document_units() {
|
||||
let mut s = session();
|
||||
let id = create(&mut s, 12.0);
|
||||
s.execute("object.scale", &json!({"sx": 200})).unwrap();
|
||||
s.execute("text.setStyle", &json!({"leading": 14})).unwrap();
|
||||
s.execute("text.setFormat", &json!({"baselineShift": 4})).unwrap();
|
||||
s.prefs.type_size_increment = 2.0;
|
||||
s.prefs.baseline_shift_increment = 3.0;
|
||||
s.execute("type.step", &json!({"attribute": "leading"})).unwrap();
|
||||
s.execute("type.step", &json!({"attribute": "baselineShift"})).unwrap();
|
||||
let t = text(&s, id);
|
||||
near(t.runs[0].style.leading.unwrap() * 2.0, 16.0);
|
||||
near(t.runs[0].style.baseline_shift * 2.0, 7.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tiny_finite_transform_rejects_unsafe_compensation_without_history_changes() {
|
||||
let mut s = session();
|
||||
let id = create(&mut s, 12.0);
|
||||
s.execute("object.transform", &json!({"matrix": [1e-306, 0, 0, 1e-306, 0, 0]})).unwrap();
|
||||
let before = text(&s, id);
|
||||
let history = s.doc().unwrap().history.undo.len();
|
||||
let error = s.execute("text.setStyle", &json!({"size": 160})).unwrap_err();
|
||||
assert!(matches!(error, EngineError::BadParams { .. }), "reject before attempting unsafe layout: {error}");
|
||||
assert_eq!(text(&s, id), before);
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), history);
|
||||
}
|
||||
@@ -30,6 +30,51 @@ fn dark(s: &Session, x: u32, y: u32) -> bool {
|
||||
vectorcraft_render::Renderer::new().render(&doc, 200, 200, vectorcraft_geom::Affine::IDENTITY, &opts).pixel(x, y)[0] < 128
|
||||
}
|
||||
|
||||
/// Invalid entries in an explicit multi-point delete must not delete the valid entries.
|
||||
#[test]
|
||||
fn invalid_width_point_indices_leave_the_entire_profile_unchanged() {
|
||||
let (mut s, id) = line_session();
|
||||
set(&mut s, json!({"id": id.0, "t": 0.5, "left": 10, "right": 10}));
|
||||
let before = points(&s, id);
|
||||
let history = s.doc().unwrap().history.undo.len();
|
||||
for bad in [
|
||||
json!({"indices": [1, "bad"]}),
|
||||
json!({"indices": [-1, 1]}),
|
||||
json!({"indices": [1.5, 1]}),
|
||||
json!({"indices": {"point": 1}}),
|
||||
json!({"index": "bad"}),
|
||||
] {
|
||||
let mut params = bad.as_object().unwrap().clone();
|
||||
params.insert("id".into(), json!(id.0));
|
||||
let err = s.execute("stroke.widthPoint.remove", &serde_json::Value::Object(params));
|
||||
assert!(err.is_err(), "accepted malformed width point indices");
|
||||
assert_eq!(points(&s, id), before);
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), history);
|
||||
}
|
||||
s.execute("stroke.widthPoint.remove", &json!({"id": id.0, "indices": [1]})).unwrap();
|
||||
assert_eq!(points(&s, id).len(), before.len() - 1);
|
||||
}
|
||||
|
||||
/// Setting or copying a width point with a malformed `index` is refused like one out of range: it
|
||||
/// never adds a point instead, or wraps a huge index round. A null `index` is no index (a new point).
|
||||
#[test]
|
||||
fn a_malformed_width_point_index_is_refused_by_set_and_copy() {
|
||||
let (mut s, id) = line_session();
|
||||
set(&mut s, json!({"id": id.0, "t": 0.5, "left": 10, "right": 10}));
|
||||
let before = points(&s, id);
|
||||
let history = s.doc().unwrap().history.undo.len();
|
||||
for index in [json!("bad"), json!(-1), json!(1.5), json!([1]), json!(u64::MAX), json!(before.len())] {
|
||||
for cmd in ["stroke.widthPoint.set", "stroke.widthPoint.copy"] {
|
||||
let r = s.execute(cmd, &json!({"id": id.0, "index": index, "t": 0.3, "left": 4, "right": 4}));
|
||||
assert!(r.is_err(), "{cmd} took index {index}");
|
||||
assert_eq!(points(&s, id), before, "{cmd} with index {index}");
|
||||
}
|
||||
}
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), history);
|
||||
set(&mut s, json!({"id": id.0, "index": null, "t": 0.3, "left": 4, "right": 4}));
|
||||
assert_eq!(points(&s, id).len(), before.len() + 1, "a null index adds a point");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_point_moved_onto_another_makes_a_discontinuous_point_that_renders_a_step() {
|
||||
let (mut s, id) = line_session();
|
||||
|
||||
@@ -104,6 +104,37 @@ fn reflect_and_shear_tools() {
|
||||
assert!((b.width() - 150.0).abs() < 1e-6, "{b:?}");
|
||||
}
|
||||
|
||||
/// Reflect and Shear return an error for an `axis` they don't take (Reflect's names it and also
|
||||
/// takes an angle): nothing moves and no undo step is recorded. A null `axis` counts as not given: Reflect
|
||||
/// then reflects across the vertical axis and Shear shears along the horizontal one.
|
||||
#[test]
|
||||
fn reflect_and_shear_reject_unknown_axes_without_changing_state() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 10.0, 20.0, 30.0, 10.0);
|
||||
let n = undo_len(&s);
|
||||
let reflect = "axis must be \"vertical\", \"horizontal\" or an angle in degrees";
|
||||
let shear = "`axis` must be one of horizontal, vertical";
|
||||
for (cmd, p, msg) in [
|
||||
("object.reflect", json!({"axis": "diagonal", "origin": [0, 0]}), format!("{reflect}, not `diagonal`")),
|
||||
("object.reflect", json!({"axis": "45", "origin": [0, 0]}), format!("{reflect}, not `45`")),
|
||||
("object.reflect", json!({"axis": true, "origin": [0, 0]}), format!("{reflect}, not `true`")),
|
||||
("object.shear", json!({"angle": 30, "axis": "diagonal"}), shear.to_string()),
|
||||
("object.shear", json!({"angle": 30, "axis": 90}), shear.to_string()),
|
||||
] {
|
||||
let e = s.execute(cmd, &p).expect_err(&format!("{cmd} {p}"));
|
||||
assert_eq!(e.to_string(), format!("invalid parameters for `{cmd}`: {msg}"));
|
||||
}
|
||||
assert!(close(bounds(&s, a), Rect::new(10.0, 20.0, 40.0, 30.0)), "nothing moved: {:?}", bounds(&s, a));
|
||||
assert_eq!(undo_len(&s), n, "no undo step");
|
||||
s.execute("object.reflect", &json!({"axis": null, "origin": [0, 0]})).unwrap();
|
||||
assert!(close(bounds(&s, a), Rect::new(-40.0, 20.0, -10.0, 30.0)), "across the vertical axis: {:?}", bounds(&s, a));
|
||||
s.execute("object.reflect", &json!({"axis": 0, "origin": [0, 0]})).unwrap();
|
||||
assert!(close(bounds(&s, a), Rect::new(-40.0, -30.0, -10.0, -20.0)), "an angle still reflects: {:?}", bounds(&s, a));
|
||||
s.execute("object.shear", &json!({"angle": 45, "axis": null, "origin": [0, 0]})).unwrap();
|
||||
let b = bounds(&s, a);
|
||||
assert!((b.y0 + 30.0).abs() < 1e-6 && (b.y1 + 20.0).abs() < 1e-6 && b.width() > 30.0, "along the horizontal axis: {b:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn distort_command_maps_corners() {
|
||||
let mut s = session();
|
||||
@@ -596,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]]);
|
||||
}
|
||||
@@ -81,23 +81,6 @@ fn every_command_without_a_document() {
|
||||
assert!(failures.is_empty(), "{}", failures.join("\n"));
|
||||
}
|
||||
|
||||
/// Known bugs (see the `#[ignore]` repro tests below): (command, param key) pairs skipped by the
|
||||
/// junk sweep so the rest of the sweep stays meaningful.
|
||||
const KNOWN_BUGS: &[(&str, &str)] = &[
|
||||
("object.scale", "sx"),
|
||||
("object.transformEach", "scaleH"),
|
||||
("object.transformEach", "scaleV"),
|
||||
("object.distributeSpacing", "spacing"),
|
||||
("artboard.setProps", "x"),
|
||||
("artboard.setProps", "y"),
|
||||
("artboard.setProps", "width"),
|
||||
("artboard.setProps", "height"),
|
||||
];
|
||||
|
||||
fn known_bug(id: &str, p: &Value) -> bool {
|
||||
KNOWN_BUGS.iter().any(|(c, k)| *c == id && p.get(*k).is_some())
|
||||
}
|
||||
|
||||
/// Regression: huge column counts used to panic with `capacity overflow`; in every layout and
|
||||
/// order (#681), with huge and negative spacing too.
|
||||
#[test]
|
||||
@@ -157,9 +140,6 @@ fn every_command_with_junk_params() {
|
||||
cases.truncate(12);
|
||||
}
|
||||
for p in &cases {
|
||||
if known_bug(c.id, p) {
|
||||
continue;
|
||||
}
|
||||
calls += 1;
|
||||
if let Some(f) = probe(fx, c.id, p) {
|
||||
failures.push(f);
|
||||
@@ -341,6 +321,9 @@ fn structured_junk() {
|
||||
("object.setBounds", json!({"width": 0, "height": 0})),
|
||||
("object.setBounds", json!({"width": -10, "height": 1e308, "reference": 99})),
|
||||
("object.distributeSpacing", json!({"axis": "horizontal", "spacing": -1e308})),
|
||||
("object.distributeSpacing", json!({"axis": "vertical", "spacing": 0})),
|
||||
("object.align", json!({"horizontal": "center", "vertical": "center", "to": "artboard", "bounds": "preview"})),
|
||||
("object.distribute", json!({"vertical": "center", "bounds": "geometric"})),
|
||||
("artboard.setProps", json!({"index": 0, "width": -1, "height": 0})),
|
||||
("artboard.setProps", json!({"index": 0, "width": 1e300, "height": 1e-300, "scaleArt": true, "strokes": true, "corners": true})),
|
||||
("artboard.setProps", json!({"index": 0, "x": -1e308, "width": 0, "height": 1, "scaleArt": true, "patterns": true})),
|
||||
@@ -494,9 +477,6 @@ fn fuzzed_calls_keep_documents_serializable() {
|
||||
let k = if k == "path" { continue } else { k };
|
||||
let mut s = Fixture::Multi.session();
|
||||
let p = json!({ k.clone(): v });
|
||||
if known_bug(c.id, &p) {
|
||||
continue;
|
||||
}
|
||||
let ok = catch_quiet(|| s.execute(c.id, &p)).map(|r| r.is_ok()).unwrap_or(false);
|
||||
if ok && let Err(e) = catch_quiet(|| check_all(&mut s)).unwrap_or_else(|m| Err(format!("panic in checks: {m}"))) {
|
||||
failures.push(format!("{} {p}: {e}", c.id));
|
||||
|
||||
@@ -27,7 +27,7 @@ use std::io::Read as _;
|
||||
use super::lex::find;
|
||||
use crate::ps;
|
||||
|
||||
pub use read::{Structure, read};
|
||||
pub use read::{Structure, artboards, read};
|
||||
pub(crate) use read::{slot_frame, slot_of};
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
//!
|
||||
//! - Colours: `g`/`G` grey (1 is white), `k`/`K` CMYK, `x`/`X` a named CMYK colour at a tint
|
||||
//! (`c m y k (name) tint`; tint 0 is the full colour), `Xa`/`XA` RGB (`c m y k r g b`, the CMYK
|
||||
//! values its equivalent), `Xx`/`XX` a named colour of either model
|
||||
//! (`c m y k [r g b] (name) tint type`), `Xk`/`XK` a global process colour (its operands as
|
||||
//! `Xx`'s: a global swatch that isn't an ink of its own). Lower case paints fills, upper case
|
||||
//! strokes.
|
||||
//! values its equivalent), `Xx`/`XX` a named colour of any model
|
||||
//! (`c m y k [r g b | L a b] (name) tint type`, type 0 CMYK, 1 RGB, 2 Lab), `Xk`/`XK` a global
|
||||
//! process colour (its operands as `Xx`'s: a global swatch that isn't an ink of its own). Lower
|
||||
//! case paints fills, upper case strokes.
|
||||
//! - Gradient definitions: `(name) type stops Bd`, its stops as `colour style [opacity 6]
|
||||
//! midpoint ramp Bs` (style 0 grey, 1 CMYK, 2 RGB with its CMYK, 3 and 4 named colours), `BD`.
|
||||
//! - A gradient on an object: `[flag] Bb` (1: the stroke), `flag (name) x y angle length a b c d
|
||||
@@ -57,8 +57,8 @@ pub(super) fn color_op(op: &str, vals: &[V]) -> Option<(Paint, Option<Named>)> {
|
||||
}
|
||||
}
|
||||
|
||||
/// `c m y k (name) tint x` or `c m y k [r g b] (name) tint type Xx` (type 1: RGB); `rgb` forces
|
||||
/// the model.
|
||||
/// `c m y k (name) tint x` or `c m y k [r g b | L a b] (name) tint type Xx` (type 1: RGB, 2: Lab);
|
||||
/// `rgb` forces RGB or CMYK.
|
||||
fn named(vals: &[V], rgb: Option<bool>) -> Option<(Paint, Option<Named>)> {
|
||||
let at = vals.iter().rposition(|v| matches!(v, V::Str(_)))?;
|
||||
let name = vals.get(at)?.text()?;
|
||||
@@ -67,9 +67,14 @@ fn named(vals: &[V], rgb: Option<bool>) -> Option<(Paint, Option<Named>)> {
|
||||
let unit = |v: f32| v.clamp(0.0, 1.0);
|
||||
let tint = 1.0 - unit(*after.first()?);
|
||||
let n = before.len();
|
||||
let color = if rgb.unwrap_or(after.get(1).is_some_and(|t| *t == 1.0)) {
|
||||
let kind = after.get(1).copied();
|
||||
let color = if rgb.unwrap_or(kind == Some(1.0)) {
|
||||
let [r, g, b] = before.get(n.checked_sub(3)?..)? else { return None };
|
||||
Color::rgb(unit(*r), unit(*g), unit(*b))
|
||||
} else if rgb.is_none() && kind == Some(2.0) {
|
||||
// Type 2: a colour defined in Lab, its L a b values after the CMYK equivalent (#1032).
|
||||
let [l, a, b] = before.get(n.checked_sub(3)?..)? else { return None };
|
||||
Color::lab(l.clamp(0.0, 100.0), a.clamp(-128.0, 127.0), b.clamp(-128.0, 127.0))
|
||||
} else {
|
||||
// The CMYK values come first (an RGB equivalent may follow them).
|
||||
let from = if n >= 7 { n - 7 } else { n.checked_sub(4)? };
|
||||
@@ -266,6 +271,16 @@ mod tests {
|
||||
v.extend([V::Str(b"Orange".to_vec()), V::Num(0.0), V::Num(1.0)]);
|
||||
let (p, _) = color_op("Xk", &v).unwrap();
|
||||
assert_eq!(p.color(), Some(Color::rgb(1.0, 0.5, 0.0)));
|
||||
// A spot colour defined in Lab (type 2): its L a b values, not its CMYK equivalent (#1032).
|
||||
let mut v = nums(&[0.52, 0.98, 0.28, 0.1, 32.9412, 45.0, -18.0]);
|
||||
v.extend([V::Str(b"Plum Ink".to_vec()), V::Num(0.0), V::Num(2.0)]);
|
||||
let (p, named) = color_op("Xx", &v).unwrap();
|
||||
let named = named.unwrap();
|
||||
assert_eq!((named.color, named.spot, named.tint), (Color::lab(32.9412, 45.0, -18.0), true, 1.0));
|
||||
assert_eq!(p.color(), Some(Color::lab(32.9412, 45.0, -18.0)));
|
||||
v[8] = V::Num(0.25);
|
||||
let (_, named) = color_op("XX", &v).unwrap();
|
||||
assert_eq!(named.map(|n| (n.color, n.tint)), Some((Color::lab(32.9412, 45.0, -18.0), 0.75)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -106,6 +106,15 @@ pub fn read(data: &[u8]) -> Result<Structure, String> {
|
||||
r.finish()
|
||||
}
|
||||
|
||||
/// The artboards (art space) of the editing data `data` that [`read`] can't read as a whole, as far
|
||||
/// as it gets: where to put the pages of its PDF part instead (#1068).
|
||||
pub fn artboards(data: &[u8]) -> Vec<Rect> {
|
||||
let mut r = Reader::new(data);
|
||||
// Artboards read before an error still say where they are.
|
||||
let _ = r.run();
|
||||
r.artboards.into_iter().map(|(_, rect)| rect).collect()
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct GState {
|
||||
fill: Paint,
|
||||
|
||||
@@ -339,6 +339,12 @@ fn turn(a: f64, b: f64) -> f64 {
|
||||
if d > std::f64::consts::PI { d - std::f64::consts::TAU } else { d }
|
||||
}
|
||||
|
||||
/// The angle from `a` counterclockwise to `b` (degrees, 0 to 360), worked out from what is left
|
||||
/// of each after whole turns.
|
||||
fn ccw_turn(a: f64, b: f64) -> f64 {
|
||||
(b % 360.0 - a % 360.0).rem_euclid(360.0)
|
||||
}
|
||||
|
||||
impl Interp<'_> {
|
||||
/// User space → document space.
|
||||
pub fn xf(&self) -> Affine {
|
||||
@@ -436,18 +442,19 @@ impl Interp<'_> {
|
||||
|
||||
/// `arc` / `arcn`: from angle `a1` to `a2` (degrees, counter-clockwise or clockwise).
|
||||
fn arc(&mut self, c: Point, r: f64, a1: f64, a2: f64, ccw: bool) -> Res {
|
||||
let (a1, mut a2) = (a1.to_radians(), a2.to_radians());
|
||||
let tau = std::f64::consts::TAU;
|
||||
if ccw {
|
||||
while a2 < a1 {
|
||||
a2 += tau;
|
||||
}
|
||||
// `arc` raises `a2` by multiples of 360 until it is at least `a1`, and `arcn` lowers it
|
||||
// until it is at most `a1` (PLRM 3rd ed., `arc` and `arcn` in chapter 8). Those sweeps,
|
||||
// and the first endpoint, are worked out from what is left of each angle after whole turns.
|
||||
let sweep = if ccw && a2 < a1 {
|
||||
ccw_turn(a1, a2)
|
||||
} else if !ccw && a2 > a1 {
|
||||
-ccw_turn(a2, a1)
|
||||
} else {
|
||||
while a2 > a1 {
|
||||
a2 -= tau;
|
||||
}
|
||||
}
|
||||
let sweep = (a2 - a1).clamp(-tau * 4.0, tau * 4.0);
|
||||
a2 - a1
|
||||
};
|
||||
let tau = std::f64::consts::TAU;
|
||||
let sweep = sweep.to_radians().clamp(-tau * 4.0, tau * 4.0);
|
||||
let a1 = (a1 % 360.0).to_radians();
|
||||
let start = c + Vec2::new(r * a1.cos(), r * a1.sin());
|
||||
self.append_user(std::iter::once(PathEl::MoveTo(start)))?;
|
||||
let arc = kurbo::Arc::new(c, Vec2::new(r, r), a1, sweep, 0.0);
|
||||
|
||||
@@ -624,6 +624,17 @@ fn differ(p: &[u8; 4], q: &[u8; 4]) -> bool {
|
||||
lightness(p).abs_diff(lightness(q)) > SAME_PIXEL || (a.min(b) <= WHITE && a.abs_diff(b) > SAME_PIXEL)
|
||||
}
|
||||
|
||||
/// How far apart two pixels' red, green or blue may be for them to be the same colour (a colour
|
||||
/// and its CMYK equivalent stay within it; a red and a dark red don't).
|
||||
const SAME_COLOUR: u32 = 64;
|
||||
|
||||
/// Are two pixels different colours ([`differ`] also takes a red and a dark red of about the same
|
||||
/// lightness to be alike)?
|
||||
fn differ_in_colour(p: &[u8; 4], q: &[u8; 4]) -> bool {
|
||||
let (p, q) = (on_white(p), on_white(q));
|
||||
p.iter().zip(q).any(|(a, b)| a.abs_diff(b) > SAME_COLOUR)
|
||||
}
|
||||
|
||||
/// How much two documents draw differently: the share (0 to 1) of the pixels that differ, and of
|
||||
/// those whose neighbours all differ too, away from type (where one draws an object that the other
|
||||
/// doesn't, not the edges of one drawn a little differently, or type laid out a little
|
||||
@@ -672,6 +683,20 @@ impl PageView {
|
||||
type_areas(&b.layers, rb, self.scale, &mut areas);
|
||||
compared(&self.page, &y, &areas)
|
||||
}
|
||||
|
||||
/// The share (0 to 1) of the pixels where `b` on `rb` draws another colour than the page
|
||||
/// ([`differ_in_colour`]), type included: what taking an object out of the layers changes
|
||||
/// there, when it is covered by one of about the same lightness (#1102).
|
||||
fn colour_difference(&mut self, b: &Document, rb: Rect) -> f64 {
|
||||
let printed = as_printed(b);
|
||||
let b = printed.as_ref().unwrap_or(b);
|
||||
let y = self.layers.render_region(b, rb, self.scale, false);
|
||||
if (y.width, y.height) != (self.page.width, self.page.height) {
|
||||
return 1.0;
|
||||
}
|
||||
let differing = self.page.pixels.as_chunks::<4>().0.iter().zip(y.pixels.as_chunks::<4>().0).filter(|(p, q)| differ_in_colour(p, q)).count();
|
||||
differing as f64 / (f64::from(y.width) * f64::from(y.height)).max(1.0)
|
||||
}
|
||||
}
|
||||
|
||||
/// How `a` on `ra` and `b` on `rb` (rectangles of the same size) draw differently ([`compared`]),
|
||||
@@ -823,6 +848,14 @@ fn build(data: &[u8], visible: Option<&Document>, page: Page, outlined: bool) ->
|
||||
let artboard_page =
|
||||
artboard.is_some_and(|a| [a.x0 - art_box.x0, a.y0 - art_box.y0, a.x1 - art_box.x1, a.y1 - art_box.y1].iter().all(|d| d.abs() <= 1.0));
|
||||
let whole = matches!(page, Page::Art) && !artboard_page;
|
||||
if whole {
|
||||
// Its page is the file's own (its bounding box), as when it comes in as its page: art
|
||||
// reaching past the editing data's artboard stays on it (#1102).
|
||||
doc.artboards.truncate(1);
|
||||
if let Some(a) = doc.artboards.first_mut() {
|
||||
a.rect = rect;
|
||||
}
|
||||
}
|
||||
frame = Some(Frame { page: visible, page_rect, rect, whole });
|
||||
}
|
||||
let mut infos = vec![];
|
||||
@@ -940,7 +973,7 @@ fn prune_unseen(frame: &Frame<'_>, view: &mut PageView, doc: &mut Document) -> u
|
||||
if found.is_empty() || found.len() > MAX_WEIGHED {
|
||||
return 0;
|
||||
}
|
||||
let mut with = view.difference(doc, rect).any;
|
||||
let with = view.difference(doc, rect).any;
|
||||
// Taking a text object out changes only what is drawn in its box. When the art would still
|
||||
// differ from the page by more than `compare` allows with every candidate gone, the layers
|
||||
// aren't used whatever is pruned: don't draw them again for each one (#758).
|
||||
@@ -949,6 +982,9 @@ fn prune_unseen(frame: &Frame<'_>, view: &mut PageView, doc: &mut Document) -> u
|
||||
if with - boxes / rect.area().max(1e-9) > MAX_DIFFERENCE {
|
||||
return 0;
|
||||
}
|
||||
// Weighed by colour, not only lightness: a text object's red stroke over a dark red one is no
|
||||
// less there for being about as light (#1102).
|
||||
let mut with = view.colour_difference(doc, rect);
|
||||
let mut removed = 0;
|
||||
for (id, bounds) in found {
|
||||
if bounds.is_none_or(|b| b.intersect(rect).is_zero_area()) {
|
||||
@@ -958,7 +994,7 @@ fn prune_unseen(frame: &Frame<'_>, view: &mut PageView, doc: &mut Document) -> u
|
||||
if !take_out(&mut without.layers, id, true) {
|
||||
continue;
|
||||
}
|
||||
let d = view.difference(&without, rect).any;
|
||||
let d = view.colour_difference(&without, rect);
|
||||
if d - with <= UNSEEN_MARGIN {
|
||||
take_out(&mut doc.layers, id, false);
|
||||
with = d;
|
||||
@@ -1041,11 +1077,53 @@ pub fn layered_ai(private: &[u8], visible: Document, warnings: Vec<String>, outl
|
||||
Err(why) => {
|
||||
let mut all = warnings;
|
||||
all.push(format!("{LAYERS_UNREAD} from its editing data ({why}): they come from its PDF part"));
|
||||
let mut visible = visible;
|
||||
place_pages(&ai::artboards(&data), &mut visible);
|
||||
(visible, all)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The pages of `doc` (a `.ai` read as its PDF part: a page per artboard, in a row) moved, with
|
||||
/// the art on each, to where `boards` (the artboards of its editing data, in art space) are, so
|
||||
/// the artboards keep their layout when the layers can't be read (#1068). Left as they are unless
|
||||
/// there are as many artboards as pages, of the same sizes.
|
||||
fn place_pages(boards: &[Rect], doc: &mut Document) {
|
||||
let (Some(first), true) = (boards.first(), boards.len() == doc.artboards.len() && boards.len() > 1) else { return };
|
||||
if boards.iter().zip(&doc.artboards).any(|(b, a)| (b.width() - a.rect.width()).abs() > 0.5 || (b.height() - a.rect.height()).abs() > 0.5) {
|
||||
return;
|
||||
}
|
||||
// Art space (y up) → the document, the first artboard's top left at the origin, as `read` has it.
|
||||
let to_doc = Affine::new([1.0, 0.0, 0.0, -1.0, -first.x0, first.y1]);
|
||||
let pages: Vec<Rect> = doc.artboards.iter().map(|a| a.rect).collect();
|
||||
let moves: Vec<Vec2> = boards.iter().zip(&pages).map(|(b, page)| to_doc.transform_rect_bbox(*b).origin() - page.origin()).collect();
|
||||
for layer in &mut doc.layers {
|
||||
place_art(Arc::make_mut(layer), &pages, &moves);
|
||||
}
|
||||
for (a, v) in doc.artboards.iter_mut().zip(&moves) {
|
||||
a.rect = a.rect + *v;
|
||||
}
|
||||
}
|
||||
|
||||
/// The objects in `layer` (and its sublayers) moved by the move of the page they are on.
|
||||
fn place_art(layer: &mut Node, pages: &[Rect], moves: &[Vec2]) {
|
||||
let Some(children) = layer.children_mut() else { return };
|
||||
for child in children {
|
||||
if matches!(child.kind, NodeKind::Layer { .. }) {
|
||||
place_art(Arc::make_mut(child), pages, moves);
|
||||
continue;
|
||||
}
|
||||
let Some(r) = child.visual_bounds() else { continue };
|
||||
// The page it is on: the one with most of it (pages keep only the art that reaches into
|
||||
// them), else the nearest.
|
||||
let fit = |p: &Rect| (p.intersect(r).area().max(0.0), -(p.center() - r.center()).hypot());
|
||||
let Some((i, _)) = pages.iter().map(fit).enumerate().max_by(|(_, a), (_, b)| a.0.total_cmp(&b.0).then(a.1.total_cmp(&b.1))) else { continue };
|
||||
if let Some(v) = moves.get(i).filter(|v| **v != Vec2::ZERO) {
|
||||
Arc::make_mut(child).transform(Affine::translate(*v), false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A `.ai` saved without its PDF part (its pages show only a placeholder) from its editing copy
|
||||
/// alone (`private`, the joined `AIPrivateData` streams) → the document and the import's notes.
|
||||
pub fn ai_alone(private: &[u8]) -> Result<(Document, Vec<String>), String> {
|
||||
@@ -1079,6 +1157,47 @@ mod tests {
|
||||
use super::*;
|
||||
use vectorcraft_doc::{CharStyle, TextObject};
|
||||
|
||||
/// `doc` with a square ring round (20, 20)–(80, 80) (or at `at`), stroked `color` `width` wide,
|
||||
/// named `name`.
|
||||
fn ring(doc: &mut Document, name: Option<&str>, color: vectorcraft_color::Color, width: f64, at: Rect) {
|
||||
let stroke = vectorcraft_color::Paint::solid(color);
|
||||
let mut n = Node::path(
|
||||
doc.alloc_id(),
|
||||
vectorcraft_geom::shapes::rectangle(at),
|
||||
vectorcraft_doc::Appearance::basic(vectorcraft_color::Paint::None, stroke, width),
|
||||
);
|
||||
n.name = name.map(str::to_string);
|
||||
let layer = doc.layers[0].id;
|
||||
doc.insert(Some(layer), usize::MAX, n).unwrap();
|
||||
}
|
||||
|
||||
/// A text object's red stroke drawn over a dark red one of about the same lightness is drawn
|
||||
/// by the page: it isn't taken out as unseen (#1102). An object the page doesn't draw still is.
|
||||
#[test]
|
||||
fn a_stroke_over_one_as_light_is_not_taken_for_unseen() {
|
||||
let (dark, red) = (vectorcraft_color::Color::rgb(0.55, 0.0, 0.0), vectorcraft_color::Color::rgb(1.0, 0.0, 0.0));
|
||||
let square = Rect::new(20.0, 20.0, 80.0, 80.0);
|
||||
let mut page = Document::new(100.0, 100.0);
|
||||
ring(&mut page, None, dark, 12.0, square);
|
||||
ring(&mut page, None, red, 6.0, square);
|
||||
let mut layers = Document::new(100.0, 100.0);
|
||||
ring(&mut layers, Some(MADE), dark, 12.0, square);
|
||||
ring(&mut layers, Some(MADE), red, 6.0, square);
|
||||
ring(&mut layers, Some(MADE), red, 2.0, Rect::new(40.0, 40.0, 60.0, 60.0));
|
||||
let rect = page.artboards[0].rect;
|
||||
let frame = Frame { page: &page, page_rect: rect, rect, whole: false };
|
||||
let mut view = PageView::new(&frame);
|
||||
assert_eq!(prune_unseen(&frame, &mut view, &mut layers), 1, "only the ring the page doesn't draw");
|
||||
let widths: Vec<f64> = layers.layers[0].children().unwrap().iter().filter_map(|n| n.appearance.stroke().map(|s| s.width)).collect();
|
||||
assert_eq!(widths, [12.0, 6.0]);
|
||||
// Taken by lightness alone, the red and the dark red are alike.
|
||||
let px = |c: vectorcraft_color::Color| {
|
||||
let [r, g, b] = c.to_rgb().map(|v| (v * 255.0).round() as u8);
|
||||
[r, g, b, 255]
|
||||
};
|
||||
assert!(!differ(&px(dark), &px(red)) && differ_in_colour(&px(dark), &px(red)));
|
||||
}
|
||||
|
||||
fn piece(text: &str, x: f64, y: f64) -> Arc<Node> {
|
||||
Arc::new(Node::new(NodeId(0), NodeKind::Text(Box::new(TextObject::point(Point::new(x, y), text, CharStyle::default())))))
|
||||
}
|
||||
|
||||
@@ -640,6 +640,42 @@ fn hostile_executable_files_and_type_loops_are_refused() {
|
||||
blue("currentfile cvx exec currentfile flushfile 0 0 1 setrgbcolor");
|
||||
}
|
||||
|
||||
/// Reads `body` as [`read`] does, on a thread of its own, and fails the test when reading takes
|
||||
/// more than 20 s.
|
||||
fn read_soon(body: &str) -> crate::Imported {
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
let ps = eps(body);
|
||||
std::thread::spawn(move || tx.send(import(&ps)).ok());
|
||||
rx.recv_timeout(std::time::Duration::from_secs(20)).unwrap_or_else(|_| panic!("reading {body} did not finish")).unwrap()
|
||||
}
|
||||
|
||||
/// `arc` raises its second angle by multiples of 360 until it is at least the first, and `arcn`
|
||||
/// lowers it until it is at most the first (PLRM 3rd ed., `arc` and `arcn` in chapter 8), however
|
||||
/// large the angles: the arc runs between the angles' remainders after whole turns and ends at
|
||||
/// the second angle, the new current point.
|
||||
#[test]
|
||||
fn arcs_with_huge_angles_end_at_their_second_angle() {
|
||||
// `at(deg)` is the point at `deg` degrees on the circle of radius 10 around (50, 50), in
|
||||
// document space.
|
||||
let at = |deg: f64| Point::new(50.0 + 10.0 * deg.to_radians().cos(), 50.0 - 10.0 * deg.to_radians().sin());
|
||||
// Each program is listed with the remainders of its first and second angles after whole turns.
|
||||
for (body, first, second) in [
|
||||
("50 50 10 1e18 0 arc", 280.0, 0.0),
|
||||
("50 50 10 1e308 0 arc", 296.0, 0.0),
|
||||
("50 50 10 0 1e18 arcn", 0.0, 280.0),
|
||||
("50 50 10 0 1e308 arcn", 0.0, 296.0),
|
||||
] {
|
||||
let r = read_soon(&format!("newpath {body} currentpoint stroke 1 1 rectfill"));
|
||||
let o = objects(&r.document);
|
||||
assert_eq!(o.len(), 2, "{body}: {:?}", r.warnings);
|
||||
// The arc lies within one quarter of the circle, so its endpoints span its bounds.
|
||||
let (a, b) = (at(first), at(second));
|
||||
assert!(near(bounds(&o[0]), Rect::from_points(a, b)), "{body}: {:?}", bounds(&o[0]));
|
||||
// The second object is the square drawn at the current point.
|
||||
assert!(near(bounds(&o[1]), Rect::new(b.x, b.y - 1.0, b.x + 1.0, b.y)), "{body}: {:?}", bounds(&o[1]));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cshow_runs_its_procedure_for_each_character() {
|
||||
check("/n 0 def { pop pop pop /n n 1 add def } (abc) cshow n 3 eq");
|
||||
|
||||
@@ -237,6 +237,49 @@ fn an_ai_file_that_cant_be_read_through_its_editing_data_keeps_its_pdf_part() {
|
||||
assert!(w.iter().any(|w| w.contains("different page")), "{w:?}");
|
||||
}
|
||||
|
||||
/// A `.ai` whose layers can't be read comes in as its PDF part, a page per artboard in a row; its
|
||||
/// pages still go where its editing data puts its artboards, with their art (#1068).
|
||||
#[test]
|
||||
fn an_ai_file_read_as_its_pdf_part_keeps_its_artboard_layout() {
|
||||
// Two 100-point artboards, the second below the first (art space, y up).
|
||||
let board = |x0: i32, y0: i32| {
|
||||
format!(
|
||||
"%_/Dictionary :\n%_{x0} {} /RealPointRelToROrigin\n%_ (PositionPoint1) ,\n%_{} {y0} /RealPointRelToROrigin\n%_ (PositionPoint2) ,\n%_; ,\n",
|
||||
y0 + 100,
|
||||
x0 + 100
|
||||
)
|
||||
};
|
||||
let boards = |list: &[(i32, i32)]| {
|
||||
let all: String = list.iter().map(|(x, y)| board(*x, *y)).collect();
|
||||
format!("%_/Document :\n%_/Dictionary :\n%_/Array :\n{all}%_; (ArtboardArray) ,\n%_; /NotRecorded ,\n%_;\n")
|
||||
};
|
||||
// An operator this doesn't know, so the layers aren't read.
|
||||
let unread = |list: &[(i32, i32)]| native(&(boards(list) + &layer("Art", true, &(square(10, 10) + "\n1 Zz"))));
|
||||
// The PDF part: the pages side by side, 20 points apart, a square on each.
|
||||
let mut pdf = pdf_part(10.0, 10.0);
|
||||
let second = pdf_part(130.0, 10.0).layers[0].children().unwrap()[0].clone();
|
||||
let mut second = (*second).clone();
|
||||
second.id = pdf.alloc_id();
|
||||
let layer_id = pdf.layers[0].id;
|
||||
pdf.insert(Some(layer_id), 1, second).unwrap();
|
||||
let mut page = pdf.artboards[0].clone();
|
||||
(page.id, page.rect) = (2, vectorcraft_geom::Rect::new(120.0, 0.0, 220.0, 100.0));
|
||||
pdf.artboards.push(page);
|
||||
let squares = |d: &Document| d.layers[0].children().unwrap().iter().map(|n| n.visual_bounds().unwrap().origin()).collect::<Vec<_>>();
|
||||
let (d, w) = crate::layered_ai(&private(&unread(&[(0, 0), (0, -150)])), pdf.clone(), vec![], false);
|
||||
assert!(w.iter().any(|w| w.contains("layers weren't read")), "{w:?}");
|
||||
let rects: Vec<_> = d.artboards.iter().map(|a| a.rect).collect();
|
||||
assert_eq!(rects, [vectorcraft_geom::Rect::new(0.0, 0.0, 100.0, 100.0), vectorcraft_geom::Rect::new(0.0, 150.0, 100.0, 250.0)]);
|
||||
let moved = squares(&d);
|
||||
assert!((moved[0] - squares(&pdf)[0]).hypot() < 1e-6, "the first page's art stays: {moved:?}");
|
||||
assert!((moved[1] - vectorcraft_geom::Point::new(10.0, 160.0)).hypot() < 1e-6, "the second's goes with its page: {moved:?}");
|
||||
// Artboards that don't match the pages leave them in a row.
|
||||
for list in [&[(0, 0)][..], &[(0, 0), (0, -150), (200, 0)]] {
|
||||
let (d, _) = crate::layered_ai(&private(&unread(list)), pdf.clone(), vec![], false);
|
||||
assert_eq!(d.artboards[1].rect, pdf.artboards[1].rect, "{list:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_gray_is_a_grayscale_colour() {
|
||||
// `g` is a gray: 0.25 is 75 % of the black ink, as the page's own CMYK black says; the colour
|
||||
@@ -611,6 +654,20 @@ fn art_the_layers_print_outside_an_eps_page_of_its_art_makes_it_come_in_as_its_p
|
||||
assert!(r.warnings.is_empty(), "{:?}", r.warnings);
|
||||
}
|
||||
|
||||
/// An EPS whose page is its art's box keeps that page when it comes in through its layers, as it
|
||||
/// does as its page: not the editing data's artboard (#1102).
|
||||
#[test]
|
||||
fn an_eps_page_of_its_art_stays_its_page_through_the_layers() {
|
||||
let file = eps_of_art([10, 10, 14, 14], &page_square(10, 10), &(layer("Art", true, &square(10, 10)) + &layer("Far", false, &square(80, 80))));
|
||||
let r = import(&file).unwrap();
|
||||
assert_eq!(names(&r.document), ["Art", "Far"], "{:?}", r.warnings);
|
||||
let boards: Vec<_> = r.document.artboards.iter().map(|a| a.rect).collect();
|
||||
let art = r.document.layers[0].children().unwrap()[0].visual_bounds().unwrap();
|
||||
assert_eq!(boards.len(), 1);
|
||||
assert!((boards[0].width() - 4.0).abs() < 1e-6 && (boards[0].height() - 4.0).abs() < 1e-6, "{boards:?}");
|
||||
assert!(boards[0].inflate(1e-6, 1e-6).contains_rect(art), "{boards:?} round {art:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_eps_saved_as_its_artboard_keeps_the_art_outside_it() {
|
||||
// The page is the artboard (0 0 100 100): the art beyond it is the pasteboard's.
|
||||
|
||||
+183
-44
@@ -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;
|
||||
|
||||
@@ -138,7 +138,7 @@ pub fn tool_definitions() -> Vec<Value> {
|
||||
tool(
|
||||
"run_command",
|
||||
"Run command",
|
||||
"Execute any VectorCraft command by id with JSON params (see list_commands for ids and params). Examples: {\"command\":\"object.group\"}, {\"command\":\"object.align\",\"params\":{\"align\":\"left\"}}, {\"command\":\"file.new\",\"params\":{\"width\":800,\"height\":600}}. Returns the command's result (e.g. {id} for creation commands).",
|
||||
"Execute any VectorCraft command by id with JSON params (see list_commands for ids and params). Examples: {\"command\":\"object.group\"}, {\"command\":\"object.align\",\"params\":{\"horizontal\":\"left\"}}, {\"command\":\"file.new\",\"params\":{\"width\":800,\"height\":600}}. Returns the command's result (e.g. {id} for creation commands).",
|
||||
obj(
|
||||
json!({"command": string("Command id, e.g. shape.rectangle, object.group, paint.setFill"), "params": {"type": "object", "description": "Command parameters"}}),
|
||||
&["command"],
|
||||
|
||||
@@ -268,23 +268,42 @@ pub fn merge_regions(regions: &[&Region]) -> PathData {
|
||||
/// path, from the arrangement itself, made once for every merge of the set: the faces' contours
|
||||
/// share their edges exactly, so the edges between merged faces vanish. Uniting the faces' tidied
|
||||
/// outlines ([`merge_regions`]) could leave those edges in, as a hairline gap or a spur, where
|
||||
/// neighbouring faces' outlines had been refit apart. Shapes with open paths (faces lines cut)
|
||||
/// fall back to [`merge_regions`].
|
||||
/// neighbouring faces' outlines had been refit apart. Shapes with open paths (faces lines cut, as
|
||||
/// [`shape_builder`](crate::shape_builder) gives them) merge in their planar map the same way
|
||||
/// (#893). Either falls back to [`merge_regions`] when it can't place a face.
|
||||
pub struct FaceMerger {
|
||||
/// The arrangement (`None`: open paths, or the sweep failed) and the number of shapes.
|
||||
arr: Option<Arrangement>,
|
||||
shapes: usize,
|
||||
/// With open paths, the shapes, and their planar map once a merge has needed it.
|
||||
lines: Option<(Vec<Shape>, std::cell::OnceCell<Option<crate::planar::Map>>)>,
|
||||
}
|
||||
|
||||
impl FaceMerger {
|
||||
pub fn new(shapes: &[Shape]) -> Self {
|
||||
let has_open = shapes.iter().flat_map(|s| &s.path.subpaths).any(|sp| !sp.closed && sp.anchors.len() >= 2);
|
||||
Self { arr: if has_open { None } else { arrangement(shapes) }, shapes: shapes.len() }
|
||||
// The Shape Builder's faces come from the planar map only up to its cap.
|
||||
let segments: usize = shapes.iter().flat_map(|s| &s.path.subpaths).map(|sp| sp.segment_count()).sum();
|
||||
let lines = (has_open && segments <= crate::SHAPE_BUILDER_MAX_SEGMENTS).then(|| (shapes.to_vec(), std::cell::OnceCell::new()));
|
||||
Self { arr: if has_open { None } else { arrangement(shapes) }, shapes: shapes.len(), lines }
|
||||
}
|
||||
|
||||
/// The union of `faces`.
|
||||
pub fn merge(&self, faces: &[&Region]) -> PathData {
|
||||
self.exact(faces).filter(|p| !p.is_empty()).unwrap_or_else(|| merge_regions(faces))
|
||||
self.exact(faces).or_else(|| self.planar(faces)).filter(|p| !p.is_empty()).unwrap_or_else(|| merge_regions(faces))
|
||||
}
|
||||
|
||||
/// `faces` merged in the planar map of shapes with open paths: each face found again by a
|
||||
/// point inside it, and the result checked to cover what they cover.
|
||||
fn planar(&self, faces: &[&Region]) -> Option<PathData> {
|
||||
let (shapes, map) = self.lines.as_ref()?;
|
||||
let map = map.get_or_init(|| crate::planar::map(shapes)).as_ref()?;
|
||||
let inside: Vec<Point> = faces.iter().map(|r| crate::planar::interior_point(&r.path)).collect::<Option<_>>()?;
|
||||
let merged = map.merge(&inside)?;
|
||||
// A face not found as itself (two points in one face of the map) shows in the area.
|
||||
let want: f64 = faces.iter().map(|r| crate::area(&r.path, FillRule::NonZero)).sum();
|
||||
let outlines: f64 = faces.iter().map(|r| r.path.to_bezpath().perimeter(1e-6)).sum();
|
||||
((crate::area(&merged, FillRule::NonZero) - want).abs() <= 1e-9 * want.max(1.0) + crate::DEFAULT_PRECISION * outlines).then_some(merged)
|
||||
}
|
||||
|
||||
fn exact(&self, faces: &[&Region]) -> Option<PathData> {
|
||||
|
||||
+206
-17
@@ -149,6 +149,20 @@ struct Edge {
|
||||
source: usize,
|
||||
}
|
||||
|
||||
/// `segs` (a chain) moved to start at `a` and end at `b`, the handles next to each moving with it.
|
||||
fn pin_ends(segs: &mut [Seg], a: Point, b: Point) {
|
||||
if let Some(s) = segs.first_mut() {
|
||||
let d = a - s.c.p0;
|
||||
s.c.p0 = a;
|
||||
s.c.p1 += d;
|
||||
}
|
||||
if let Some(s) = segs.last_mut() {
|
||||
let d = b - s.c.p3;
|
||||
s.c.p3 = b;
|
||||
s.c.p2 += d;
|
||||
}
|
||||
}
|
||||
|
||||
/// Half-edge `2e` runs along edge `e`, `2e + 1` back along it.
|
||||
fn twin(h: usize) -> usize {
|
||||
h ^ 1
|
||||
@@ -465,7 +479,24 @@ fn span(sp: &SubPath, u0: f64, u1: f64) -> Option<SubPath> {
|
||||
segs_to_subpath(&segs, false)
|
||||
}
|
||||
|
||||
fn build(shapes: &[Shape]) -> Option<(Vec<Region>, Vec<Shape>)> {
|
||||
/// The planar graph of `shapes` (in the sweep's frame) with its faces walked: what [`build`] makes
|
||||
/// Live Paint faces and edges from, and what [`Map::merge`] merges faces in.
|
||||
pub(crate) struct Map {
|
||||
edges: Vec<Edge>,
|
||||
around: Vec<Vec<usize>>,
|
||||
slot_of: Vec<usize>,
|
||||
/// Bounded faces: their cycle of half-edges, path and area.
|
||||
faces: Vec<(Vec<usize>, BezPath, f64)>,
|
||||
/// Outer boundaries of connected pieces, and the face each one is a hole in.
|
||||
holes: Vec<(Vec<usize>, BezPath)>,
|
||||
face_holes: Vec<Vec<usize>>,
|
||||
tidy: Tidy,
|
||||
/// The inputs' anchors (sorted by x) and how close results snap back onto them.
|
||||
anchors: Vec<Point>,
|
||||
snap: f64,
|
||||
}
|
||||
|
||||
pub(crate) fn map(shapes: &[Shape]) -> Option<Map> {
|
||||
let mut inputs: Vec<(BezPath, usize)> = Vec::new();
|
||||
for (i, s) in shapes.iter().enumerate() {
|
||||
let mut bp = BezPath::new();
|
||||
@@ -508,11 +539,11 @@ fn build(shapes: &[Shape]) -> Option<(Vec<Region>, Vec<Shape>)> {
|
||||
.filter_map(|k| grid.get(&k))
|
||||
.flatten()
|
||||
.copied()
|
||||
.find(|&v| points.get(v).is_some_and(|q| q.distance(p) <= eps));
|
||||
.find_map(|v| points.get(v).filter(|q| q.distance(p) <= eps).map(|&q| (v, q)));
|
||||
near.unwrap_or_else(|| {
|
||||
points.push(p);
|
||||
grid.entry((cx, cy)).or_default().push(points.len() - 1);
|
||||
points.len() - 1
|
||||
(points.len() - 1, p)
|
||||
})
|
||||
};
|
||||
let mut edges: Vec<Edge> = Vec::new();
|
||||
@@ -520,11 +551,16 @@ fn build(shapes: &[Shape]) -> Option<(Vec<Region>, Vec<Shape>)> {
|
||||
let outlines = Outlines::new(&inputs, eps);
|
||||
for idx in top.segment_indices() {
|
||||
let half = idx.first_half();
|
||||
let segs: Vec<Seg> = positions[idx].path.segments().map(to_seg).collect();
|
||||
let ends = [vertex_id(top.point(half).to_kurbo()), vertex_id(top.point(idx.second_half()).to_kurbo())];
|
||||
if segs.is_empty() || ends[0] == ends[1] {
|
||||
let mut segs: Vec<Seg> = positions[idx].path.segments().map(to_seg).collect();
|
||||
let ((v0, p0), (v1, p1)) = (vertex_id(top.point(half).to_kurbo()), vertex_id(top.point(idx.second_half()).to_kurbo()));
|
||||
let ends = [v0, v1];
|
||||
if segs.is_empty() || v0 == v1 {
|
||||
continue;
|
||||
}
|
||||
// Every edge at a vertex starts or ends exactly on it, so faces meeting there share its
|
||||
// coordinates: merging them (a union) then cancels their common edge, which it can't when
|
||||
// each face carries its own rounding of the corner (#893).
|
||||
pin_ends(&mut segs, p0, p1);
|
||||
let Some(source) = mid(&segs).and_then(|m| outlines.source_at(m)) else { continue };
|
||||
// Coincident pieces the sweep left apart are one edge (the front-most input's).
|
||||
let key = (ends[0].min(ends[1]), ends[0].max(ends[1]));
|
||||
@@ -637,22 +673,89 @@ fn build(shapes: &[Shape]) -> Option<(Vec<Region>, Vec<Shape>)> {
|
||||
}
|
||||
}
|
||||
|
||||
let fills: Vec<Fill> = shapes.iter().map(Fill::new).collect();
|
||||
// The inputs' anchors, which results land on again up to the sweep's tolerance.
|
||||
let snap = 2.0 * eps;
|
||||
let mut anchors: Vec<Point> = shapes.iter().flat_map(|s| s.path.subpaths.iter().flat_map(|sp| sp.anchors.iter().map(|a| a.p))).collect();
|
||||
anchors.sort_by(|a, b| a.x.total_cmp(&b.x).then(a.y.total_cmp(&b.y)));
|
||||
Some(Map { edges, around, slot_of, faces, holes, face_holes, tidy, anchors, snap })
|
||||
}
|
||||
|
||||
impl Map {
|
||||
/// The union of the faces that hold `inside` (document points, one per face), traced along the
|
||||
/// map's own edges: those between two merged faces drop out exactly, where a boolean union of
|
||||
/// the faces' outlines can leave them in when its sweep rounds their shared corners apart
|
||||
/// (#893). Outer outlines come out positive, holes negative. None when a point is in no face.
|
||||
pub(crate) fn merge(&self, inside: &[Point]) -> Option<PathData> {
|
||||
let halves = 2 * self.edges.len();
|
||||
// The face on each half-edge's side; holes bound the face around them.
|
||||
let mut face_of: Vec<Option<usize>> = vec![None; halves];
|
||||
for (i, (cycle, ..)) in self.faces.iter().enumerate() {
|
||||
let hole_cycles = self.face_holes.get(i)?.iter().filter_map(|&k| self.holes.get(k)).map(|(c, _)| c);
|
||||
for &h in std::iter::once(cycle).chain(hole_cycles).flatten() {
|
||||
*face_of.get_mut(h)? = Some(i);
|
||||
}
|
||||
}
|
||||
let mut picked = vec![false; self.faces.len()];
|
||||
for &p in inside {
|
||||
let q = SHEAR * p;
|
||||
// The smallest face around it: faces of a piece inside a hole lie within the face around.
|
||||
let (i, _) = self
|
||||
.faces
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, (_, bp, _))| bp.bounding_box().contains(q) && bp.winding(q) != 0)
|
||||
.min_by(|a, b| a.1.2.total_cmp(&b.1.2))?;
|
||||
*picked.get_mut(i)? = true;
|
||||
}
|
||||
let merged = |h: usize| face_of.get(h).copied().flatten().is_some_and(|f| picked.get(f) == Some(&true));
|
||||
let boundary = |h: usize| merged(h) && !merged(twin(h));
|
||||
let mut used = vec![false; halves];
|
||||
let mut subpaths = Vec::new();
|
||||
for start in 0..halves {
|
||||
if !boundary(start) || used.get(start).copied().unwrap_or(true) {
|
||||
continue;
|
||||
}
|
||||
let mut cycle = vec![];
|
||||
let mut h = start;
|
||||
while !used.get(h).copied().unwrap_or(true) {
|
||||
if let Some(u) = used.get_mut(h) {
|
||||
*u = true;
|
||||
}
|
||||
cycle.push(h);
|
||||
// Turn from the way back as a face walk does, past the edges inside the merged
|
||||
// faces (between two of them, or a line's loose end), to the next boundary edge.
|
||||
let back = twin(h);
|
||||
let list = self.around.get(origin(&self.edges, back)?)?;
|
||||
let k = *self.slot_of.get(back)?;
|
||||
let n = list.len();
|
||||
h = (1..=n).filter_map(|j| list.get((k + n * 2 - j) % n).copied()).find(|&g| boundary(g))?;
|
||||
}
|
||||
if h != start {
|
||||
return None;
|
||||
}
|
||||
let segs: Vec<Seg> = cycle.iter().flat_map(|&h| half_segs(&self.edges, h)).collect();
|
||||
subpaths.push(segs_to_subpath(&tidy_segments(segs, true, &self.tidy), true)?);
|
||||
}
|
||||
(!subpaths.is_empty()).then(|| unshear(PathData::new(subpaths), &self.anchors, self.snap))
|
||||
}
|
||||
}
|
||||
|
||||
fn build(shapes: &[Shape]) -> Option<(Vec<Region>, Vec<Shape>)> {
|
||||
let m = map(shapes)?;
|
||||
let Map { edges, around, faces, holes, face_holes, tidy, anchors, snap, .. } = &m;
|
||||
let snap = *snap;
|
||||
let fills: Vec<Fill> = shapes.iter().map(Fill::new).collect();
|
||||
let subpath = |cycle: &[usize]| {
|
||||
let segs: Vec<Seg> = cycle.iter().flat_map(|&h| half_segs(&edges, h)).collect();
|
||||
segs_to_subpath(&tidy_segments(segs, true, &tidy), true)
|
||||
let segs: Vec<Seg> = cycle.iter().flat_map(|&h| half_segs(edges, h)).collect();
|
||||
segs_to_subpath(&tidy_segments(segs, true, tidy), true)
|
||||
};
|
||||
let regions = faces
|
||||
.iter()
|
||||
.zip(&face_holes)
|
||||
.zip(face_holes)
|
||||
.filter_map(|((cycle, _, _), hs)| {
|
||||
let mut subpaths = vec![subpath(cycle)?];
|
||||
subpaths.extend(hs.iter().filter_map(|&k| holes.get(k)).filter_map(|(c, _)| subpath(c)));
|
||||
let path = unshear(PathData::new(subpaths), &anchors, snap);
|
||||
let path = unshear(PathData::new(subpaths), anchors, snap);
|
||||
let sources =
|
||||
interior_point(&path).map(|p| fills.iter().enumerate().filter(|(_, f)| f.contains(p)).map(|(i, _)| i).collect()).unwrap_or_default();
|
||||
Some(Region { path, sources })
|
||||
@@ -680,13 +783,13 @@ fn build(shapes: &[Shape]) -> Option<(Vec<Region>, Vec<Shape>)> {
|
||||
return Some((segs, true, source));
|
||||
}
|
||||
*u = true;
|
||||
segs.extend(half_segs(&edges, h));
|
||||
let v = origin(&edges, twin(h))?;
|
||||
let Some((a, b)) = passes(v) else { return Some((segs, origin(&edges, h0) == Some(v), source)) };
|
||||
segs.extend(half_segs(edges, h));
|
||||
let v = origin(edges, twin(h))?;
|
||||
let Some((a, b)) = passes(v) else { return Some((segs, origin(edges, h0) == Some(v), source)) };
|
||||
h = if a == twin(h) { b } else { a };
|
||||
}
|
||||
};
|
||||
for v in 0..nv {
|
||||
for v in 0..around.len() {
|
||||
if passes(v).is_some() {
|
||||
continue;
|
||||
}
|
||||
@@ -709,8 +812,8 @@ fn build(shapes: &[Shape]) -> Option<(Vec<Region>, Vec<Shape>)> {
|
||||
let edge_shapes = chains
|
||||
.into_iter()
|
||||
.filter_map(|(segs, closed, source)| {
|
||||
let sp = segs_to_subpath(&tidy_segments(segs, closed, &tidy), closed)?;
|
||||
Some(Shape::new(unshear(PathData::new(vec![sp]), &anchors, snap), FillRule::NonZero, shapes.get(source)?.key))
|
||||
let sp = segs_to_subpath(&tidy_segments(segs, closed, tidy), closed)?;
|
||||
Some(Shape::new(unshear(PathData::new(vec![sp]), anchors, snap), FillRule::NonZero, shapes.get(source)?.key))
|
||||
})
|
||||
.collect();
|
||||
Some((regions, edge_shapes))
|
||||
@@ -913,6 +1016,92 @@ mod tests {
|
||||
assert_eq!(edges.len(), 1);
|
||||
}
|
||||
|
||||
/// A strip merged across an axonometric grid of lines (#893, the reporter's file): faces meeting
|
||||
/// at a vertex share its exact coordinates, so the merge is one outline with no edge left inside.
|
||||
#[test]
|
||||
fn faces_share_their_corners_exactly_so_merging_them_leaves_no_seam() {
|
||||
const LINES: &[[f64; 4]] = &[
|
||||
[416.57797027866155, 268.45362144397683, 416.5779702786616, 811.5463785560231],
|
||||
[243.12292300414873, 511.1134238473054, 713.4550472745127, 782.6598024033286],
|
||||
[836.8770769958512, 511.1134238473054, 366.5449527254871, 782.6598024033286],
|
||||
[457.718646852441, 268.45362144397683, 457.718646852441, 811.5463785560231],
|
||||
[263.6932612910385, 475.4845528055331, 734.0253855614023, 747.0309313615563],
|
||||
[816.3067387089613, 475.4845528055331, 345.9746144385975, 747.0309313615563],
|
||||
[498.85932342622044, 268.45362144397683, 498.85932342622044, 811.5463785560231],
|
||||
[284.26359957792823, 439.8556817637607, 754.5957238482922, 711.4020603197839],
|
||||
[795.7364004220717, 439.8556817637607, 325.40427615170773, 711.4020603197839],
|
||||
[539.9999999999998, 268.45362144397683, 539.9999999999998, 811.5463785560231],
|
||||
[304.83393786481787, 404.22681072198844, 775.1660621351818, 675.7731892780117],
|
||||
[775.1660621351821, 404.22681072198844, 304.833937864818, 675.7731892780117],
|
||||
[581.1406765737793, 268.45362144397683, 581.1406765737793, 811.5463785560231],
|
||||
[325.4042761517076, 368.59793968021614, 795.7364004220715, 640.1443182362394],
|
||||
[754.5957238482922, 368.59793968021614, 284.26359957792835, 640.1443182362394],
|
||||
[622.2813531475588, 268.45362144397683, 622.2813531475588, 811.5463785560231],
|
||||
[345.97461443859737, 332.9690686384438, 816.3067387089613, 604.515447194467],
|
||||
[734.0253855614026, 332.9690686384438, 263.6932612910385, 604.515447194467],
|
||||
[663.422029721338, 268.45362144397683, 663.422029721338, 811.5463785560231],
|
||||
[366.54495272548706, 297.3401975966715, 836.877076995851, 568.8865761526947],
|
||||
[713.4550472745128, 297.3401975966715, 243.12292300414884, 568.8865761526947],
|
||||
];
|
||||
let s: Vec<Shape> = LINES.iter().enumerate().map(|(i, &[x0, y0, x1, y1])| line((x0, y0), (x1, y1), i as u64)).collect();
|
||||
let (faces, _) = live_paint(&s);
|
||||
let corners: Vec<Point> = faces.iter().flat_map(|f| f.path.subpaths.iter().flat_map(|sp| sp.anchors.iter().map(|a| a.p))).collect();
|
||||
for p in &corners {
|
||||
for q in &corners {
|
||||
assert!(p == q || p.distance(*q) > 1e-6, "one corner, two positions: {p:?} {q:?}");
|
||||
}
|
||||
}
|
||||
// Down the strip between the lines x ≈ 498.9 and x = 540: 11 triangles, one outline.
|
||||
let mut strip: Vec<&Region> = vec![];
|
||||
for k in 0..=240 {
|
||||
if let Some(f) = faces.iter().find(|f| f.contains(Point::new(519.43, 420.0 + f64::from(k))))
|
||||
&& !strip.iter().any(|g| std::ptr::eq(*g, f))
|
||||
{
|
||||
strip.push(f);
|
||||
}
|
||||
}
|
||||
assert_eq!(strip.len(), 11);
|
||||
// As the Shape Builder merges them: in the map, so no sweep can round the corners apart.
|
||||
let merged = crate::FaceMerger::new(&s).merge(&strip);
|
||||
assert_eq!(merged.subpaths.len(), 1, "{merged:?}");
|
||||
let want: f64 = strip.iter().map(|f| area(f)).sum();
|
||||
assert!((crate::area(&merged, FillRule::NonZero) - want).abs() < 1e-6 * want);
|
||||
}
|
||||
|
||||
/// Merging faces of a grid of lines in the map: a ring keeps its hole, cells that only touch at
|
||||
/// a corner stay two outlines, a line's loose end inside the merge leaves no spur.
|
||||
#[test]
|
||||
fn faces_merge_in_the_map_with_holes_corners_and_loose_ends() {
|
||||
let mut s = vec![];
|
||||
for k in 0..4 {
|
||||
let v = f64::from(k) * 10.0;
|
||||
s.push(line((v, -5.0), (v, 35.0), 0));
|
||||
s.push(line((-5.0, v), (35.0, v), 1));
|
||||
}
|
||||
// A loose end poking into the middle cell.
|
||||
s.push(line((15.0, 10.0), (15.0, 15.0), 2));
|
||||
let (faces, _) = live_paint(&s);
|
||||
let cell = |x: f64, y: f64| at(&faces, x, y)[0];
|
||||
let merger = crate::FaceMerger::new(&s);
|
||||
let m = map(&s).unwrap();
|
||||
let check = |picked: &[&Region], subpaths: usize, area_want: f64| {
|
||||
// Merged in the map itself, not by the fallback union.
|
||||
let inside: Vec<Point> = picked.iter().map(|f| interior_point(&f.path).unwrap()).collect();
|
||||
assert_eq!(m.merge(&inside).as_ref(), Some(&merger.merge(picked)));
|
||||
let m = merger.merge(picked);
|
||||
assert_eq!(m.subpaths.len(), subpaths, "{m:?}");
|
||||
assert!((crate::area(&m, FillRule::NonZero) - area_want).abs() < 1e-6, "{}", crate::area(&m, FillRule::NonZero));
|
||||
m
|
||||
};
|
||||
let all: Vec<&Region> = faces.iter().collect();
|
||||
let whole = check(&all, 1, 900.0);
|
||||
assert_eq!(whole.subpaths[0].anchors.len(), 4, "one square, no anchors left from the inner lines or the loose end");
|
||||
let ring: Vec<&Region> =
|
||||
[(5., 5.), (15., 5.), (25., 5.), (5., 15.), (25., 15.), (5., 25.), (15., 25.), (25., 25.)].iter().map(|&(x, y)| cell(x, y)).collect();
|
||||
check(&ring, 2, 800.0);
|
||||
check(&[cell(5.0, 5.0), cell(15.0, 15.0)], 2, 200.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shape_builder_lines_cut_regions() {
|
||||
// A line across a rectangle: two regions, the line in three pieces keyed by its index.
|
||||
|
||||
@@ -389,12 +389,16 @@ fn alt_model(o: &Object<'_>) -> Option<Alt> {
|
||||
Object::Array(a) => {
|
||||
let mut it = a.flex_iter();
|
||||
match it.next::<Name<'_>>()?.as_str() {
|
||||
"ICCBased" => match it.next::<Stream<'_>>()?.dict().get::<u8>(b"N")? {
|
||||
1 => Some(Alt::Gray),
|
||||
3 => Some(Alt::Rgb),
|
||||
4 => Some(Alt::Cmyk),
|
||||
_ => None,
|
||||
},
|
||||
"ICCBased" => {
|
||||
let profile = it.next::<Stream<'_>>()?;
|
||||
match profile.dict().get::<u8>(b"N")? {
|
||||
1 => Some(Alt::Gray),
|
||||
3 if lab_profile(&profile) => Some(Alt::Lab),
|
||||
3 => Some(Alt::Rgb),
|
||||
4 => Some(Alt::Cmyk),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
"Lab" => Some(Alt::Lab),
|
||||
"CalRGB" => Some(Alt::Rgb),
|
||||
"CalGray" => Some(Alt::Gray),
|
||||
@@ -406,6 +410,27 @@ fn alt_model(o: &Object<'_>) -> Option<Alt> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Is the ICC profile of an `ICCBased` space a Lab one (its alternate space `Lab`, else its header's
|
||||
/// data colour space `Lab `)? Spot colours defined in Lab come with such a profile as their
|
||||
/// alternate space (#1032), and their tint transforms give Lab values.
|
||||
fn lab_profile(profile: &Stream<'_>) -> bool {
|
||||
use std::io::Read;
|
||||
if let Some(Object::Array(a)) = profile.dict().get::<Object<'_>>(b"Alternate") {
|
||||
return a.flex_iter().next::<Name<'_>>().is_some_and(|n| n.as_str() == "Lab");
|
||||
}
|
||||
// The header's first 20 bytes, without decoding the rest of the profile.
|
||||
let raw = profile.raw_data();
|
||||
let mut head = vec![];
|
||||
match profile.filters().as_slice() {
|
||||
[] => head.extend(raw.iter().take(20)),
|
||||
[hayro_syntax::Filter::FlateDecode] => {
|
||||
let _ = flate2::read::ZlibDecoder::new(&*raw).take(20).read_to_end(&mut head); // a damaged profile reads as not Lab
|
||||
}
|
||||
_ => return false,
|
||||
}
|
||||
head.get(16..20) == Some(b"Lab ")
|
||||
}
|
||||
|
||||
/// An alternate-space colour (the tint transform's output) as a VectorCraft colour.
|
||||
fn alt_color(alt: Alt, v: &[f32]) -> Option<Color> {
|
||||
let c = |i: usize| v.get(i).copied().filter(|x| x.is_finite());
|
||||
|
||||
@@ -136,6 +136,8 @@ pub(crate) struct TextLine {
|
||||
/// The spaces read from gaps between glyphs: their byte offset in the text, and the gap from
|
||||
/// the end of the glyph before to the start of the next (points).
|
||||
gaps: Vec<(usize, f64)>,
|
||||
/// Each glyph's advance in the file's font (points), with [`Self::baseline`].
|
||||
advances: Vec<f64>,
|
||||
}
|
||||
|
||||
/// What a finished line of type tells beyond its text object (see [`crate::import_lines`]).
|
||||
@@ -168,6 +170,7 @@ impl TextLine {
|
||||
column: false,
|
||||
ink: vec![],
|
||||
gaps: vec![],
|
||||
advances: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -228,7 +231,8 @@ impl TextLine {
|
||||
&& at.dir.dot(self.at.dir) > 0.9995
|
||||
&& (at.slant - self.at.slant).abs() < 1e-3
|
||||
&& (at.origin - self.at.origin).dot(self.at.up()).abs() < size * 0.15
|
||||
&& gap > -size * 0.3
|
||||
// Proportional widths close CJK marks up by up to half an em.
|
||||
&& gap > -size * 0.6
|
||||
&& gap < size * 3.0;
|
||||
// Or on a curve: turned a little from the glyph before it, and starting about where
|
||||
// that one ends.
|
||||
@@ -263,6 +267,7 @@ impl TextLine {
|
||||
self.last = at.origin;
|
||||
self.last_dir = at.dir;
|
||||
self.baseline.push(at.origin);
|
||||
self.advances.push(advance);
|
||||
true
|
||||
}
|
||||
|
||||
@@ -329,6 +334,14 @@ impl TextLine {
|
||||
// The glyphs' tops lean along the baseline (+x), up being -y in the type's own space.
|
||||
t.xf = Affine::translate(self.at.origin.to_vec2()) * Affine::rotate(angle) * Affine::skew(-self.at.slant, 0.0);
|
||||
let db = vectorcraft_text::FontDb::global();
|
||||
// Glyphs spaced each their own way (character tightening, optical kerning) keep their
|
||||
// place: each character its own tracking.
|
||||
let own = (self.upright.is_none() && t.para.direction.is_none())
|
||||
.then(|| own_spacing(&t, &self.baseline, &self.advances, path.as_ref(), self.at.dir, self.at.size))
|
||||
.flatten();
|
||||
if let Some(own) = &own {
|
||||
t.runs = tracked_each(std::mem::take(&mut t.runs), own, self.at.size);
|
||||
}
|
||||
if let Some(path) = path {
|
||||
t.kind = TextKind::OnPath { path: vectorcraft_geom::PathData::from_bezpath(&path), start: 0.0, end: None };
|
||||
t.xf = Affine::IDENTITY;
|
||||
@@ -358,7 +371,7 @@ impl TextLine {
|
||||
let chars: usize = t.runs.iter().map(|r| r.text.chars().count()).sum();
|
||||
let size = self.at.size;
|
||||
let mut bounds = laid.bounds;
|
||||
if chars > 1 && length.is_finite() && (length - natural).abs() > size * 0.01 {
|
||||
if own.is_none() && chars > 1 && length.is_finite() && (length - natural).abs() > size * 0.01 {
|
||||
// The spaces read from gaps keep their own place only in text left in the file's order.
|
||||
let total: usize = t.runs.iter().map(|r| r.text.len()).sum();
|
||||
let gaps: Vec<(usize, f64)> =
|
||||
@@ -373,6 +386,59 @@ impl TextLine {
|
||||
}
|
||||
}
|
||||
|
||||
/// The space each glyph of `t` (laid out untracked) leaves after its advance to put the next one
|
||||
/// where the file did (points; the last glyph's is the one before it), when that varies by more
|
||||
/// than a fiftieth of an em: one tracking value can't place them. Glyph origins are `baseline`,
|
||||
/// measured along `path` (type on a path: one curve per glyph) or along `dir`. None when the
|
||||
/// glyphs can't be matched to the characters one to one (spaces, ligatures), or when the font
|
||||
/// laid out isn't the file's (its advances differ from `advances`): spacing measured against a
|
||||
/// stand-in's widths would keep the stand-in's differences once the font is installed.
|
||||
fn own_spacing(t: &TextObject, baseline: &[Point], advances: &[f64], path: Option<&BezPath>, dir: Vec2, size: f64) -> Option<Vec<f64>> {
|
||||
let text: String = t.runs.iter().map(|r| r.text.as_str()).collect();
|
||||
let chars = text.chars().count();
|
||||
if chars < 3 || text.contains(' ') || baseline.len() != chars || advances.len() != chars {
|
||||
return None;
|
||||
}
|
||||
let laid = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t);
|
||||
let advs: Vec<f64> = laid.glyphs.iter().map(|g| g.advance).collect();
|
||||
if advs.len() != chars || advs.iter().zip(advances).any(|(a, b)| (a - b).abs() > size * 0.01) {
|
||||
return None;
|
||||
}
|
||||
let dist: Vec<f64> = match path {
|
||||
Some(bp) => bp.segments().take(chars - 1).map(|seg| kurbo::ParamCurveArclen::arclen(&seg, 1e-3)).collect(),
|
||||
None => baseline.windows(2).map(|w| (w[1] - w[0]).dot(dir)).collect(),
|
||||
};
|
||||
if dist.len() != chars - 1 {
|
||||
return None;
|
||||
}
|
||||
let mut extra: Vec<f64> = dist.iter().zip(&advs).map(|(d, a)| d - a).collect();
|
||||
let mean = extra.iter().sum::<f64>() / extra.len() as f64;
|
||||
if extra.iter().all(|e| (e - mean).abs() <= size * 0.02) {
|
||||
return None;
|
||||
}
|
||||
extra.push(*extra.last()?);
|
||||
Some(extra)
|
||||
}
|
||||
|
||||
/// `runs` with each character's tracking from `own` (points, one per character), characters with
|
||||
/// the same tracking kept in one run.
|
||||
fn tracked_each(runs: Vec<TextRun>, own: &[f64], size: f64) -> Vec<TextRun> {
|
||||
let em = |pts: f64| (pts / size * 1000.0).clamp(-1000.0, 1000.0).round();
|
||||
let mut out: Vec<TextRun> = vec![];
|
||||
let mut k = 0;
|
||||
for r in runs {
|
||||
for c in r.text.chars() {
|
||||
let style = CharStyle { tracking: own.get(k).map_or(0.0, |e| em(*e)), ..r.style.clone() };
|
||||
k += 1;
|
||||
match out.last_mut() {
|
||||
Some(last) if last.style == style && last.inline == r.inline => last.text.push(c),
|
||||
_ => out.push(TextRun { text: c.to_string(), style, inline: r.inline.clone() }),
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The tracking (thousandths of an em) that sets a line of `chars` characters, laid out untracked
|
||||
/// as `laid`, `extra` points longer, as the file set it: the same after every character, except
|
||||
/// the spaces read from gaps (`gaps`: byte offset, gap in points) where that would put the glyphs
|
||||
|
||||
@@ -215,3 +215,33 @@ fn colour_spaces_are_read_from_the_interpreters_debug_form() {
|
||||
assert_eq!((space, comps), ("ColorSpace(DeviceCmyk)", vec![0.101960786, 0.2, 0.3, 0.4]));
|
||||
assert_eq!(crate::import_color::split_color("Color { color_space: X, components: [NaN], opacity: 1.0 }"), None);
|
||||
}
|
||||
|
||||
/// Spot colours defined in Lab can have an ICC Lab profile as their alternate space, `[/ICCBased
|
||||
/// profile]` with 3 components: the tint transform gives Lab values, which are not RGB (#1032). The
|
||||
/// profile is told by its `Alternate` (`[/Lab …]`), else by its header's data colour space.
|
||||
#[test]
|
||||
fn spot_colours_with_an_icc_lab_alternate_keep_their_lab_values() {
|
||||
let f = first_extra(1);
|
||||
// A profile's first 20 bytes: size, CMM, version, class, then the data colour space.
|
||||
let profile = |head: &str, alternate: &str| format!("<< /N 3 {alternate} /Length {} >>\nstream\n{head}\nendstream", head.len());
|
||||
let lab_header = profile("0000none0000spacLab XYZ ", "");
|
||||
let lab_alternate = profile("0000none0000spacXYZ XYZ ", "/Alternate [/Lab << /WhitePoint [0.9642 1 0.8249] /Range [-128 127 -128 127] >>]");
|
||||
let rgb = profile("0000none0000mntrRGB XYZ ", "");
|
||||
let tint = |c1: &str| format!("<< /FunctionType 2 /Domain [0 1] /C0 [100 0 0] /C1 [{c1}] /N 1 >>");
|
||||
let spaces = format!(
|
||||
"/CS0 [/Separation /Plum [/ICCBased {f} 0 R] {}] /CS1 [/Separation /Sky [/ICCBased {} 0 R] {}] /CS2 [/Separation /Leafy [/ICCBased {} 0 R] << /FunctionType 2 /Domain [0 1] /C0 [1 1 1] /C1 [0.5 0.9 0.1] /N 1 >>]",
|
||||
tint("32.9412 45 -18"),
|
||||
f + 1,
|
||||
tint("51 -26 -45"),
|
||||
f + 2
|
||||
);
|
||||
let page = page_with(&spaces, &[("CS0", "1"), ("CS1", "0.5"), ("CS2", "1")]);
|
||||
let d = import(&pdf_with(&[page], &[&lab_header, &lab_alternate, &rgb], None)).unwrap();
|
||||
let f = fills(&d);
|
||||
assert_eq!(solid(&f[0]).1, Some("Plum"));
|
||||
assert!(near(d.swatch("Plum").unwrap().paint.color().unwrap(), Color::lab(32.941, 45.0, -18.0)), "{:?}", d.swatch("Plum"));
|
||||
assert_eq!((solid(&f[1]).1, solid(&f[1]).2), (Some("Sky"), 0.5));
|
||||
assert!(near(d.swatch("Sky").unwrap().paint.color().unwrap(), Color::lab(51.0, -26.0, -45.0)), "{:?}", d.swatch("Sky"));
|
||||
// An RGB profile stays RGB.
|
||||
assert!(near(d.swatch("Leafy").unwrap().paint.color().unwrap(), Color::rgb(0.5, 0.9, 0.1)), "{:?}", d.swatch("Leafy"));
|
||||
}
|
||||
@@ -464,3 +464,12 @@ fn art_past_the_page_clip_keeps_the_clip() {
|
||||
assert!(!all(&r.document).iter().any(|n| matches!(n.kind, NodeKind::Group { clip: true, .. })));
|
||||
assert!(!r.warnings.iter().any(|w| w == crate::import::PAST_PAGE_NOTE), "{:?}", r.warnings);
|
||||
}
|
||||
|
||||
/// Glyphs closed up by up to half an em (the proportional widths of CJK punctuation) still
|
||||
/// continue their line.
|
||||
#[test]
|
||||
fn glyphs_closed_up_by_half_an_em_stay_one_line() {
|
||||
let t = texts(&open(&one_page("BT /F1 20 Tf 10 40 Td [(T) 400 (i) 450 (g) 0 (h) 0 (t)] TJ ET", HELVETICA, &[])));
|
||||
assert_eq!(t.len(), 1, "one line: {:?}", t.iter().map(|t| t.plain_text()).collect::<Vec<_>>());
|
||||
assert_eq!(t[0].plain_text(), "Tight");
|
||||
}
|
||||
@@ -329,3 +329,46 @@ fn type_in_another_installed_version_reopens_in_that_version() {
|
||||
assert_eq!(found, [("Hamburgefonstiv".to_string(), version.map(String::from))], "{version:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The glyph origins of the type objects of `d`, in document space, in order.
|
||||
fn origins(d: &Document) -> Vec<Point> {
|
||||
let mut out = vec![];
|
||||
d.walk(|n| {
|
||||
if let NodeKind::Text(t) = &n.kind {
|
||||
out.extend(vectorcraft_text::layout(FontDb::global(), t).glyphs.iter().map(|g| t.xf * g.origin));
|
||||
}
|
||||
});
|
||||
out
|
||||
}
|
||||
|
||||
/// Type spaced glyph by glyph (character tightening, optical kerning) comes back with each glyph
|
||||
/// in its place, not evened out by one tracking value; evenly spaced type keeps one run.
|
||||
#[test]
|
||||
fn glyphs_spaced_each_their_own_way_keep_their_place() {
|
||||
let runs = [("T", 0.0), ("i", -120.0), ("g", 160.0), ("h", -60.0), ("tl", 0.0), ("y", 0.0)];
|
||||
let mut t = TextObject::point(Point::new(20.0, 100.0), "", style(40.0));
|
||||
t.runs = runs.iter().map(|(s, tr)| TextRun { text: s.to_string(), style: CharStyle { tracking: *tr, ..style(40.0) }, inline: None }).collect();
|
||||
let d = doc(vec![t]);
|
||||
let want = origins(&d);
|
||||
let back = import_as(&pdf(&d, false).bytes, TextAs::default());
|
||||
let got = origins(&back);
|
||||
assert_eq!(got.len(), want.len(), "{:?}", texts(&back));
|
||||
for (g, w) in got.iter().zip(&want) {
|
||||
assert!((g.x - w.x).abs() < 0.1 && (g.y - w.y).abs() < 0.1, "{got:?}\nvs {want:?}");
|
||||
}
|
||||
let mut runs_back = 0;
|
||||
back.walk(|n| {
|
||||
if let NodeKind::Text(t) = &n.kind {
|
||||
runs_back += t.runs.len();
|
||||
}
|
||||
});
|
||||
assert!(runs_back > 1, "one tracking can't place them");
|
||||
// Evenly tracked: one run.
|
||||
let even = doc(vec![TextObject::point(Point::new(20.0, 100.0), "Tightly", CharStyle { tracking: -50.0, ..style(40.0) })]);
|
||||
let back = import_as(&pdf(&even, false).bytes, TextAs::default());
|
||||
back.walk(|n| {
|
||||
if let NodeKind::Text(t) = &n.kind {
|
||||
assert_eq!(t.runs.len(), 1, "{:?}", t.runs.iter().map(|r| r.style.tracking).collect::<Vec<_>>());
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -203,8 +203,17 @@ impl Manifest {
|
||||
let resolved = match spec {
|
||||
ParamSpec::Number { min, max, .. } => json!(v.as_f64().filter(|f| f.is_finite()).ok_or_else(|| wrong("a number"))?.clamp(*min, *max)),
|
||||
ParamSpec::Int { min, max, .. } => {
|
||||
let f = v.as_f64().filter(|f| f.is_finite()).ok_or_else(|| wrong("a number"))?;
|
||||
json!((f.round().clamp(*min as f64, *max as f64)) as i64)
|
||||
// Keep JSON integers exact: f64 cannot represent every i64 or its bounds.
|
||||
let value = if let Some(i) = v.as_i64() {
|
||||
i.clamp(*min, *max)
|
||||
} else if v.is_u64() {
|
||||
// Past i64::MAX, so past any maximum.
|
||||
*max
|
||||
} else {
|
||||
let f = v.as_f64().filter(|f| f.is_finite()).ok_or_else(|| wrong("a number"))?;
|
||||
(f.round() as i64).clamp(*min, *max)
|
||||
};
|
||||
json!(value)
|
||||
}
|
||||
ParamSpec::Bool { .. } => json!(v.as_bool().ok_or_else(|| wrong("true or false"))?),
|
||||
ParamSpec::Choice { options, .. } => {
|
||||
@@ -303,6 +312,29 @@ mod tests {
|
||||
assert!(m.resolve_params(&Value::Null).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn integer_parameters_preserve_precision_and_exact_bounds() {
|
||||
let m = Manifest::parse(
|
||||
br#"{"id":"org.example.generative","name":"Generative Pattern","kind":"filter",
|
||||
"params":{"seed":{"type":"int","min":-9007199254740995,"max":9007199254740995}}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
for (given, expected) in [
|
||||
(json!(9007199254740992_i64), 9007199254740992_i64),
|
||||
(json!(9007199254740993_i64), 9007199254740993_i64),
|
||||
(json!(-9007199254740993_i64), -9007199254740993_i64),
|
||||
(json!(i64::MAX), 9007199254740995_i64),
|
||||
(json!(i64::MIN), -9007199254740995_i64),
|
||||
(json!(u64::MAX), 9007199254740995_i64),
|
||||
(json!(1e30), 9007199254740995_i64),
|
||||
(json!(-1e30), -9007199254740995_i64),
|
||||
(json!(3.6), 4_i64),
|
||||
] {
|
||||
let resolved = m.resolve_params(&json!({"seed": given})).unwrap();
|
||||
assert_eq!(resolved["seed"], json!(expected), "input: {given}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_bad_manifests() {
|
||||
for bad in [
|
||||
|
||||
@@ -977,7 +977,7 @@ impl Renderer {
|
||||
fn isolation_path(&mut self, f: &Frame) -> Option<(NodeId, Vec<NodeId>)> {
|
||||
let isolated = f.opts.isolated.filter(|_| !self.isolation_settled)?;
|
||||
let doc = std::ptr::from_ref(f.doc) as usize;
|
||||
if !self.isolation.as_ref().is_some_and(|(stamp, d, id, _)| (*stamp, *d, *id) == (self.stamp, doc, isolated)) {
|
||||
if self.isolation.as_ref().is_none_or(|(stamp, d, id, _)| (*stamp, *d, *id) != (self.stamp, doc, isolated)) {
|
||||
self.isolation = Some((self.stamp, doc, isolated, f.doc.ancestry(isolated).unwrap_or_default()));
|
||||
}
|
||||
let path = self.isolation.as_ref().map(|(.., path)| path.clone()).filter(|p| !p.is_empty())?;
|
||||
@@ -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 {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
+13
-9
@@ -109,15 +109,19 @@ pub(crate) fn type_props(st: &CharStyle, len: &dyn Fn(f64) -> String) -> Props {
|
||||
// Manual kerning replaces the font's pair kerning.
|
||||
p.push(("font-kerning", "none".into()));
|
||||
}
|
||||
if !st.features.is_empty() {
|
||||
let v: Vec<String> = st
|
||||
.features
|
||||
.iter()
|
||||
.map(|t| match t.strip_prefix('-') {
|
||||
Some(off) => format!("\"{off}\" 0"),
|
||||
None => format!("\"{t}\" 1"),
|
||||
})
|
||||
.collect();
|
||||
let mut v: Vec<String> = st
|
||||
.features
|
||||
.iter()
|
||||
.map(|t| match t.strip_prefix('-') {
|
||||
Some(off) => format!("\"{off}\" 0"),
|
||||
None => format!("\"{t}\" 1"),
|
||||
})
|
||||
.collect();
|
||||
// Proportional Metrics: `palt` (SVG writes vertical type as outlines).
|
||||
if st.proportional_metrics {
|
||||
v.push("\"palt\" 1".into());
|
||||
}
|
||||
if !v.is_empty() {
|
||||
p.push(("font-feature-settings", v.join(", ")));
|
||||
}
|
||||
match (st.underline, st.strikethrough) {
|
||||
|
||||
@@ -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 }),
|
||||
};
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -105,6 +105,20 @@ fn rich_text_writes_spacing_shift_and_scale() {
|
||||
assert_eq!(attr(tags[1], "y"), attr(tags[0], "y"));
|
||||
}
|
||||
|
||||
/// Proportional Metrics (#966) is written as the `palt` feature, beside the character's own.
|
||||
#[test]
|
||||
fn proportional_metrics_write_palt() {
|
||||
let proportional = CharStyle { proportional_metrics: true, ..style(12.0) };
|
||||
let both = CharStyle { proportional_metrics: true, features: vec!["dlig".into()], ..style(12.0) };
|
||||
let d = area_doc(vec![run("雅楽 ", style(12.0)), run("「あ」 ", proportional), run("ab", both)], 280.0, Justify::Left);
|
||||
let svg = export(&d, &ExportOptions::default());
|
||||
let tags = tspans(&svg);
|
||||
assert_eq!(tags.len(), 3, "{svg}");
|
||||
assert!(!tags[0].contains("palt"), "{}", tags[0]);
|
||||
assert!(tags[1].contains("font-feature-settings:"palt" 1"), "{}", tags[1]);
|
||||
assert!(tags[2].contains("dlig") && tags[2].contains("palt"), "{}", tags[2]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fewer_tspans_writes_one_per_line() {
|
||||
let bold = CharStyle { font_style: "Bold".into(), ..style(12.0) };
|
||||
|
||||
@@ -51,6 +51,12 @@ fn main() {
|
||||
}
|
||||
let src = format!("pub static CRAFT_FONTS: &[CraftFont] = &[\n{fonts}];\npub static WEB_FONTS: &[WebFont] = &[\n{web}];\n");
|
||||
let out = PathBuf::from(std::env::var_os("OUT_DIR").unwrap_or_default()).join("craft_fonts.rs");
|
||||
// Unchanged output keeps its mtime. Rewritten just before this crate compiles, it can read as
|
||||
// newer than that compile where cargo's recorded times lose their sub-second part (ecryptfs
|
||||
// home directories), and the next build would compile this crate and everything above it again.
|
||||
if std::fs::read_to_string(&out).is_ok_and(|old| old == src) {
|
||||
return;
|
||||
}
|
||||
if let Err(e) = std::fs::write(&out, src) {
|
||||
println!("cargo::error=writing {}: {e}", out.display());
|
||||
}
|
||||
|
||||
@@ -160,6 +160,40 @@ impl OtFeatures {
|
||||
// and digits itself (they would lie upside down, and tate-chu-yoko break).
|
||||
v.push(f(b"vert", true));
|
||||
}
|
||||
if self.proportional(st) {
|
||||
v.push(f(b"palt", true));
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
/// Does text in style `st` take proportional widths (`palt`)? Proportional Metrics in
|
||||
/// horizontal type; vertical type takes proportional heights instead
|
||||
/// ([`Self::proportional_vertical`]).
|
||||
pub(crate) fn proportional(&self, st: &CharStyle) -> bool {
|
||||
st.proportional_metrics && !self.vertical
|
||||
}
|
||||
|
||||
/// Does text in style `st` take proportional heights (`vpal`)? Proportional Metrics in
|
||||
/// vertical type.
|
||||
pub(crate) fn proportional_vertical(&self, st: &CharStyle) -> bool {
|
||||
st.proportional_metrics && self.vertical
|
||||
}
|
||||
|
||||
/// The harfrust features for vertical text in style `st` shaped top to bottom: [`Self::resolve`],
|
||||
/// with `vpal` when `proportional`.
|
||||
pub(crate) fn resolve_vertical(&self, st: &CharStyle, proportional: bool) -> Vec<Feature> {
|
||||
let mut v = self.resolve(st);
|
||||
if proportional {
|
||||
v.push(f(b"vpal", true));
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
/// The harfrust features for text in style `st` with full-width glyphs on their full widths:
|
||||
/// [`Self::resolve`] without `palt`.
|
||||
pub(crate) fn resolve_fixed_width(&self, st: &CharStyle) -> Vec<Feature> {
|
||||
let mut v = self.resolve(st);
|
||||
v.retain(|x| x.tag != Tag::new(b"palt"));
|
||||
v
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,6 +55,14 @@ impl Ctx<'_> {
|
||||
let extra = upright_cell(g) - g.face.advance(g.gid) * g.sx;
|
||||
g.dx += extra * 0.5;
|
||||
g.adv += extra;
|
||||
// Proportional Metrics: `vpal` moves it and changes its length down the column,
|
||||
// in fonts with vertical metrics (else the cell stays as it is, #966).
|
||||
if let Some(a) = g.vpal.filter(|_| g.tcy.is_none() && g.face.vertical_glyph(g.gid).is_some()) {
|
||||
g.dx += a.dx;
|
||||
g.dy += a.dy;
|
||||
g.adv += a.advance;
|
||||
g.proportional = a.advance != 0.0;
|
||||
}
|
||||
}
|
||||
}
|
||||
v
|
||||
@@ -200,15 +208,21 @@ pub fn layout_with(db: &FontDb, t: &TextObject, opts: &LayoutOptions) -> TextLay
|
||||
}
|
||||
|
||||
/// Shrink Text to Fit for text that overflows at full size: the largest scale in `min..1` at which
|
||||
/// it fits, searched by bisection over the first run's size in steps of 0.1 pt (so its scaled size
|
||||
/// it fits, searched by bisection over the first run's document size in steps of 0.1 pt (so its scaled size
|
||||
/// is a whole number of tenths of a point, the same every time). Down at `min` it lays out there,
|
||||
/// still overflowing. None (keep the full-size layout) when the text has no usable size.
|
||||
fn shrink_to_fit(db: &FontDb, t: &TextObject, opts: &LayoutOptions, min: f64) -> Option<TextLayout> {
|
||||
let size = t.runs.first().map(|r| r.style.size).filter(|s| s.is_finite() && *s >= 0.1)?;
|
||||
// Exact size edits after object scaling may store a local size below 0.1. Fit in the
|
||||
// same document points as the Character controls, independent of that representation.
|
||||
let points = t.style_scale()?.points;
|
||||
let size = t.runs.first().map(|r| r.style.size * points).filter(|s| s.is_finite() && *s >= 0.1)?;
|
||||
if !(min.is_finite() && min < 1.0) {
|
||||
return None;
|
||||
}
|
||||
let tenths = size * 10.0;
|
||||
if !tenths.is_finite() {
|
||||
return None;
|
||||
}
|
||||
// Candidate sizes k/10 pt for k in lo..hi: `hi` (full size, rounded down) is known to overflow
|
||||
// unless it is below the full size; `lo` is the smallest allowed.
|
||||
let mut lo = (tenths * min).ceil().max(1.0);
|
||||
@@ -234,7 +248,7 @@ fn shrink_to_fit(db: &FontDb, t: &TextObject, opts: &LayoutOptions, min: f64) ->
|
||||
}
|
||||
// Invariant: everything at `hi` or above overflows; `lo` fits or is the floor.
|
||||
while hi - lo > 1.0 && passes + 1 < SHRINK_PASSES {
|
||||
let mid = ((lo + hi) * 0.5).floor();
|
||||
let mid = (lo + (hi - lo) * 0.5).floor();
|
||||
let l = at(mid);
|
||||
passes += 1;
|
||||
if l.overflow {
|
||||
@@ -381,7 +395,8 @@ fn tate_chu_yoko(g: &mut [SGlyph], size: impl Fn(&SGlyph) -> f64) {
|
||||
/// Half an em of `g`'s size when it is full-width Japanese punctuation of kind `kind` (its
|
||||
/// advance an em, give or take a tenth), the space that mojikumi can take off.
|
||||
fn punct_half(g: &SGlyph, kind: Punct) -> Option<f64> {
|
||||
if punct(g.ch)? != kind || g.tcy.is_some() {
|
||||
// A glyph Proportional Metrics re-spaced has no empty half left to take off (#966).
|
||||
if punct(g.ch)? != kind || g.tcy.is_some() || g.proportional {
|
||||
return None;
|
||||
}
|
||||
let em = g.face.units_per_em();
|
||||
@@ -1170,6 +1185,8 @@ mod wrapping_tests {
|
||||
tcy: None,
|
||||
inline: None,
|
||||
lead: 0.0,
|
||||
proportional: false,
|
||||
vpal: None,
|
||||
level: unicode_bidi::Level::ltr(),
|
||||
})
|
||||
.collect()
|
||||
|
||||
+82
-23
@@ -49,12 +49,28 @@ pub(crate) struct SGlyph {
|
||||
/// Japanese composition: the space taken off before the glyph (an opening bracket after
|
||||
/// another, see [`crate::layout`]); the glyph is drawn that much earlier on the line.
|
||||
pub lead: f64,
|
||||
/// Proportional Metrics changed the glyph's advance (`palt`): it has its proportional width,
|
||||
/// and Japanese composition takes no more space off it (#966).
|
||||
pub proportional: bool,
|
||||
/// Vertical type with Proportional Metrics: what `vpal` does to the glyph when it stands
|
||||
/// upright ([`crate::layout`] applies it).
|
||||
pub vpal: Option<VAdjust>,
|
||||
/// Resolved Unicode bidi embedding level (logical source order).
|
||||
pub level: Level,
|
||||
/// An inline graphic ([`vectorcraft_doc::TextRun::inline`]) standing in for a glyph.
|
||||
pub inline: Option<InlineBox>,
|
||||
}
|
||||
|
||||
/// How `vpal` moves an upright glyph in vertical type and changes its length down the column, in
|
||||
/// points: `advance` down the column, `dx` and `dy` in glyph space (y down; `dy` is down the column
|
||||
/// once the glyph stands upright).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
pub(crate) struct VAdjust {
|
||||
pub advance: f64,
|
||||
pub dx: f64,
|
||||
pub dy: f64,
|
||||
}
|
||||
|
||||
/// Placement of an inline graphic's art, glyph space being points with the pen at the origin on
|
||||
/// the baseline (y down).
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
@@ -117,6 +133,8 @@ pub(crate) fn hyphen_glyph(g: &SGlyph) -> SGlyph {
|
||||
h.len = 0;
|
||||
h.ch = '-';
|
||||
h.inline = None;
|
||||
h.proportional = false;
|
||||
h.vpal = None;
|
||||
h
|
||||
}
|
||||
|
||||
@@ -422,6 +440,8 @@ fn inline_glyph(face: &Arc<FontFace>, st: &CharStyle, art: &InlineArt, run: usiz
|
||||
ch: text.get(range.clone()).and_then(|s| s.chars().next()).unwrap_or(INLINE_CHAR),
|
||||
tcy: None,
|
||||
lead: 0.0,
|
||||
proportional: false,
|
||||
vpal: None,
|
||||
// U+FFFC is a bidi neutral (ON): its level, resolved from its neighbours, places it in
|
||||
// RTL text like any other glyph once the line is reordered.
|
||||
level,
|
||||
@@ -470,34 +490,71 @@ fn shape_segment(text: &str, context: &Range<usize>, seg: &Segment, feats: &OtFe
|
||||
let first_char = |byte: usize| text[byte..].chars().next().unwrap_or(' ');
|
||||
|
||||
let mut raw: Vec<(u32, u32, i32, i32, i32)> = Vec::with_capacity(text_seg.len()); // gid, cluster, xadv, xoff, yoff
|
||||
// Proportional Metrics: which glyphs `palt` gave another advance than their full width, and
|
||||
// what `vpal` does to each in vertical type.
|
||||
let mut proportional = vec![];
|
||||
let mut vpal = vec![];
|
||||
let shaped = face.hb().map(|hb| {
|
||||
let shaper = face.shaper.shaper(&hb).instance(face.instance.as_ref()).build();
|
||||
let mut buf = UnicodeBuffer::new();
|
||||
for (i, c) in text_seg.char_indices() {
|
||||
let cl = (range.start + i) as u32;
|
||||
if upper {
|
||||
for u in c.to_uppercase() {
|
||||
buf.add(u, cl);
|
||||
// gid, cluster, x and y advance, x and y offset.
|
||||
let shape = |feats: &[Feature], direction: Direction| {
|
||||
let mut buf = UnicodeBuffer::new();
|
||||
for (i, c) in text_seg.char_indices() {
|
||||
let cl = (range.start + i) as u32;
|
||||
if upper {
|
||||
for u in c.to_uppercase() {
|
||||
buf.add(u, cl);
|
||||
}
|
||||
} else {
|
||||
buf.add(c, cl);
|
||||
}
|
||||
} else {
|
||||
buf.add(c, cl);
|
||||
}
|
||||
buf.set_direction(direction);
|
||||
buf.guess_segment_properties();
|
||||
// Harfrust keeps this Unicode context for joining/positional shaping, but does not emit
|
||||
// glyphs for it. This retains context at real style/font segmentation boundaries without
|
||||
// shaping across those boundaries or claiming cross-style ligatures/mark attachment.
|
||||
if let Some(pre) = text.get(context.start..range.start) {
|
||||
buf.set_pre_context(pre);
|
||||
}
|
||||
if let Some(post) = text.get(range.end..context.end) {
|
||||
buf.set_post_context(post);
|
||||
}
|
||||
let gb = shaper.shape(buf, ShapeOptions::new().features(feats));
|
||||
gb.glyph_infos()
|
||||
.iter()
|
||||
.zip(gb.glyph_positions())
|
||||
.map(|(info, pos)| (info.glyph_id, info.cluster, pos.x_advance, pos.y_advance, pos.x_offset, pos.y_offset))
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
let along = if level.is_rtl() { Direction::RightToLeft } else { Direction::LeftToRight };
|
||||
raw = shape(&feats.resolve(st), along).into_iter().map(|(g, c, xa, _, xo, yo)| (g, c, xa, xo, yo)).collect();
|
||||
let same_glyphs = |v: &[(u32, u32, i32, i32, i32, i32)]| v.len() == raw.len() && v.iter().zip(&raw).all(|(a, b)| a.0 == b.0);
|
||||
if feats.proportional(st) {
|
||||
// `palt` is positioning only: the same glyphs set on their full widths tell which
|
||||
// advances it changed (kerning and the rest change both alike).
|
||||
let full = shape(&feats.resolve_fixed_width(st), along);
|
||||
if same_glyphs(&full) {
|
||||
proportional = full.iter().zip(&raw).map(|(a, b)| a.2 != b.2).collect();
|
||||
}
|
||||
}
|
||||
buf.set_direction(if level.is_rtl() { Direction::RightToLeft } else { Direction::LeftToRight });
|
||||
buf.guess_segment_properties();
|
||||
// Harfrust keeps this Unicode context for joining/positional shaping, but does not emit
|
||||
// glyphs for it. This retains context at real style/font segmentation boundaries without
|
||||
// shaping across those boundaries or claiming cross-style ligatures/mark attachment.
|
||||
if let Some(pre) = text.get(context.start..range.start) {
|
||||
buf.set_pre_context(pre);
|
||||
}
|
||||
if let Some(post) = text.get(range.end..context.end) {
|
||||
buf.set_post_context(post);
|
||||
}
|
||||
let feats: Vec<Feature> = feats.resolve(st);
|
||||
let gb = shaper.shape(buf, ShapeOptions::new().features(&feats));
|
||||
for (info, pos) in gb.glyph_infos().iter().zip(gb.glyph_positions()) {
|
||||
raw.push((info.glyph_id, info.cluster, pos.x_advance, pos.x_offset, pos.y_offset));
|
||||
if feats.proportional_vertical(st) {
|
||||
// `vpal` adjusts heights, which only top-to-bottom shaping applies: the same glyphs set
|
||||
// top to bottom with and without it tell how it moves each and changes its advance down
|
||||
// the column (y up, advances negative).
|
||||
let (with, without) =
|
||||
(shape(&feats.resolve_vertical(st, true), Direction::TopToBottom), shape(&feats.resolve_vertical(st, false), Direction::TopToBottom));
|
||||
if same_glyphs(&with) && same_glyphs(&without) {
|
||||
vpal = with
|
||||
.iter()
|
||||
.zip(&without)
|
||||
.map(|(a, b)| {
|
||||
let adj =
|
||||
VAdjust { advance: f64::from(b.3 - a.3) * k * vs, dx: f64::from(a.4 - b.4) * k * hs, dy: -f64::from(a.5 - b.5) * k * vs };
|
||||
(adj != VAdjust::default()).then_some(adj)
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
}
|
||||
});
|
||||
if shaped.is_none() {
|
||||
@@ -553,6 +610,8 @@ fn shape_segment(text: &str, context: &Range<usize>, seg: &Segment, feats: &OtFe
|
||||
ch,
|
||||
tcy: None,
|
||||
lead: 0.0,
|
||||
proportional: proportional.get(gi).copied().unwrap_or(false),
|
||||
vpal: vpal.get(gi).copied().flatten(),
|
||||
level: *level,
|
||||
inline: None,
|
||||
});
|
||||
|
||||
@@ -54,6 +54,19 @@ fn shrink_text_makes_overflowing_text_fit() {
|
||||
assert_eq!(layout(db(), &t).fit_scale, l.fit_scale);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scaled_area_shrink_accepts_small_local_sizes_and_uses_document_tenths() {
|
||||
// A 14 pt story represented in a scaled local coordinate system. Its local size is
|
||||
// below 0.1, as happens when setting an exact size after enlarging a text group.
|
||||
let mut t = area(COPY, 0.014, Rect::new(0.0, 0.0, 0.2, 0.09), shrink(30.0));
|
||||
t.xf = Affine::scale(1000.0);
|
||||
assert!(at_size(&t, 0.014).overflow);
|
||||
let l = layout(db(), &t);
|
||||
assert!(l.fit_scale < 1.0 && l.fit_scale >= 0.3, "{}", l.fit_scale);
|
||||
let document_size = 14.0 * l.fit_scale;
|
||||
assert!((document_size * 10.0 - (document_size * 10.0).round()).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shrink_text_respects_its_minimum() {
|
||||
let t = area(COPY, 14.0, Rect::new(0.0, 0.0, 120.0, 40.0), shrink(90.0));
|
||||
|
||||
@@ -749,3 +749,45 @@ fn layout_10k_area_text_is_fast() {
|
||||
assert!(per < budget, "{justify:?} {composer:?}: {per} ms");
|
||||
}
|
||||
}
|
||||
|
||||
/// Proportional Metrics (#966): in horizontal type full-width glyphs take the font's proportional
|
||||
/// widths (`palt`), and Japanese composition takes nothing more off the punctuation `palt`
|
||||
/// re-spaced (the flush bracket at a paragraph's start, consecutive punctuation, half width at a
|
||||
/// line's end); punctuation it leaves full width is trimmed as before (vertical type: see
|
||||
/// `tests_vertical`). Needs craft-fonts' Shippori Mincho, whose `palt` covers kana and most brackets but not
|
||||
/// 〘〙 (skipped without it).
|
||||
#[test]
|
||||
fn proportional_metrics_set_full_width_glyphs_on_their_proportional_widths() {
|
||||
use vectorcraft_doc::Mojikumi;
|
||||
if db().face("Shippori Mincho", "Regular").is_none_or(|f| f.family != "Shippori Mincho") {
|
||||
eprintln!("skipped: built without craft-fonts (set CRAFT_FONTS_DIR to a craft-fonts checkout to run it)");
|
||||
return;
|
||||
}
|
||||
let st = |on: bool| CharStyle { size: 20.0, font_family: "Shippori Mincho".into(), proportional_metrics: on, ..CharStyle::default() };
|
||||
// Right-aligned in a frame ten ems wide, so a line's end trim moves the line.
|
||||
let lay = |text: &str, on: bool, m: Mojikumi| {
|
||||
let mut t = area(text, st(on), Rect::new(0.0, 0.0, 200.0, 200.0), Justify::Right);
|
||||
t.para.mojikumi = m;
|
||||
layout(db(), &t)
|
||||
};
|
||||
let adv = |l: &TextLayout| l.glyphs.iter().map(|g| g.advance).collect::<Vec<_>>();
|
||||
let x0 = |l: &TextLayout| l.glyphs.iter().map(|g| g.outline.bounding_box().x0).collect::<Vec<_>>();
|
||||
let near = |a: &[f64], b: &[f64]| a.len() == b.len() && a.iter().zip(b).all(|(a, b)| (a - b).abs() < 0.01);
|
||||
let off = adv(&lay("「あ漢」", false, Mojikumi::None));
|
||||
let on = adv(&lay("「あ漢」", true, Mojikumi::None));
|
||||
assert!(off.iter().all(|a| (a - 20.0).abs() < 0.01), "{off:?}");
|
||||
assert!(on[0] < 15.0 && on[1] < 19.0 && on[3] < 15.0, "kana and brackets narrower: {on:?}");
|
||||
assert!((on[2] - 20.0).abs() < 0.01, "the kanji keeps its em: {on:?}");
|
||||
// Re-spaced punctuation: Line-end Punctuation Half Width changes nothing, at the paragraph's
|
||||
// start (「), between punctuation (」、) or at the line's end (」).
|
||||
for text in ["「あ」、漢", "漢「あ」"] {
|
||||
let (half, none) = (lay(text, true, Mojikumi::LineEndHalf), lay(text, true, Mojikumi::None));
|
||||
assert!(near(&adv(&half), &adv(&none)), "{text}: {:?} {:?}", adv(&half), adv(&none));
|
||||
assert!(near(&x0(&half), &x0(&none)), "{text}: {:?} {:?}", x0(&half), x0(&none));
|
||||
}
|
||||
// Punctuation `palt` leaves full width: flush at the paragraph's start, half width at the end.
|
||||
let (half, none) = (lay("〘あ〙", true, Mojikumi::LineEndHalf), lay("〘あ〙", true, Mojikumi::None));
|
||||
assert!((none.glyphs[0].advance - 20.0).abs() < 0.01 && (half.glyphs[0].advance - 10.0).abs() < 0.01, "{:?}", adv(&half));
|
||||
let moved = x0(&half)[1] - x0(&none)[1];
|
||||
assert!((moved - 10.0).abs() < 0.01, "〙's empty half goes past the line's end: あ moved {moved}");
|
||||
}
|
||||
@@ -539,3 +539,46 @@ fn kinsoku_set_decides_whether_a_small_kana_may_start_a_line() {
|
||||
assert_eq!(stop(Kinsoku::Soft), [0, 1, 1], "vertical {vertical_type}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Proportional Metrics in vertical type (#966): upright glyphs take the font's proportional
|
||||
/// heights (`vpal`, from shaping them top to bottom) and move with them, so each glyph's ink stays in
|
||||
/// its shorter cell. Line-end Punctuation Half Width takes nothing more off the punctuation `vpal`
|
||||
/// re-spaced, and trims the punctuation it leaves full height (〘〙 in Shippori Mincho) as before.
|
||||
/// Off, the column is as before, and a font without vertical metrics (the bundled Source Sans 3)
|
||||
/// keeps today's cells. Needs craft-fonts' Shippori Mincho (skipped without it).
|
||||
#[test]
|
||||
fn proportional_metrics_set_upright_glyphs_on_their_proportional_heights() {
|
||||
use vectorcraft_doc::Mojikumi;
|
||||
let lay = |text: &str, font: &str, on: bool, m: Mojikumi| {
|
||||
let mut t = vertical_styled(text, CharStyle { size: 20.0, font_family: font.into(), proportional_metrics: on, ..CharStyle::default() });
|
||||
t.para.mojikumi = m;
|
||||
layout(FontDb::global(), &t)
|
||||
};
|
||||
let adv = |l: &TextLayout| l.glyphs.iter().map(|g| g.advance).collect::<Vec<_>>();
|
||||
let y0 = |l: &TextLayout| l.glyphs.iter().map(|g| g.outline.bounding_box().y0).collect::<Vec<_>>();
|
||||
let near = |a: &[f64], b: &[f64]| a.len() == b.len() && a.iter().zip(b).all(|(a, b)| (a - b).abs() < 0.01);
|
||||
// Without vertical metrics the cells stay as they are.
|
||||
let (on, off) = (lay("§§", "Source Sans 3", true, Mojikumi::None), lay("§§", "Source Sans 3", false, Mojikumi::None));
|
||||
assert!(near(&adv(&on), &adv(&off)) && near(&y0(&on), &y0(&off)), "{:?} {:?}", adv(&on), adv(&off));
|
||||
if FontDb::global().face("Shippori Mincho", "Regular").is_none_or(|f| f.family != "Shippori Mincho") {
|
||||
eprintln!("skipped: built without craft-fonts (set CRAFT_FONTS_DIR to a craft-fonts checkout to run it)");
|
||||
return;
|
||||
}
|
||||
let text = "「あ」、ッ漢。";
|
||||
let off = lay(text, "Shippori Mincho", false, Mojikumi::None);
|
||||
assert!(off.glyphs.iter().all(|g| (g.advance - 20.0).abs() < 0.01), "off: an em each, as before: {:?}", adv(&off));
|
||||
let on = lay(text, "Shippori Mincho", true, Mojikumi::None);
|
||||
assert!(on.glyphs[0].advance < 12.0 && on.glyphs[4].advance < 16.0, "「 and ッ shorter: {:?}", adv(&on));
|
||||
assert!((on.glyphs[5].advance - 20.0).abs() < 0.01, "the kanji keeps its em: {:?}", adv(&on));
|
||||
// Each glyph's ink stays in its own cell, so none runs into the next.
|
||||
for g in &on.glyphs {
|
||||
let b = g.outline.bounding_box();
|
||||
assert!(b.y0 > g.origin.y - 0.5 && b.y1 < g.origin.y + g.advance + 0.5, "byte {}: ink {b:?} in [{}, +{}]", g.byte, g.origin.y, g.advance);
|
||||
}
|
||||
// Re-spaced punctuation: Line-end Punctuation Half Width changes nothing.
|
||||
let half = lay(text, "Shippori Mincho", true, Mojikumi::LineEndHalf);
|
||||
assert!(near(&adv(&half), &adv(&on)) && near(&y0(&half), &y0(&on)), "{:?} {:?}", adv(&half), adv(&on));
|
||||
// Punctuation `vpal` leaves full height: set flush at the paragraph's start, as before.
|
||||
let (half, none) = (lay("〘あ〙", "Shippori Mincho", true, Mojikumi::LineEndHalf), lay("〘あ〙", "Shippori Mincho", true, Mojikumi::None));
|
||||
assert!((none.glyphs[0].advance - 20.0).abs() < 0.01 && (half.glyphs[0].advance - 10.0).abs() < 0.01, "{:?}", adv(&half));
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
+158
-14
@@ -3,7 +3,8 @@
|
||||
//! Direct Selection: click an anchor to select it (Shift toggles), click a segment to select the
|
||||
//! path's anchors on that segment, drag to move selected anchors (the one pressed on snaps to
|
||||
//! anchors, segments and Smart Guides, or with them off Snap to Point; Shift keeps the move at 45°
|
||||
//! steps), drag a direction handle to
|
||||
//! steps; Alt on an anchor or a segment picks the whole path and Alt-drag copies it), drag a
|
||||
//! direction handle to
|
||||
//! reshape (Shift keeps it at 45° steps round its anchor, Alt moves it alone; smart guides snap
|
||||
//! it), marquee to select anchors (Shift-drag toggles them: the selected ones inside are
|
||||
//! deselected, the others selected), drag a corner widget of any path (a star, a pen path) to round its corners
|
||||
@@ -15,7 +16,8 @@
|
||||
//! Both pick the key objects of a blend, and click or drag ruler guides ([`crate::rulerguide`]).
|
||||
//! Direct Selection also edits a blend's spine: drag its points (a key object on a point moves
|
||||
//! with it) and, once a point is clicked, its handles; and the points of selected gradient meshes
|
||||
//! and mesh envelopes and their handles ([`MeshEdit`]). Dragging a corner or an edge of area
|
||||
//! and mesh envelopes and their handles ([`MeshEdit`]; a solid fill colour recolours a gradient
|
||||
//! mesh's point last clicked). Dragging a corner or an edge of area
|
||||
//! type's frame reshapes the type area (`text.reshapeArea`): the text reflows at its size.
|
||||
//! Dragging the brackets of selected type on a path moves or flips it ([`crate::pathtype`]).
|
||||
//!
|
||||
@@ -27,7 +29,7 @@ use std::borrow::Cow;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::hit::{HitKind, hit_test};
|
||||
use vectorcraft_doc::{AnchorRef, Node, NodeId, NodeKind};
|
||||
use vectorcraft_doc::{AnchorRef, Node, NodeId, NodeKind, Selection};
|
||||
use vectorcraft_geom::{Anchor, PathData, Point, Rect};
|
||||
|
||||
use crate::bbox::move_delta;
|
||||
@@ -49,8 +51,11 @@ enum State {
|
||||
grab: Point,
|
||||
began: bool,
|
||||
},
|
||||
/// Moving (Alt: copying) the selected objects; `grab`: the anchor an Alt-press picked the
|
||||
/// path by, which snaps as a dragged anchor does.
|
||||
MoveObject {
|
||||
start: Point,
|
||||
grab: Option<Point>,
|
||||
began: bool,
|
||||
},
|
||||
/// Dragging a curved segment: it follows the pointer (`path.reshapeSegment`).
|
||||
@@ -112,6 +117,9 @@ pub struct DirectSelectionTool {
|
||||
guides: Vec<Overlay>,
|
||||
/// What the dragged anchors snap to, gathered when the drag begins.
|
||||
anchor_snap: Option<PointSnap>,
|
||||
/// What the anchor holding paths moved whole snaps to while Alt makes a copy: the original left
|
||||
/// behind too. Gathered with [`Self::anchor_snap`], before the previews move the original.
|
||||
copy_snap: Option<PointSnap>,
|
||||
/// What the dragged handle snaps to.
|
||||
handle_snap: HandleSnap,
|
||||
/// What the objects being moved snap to, gathered when the move begins.
|
||||
@@ -130,6 +138,7 @@ impl DirectSelectionTool {
|
||||
mesh: MeshEdit::default(),
|
||||
guides: vec![],
|
||||
anchor_snap: None,
|
||||
copy_snap: None,
|
||||
handle_snap: HandleSnap::default(),
|
||||
move_snap: None,
|
||||
hover: None,
|
||||
@@ -291,6 +300,9 @@ impl Tool for DirectSelectionTool {
|
||||
fn busy(&self) -> bool {
|
||||
!matches!(self.state, State::Idle) || self.guide.busy()
|
||||
}
|
||||
fn options(&self) -> Value {
|
||||
self.mesh.options()
|
||||
}
|
||||
fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec<Action> {
|
||||
if let Some(out) = self.guide.pointer(cx, ev) {
|
||||
return out;
|
||||
@@ -315,7 +327,7 @@ impl Tool for DirectSelectionTool {
|
||||
} else {
|
||||
h.leaf
|
||||
};
|
||||
self.state = State::MoveObject { start: p, began: false };
|
||||
self.state = State::MoveObject { start: p, grab: None, began: false };
|
||||
if cx.selection.contains(target) {
|
||||
vec![]
|
||||
} else if ev.mods.shift || cx.selection.contains(h.leaf) {
|
||||
@@ -364,8 +376,21 @@ impl Tool for DirectSelectionTool {
|
||||
if cx.doc.node(id).is_some_and(is_area_type) {
|
||||
return self.press_type_area(cx, id, vec![(si, ai)], p, ev.mods.shift);
|
||||
}
|
||||
let already = cx.selection.partial(id).is_some_and(|s| s.contains(&(si, ai)));
|
||||
self.state = State::MoveAnchors { start: p, grab, began: false };
|
||||
// Alt picks the whole path (and Alt-drag copies it), as on a segment.
|
||||
if ev.mods.alt {
|
||||
self.state = State::MoveObject { start: p, grab: Some(grab), began: false };
|
||||
let whole = cx.selection.contains(id) && cx.selection.partial(id).is_none();
|
||||
return if whole { vec![] } else { vec![Action::Exec("select.set".into(), json!({"ids": [id.0]}))] };
|
||||
}
|
||||
// A path with all its anchors selected is stored whole (no partial entry).
|
||||
let already = cx.selection.contains(id) && cx.selection.partial(id).is_none_or(|s| s.contains(&(si, ai)));
|
||||
// Paths all selected whole move as objects, held by this anchor, so Alt can turn
|
||||
// the copy on and off during the drag as with the Selection tool.
|
||||
self.state = if already && cx.selection.anchors.is_empty() {
|
||||
State::MoveObject { start: p, grab: Some(grab), began: false }
|
||||
} else {
|
||||
State::MoveAnchors { start: p, grab, began: false }
|
||||
};
|
||||
if ev.mods.shift {
|
||||
return vec![Action::Exec("select.anchors".into(), json!({"id": id.0, "anchors": [[si, ai]], "mode": "toggle"}))];
|
||||
}
|
||||
@@ -405,7 +430,7 @@ impl Tool for DirectSelectionTool {
|
||||
return vec![Action::Exec("select.anchors".into(), json!({"id": h.leaf.0, "anchors": anchors, "mode": "set"}))];
|
||||
}
|
||||
// Clicking the fill selects the whole leaf path (all anchors).
|
||||
self.state = State::MoveObject { start: p, began: false };
|
||||
self.state = State::MoveObject { start: p, grab: None, began: false };
|
||||
if ev.mods.shift {
|
||||
return vec![Action::Exec("select.toggle".into(), json!({"id": h.leaf.0}))];
|
||||
}
|
||||
@@ -455,19 +480,31 @@ impl Tool for DirectSelectionTool {
|
||||
));
|
||||
out
|
||||
}
|
||||
(PointerKind::Drag, State::MoveObject { start, began }) => {
|
||||
(PointerKind::Drag, State::MoveObject { start, grab, began }) => {
|
||||
let mut out = vec![];
|
||||
if !began {
|
||||
if p.distance(start) < cx.tol(3.0) {
|
||||
return out;
|
||||
}
|
||||
out.push(Action::Begin("Move".into()));
|
||||
self.state = State::MoveObject { start, began: true };
|
||||
self.move_snap = Some(MoveSnap::new(cx));
|
||||
out.push(Action::Begin(if ev.mods.alt { "Copy".into() } else { "Move".into() }));
|
||||
self.state = State::MoveObject { start, grab, began: true };
|
||||
if grab.is_some() {
|
||||
// Alt can go down or up at any time: gather both while nothing has moved.
|
||||
self.anchor_snap = Some(PointSnap::new(cx, || Targets::for_anchor_drag(cx.doc, cx.selection)));
|
||||
self.copy_snap = Some(PointSnap::new(cx, || Targets::for_anchor_drag(cx.doc, &Selection::default())));
|
||||
} else {
|
||||
self.move_snap = Some(MoveSnap::new(cx));
|
||||
}
|
||||
}
|
||||
let mut d = move_delta(start, p, ev.mods.shift);
|
||||
if let Some(snap) = &self.move_snap {
|
||||
(d, self.guides) = snap.snap(cx, start, d);
|
||||
self.guides.clear();
|
||||
let snap = if ev.mods.alt { &self.copy_snap } else { &self.anchor_snap };
|
||||
if let (Some(grab), Some(snap)) = (grab, snap) {
|
||||
let shift = ev.mods.shift.then(|| Leave::segment(cx, grab, true));
|
||||
let (q, guides) = snap.snap_from(cx, grab + (p - start), shift.as_ref());
|
||||
(d, self.guides) = (q - grab, guides);
|
||||
} else if let Some(snap) = &self.move_snap {
|
||||
(d, self.guides) = snap.snap(cx, start, d, ev.mods.alt);
|
||||
}
|
||||
out.push(Action::Preview(
|
||||
"object.transform".into(),
|
||||
@@ -550,7 +587,7 @@ impl Tool for DirectSelectionTool {
|
||||
) => {
|
||||
self.state = State::Idle;
|
||||
self.guides.clear();
|
||||
(self.anchor_snap, self.move_snap) = (None, None);
|
||||
(self.anchor_snap, self.copy_snap, self.move_snap) = (None, None, None);
|
||||
if began { vec![Action::Commit] } else { vec![] }
|
||||
}
|
||||
(PointerKind::Up, State::Handle { .. } | State::SpineHandle { .. }) => {
|
||||
@@ -834,6 +871,45 @@ mod tests {
|
||||
assert_eq!(a, vec![Action::Exec("select.anchors".into(), json!({"id": id.0, "anchors": [[0, 0]], "mode": "set"}))]);
|
||||
}
|
||||
|
||||
/// Pressing an anchor of a path whose anchors are all selected (stored as the whole path)
|
||||
/// keeps them all selected, so the drag moves them all; Shift still deselects that anchor.
|
||||
#[test]
|
||||
fn pressing_an_anchor_of_a_fully_selected_path_keeps_the_selection() {
|
||||
let (d, id) = doc_with_rect();
|
||||
let mut s = Selection::default();
|
||||
s.set([id]);
|
||||
let p = paint();
|
||||
let cx = cx(&d, &s, &p);
|
||||
let mut t = DirectSelectionTool::new(false);
|
||||
assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 100.0, 100.0)).is_empty());
|
||||
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 120.0, 100.0));
|
||||
assert!(matches!(a.as_slice(), [Action::Begin(_), Action::Preview(c, _)] if c == "object.transform"), "{a:?}");
|
||||
t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 120.0, 100.0));
|
||||
let shift = Mods { shift: true, ..Default::default() };
|
||||
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 100.0, 100.0).with_mods(shift));
|
||||
assert_eq!(a, vec![Action::Exec("select.anchors".into(), json!({"id": id.0, "anchors": [[0, 0]], "mode": "toggle"}))]);
|
||||
}
|
||||
|
||||
/// Alt-pressing an anchor picks the whole path, as Alt on a segment does, and the drag copies
|
||||
/// it as the Selection tool's Alt-drag does.
|
||||
#[test]
|
||||
fn alt_dragging_an_anchor_copies_the_whole_path() {
|
||||
let (d, id) = doc_with_rect();
|
||||
let alt = Mods { alt: true, ..Default::default() };
|
||||
let mut whole = Selection::default();
|
||||
whole.set([id]);
|
||||
let p = paint();
|
||||
for (s, pick) in [(Selection::default(), true), (anchors_of(id, &[(0, 0)]), true), (whole, false)] {
|
||||
let cx = cx(&d, &s, &p);
|
||||
let mut t = DirectSelectionTool::new(false);
|
||||
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 100.0, 100.0).with_mods(alt));
|
||||
let set = vec![Action::Exec("select.set".into(), json!({"ids": [id.0]}))];
|
||||
assert_eq!(a, if pick { set } else { vec![] }, "{s:?}");
|
||||
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 120.0, 100.0).with_mods(alt));
|
||||
assert!(matches!(a.as_slice(), [Action::Begin(_), Action::Preview(c, v)] if c == "object.transform" && v["copy"] == true), "{a:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn marquee_selects_anchors() {
|
||||
let (d, id) = doc_with_rect();
|
||||
@@ -1033,6 +1109,74 @@ mod tests {
|
||||
(v.clone(), labels)
|
||||
}
|
||||
|
||||
/// Alt-dragging a path by an anchor copies it, and that anchor snaps as a dragged anchor does:
|
||||
/// onto another path's anchor, or onto an anchor of the original the copy leaves behind.
|
||||
#[test]
|
||||
fn alt_dragged_copies_snap_by_the_anchor_pressed() {
|
||||
let (d, _, b) = two_squares();
|
||||
let p = paint();
|
||||
let alt = Mods { alt: true, ..Default::default() };
|
||||
let mut whole = Selection::default();
|
||||
whole.set([b]);
|
||||
let c = cx(&d, &whole, &p);
|
||||
let mut t = DirectSelectionTool::new(false);
|
||||
let copy =
|
||||
|dx: f64, dy: f64| json!({"matrix": matrix_json(vectorcraft_geom::Affine::translate(vectorcraft_geom::Vec2::new(dx, dy))), "copy": true});
|
||||
// B's top-left corner, pressed 1 pt off, onto A's bottom-right one.
|
||||
let (v, labels) = drag(&mut t, &c, (301.0, 301.0), (203.0, 199.0), alt);
|
||||
assert_eq!((v, labels), (copy(-100.0, -100.0), vec!["anchor".to_string()]));
|
||||
// B's top-left corner onto the original B's top-right one: the copy sits beside it.
|
||||
let (v, labels) = drag(&mut t, &c, (300.0, 300.0), (402.0, 301.0), alt);
|
||||
assert_eq!((v, labels), (copy(100.0, 0.0), vec!["anchor".to_string()]));
|
||||
// The history says what the drag did.
|
||||
t.pointer(&c, &PointerEvent::new(PointerKind::Down, 300.0, 300.0).with_mods(alt));
|
||||
let out = t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 250.0, 250.0).with_mods(alt));
|
||||
assert_eq!(out.first(), Some(&Action::Begin("Copy".into())));
|
||||
}
|
||||
|
||||
/// Dragging a fully selected path by an anchor moves it whole, so Alt pressed or released
|
||||
/// during the drag turns the copy on and off, as with the Selection tool; the anchor pressed
|
||||
/// snaps all along, onto the original only while it is left behind.
|
||||
#[test]
|
||||
fn alt_during_a_drag_by_an_anchor_toggles_the_copy() {
|
||||
let (d, _, b) = two_squares();
|
||||
let p = paint();
|
||||
let (none, alt) = (Mods::default(), Mods { alt: true, ..Default::default() });
|
||||
let mut whole = Selection::default();
|
||||
whole.set([b]);
|
||||
let c = cx(&d, &whole, &p);
|
||||
let mut t = DirectSelectionTool::new(false);
|
||||
let preview = |dx: f64, dy: f64, copy: bool| {
|
||||
Action::Preview(
|
||||
"object.transform".into(),
|
||||
json!({"matrix": matrix_json(vectorcraft_geom::Affine::translate(vectorcraft_geom::Vec2::new(dx, dy))), "copy": copy}),
|
||||
)
|
||||
};
|
||||
assert!(t.pointer(&c, &PointerEvent::new(PointerKind::Down, 300.0, 300.0)).is_empty());
|
||||
assert_eq!(
|
||||
t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 250.0, 250.0)),
|
||||
vec![Action::Begin("Move".into()), preview(-50.0, -50.0, false)]
|
||||
);
|
||||
// The tool sees the document as previewed: B moved along, not where the copy leaves it.
|
||||
let (mut moved, _, b2) = two_squares();
|
||||
let l = moved.layers[0].id;
|
||||
let sq = vectorcraft_geom::shapes::rectangle(Rect::new(250.0, 250.0, 350.0, 350.0));
|
||||
moved.remove(b2).unwrap();
|
||||
moved.insert(Some(l), 1, vectorcraft_doc::Node::path(b, sq, vectorcraft_doc::Appearance::default_art())).unwrap();
|
||||
let c = cx(&moved, &whole, &p);
|
||||
// Alt down mid-drag: a copy, whose pressed corner can land on the original's top-right one.
|
||||
assert_eq!(t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 402.0, 301.0).with_mods(alt)), vec![preview(100.0, 0.0, true)]);
|
||||
// Alt up: a move again, which leaves nothing behind to snap to.
|
||||
assert_eq!(t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 402.0, 301.0).with_mods(none)), vec![preview(102.0, 1.0, false)]);
|
||||
assert_eq!(t.pointer(&c, &PointerEvent::new(PointerKind::Up, 402.0, 301.0)), vec![Action::Commit]);
|
||||
// With only some anchors selected, the drag still moves just those.
|
||||
let one = anchors_of(b, &[(0, 0)]);
|
||||
let c = cx(&d, &one, &p);
|
||||
t.pointer(&c, &PointerEvent::new(PointerKind::Down, 300.0, 300.0));
|
||||
let a = t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 250.0, 260.0).with_mods(alt));
|
||||
assert!(matches!(a.last(), Some(Action::Preview(c, _)) if c == "path.moveAnchors"), "{a:?}");
|
||||
}
|
||||
|
||||
/// A dragged anchor lands on another path's anchor or segment, lines up with the other anchors
|
||||
/// of its own path, and with several selected the one pressed on snaps, the others following.
|
||||
#[test]
|
||||
|
||||
@@ -179,10 +179,11 @@ impl Targets {
|
||||
Self::collect_inner(doc, exclude, None, visible, Gather::Bounds)
|
||||
}
|
||||
|
||||
/// Targets for moving the selection of `cx`: [`Self::collect`]'s, styled as `cx` asks, and with
|
||||
/// Smart Guides › Spacing Guides the objects it spaces itself evenly with ([`Self::snap_rect`]).
|
||||
pub fn for_move(cx: &ToolContext) -> Self {
|
||||
let mut t = Self::collect(cx.doc, &cx.selection.objects, None).styled(cx);
|
||||
/// Targets for moving the selection of `cx`: [`Self::collect`]'s but `exclude` (the selection,
|
||||
/// or nothing for a copy, which leaves it behind), styled as `cx` asks, and with Smart Guides ›
|
||||
/// Spacing Guides the objects it spaces itself evenly with ([`Self::snap_rect`]).
|
||||
pub fn for_move(cx: &ToolContext, exclude: &[NodeId]) -> Self {
|
||||
let mut t = Self::collect(cx.doc, exclude, None).styled(cx);
|
||||
if cx.spacing_guides {
|
||||
t.boxes = object_boxes(cx.doc, &cx.selection.objects);
|
||||
}
|
||||
@@ -1229,7 +1230,7 @@ mod tests {
|
||||
}
|
||||
let (s, p) = (Selection::default(), paint());
|
||||
let lines = |ov: &[Overlay]| ov.iter().filter(|o| matches!(o, Overlay::Line { color, .. } if *color == MAGENTA)).count();
|
||||
let t = Targets::for_move(&cx(&d, &s, &p));
|
||||
let t = Targets::for_move(&cx(&d, &s, &p), &s.objects);
|
||||
// 21 after B: 20, as A and B, with both gaps marked (a line and two ticks each).
|
||||
let (dv, ov) = t.snap_rect(Rect::new(141.0, 305.0, 181.0, 345.0), 4.0);
|
||||
assert_eq!(dv, Vec2::new(-1.0, 0.0));
|
||||
@@ -1241,7 +1242,7 @@ mod tests {
|
||||
let id = wide.alloc_id();
|
||||
wide.insert(Some(l), 0, Node::path(id, vectorcraft_geom::shapes::rectangle(Rect::new(220.0, 300.0, 260.0, 340.0)), Default::default()))
|
||||
.unwrap();
|
||||
let (dv, ov) = Targets::for_move(&cx(&wide, &s, &p)).snap_rect(Rect::new(148.0, 305.0, 188.0, 345.0), 4.0);
|
||||
let (dv, ov) = Targets::for_move(&cx(&wide, &s, &p), &s.objects).snap_rect(Rect::new(148.0, 305.0, 188.0, 345.0), 4.0);
|
||||
assert_eq!(dv, Vec2::new(2.0, 0.0));
|
||||
assert!(lines(&ov) >= 6, "{ov:?}");
|
||||
// A column: 28 under D, as C and D are 30 apart.
|
||||
@@ -1253,7 +1254,7 @@ mod tests {
|
||||
assert_eq!(dv, Vec2::new(0.0, 2.0));
|
||||
// Off: nowhere to go, and no marks.
|
||||
let off = ToolContext { spacing_guides: false, ..cx(&d, &s, &p) };
|
||||
let (dv, ov) = Targets::for_move(&off).snap_rect(Rect::new(141.0, 305.0, 181.0, 345.0), 4.0);
|
||||
let (dv, ov) = Targets::for_move(&off, &s.objects).snap_rect(Rect::new(141.0, 305.0, 181.0, 345.0), 4.0);
|
||||
assert_eq!((dv, lines(&ov)), (Vec2::ZERO, 0));
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,8 @@
|
||||
//! the click); click inside a mesh or a mesh envelope → `object.mesh.addLine` (a gradient mesh's
|
||||
//! new point takes the current fill colour unless Shift); drag a mesh point, or a handle of the
|
||||
//! point last clicked → `object.mesh.movePoint` previews ([`MeshEdit`]); Alt-click a mesh point →
|
||||
//! `object.mesh.deletePoint`.
|
||||
//! `object.mesh.deletePoint`. Clicking a gradient mesh's point selects the mesh, and the next
|
||||
//! solid fill colour recolours just that point.
|
||||
|
||||
use serde_json::json;
|
||||
use vectorcraft_color::Paint;
|
||||
@@ -183,7 +184,14 @@ impl Tool for MeshTool {
|
||||
self.edit.unfocus();
|
||||
return vec![Action::Exec("object.mesh.deletePoint".into(), json!({"id": g.id.0, "index": g.index}))];
|
||||
}
|
||||
return self.edit.press(g);
|
||||
// A gradient mesh's point is picked for the next fill colour, so its mesh is
|
||||
// what gets selected.
|
||||
let mut out = vec![];
|
||||
if cx.selection.objects != [g.id] && cx.doc.node(g.id).is_some_and(|n| matches!(n.kind, NodeKind::Mesh(_))) {
|
||||
out.push(Action::Exec("select.set".into(), json!({"ids": [g.id.0]})));
|
||||
}
|
||||
out.extend(self.edit.press(g));
|
||||
return out;
|
||||
}
|
||||
self.edit.unfocus();
|
||||
let Some((top, leaf)) = hit_top(cx, p) else { return vec![] };
|
||||
@@ -236,6 +244,9 @@ impl Tool for MeshTool {
|
||||
None => Cursor::PenAdd,
|
||||
}
|
||||
}
|
||||
fn options(&self) -> serde_json::Value {
|
||||
self.edit.options()
|
||||
}
|
||||
fn busy(&self) -> bool {
|
||||
self.edit.dragging()
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
//! Dragging mesh points and their handles on the canvas, shared by the Mesh tool and Direct
|
||||
//! Selection: gradient meshes and mesh envelopes alike (`object.mesh.movePoint`). Clicking a
|
||||
//! point focuses it: its handles show and can be dragged too.
|
||||
//! point focuses it: its handles show and can be dragged too, and on a gradient mesh a solid fill
|
||||
//! colour recolours it (the tool's `meshPoint` option).
|
||||
|
||||
use std::borrow::Cow;
|
||||
|
||||
use serde_json::json;
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::live::{GradientMesh, envelope_grid};
|
||||
use vectorcraft_doc::{EnvelopeKind, Node, NodeId, NodeKind};
|
||||
use vectorcraft_geom::Point;
|
||||
@@ -91,6 +92,12 @@ impl MeshEdit {
|
||||
self.focus = None;
|
||||
}
|
||||
|
||||
/// `{meshPoint: {id, index}|null}`: the focused point, which a solid fill colour recolours
|
||||
/// (`paint.setFill` on a gradient mesh).
|
||||
pub fn options(&self) -> Value {
|
||||
json!({"meshPoint": self.focus.map(|(id, index)| json!({"id": id.0, "index": index}))})
|
||||
}
|
||||
|
||||
/// The focused point's handles (lines and ends).
|
||||
pub fn overlays(&self, cx: &ToolContext) -> Vec<Overlay> {
|
||||
let Some((id, index)) = self.focus else { return vec![] };
|
||||
|
||||
@@ -79,36 +79,47 @@ pub struct SelectionTool {
|
||||
/// Snap to Grid lands its bounds on the grid (#740), taking over from the rest; otherwise Smart
|
||||
/// Guides line its bounds up with the other art, and with them off, View → Snap to Point lands the
|
||||
/// point it was grabbed by on an anchor or a ruler guide. Snap to Pixel puts its top-left on whole
|
||||
/// pixels.
|
||||
/// pixels. A copy (Alt, which can go down or up at any time during the drag) leaves the original
|
||||
/// behind, so it snaps to the original too.
|
||||
pub(crate) struct MoveSnap {
|
||||
bounds: Option<Rect>,
|
||||
targets: Option<Targets>,
|
||||
points: Option<Targets>,
|
||||
/// The targets and points for a copy, gathered with the others before the previews move the
|
||||
/// original.
|
||||
copy_targets: Option<Targets>,
|
||||
copy_points: Option<Targets>,
|
||||
}
|
||||
|
||||
impl MoveSnap {
|
||||
pub(crate) fn new(cx: &ToolContext) -> Self {
|
||||
let guides = cx.smart_guides && !cx.snap_to_grid;
|
||||
let points = |exclude: &[NodeId]| if cx.snap_to_grid { None } else { Targets::snap_to_point(cx, exclude) };
|
||||
Self {
|
||||
bounds: selection_bounds(cx),
|
||||
targets: (cx.smart_guides && !cx.snap_to_grid).then(|| Targets::for_move(cx)),
|
||||
points: if cx.snap_to_grid { None } else { Targets::snap_to_point(cx, &cx.selection.objects) },
|
||||
targets: guides.then(|| Targets::for_move(cx, &cx.selection.objects)),
|
||||
points: points(&cx.selection.objects),
|
||||
copy_targets: guides.then(|| Targets::for_move(cx, &[])),
|
||||
copy_points: points(&[]),
|
||||
}
|
||||
}
|
||||
|
||||
/// The move by `d` of the selection grabbed at `start`, snapped, and its guides.
|
||||
pub(crate) fn snap(&self, cx: &ToolContext, start: Point, mut d: Vec2) -> (Vec2, Vec<Overlay>) {
|
||||
/// The move (`copy`: the copy) by `d` of the selection grabbed at `start`, snapped, and its
|
||||
/// guides.
|
||||
pub(crate) fn snap(&self, cx: &ToolContext, start: Point, mut d: Vec2, copy: bool) -> (Vec2, Vec<Overlay>) {
|
||||
let (targets, points) = if copy { (&self.copy_targets, &self.copy_points) } else { (&self.targets, &self.points) };
|
||||
let mut guides = vec![];
|
||||
if cx.snap_to_grid
|
||||
&& let Some(b) = self.bounds
|
||||
{
|
||||
d += grid_pull(b + d, cx.grid_step());
|
||||
}
|
||||
if let Some(t) = &self.points {
|
||||
if let Some(t) = points {
|
||||
let (q, ov) = t.snap_point(start + d, cx.tol(cx.snap_tolerance));
|
||||
d = q - start;
|
||||
guides = ov;
|
||||
}
|
||||
if let (Some(t), Some(b)) = (&self.targets, self.bounds) {
|
||||
if let (Some(t), Some(b)) = (targets, self.bounds) {
|
||||
let (adj, ov) = t.snap_rect(b + d, cx.snap_tol());
|
||||
d += adj;
|
||||
guides = ov;
|
||||
@@ -331,7 +342,7 @@ impl Tool for SelectionTool {
|
||||
}
|
||||
let mut d = move_delta(start, p, m.shift);
|
||||
if let Some(snap) = &self.moving {
|
||||
(d, self.guides) = snap.snap(cx, start, d);
|
||||
(d, self.guides) = snap.snap(cx, start, d, m.alt);
|
||||
}
|
||||
self.state = State::Moving { start, began: true, deselect: None, key: None };
|
||||
self.measure = cx.measurement_labels.then(|| (p, cx.offset_label(d.x, d.y)));
|
||||
@@ -622,6 +633,38 @@ mod tests {
|
||||
assert_eq!(a, vec![Action::Commit]);
|
||||
}
|
||||
|
||||
/// Alt pressed during a move makes a copy, which snaps to the original it leaves behind (as
|
||||
/// gathered before the previews moved it); released, the move doesn't.
|
||||
#[test]
|
||||
fn an_alt_copy_snaps_to_the_original_left_behind() {
|
||||
use vectorcraft_doc::{Appearance, Node};
|
||||
let (mut d, id) = doc_with_rect();
|
||||
// An artboard whose edges and centre are out of reach.
|
||||
d.artboards[0].rect = Rect::new(0.0, 0.0, 1000.0, 1000.0);
|
||||
let mut s = Selection::default();
|
||||
s.add(id);
|
||||
let p = paint();
|
||||
let mut t = SelectionTool::default();
|
||||
let dx = |a: &[Action]| {
|
||||
let Some(Action::Preview(_, v)) = a.last() else { panic!("{a:?}") };
|
||||
v["matrix"][4].as_f64().unwrap()
|
||||
};
|
||||
t.pointer(&cx(&d, &s, &p), &ev(PointerKind::Down, 150.0, 150.0));
|
||||
assert_eq!(dx(&t.pointer(&cx(&d, &s, &p), &ev(PointerKind::Drag, 200.0, 150.0))), 50.0);
|
||||
// The previews moved the rectangle: the tool sees it 50 pt along.
|
||||
let mut moved = d.clone();
|
||||
let l = moved.layers[0].id;
|
||||
moved.remove(id).unwrap();
|
||||
let r = vectorcraft_geom::shapes::rectangle(Rect::new(150.0, 100.0, 250.0, 200.0));
|
||||
moved.insert(Some(l), 0, Node::path(id, r, Appearance::default_art())).unwrap();
|
||||
let c = cx(&moved, &s, &p);
|
||||
let alt = Mods { alt: true, ..Mods::default() };
|
||||
// The copy's left edge, 3 pt past the original's right edge (x = 200), lands on it.
|
||||
assert_eq!(dx(&t.pointer(&c, &ev(PointerKind::Drag, 253.0, 150.0).with_mods(alt))), 100.0);
|
||||
// Without Alt nothing stays behind there.
|
||||
assert_eq!(dx(&t.pointer(&c, &ev(PointerKind::Drag, 253.0, 150.0))), 103.0);
|
||||
}
|
||||
|
||||
/// #740: with Snap to Grid on, a moved object lands on the grid (every 9 pt by default) by
|
||||
/// whichever of its edges or its centre is nearest a gridline, on each axis.
|
||||
#[test]
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
//!
|
||||
//! Drag draws (Shift constrains to square/circle/45°, Alt draws from the centre, Space held moves
|
||||
//! the shape being drawn); a click without a drag asks the UI for the size dialog (like
|
||||
//! Illustrator). ↑/↓ during a polygon/star drag change the side/point count. The start point and
|
||||
//! Illustrator). ↑/↓ during a polygon/star drag change the side/point count; during a star drag,
|
||||
//! Ctrl/Cmd keeps the inner radius where it is while the outer one follows the pointer, and Alt
|
||||
//! keeps the star's shoulders straight (#812). The start point and
|
||||
//! the dragged corner snap to Smart Guides ([`DrawSnap`]), hovering before the press too.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
@@ -22,9 +24,19 @@ pub struct ShapeTool {
|
||||
pub corner_radius: f64,
|
||||
/// Star inner radius as a fraction of the outer radius.
|
||||
pub star_ratio: f64,
|
||||
/// The star's inner radius held while Ctrl/Cmd is down in a drag.
|
||||
inner: Option<f64>,
|
||||
snap: DrawSnap,
|
||||
}
|
||||
|
||||
/// The inner-to-outer radius ratio that keeps a star of `points` points' shoulders straight (each
|
||||
/// point's sides in line with the next points' sides: a regular star polygon {n/2}); none for
|
||||
/// fewer than five points, whose shoulders can't line up.
|
||||
fn straight_ratio(points: u32) -> Option<f64> {
|
||||
let n = f64::from(points);
|
||||
(points >= 5).then(|| (2.0 * std::f64::consts::PI / n).cos() / (std::f64::consts::PI / n).cos())
|
||||
}
|
||||
|
||||
impl ShapeTool {
|
||||
pub fn new(id: &str) -> Self {
|
||||
let id: &'static str = match id {
|
||||
@@ -45,10 +57,28 @@ impl ShapeTool {
|
||||
points: 5,
|
||||
corner_radius: 12.0,
|
||||
star_ratio: 0.5,
|
||||
inner: None,
|
||||
snap: DrawSnap::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Ctrl/Cmd in a star drag from `start` to `p`: the inner radius stays as it was when the key
|
||||
/// went down, so the ratio follows the outer radius (and stays so once the key is let go).
|
||||
fn hold_inner(&mut self, start: Point, p: Point, m: Mods) {
|
||||
let outer = (p - start).hypot();
|
||||
match (m.cmd, self.inner) {
|
||||
(true, None) => self.inner = Some(outer * self.star_ratio),
|
||||
(false, Some(_)) => self.inner = None,
|
||||
_ => {}
|
||||
}
|
||||
if let Some(inner) = self.inner
|
||||
&& outer > 0.0
|
||||
&& (inner / outer).is_finite()
|
||||
{
|
||||
self.star_ratio = (inner / outer).clamp(0.01, 1.0);
|
||||
}
|
||||
}
|
||||
|
||||
fn label(&self) -> &'static str {
|
||||
match self.id {
|
||||
"roundedRectangle" => "Rounded Rectangle",
|
||||
@@ -81,9 +111,10 @@ impl ShapeTool {
|
||||
if self.id == "polygon" {
|
||||
("shape.polygon".into(), json!({ "cx": start.x, "cy": start.y, "radius": r, "sides": self.sides, "rotation": rot }))
|
||||
} else {
|
||||
let ratio = if m.alt { straight_ratio(self.points).unwrap_or(self.star_ratio) } else { self.star_ratio };
|
||||
(
|
||||
"shape.star".into(),
|
||||
json!({ "cx": start.x, "cy": start.y, "radius1": r, "radius2": r * self.star_ratio, "points": self.points, "rotation": rot }),
|
||||
json!({ "cx": start.x, "cy": start.y, "radius1": r, "radius2": r * ratio, "points": self.points, "rotation": rot }),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -147,6 +178,9 @@ impl Tool for ShapeTool {
|
||||
space_moves(&mut s, &mut self.last, ev, self.began);
|
||||
self.start = Some(s);
|
||||
self.mods = ev.mods;
|
||||
if self.id == "star" {
|
||||
self.hold_inner(s, ev.pos, ev.mods);
|
||||
}
|
||||
let mut out = vec![];
|
||||
if !self.began {
|
||||
if ev.pos.distance(s) < cx.tol(2.0) {
|
||||
@@ -162,6 +196,7 @@ impl Tool for ShapeTool {
|
||||
PointerKind::Up => {
|
||||
let Some(s) = self.start.take() else { return vec![] };
|
||||
self.snap.clear();
|
||||
self.inner = None;
|
||||
if self.began {
|
||||
self.began = false;
|
||||
vec![Action::Commit]
|
||||
@@ -290,6 +325,38 @@ mod tests {
|
||||
assert!(matches!(a.last(), Some(Action::Preview(_, v)) if v["cx"] == 60.0 && v["cy"] == 60.0), "{a:?}");
|
||||
}
|
||||
|
||||
/// During a star drag, Ctrl/Cmd keeps the inner radius while the outer one follows the pointer
|
||||
/// (the new ratio stays once the key is let go), and Alt keeps the shoulders straight (#812).
|
||||
#[test]
|
||||
fn a_star_drag_holds_its_inner_radius_with_ctrl_and_straightens_with_alt() {
|
||||
let (d, _) = doc_with_rect();
|
||||
let s = Selection::default();
|
||||
let p = paint();
|
||||
let cx = cx(&d, &s, &p);
|
||||
let radii = |a: &[Action]| match a.last() {
|
||||
Some(Action::Preview(_, v)) => (v["radius1"].as_f64().unwrap(), v["radius2"].as_f64().unwrap()),
|
||||
other => panic!("{other:?}"),
|
||||
};
|
||||
let ctrl = Mods { cmd: true, ..Default::default() };
|
||||
let mut t = ShapeTool::new("star");
|
||||
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 50.0, 50.0));
|
||||
let (r1, r2) = radii(&t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 90.0, 50.0)));
|
||||
assert_eq!((r1, r2), (40.0, 20.0));
|
||||
// Ctrl: the inner radius stays 20 as the outer one grows to 80.
|
||||
t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 90.0, 50.0).with_mods(ctrl));
|
||||
let (r1, r2) = radii(&t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 130.0, 50.0).with_mods(ctrl)));
|
||||
assert!((r1 - 80.0).abs() < 1e-9 && (r2 - 20.0).abs() < 1e-9, "{r1} {r2}");
|
||||
// Let go: the ratio (a quarter) stays.
|
||||
let (r1, r2) = radii(&t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 150.0, 50.0)));
|
||||
assert!((r2 / r1 - 0.25).abs() < 1e-9, "{r1} {r2}");
|
||||
// Alt: straight shoulders, about 0.382 for five points.
|
||||
let alt = Mods { alt: true, ..Default::default() };
|
||||
let (r1, r2) = radii(&t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 150.0, 50.0).with_mods(alt)));
|
||||
assert!((r2 / r1 - 0.381_966).abs() < 1e-5, "{}", r2 / r1);
|
||||
assert_eq!(straight_ratio(4), None, "four points can't line up");
|
||||
assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 150.0, 50.0)), vec![Action::Commit]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shift_square_alt_center() {
|
||||
let t = ShapeTool::new("ellipse");
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ description = "VectorCraft egui frontend (menus, panels, tools and canvas); swap
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
craft-ui = { workspace = true }
|
||||
vectorcraft-geom = { workspace = true }
|
||||
vectorcraft-color = { workspace = true }
|
||||
vectorcraft-doc = { workspace = true }
|
||||
@@ -33,6 +34,7 @@ serde_json = { workspace = true }
|
||||
log = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
egui_kittest = { workspace = true, features = ["wgpu", "snapshot"] }
|
||||
vectorcraft-testkit = { workspace = true }
|
||||
wat = { workspace = true }
|
||||
|
||||
|
||||
@@ -21,7 +21,13 @@ fn main() {
|
||||
let people = read(&dir.join("people.toml"), "contributors are shown by username only", |t| t.parse::<toml::Table>().map_err(|e| e.to_string()))
|
||||
.unwrap_or_default();
|
||||
let out = std::path::PathBuf::from(std::env::var_os("OUT_DIR").unwrap_or_default()).join("credits.rs");
|
||||
if let Err(e) = std::fs::write(&out, generate(&json, &people)) {
|
||||
let src = generate(&json, &people);
|
||||
// Unchanged output keeps its mtime, so the next build doesn't compile this crate again
|
||||
// (see crates/text/build.rs).
|
||||
if std::fs::read_to_string(&out).is_ok_and(|old| old == src) {
|
||||
return;
|
||||
}
|
||||
if let Err(e) = std::fs::write(&out, src) {
|
||||
println!("cargo::warning=writing {}: {e}", out.display());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -362,7 +362,6 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
let artboard_tool = app.session.tool_id() == "artboard";
|
||||
let tool_labelled = artboard_tool.then(|| app.session.tool_options()["active"].as_u64()).flatten();
|
||||
for (i, ab) in doc.artboards.iter().enumerate() {
|
||||
let r = xf.rect_to_screen(ab.rect);
|
||||
let c = if i == active_ab { Color32::from_gray(0) } else { Color32::from_gray(120) };
|
||||
painter.add(Shape::closed_line(xf.quad(ab.rect), Stroke::new(if i == active_ab { 1.0 } else { 0.6 }, c)));
|
||||
// The bleed (Document Setup) as a red outline around the artboard.
|
||||
@@ -370,7 +369,10 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
painter.add(Shape::closed_line(xf.quad(doc.setup.bleed_rect(ab.rect)), Stroke::new(1.0, t.bleed)));
|
||||
}
|
||||
if (artboard_tool || doc.artboards.len() > 1) && tool_labelled != u64::try_from(i).ok() {
|
||||
artboard_label(&painter, r, &format!("{:02} - {}", i + 1, ab.name), t.text_dim);
|
||||
// At the artboard's own top-left corner, which a rotated view turns away from its
|
||||
// bounding box's (#1095).
|
||||
let corner = xf.to_screen(Point::new(ab.rect.x0, ab.rect.y0));
|
||||
artboard_label(&painter, corner, (ab.rect.width() * xf.zoom) as f32, &format!("{:02} - {}", i + 1, ab.name), t.text_dim);
|
||||
}
|
||||
}
|
||||
if app.ui.view.guides {
|
||||
@@ -905,7 +907,8 @@ pub fn dispatch(app: &mut VectorcraftApp, ev: &PointerEvent, view: vectorcraft_e
|
||||
v.artboard = i;
|
||||
}
|
||||
// A press with a selection tool inside an artboard makes it the active one (#693), as the
|
||||
// Artboard tool's does: Paste in Place and in Front or Back then paste onto it.
|
||||
// Artboard tool's does: Paste in Place and in Front or Back then paste onto it, Align to
|
||||
// Artboard aligns to it, and All on Active Artboard selects on it.
|
||||
if ev.kind == PointerKind::Down
|
||||
&& vectorcraft_tools::catalog::is_selection_tool(app.session.tool_id())
|
||||
&& let Some(i) = app.session.active().and_then(|d| d.doc.artboard_at(ev.pos))
|
||||
@@ -1752,16 +1755,16 @@ fn ime_output(app: &mut VectorcraftApp, ctx: &egui::Context, xf: &Xf) {
|
||||
/// out rather than spilling over its neighbours.
|
||||
const MIN_LABELLED_ARTBOARD: f32 = 24.0;
|
||||
|
||||
/// Artboard `name` above the top-left corner of its on-screen rect `r`, no wider than the
|
||||
/// artboard: a longer name ends in "…", and an artboard too narrow shows none (#949).
|
||||
fn artboard_label(painter: &egui::Painter, r: egui::Rect, name: &str, color: Color32) {
|
||||
if r.width() < MIN_LABELLED_ARTBOARD {
|
||||
/// Artboard `name` above its top-left `corner` on screen, no wider than the artboard (`width` on
|
||||
/// screen): a longer name ends in "…", and an artboard too narrow shows none (#949).
|
||||
fn artboard_label(painter: &egui::Painter, corner: Pos2, width: f32, name: &str, color: Color32) {
|
||||
if !width.is_finite() || width < MIN_LABELLED_ARTBOARD {
|
||||
return;
|
||||
}
|
||||
let mut job = egui::text::LayoutJob::simple_singleline(name.to_owned(), egui::FontId::proportional(11.0), color);
|
||||
job.wrap = egui::text::TextWrapping { max_width: r.width(), max_rows: 1, break_anywhere: true, overflow_character: Some('…') };
|
||||
job.wrap = egui::text::TextWrapping { max_width: width, max_rows: 1, break_anywhere: true, overflow_character: Some('…') };
|
||||
let galley = painter.layout_job(job);
|
||||
painter.galley(r.left_top() - vec2(0.0, 4.0 + galley.size().y), galley, color);
|
||||
painter.galley(corner - vec2(0.0, 4.0 + galley.size().y), galley, color);
|
||||
}
|
||||
|
||||
/// Is overlay label `text` the Artboard tool's "01 - <artboard name>"? It holds a name, so it is
|
||||
@@ -2192,6 +2195,37 @@ mod tests {
|
||||
assert_eq!((b.x0, b.y0), (boards[2].x0 + 10.0, boards[2].y0 + 15.0));
|
||||
}
|
||||
|
||||
/// A Shift-click with the Selection tool on an empty spot inside an artboard makes it the active
|
||||
/// one and keeps the selection, and Align to Artboard then aligns the objects to it unless the
|
||||
/// call names another artboard.
|
||||
#[test]
|
||||
fn a_shift_click_activates_its_artboard_and_align_to_artboard_aligns_to_it() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.session.execute("file.new", &json!({"width": 200, "height": 100, "artboards": 3})).unwrap();
|
||||
let boards: Vec<_> = app.session.active().unwrap().doc.artboards.iter().map(|a| a.rect).collect();
|
||||
let mut rect = |x: f64, y: f64| {
|
||||
let r = app.session.execute("shape.rectangle", &json!({"x": boards[0].x0 + x, "y": boards[0].y0 + y, "width": 20, "height": 20}));
|
||||
vectorcraft_doc::NodeId(r.unwrap()["id"].as_u64().unwrap())
|
||||
};
|
||||
let ids = [rect(10.0, 15.0), rect(50.0, 40.0)];
|
||||
app.session.execute("select.set", &json!({"ids": [ids[0].0, ids[1].0]})).unwrap();
|
||||
let ctx = egui::Context::default();
|
||||
frame(&mut app, &ctx, vec![]);
|
||||
app.select_tool("selection");
|
||||
let view = app.view_info();
|
||||
let shift = Mods { shift: true, ..Default::default() };
|
||||
for kind in [PointerKind::Down, PointerKind::Up] {
|
||||
dispatch(&mut app, &PointerEvent { kind, pos: boards[2].center(), mods: shift, pressure: 1.0 }, view);
|
||||
}
|
||||
assert_eq!(app.view().unwrap().artboard, 2, "the clicked artboard is the active one");
|
||||
assert_eq!(app.session.active().unwrap().selection.objects, ids, "the selection stays");
|
||||
let lefts = |app: &VectorcraftApp| ids.map(|id| app.session.active().unwrap().doc.node(id).unwrap().geometric_bounds().unwrap().x0);
|
||||
app.run("object.align", json!({"horizontal": "left", "to": "artboard"})).unwrap();
|
||||
assert_eq!(lefts(&app), [boards[2].x0; 2], "the left edges are on the active artboard's");
|
||||
app.run("object.align", json!({"horizontal": "left", "to": "artboard", "artboard": 1})).unwrap();
|
||||
assert_eq!(lefts(&app), [boards[1].x0; 2], "an artboard the caller names is kept");
|
||||
}
|
||||
|
||||
/// Enable Touch Gestures (#585): a two-finger tap undoes and a three-finger one redoes, and the
|
||||
/// first finger's press (the pointer egui makes of it) draws nothing. Off, a tap does nothing.
|
||||
#[test]
|
||||
@@ -2240,8 +2274,9 @@ mod tests {
|
||||
fn artboard_labels_fit_their_artboard() {
|
||||
let ctx = egui::Context::default();
|
||||
let drawn = |width: f32| {
|
||||
let r = egui::Rect::from_min_size(pos2(50.0, 50.0), vec2(width, 40.0));
|
||||
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| artboard_label(ui.painter(), r, "01 - A long artboard name", Color32::WHITE));
|
||||
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| {
|
||||
artboard_label(ui.painter(), pos2(50.0, 50.0), width, "01 - A long artboard name", Color32::WHITE)
|
||||
});
|
||||
out.textures_delta.clear();
|
||||
out.shapes
|
||||
.into_iter()
|
||||
@@ -2473,8 +2508,10 @@ mod tests {
|
||||
used
|
||||
};
|
||||
let bounds = |app: &VectorcraftApp| app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().geometric_bounds().unwrap();
|
||||
// Direct Selection used last: the drag moves the rectangle's bottom-right corner only.
|
||||
// Direct Selection used last: the drag moves the rectangle's bottom-right corner only (once
|
||||
// the rectangle isn't selected whole, which would move all its anchors).
|
||||
app.select_tool("directSelection");
|
||||
app.session.execute("select.none", &json!({})).unwrap();
|
||||
let corner = xf.to_screen(Point::new(200.0, 150.0));
|
||||
assert_eq!(cmd_drag(&mut app, corner, corner + vec2(20.0, 20.0)), "directSelection");
|
||||
let b = bounds(&app);
|
||||
|
||||
+179
-12
@@ -47,13 +47,7 @@ pub fn app_bar(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
}
|
||||
ui.add_space(2.0);
|
||||
// With the macOS menu bar the menus are at the top of the screen instead.
|
||||
let menus_end = if app.services.native_menu.is_some() {
|
||||
let full = ui.max_rect();
|
||||
ui.painter().text(full.center(), egui::Align2::CENTER_CENTER, "VectorCraft", egui::FontId::proportional(13.5), t.text);
|
||||
ui.cursor().min.x
|
||||
} else {
|
||||
menus::menu_bar(app, ui)
|
||||
};
|
||||
let menus_end = if app.services.native_menu.is_some() { ui.cursor().min.x } else { menus::menu_bar(app, ui) };
|
||||
// The right-side group fills the space after the menus from the right; when it runs
|
||||
// short, Discord goes first (it is also under Help), then the search icon (the palette
|
||||
// stays under its shortcut), then the workspace switcher narrows.
|
||||
@@ -97,6 +91,14 @@ pub fn app_bar(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
ui.add_space(10.0);
|
||||
crate::community::discord_button(app, ui, false);
|
||||
}
|
||||
if app.services.native_menu.is_some() {
|
||||
let title = ui.painter().layout_no_wrap("VectorCraft".into(), egui::FontId::proportional(13.5), t.text);
|
||||
let title_rect = egui::Rect::from_center_size(full.center(), title.size());
|
||||
// Keep the title centered only while the interactive groups leave room for it.
|
||||
if title_rect.left() >= menus_end + 8.0 && title_rect.right() <= ui.min_rect().left() - 8.0 {
|
||||
ui.painter().galley(title_rect.min, title, t.text);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
if custom {
|
||||
@@ -153,7 +155,9 @@ pub enum AnchorControls {
|
||||
|
||||
/// Which anchor controls show for the selection and the active tool.
|
||||
pub fn anchor_controls(app: &VectorcraftApp) -> AnchorControls {
|
||||
let Some(st) = app.session.active() else { return AnchorControls::None };
|
||||
let Some(st) = app.session.active() else {
|
||||
return AnchorControls::None;
|
||||
};
|
||||
if !st.selection.anchors.is_empty() {
|
||||
return AnchorControls::All;
|
||||
}
|
||||
@@ -229,7 +233,9 @@ pub fn control_bar(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
let first = sel.first().and_then(|id| st.doc.node(*id)).cloned();
|
||||
let anchors = anchor_controls(app);
|
||||
let label = match &first {
|
||||
Some(_) if sel.len() == 1 && anchors != AnchorControls::None => tl!("Anchor Point"),
|
||||
Some(_) if sel.len() == 1 && anchors != AnchorControls::None => {
|
||||
tl!("Anchor Point")
|
||||
}
|
||||
Some(vectorcraft_doc::Node { kind: NodeKind::Image(im), .. }) if sel.len() == 1 => {
|
||||
if im.link.is_some() {
|
||||
tl!("Linked File")
|
||||
@@ -237,7 +243,9 @@ pub fn control_bar(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
tl!("Embedded")
|
||||
}
|
||||
}
|
||||
Some(n) if sel.len() == 1 && crate::panels::image_trace::is_trace(n) => tl!("Image Tracing"),
|
||||
Some(n) if sel.len() == 1 && crate::panels::image_trace::is_trace(n) => {
|
||||
tl!("Image Tracing")
|
||||
}
|
||||
_ => tl!(crate::panels::appearance::object_label(app)),
|
||||
};
|
||||
ui.label(egui::RichText::new(label).font(theme::semibold(12.0)).color(t.text));
|
||||
@@ -571,11 +579,12 @@ pub fn status_bar(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
let more = app.background.jobs.len() - 1;
|
||||
let label = crate::i18n::msg(&job.label);
|
||||
let text = if more > 0 { format!("{label}… (+{more})") } else { format!("{label}…") };
|
||||
ui.label(egui::RichText::new(text).size(11.0).color(t.text));
|
||||
ui.add(egui::Spinner::new().size(12.0).color(t.accent));
|
||||
ui.add(egui::Label::new(egui::RichText::new(text).size(11.0).color(t.text)).truncate());
|
||||
} else if !app.ui.status.is_empty() {
|
||||
// The message stays English in `ui.status` (agents and tests read it).
|
||||
ui.label(egui::RichText::new(crate::i18n::msg(&app.ui.status)).size(11.0).color(t.text));
|
||||
let message = crate::i18n::msg(&app.ui.status);
|
||||
ui.add(egui::Label::new(egui::RichText::new(message).size(11.0).color(t.text)).truncate());
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1074,3 +1083,161 @@ mod tests {
|
||||
assert_eq!(app.last_window_title, "foo.svg \u{2014} VectorCraft");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod visual_regressions {
|
||||
use super::*;
|
||||
|
||||
fn scaled_input(size: egui::Vec2, scale: f32) -> egui::RawInput {
|
||||
let mut input = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, size)), ..Default::default() };
|
||||
input.viewports.entry(egui::ViewportId::ROOT).or_default().native_pixels_per_point = Some(scale);
|
||||
input
|
||||
}
|
||||
|
||||
struct NativeMenus;
|
||||
impl crate::native_menu::Backend for NativeMenus {
|
||||
fn sync(&mut self, _: &crate::native_menu::MenuBar) {}
|
||||
fn drain(&mut self) -> Vec<crate::native_menu::Event> {
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capture_chrome_visual_fixtures() {
|
||||
let Some(directory) = std::env::var_os("CRAFT_UI_VISUAL_FIXTURES").map(std::path::PathBuf::from) else {
|
||||
return;
|
||||
};
|
||||
std::fs::create_dir_all(&directory).unwrap();
|
||||
for brightness in theme::Brightness::ALL {
|
||||
for width in [800.0, 1280.0] {
|
||||
for scale in [1.0, 1.5, 2.0] {
|
||||
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
|
||||
app.services.native_menu = Some(crate::native_menu::NativeMenu::new(Box::new(NativeMenus)));
|
||||
app.ui.status = format!("Saved /Users/example/Documents/{}/illustration.svg", "long-folder-name/".repeat(10));
|
||||
let mut ready = false;
|
||||
let mut harness =
|
||||
egui_kittest::Harness::builder().with_size(vec2(width, 200.0)).with_pixels_per_point(scale).wgpu().build_ui_state(
|
||||
move |ui, app: &mut VectorcraftApp| {
|
||||
if !ready {
|
||||
egui_extras::install_image_loaders(ui.ctx());
|
||||
theme::install_fonts(ui.ctx());
|
||||
theme::apply(ui.ctx(), brightness);
|
||||
ready = true;
|
||||
return;
|
||||
}
|
||||
app_bar(app, ui);
|
||||
status_bar(app, ui);
|
||||
},
|
||||
app,
|
||||
);
|
||||
harness.input_mut().max_texture_side = Some(8192);
|
||||
harness.run_steps(4);
|
||||
let stem = format!("vectorcraft-chrome-{brightness:?}-{width}-{scale}x");
|
||||
harness.render().unwrap().save(directory.join(format!("{stem}.png"))).unwrap();
|
||||
let mut regions = serde_json::Map::new();
|
||||
for id in ["app_bar", "status_bar"] {
|
||||
let rect = egui::containers::panel::PanelState::load(&harness.ctx, egui::Id::new(id)).unwrap().outer_rect;
|
||||
regions.insert(id.into(), json!([rect.left(), rect.top(), rect.width(), rect.height()]));
|
||||
}
|
||||
let dynamic: Vec<_> = harness
|
||||
.output()
|
||||
.shapes
|
||||
.iter()
|
||||
.filter_map(|shape| match &shape.shape {
|
||||
egui::Shape::Text(text) if text.galley.job.text.contains(" fps") => {
|
||||
let r = text.visual_bounding_rect();
|
||||
Some(json!([r.left(), r.top(), r.width(), r.height()]))
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
std::fs::write(
|
||||
directory.join(format!("{stem}.json")),
|
||||
serde_json::to_vec_pretty(&json!({
|
||||
"screen": [width, 200.0], "scale": scale, "regions": regions, "native_dynamic_text_regions": dynamic
|
||||
}))
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_title_does_not_overlap_header_controls() {
|
||||
for brightness in theme::Brightness::ALL {
|
||||
for width in [800.0, 1280.0] {
|
||||
for scale in [1.0, 1.5, 2.0] {
|
||||
let ctx = egui::Context::default();
|
||||
egui_extras::install_image_loaders(&ctx);
|
||||
theme::install_fonts(&ctx);
|
||||
theme::apply(&ctx, brightness);
|
||||
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
|
||||
app.services.native_menu = Some(crate::native_menu::NativeMenu::new(Box::new(NativeMenus)));
|
||||
let mut output = ctx.run_ui(scaled_input(vec2(width, 640.0), scale), |ui| app_bar(&mut app, ui));
|
||||
output.textures_delta.clear();
|
||||
assert_eq!(ctx.content_rect().size(), vec2(width, 640.0));
|
||||
assert_eq!(ctx.pixels_per_point(), scale);
|
||||
let texts: Vec<_> = output
|
||||
.shapes
|
||||
.iter()
|
||||
.filter_map(|shape| match &shape.shape {
|
||||
egui::Shape::Text(text) => Some(text),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
let title = texts.iter().find(|text| text.galley.job.text == "VectorCraft");
|
||||
if width >= 1280.0 {
|
||||
assert!(title.is_some(), "the title remains visible when the header has room");
|
||||
}
|
||||
if let Some(title) = title {
|
||||
for text in texts.iter().filter(|text| text.galley.job.text != "VectorCraft") {
|
||||
assert!(
|
||||
!title.visual_bounding_rect().intersects(text.visual_bounding_rect()),
|
||||
"{width}: title overlaps {:?}",
|
||||
text.galley.job.text
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn long_status_stays_between_navigation_and_performance_readouts() {
|
||||
for brightness in theme::Brightness::ALL {
|
||||
for width in [800.0, 1280.0] {
|
||||
for scale in [1.0, 1.5, 2.0] {
|
||||
let ctx = egui::Context::default();
|
||||
egui_extras::install_image_loaders(&ctx);
|
||||
theme::install_fonts(&ctx);
|
||||
theme::apply(&ctx, brightness);
|
||||
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
|
||||
app.ui.status = format!("Saved /Users/example/Documents/{}/illustration.svg", "long-folder-name/".repeat(10));
|
||||
let mut output = ctx.run_ui(scaled_input(vec2(width, 640.0), scale), |ui| status_bar(&mut app, ui));
|
||||
output.textures_delta.clear();
|
||||
assert_eq!(ctx.content_rect().size(), vec2(width, 640.0));
|
||||
assert_eq!(ctx.pixels_per_point(), scale);
|
||||
let texts: Vec<_> = output
|
||||
.shapes
|
||||
.iter()
|
||||
.filter_map(|shape| match &shape.shape {
|
||||
egui::Shape::Text(text) => Some(text),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
let status = texts.iter().find(|text| text.galley.job.text.starts_with("Saved ")).expect("status stays visible");
|
||||
assert_eq!(status.galley.rows.len(), 1, "status is one line");
|
||||
assert!(status.galley.elided, "long status retains the full text for the label tooltip");
|
||||
let bounds = status.visual_bounding_rect();
|
||||
assert!(bounds.left() >= 0.0 && bounds.right() <= width, "{width}: {bounds:?}");
|
||||
for text in texts.iter().filter(|text| !text.galley.job.text.starts_with("Saved ")) {
|
||||
assert!(!bounds.intersects(text.visual_bounding_rect()), "{width}: status overlaps {:?}", text.galley.job.text);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -104,6 +104,9 @@ pub fn inspect(app: &VectorcraftApp, ctx: &egui::Context) -> Value {
|
||||
"nativeMenuBar": app.services.native_menu.is_some(),
|
||||
// Saves and exports still being written in the background (Background Save / Export).
|
||||
"background": app.background.jobs.iter().map(|j| j.label.as_str()).collect::<Vec<_>>(),
|
||||
// A native file dialog open off the UI thread (`open`, `place`, `save`, `folder`), waiting
|
||||
// for the person at the computer: what asked for it runs again when it answers.
|
||||
"fileDialog": app.picks.waiting_kind(),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
Loaded 100 of 188 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user