Compare commits
44
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9dfa7bf931 | ||
|
|
02a68e1738 | ||
|
|
b631002c58 | ||
|
|
041776fc9f | ||
|
|
78155ed5b8 | ||
|
|
51c18f603a | ||
|
|
66add30d65 | ||
|
|
b3f6edddfe | ||
|
|
a046fd30fe | ||
|
|
1e5f430ac5 | ||
|
|
f3040e3762 | ||
|
|
db54eefeae | ||
|
|
37954523ae | ||
|
|
c391e1dbf2 | ||
|
|
20ec49b2e7 | ||
|
|
8715130c8b | ||
|
|
6689be0351 | ||
|
|
c2c8a07b75 | ||
|
|
b83eafd794 | ||
|
|
e93fc20294 | ||
|
|
9e2a7c41a0 | ||
|
|
a4ba47a439 | ||
|
|
93e8c00607 | ||
|
|
1e0063258a | ||
|
|
15cef94ba6 | ||
|
|
431dc43e43 | ||
|
|
e750891912 | ||
|
|
fb2089aaf5 | ||
|
|
88bd4812e0 | ||
|
|
6f9e2d5c16 | ||
|
|
d084af162f | ||
|
|
e01b9a20d9 | ||
|
|
bd9b8a2db3 | ||
|
|
c67c25128c | ||
|
|
b9e5536794 | ||
|
|
15e1f6327f | ||
|
|
57a4d9c6e1 | ||
|
|
e87abf02a8 | ||
|
|
e4c2b3db94 | ||
|
|
2ec817e76a | ||
|
|
9e39a6d124 | ||
|
|
ec8dc0c2da | ||
|
|
22e06db73d | ||
|
|
aebeb66268 |
No files matched your search
Generated
+1
@@ -4580,6 +4580,7 @@ dependencies = [
|
||||
"vectorcraft-testkit",
|
||||
"vectorcraft-text",
|
||||
"windows",
|
||||
"winsafe",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -148,6 +148,9 @@ exposes to agents, and exported by VectorCraft's own renderer. The source files
|
||||
## Quick start
|
||||
|
||||
```sh
|
||||
git clone https://github.com/storytold/vectorcraft
|
||||
cd vectorcraft
|
||||
|
||||
cargo run --release -p vectorcraft # desktop app
|
||||
cargo run --release -p vectorcraft -- examples/dusk-poster.vectorcraft
|
||||
cargo run --release -p vectorcraft -- --control 7979 # + JSON control channel
|
||||
@@ -217,7 +220,7 @@ doesn't write Affinity files. Current `.af` validation includes 33 pinned files,
|
||||
and every-board SVG/PDF/PSD export, with fixes for Affinity 3 artboards, source-backed JPEGs and text runs.
|
||||
[Scope and limits](crates/affinity/README.md); [source audit and remaining gaps](docs/affinity-validation.md).
|
||||
The interface
|
||||
speaks English, German, Japanese, Korean, Traditional and Simplified Chinese, Spanish, French, Hungarian, Italian, Russian and Ukrainian (and Czech and Brazilian Portuguese in the menus);
|
||||
speaks English, German, Japanese, Korean, Vietnamese, Traditional and Simplified Chinese, Spanish, French, Hungarian, Italian, Polish, Russian and Ukrainian (and Czech and Brazilian Portuguese in the menus);
|
||||
[localization-parity.md](docs/localization-parity.md) has the details.
|
||||
The scores are self-assessed from our code against Illustrator's public documentation;
|
||||
[target-app-parity.md](docs/target-app-parity.md#how-this-was-measured-2026-10-10) explains how far to trust them.
|
||||
|
||||
+6
-5
@@ -6,7 +6,7 @@
|
||||
|
||||
**Stage: alpha** · next: beta, ~13 points (ready for real work ~62% → ~75%, and reliable `.ai` exchange) and ~170–270 h away
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (Image Trace colour and photo presets, measured in `docs/image-trace-quality.md`, headline numbers unchanged; community PRs and fixes of 2026-10-12: Split Into Grid options, #1462; Transform Objects and Reflect across an angle, #1448 and #1460; Pathfinder on a group, #1450; unknown parameter keys refused, #1442; grayscale TIFFs with alpha, #1466 in part; many-object edits, #1429 in part; headline numbers unchanged; Image Trace places edges to a fraction of a pixel, measured in `docs/image-trace-quality.md`, headline numbers unchanged; Transform dialogs preview and the transform tools act on direct-selected anchors, #1412 and #1408; `.ai` compound path paint, #1410, overprint, #1388, and text-only PDF parts, #1387; community PRs: strokes wider than their shape, corner drags without the bounding box, pasted pictures, compound paths in `document.inspect`, MCP guide; headline numbers unchanged; community PRs: `.ai` editing-data follow-ups (gradient stops, type of objects with an appearance, short area-type frames), PDF bleed with editing data, dialog keyboard focus, art flush with an artboard, font rescan, symbol instance redraw, `--control` port check; headline numbers unchanged; Rectangular Grid and Polar Grid tool options, #1377 in part; `.ai` files with placed (linked) pictures open from their editing data, #1264; community PRs: `.ai` and PDF bleed and editing-data import, gradient shapes imported as paths, ruler origin, Align To, Live Corners corner selection; headline numbers unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; community PRs: graph designs, Graph Data worksheet and Graph Type options; Align both centres; Pen endpoint fixes; earlier: Effect › Brush Strokes landed: Photoshop-style filters 28 of 57, raster effects 40% → 48%; `.ai` gradients, meshes and artboard names from the editing data; headline numbers unchanged; `.ai` art styles and clipping fills from the editing data; headline numbers unchanged; `editingData: "always"`; headline numbers unchanged) · **Target:** Adobe Illustrator 2026 (30.x)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (community PRs and fixes of late 2026-10-12: type that does not fit its frame is reported, #1471; Tabs panel drags, #1458; template layers in Outline view, #1486; dialog slider rails, #1483; Direct Selection anchor precedence, #1508; print-size images, #1517; French catalog complete again, #1511; headline numbers unchanged; Image Trace colour and photo presets, measured in `docs/image-trace-quality.md`, headline numbers unchanged; community PRs and fixes of 2026-10-12: Split Into Grid options, #1462; Transform Objects and Reflect across an angle, #1448 and #1460; Pathfinder on a group, #1450; unknown parameter keys refused, #1442; grayscale TIFFs with alpha, #1466 in part; many-object edits, #1429 in part; headline numbers unchanged; Image Trace places edges to a fraction of a pixel, measured in `docs/image-trace-quality.md`, headline numbers unchanged; Transform dialogs preview and the transform tools act on direct-selected anchors, #1412 and #1408; `.ai` compound path paint, #1410, overprint, #1388, and text-only PDF parts, #1387; community PRs: strokes wider than their shape, corner drags without the bounding box, pasted pictures, compound paths in `document.inspect`, MCP guide; headline numbers unchanged; community PRs: `.ai` editing-data follow-ups (gradient stops, type of objects with an appearance, short area-type frames), PDF bleed with editing data, dialog keyboard focus, art flush with an artboard, font rescan, symbol instance redraw, `--control` port check; headline numbers unchanged; Rectangular Grid and Polar Grid tool options, #1377 in part; `.ai` files with placed (linked) pictures open from their editing data, #1264; community PRs: `.ai` and PDF bleed and editing-data import, gradient shapes imported as paths, ruler origin, Align To, Live Corners corner selection; headline numbers unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; community PRs: graph designs, Graph Data worksheet and Graph Type options; Align both centres; Pen endpoint fixes; earlier: Effect › Brush Strokes landed: Photoshop-style filters 28 of 57, raster effects 40% → 48%; `.ai` gradients, meshes and artboard names from the editing data; headline numbers unchanged; `.ai` art styles and clipping fills from the editing data; headline numbers unchanged; `editingData: "always"`; headline numbers unchanged) · **Target:** Adobe Illustrator 2026 (30.x)
|
||||
|
||||
VectorCraft is a clean-room, open-source, pure-Rust reimplementation of the Adobe Illustrator workflow. It runs
|
||||
on macOS, Windows, Linux, FreeBSD and the web (WASM), and agents can drive all of it over MCP, the CLI and a JSON
|
||||
@@ -35,7 +35,7 @@ blocking it ([Alpha gate](docs/roadmap.md#alpha-gate)).
|
||||
|
||||
| Audience | Ready % | Opus 5.5 agent hours to ~95% (one agent) | With 4–6 agents | Work that dominates |
|
||||
|---|---:|---:|---:|---|
|
||||
| **Full Illustrator** (ready for real work; decides the stage) | **~62%** (57–67%) | **~340–560 h** | ~95–160 h | the mainstream work below, plus 3D (50–85 h), raster effects (23–39 h), advanced type (20–28 h), generative AI (30–60 h), localization (60–100 h) and ecosystem |
|
||||
| **Full Illustrator** (ready for real work; decides the stage) | **~62%** (57–67%) | **~340–560 h** | ~95–160 h | the mainstream work below, plus 3D (50–85 h), raster effects (23–39 h), advanced type (20–28 h), generative AI (30–60 h), localization (55–90 h) and ecosystem |
|
||||
| **Mainstream illustrator** | **~65%** (60–70%) | **~230–380 h** | ~65–110 h | the interaction-fidelity pass (60–90 h), hardening and QA (30–50 h), stability (20–35 h), performance budgets (15–25 h), `.ai` exchange (10–20 h), the remaining depth of the everyday areas |
|
||||
| **Essentials user** | **~75%** (70–80%) | **~60–105 h** | ~25–45 h | launch and stability on Windows (20–35 h), in-app help and onboarding (8–15 h), basic QA (10–15 h), Shape Builder regions (4–8 h), UI polish |
|
||||
|
||||
@@ -52,7 +52,7 @@ everything is measured from our code against Illustrator's public documentation.
|
||||
| UI/UX fidelity | 55% | 70–110 | [ui-parity.md](docs/ui-parity.md) |
|
||||
| File formats | 75% | 15–30 | [file-format-parity.md](docs/file-format-parity.md) |
|
||||
| Hardware (GPU, pen, touch, displays) | 35% | 25–45 | [hardware-parity.md](docs/hardware-parity.md) |
|
||||
| Localization (12 key languages) | 63% (measured) | 60–100 | [localization-parity.md](docs/localization-parity.md) |
|
||||
| Localization (12 key languages) | 71% (measured) | 55–90 | [localization-parity.md](docs/localization-parity.md) |
|
||||
| Performance | ~60% | 15–25 | [gaps.md › G15](docs/gaps.md#g15-performance-budgets-and-a-gpu-renderer) |
|
||||
| Stability | ~60% | 30–50 | [gaps.md › G10](docs/gaps.md#g10-launch-and-stability-on-real-machines) |
|
||||
| Platforms | 80% | 15–25 | [gaps.md › G8](docs/gaps.md#g8-hardening-at-scale) |
|
||||
@@ -107,7 +107,7 @@ Weights, evidence and the missing items per area: [target-app-parity.md](docs/ta
|
||||
| Japanese | `ja` | full | 100% |
|
||||
| German | `de` | full | 98% |
|
||||
| Korean | `ko` | full | 100% |
|
||||
| Vietnamese | `vi` | none | 0% |
|
||||
| Vietnamese | `vi` | full | 100% |
|
||||
|
||||
Also shipped: Traditional Chinese (partial, 80%), Hungarian, Italian, Russian and Ukrainian (full), Czech (catalog complete; render-site gaps reduced; native review pending).
|
||||
No catalog has had a native speaker's review. Details: [localization-parity.md](docs/localization-parity.md).
|
||||
@@ -129,6 +129,7 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap
|
||||
|---|---|
|
||||
| 2026-10-13 | Image Trace's colour presets trace flat art with its own colours however many are asked for (noise no longer shatters it: 16 Colors 70 → 4 paths on the harness's flat art; no slivers of in-between colour along anti-aliased edges), and the photo presets trace a photo into smooth tones without specks or hairlines (High Fidelity Photo 3,653 → 121 paths, colour error 2.30 → 1.75; 16 megapixels traced where 4 were refused) ([`docs/image-trace-quality.md`](docs/image-trace-quality.md)). |
|
||||
| 2026-10-13 | Image Trace traces anti-aliased and blurred images to a fraction of a pixel in Black and White, Grayscale and Color, with corners put back, straight edges as lines and round outlines as circles; a harness measures every preset before and after ([`docs/image-trace-quality.md`](docs/image-trace-quality.md)): outlines 0.31 → 0.09 px off on average (Default), 0.27 → 0.06 (Black and White Logo). |
|
||||
| 2026-10-13 | Effect › 3D and Materials: Inflate takes Twist, Taper and Cap (Hollow leaves the back open), as the 3D docs list for every type. |
|
||||
| 2026-10-12 | Effect › 3D and Materials: Extrude & Bevel (depth, twist, taper, solid or hollow caps, five bevel shapes with repeat, spacing, inside and both sides), Inflate (volume, both sides) and Rotate, with Position presets and the rotation and light gizmos; live, exported as vector faces, expandable (#1407). |
|
||||
| 2026-10-12 | `convert --editing-data` (`editingData: "always"`) opens a `.ai` from its editing data even where its page, which can be older, draws differently, with a note. |
|
||||
| 2026-10-12 | `.ai` import reads an object's art style (a fill and a stroke, variable widths too, with an Outer Glow or Drop Shadow) as its own appearance, paints a clipping path's own fill under what it clips and keeps a group's transparency as its PDF part draws them. |
|
||||
@@ -136,7 +137,7 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap
|
||||
| 2026-10-12 | The Rectangular Grid and Polar Grid tools take their options: a click opens the size, the divider counts with a skew slider each, Use Outside Rectangle As Frame (rectangular) or Create Compound Path From Ellipses (polar) and Fill Grid; the choices stay with the tool, so dragging draws with them, and scripts and agents pass them to `shape.rectangularGrid` and `shape.polarGrid` (#1377, in part). |
|
||||
| 2026-10-12 | A `.ai` file with placed (linked) pictures opens from its editing data, with its guides, sublayers and hidden objects: each picture is a linked image in its place, showing the low-resolution preview the file keeps until its file is found or relinked (#1264). Scores unchanged. |
|
||||
| 2026-10-12 | Desktop printing adopts the shared `craft-print` spooler with exact physical sheet sizes. Windows 7 retains its WMI/viewer transport and excludes the shared backend; compatibility CI checks the dependency tree and PE imports. Scores unchanged. |
|
||||
| 2026-10-12 | Graphs: Object › Graph › Design… and Marker… (designs with a preview, New Design, Rename, Delete Design, Paste Design, Select Unused, and markers drawn with a design or None) (#1092); Graph Data opens as a worksheet with Import Data…, column width and decimals (#1138), and takes Transpose, Switch x/y, `|` line breaks in labels and the pie Legend, Position and Sort options (#1115); Graph Type adds Add Drop Shadow and Add Legend Across Top (#1119), widths past 100%, First Row and First Column in Front, and Draw Filled Lines (#1136); graphs keep their label formatting when drawn again (#1137). Align: a Horizontal & Vertical Align Center button (#1073); Align to Key Object makes the topmost selected object the key (#1221), and the palette's key entry reads Cancel Key Object (#1224). Pen: the nearest end wins over snapping, Alt on the last anchor edits it, and Enter, Escape or a tool switch finish a handle preview as one undo step (#821). Direct Selection: a click on an anchor of an already selected path selects that anchor alone (#1237). Make Compound Path keeps the backmost object's paint, and a selected member reads and changes the compound's fill rule (#1225). Shape Builder and Live Paint no longer turn a line's loose end past a crossing into a piece that runs out and back (#1230). Art moved or duplicated with an artboard follows Transform Pattern Tiles (#1219). A click with the Rectangular Grid tool opens its options dialog (#1169). Commands that change nothing (Lock, Hide, Transform Each, Rearrange Artboards and others) add no undo step (#1233). Saving with missing placed files keeps their relink data, and .ai export honours the chosen artboards (#718). SVG export keeps Unicode data-attribute names, and SVG Options refuses an invalid artboard range (#989). CMYK colours given in percent with a component under 1 keep their scale (#1249). Colour Image Trace keeps close dark tones apart (#1247). macOS: the Save sheet has a Format menu (#1235). The Hand and Zoom tools show drawn cursors on every platform (#1238). Czech: the remaining strings are translated, and Pathfinder's Add reads "Sjednotit" (#1226). MCP: a lost reply reports that the edit may have happened instead of replaying it (#1246); `select.key` checks its id and agents can read the key object (#1222). Tests: the command sweeps and import fuzz tests fail on a panic caught by the engine's guard (#1218), and the envelope export test ignores PDF dates and file IDs (#1220). Effect › Artistic › Film Grain (Grain, Highlight Area, Intensity; Photoshop-style filters 29/57) (#1282), and Craquelure, Mosaic Tiles and Stained Glass redraw 25–30% faster with identical pixels (#1265). Direct Selection: clicking a segment selects only that segment, and Delete removes just it (#1300); Shift-click deselects one anchor of a path selected whole (#1263). Pen + Alt converts anchors of paths inside a selected group or compound path (#1306). The Curvature tool shows circular point markers without direction handles (#1284). Snap to Point: pressing near an anchor drags by that anchor, with Smart Guides on or off (#1313). Transform Again repeats the options of the transform it repeats (#1303). Aligning anchors that are already aligned adds no undo step (#1276), and type commands that change nothing record none either (#1316). Simplify keeps the path selected with its new anchors, and its dialog previews and shows the anchor count (#1305). Adding a stroke colour to an unpainted zero-width stroke gives it a visible 1 pt width (#1256). The Eyedropper samples locked objects, template layers and art outside isolation mode (#1260), and shows a pipette cursor (#1288). Fonts named by their full name resolve to the right face (#1267), and Proportional Metrics brings the matching kerning (`apkn`, `vkrn`, `vapk`) (#1291). Artboards: Escape leaves Artboard editing (#1296), Document Setup's Edit Artboards switches to the Artboard tool (#1294), and the Artboard tool starts on the active artboard (#1309). New and imported documents use the Gridline every and Subdivisions preferences (#1314). Send to Current Layer refuses a hidden or locked current layer (#1301), and layer row commands check explicit ids (#1255). Links: Find in Folder… finds missing linked files in a folder and its subfolders and can copy them into the document's folder (#1287). Image Trace traces images over 64 megapixels scaled down instead of refusing them (#1315). DXF export marks closed curves as closed SPLINE entities (#1320). EPS import: closepath respects the path size limit (#1311). The interface is available in Hungarian (#1302), and more Czech labels are translated (#1259). Linux RPMs declare shared-library capabilities (#1290). CI pins every third-party action to a verified commit (#1321). Select menu commands skip hidden and locked art and stay inside the isolated group in isolation mode (#1325). Fill, stroke, gradient and Appearance panel commands that change nothing add no undLine truncated
|
||||
| 2026-10-12 | Graphs: Object › Graph › Design… and Marker… (designs with a preview, New Design, Rename, Delete Design, Paste Design, Select Unused, and markers drawn with a design or None) (#1092); Graph Data opens as a worksheet with Import Data…, column width and decimals (#1138), and takes Transpose, Switch x/y, `|` line breaks in labels and the pie Legend, Position and Sort options (#1115); Graph Type adds Add Drop Shadow and Add Legend Across Top (#1119), widths past 100%, First Row and First Column in Front, and Draw Filled Lines (#1136); graphs keep their label formatting when drawn again (#1137). Align: a Horizontal & Vertical Align Center button (#1073); Align to Key Object makes the topmost selected object the key (#1221), and the palette's key entry reads Cancel Key Object (#1224). Pen: the nearest end wins over snapping, Alt on the last anchor edits it, and Enter, Escape or a tool switch finish a handle preview as one undo step (#821). Direct Selection: a click on an anchor of an already selected path selects that anchor alone (#1237). Make Compound Path keeps the backmost object's paint, and a selected member reads and changes the compound's fill rule (#1225). Shape Builder and Live Paint no longer turn a line's loose end past a crossing into a piece that runs out and back (#1230). Art moved or duplicated with an artboard follows Transform Pattern Tiles (#1219). A click with the Rectangular Grid tool opens its options dialog (#1169). Commands that change nothing (Lock, Hide, Transform Each, Rearrange Artboards and others) add no undo step (#1233). Saving with missing placed files keeps their relink data, and .ai export honours the chosen artboards (#718). SVG export keeps Unicode data-attribute names, and SVG Options refuses an invalid artboard range (#989). CMYK colours given in percent with a component under 1 keep their scale (#1249). Colour Image Trace keeps close dark tones apart (#1247). macOS: the Save sheet has a Format menu (#1235). The Hand and Zoom tools show drawn cursors on every platform (#1238). Czech: the remaining strings are translated, and Pathfinder's Add reads "Sjednotit" (#1226). MCP: a lost reply reports that the edit may have happened instead of replaying it (#1246); `select.key` checks its id and agents can read the key object (#1222). Tests: the command sweeps and import fuzz tests fail on a panic caught by the engine's guard (#1218), and the envelope export test ignores PDF dates and file IDs (#1220). Effect › Artistic › Film Grain (Grain, Highlight Area, Intensity; Photoshop-style filters 29/57) (#1282), and Craquelure, Mosaic Tiles and Stained Glass redraw 25–30% faster with identical pixels (#1265). Direct Selection: clicking a segment selects only that segment, and Delete removes just it (#1300); Shift-click deselects one anchor of a path selected whole (#1263). Pen + Alt converts anchors of paths inside a selected group or compound path (#1306). The Curvature tool shows circular point markers without direction handles (#1284). Snap to Point: pressing near an anchor drags by that anchor, with Smart Guides on or off (#1313). Transform Again repeats the options of the transform it repeats (#1303). Aligning anchors that are already aligned adds no undo step (#1276), and type commands that change nothing record none either (#1316). Simplify keeps the path selected with its new anchors, and its dialog previews and shows the anchor count (#1305). Adding a stroke colour to an unpainted zero-width stroke gives it a visible 1 pt width (#1256). The Eyedropper samples locked objects, template layers and art outside isolation mode (#1260), and shows a pipette cursor (#1288). Fonts named by their full name resolve to the right face (#1267), and Proportional Metrics brings the matching kerning (`apkn`, `vkrn`, `vapk`) (#1291). Artboards: Escape leaves Artboard editing (#1296), Document Setup's Edit Artboards switches to the Artboard tool (#1294), and the Artboard tool starts on the active artboard (#1309). New and imported documents use the Gridline every and Subdivisions preferences (#1314). Send to Current Layer refuses a hidden or locked current layer (#1301), and layer row commands check explicit ids (#1255). Links: Find in Folder… finds missing linked files in a folder and its subfolders and can copy them into the document's folder (#1287). Image Trace traces images over 64 megapixels scaled down instead of refusing them (#1315). DXF export marks closed curves as closed SPLINE entities (#1320). EPS import: closepath respects the path size limit (#1311). The interface is available in Hungarian (#1302), and more Czech labels are translated (#1259). Linux RPMs declare shared-library capabilities (#1290). CI pins every third-party action to a verified commit (#1321). Select menu commands skip hidden and locked art and stay inside the isolated group in isolation mode (#1325). Fill, stroke, gradient and Appearance panel commands that change nothing add no undLine truncated
|
||||
| 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 |
|
||||
|
||||
@@ -26,6 +26,12 @@ vectorcraft-text = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
log = { workspace = true }
|
||||
|
||||
# `perf` on Windows (src/perf.rs) reads how busy the processors are (GetSystemTimes) and this
|
||||
# process's working set (GetProcessMemoryInfo) through winsafe's safe wrappers over Win32 (MIT),
|
||||
# which the desktop app's src/gpu.rs also uses.
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
winsafe = { version = "0.0.29", default-features = false, features = ["kernel", "psapi"] }
|
||||
|
||||
# DirectWrite's system font collection (the desktop app's src/system_fonts.rs, shared): fonts that
|
||||
# font services such as Adobe Fonts load in place (#579). MIT OR Apache-2.0.
|
||||
[target.'cfg(all(windows, not(target_vendor = "win7")))'.dependencies]
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
//! vectorcraft-cli convert IN OUT [--scale 2] [--artboard 0 | --range 1-3,5] [--outline-text] [--decimals N] [--replace-lossy] [--editing-data]
|
||||
//! vectorcraft-cli info FILE
|
||||
//! vectorcraft-cli bench FILE [--size 2880x1800] [--iters 5]
|
||||
//! vectorcraft-cli perf [--paths 50000]
|
||||
//! vectorcraft-cli perf [--paths 50000] [--deep]
|
||||
//! ```
|
||||
// Denied, not forbidden: the font listers shared with the desktop app allow it for their
|
||||
// DirectWrite (Windows) and CoreText (macOS) calls, and nothing else may.
|
||||
@@ -104,9 +104,11 @@ USAGE:
|
||||
Render FILE (any readable format) fitted to WxH (default 2880x1800) and print ms per frame
|
||||
(warm), multithreaded and single-threaded.
|
||||
|
||||
vectorcraft-cli perf [--paths N]
|
||||
Check the performance budgets (render, pan, hit test, save/load, SVG, Pathfinder) on a
|
||||
synthetic N-path document (default 50000). Exits non-zero if a budget is exceeded.
|
||||
vectorcraft-cli perf [--paths N] [--deep]
|
||||
Check the performance budgets (render, pan, hit test, save/load, SVG, Pathfinder, handle
|
||||
and segment drags) on a synthetic N-path document (default 50000). Exits non-zero if a
|
||||
budget is exceeded, unless the report says the machine is busy. --deep also times work with
|
||||
no budget yet and prints memory use (the working set on Windows).
|
||||
";
|
||||
|
||||
/// The usage text plus the formats `document.open` reads and `document.export` writes.
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
//! `vectorcraft-cli perf`: the performance budgets (plan §4) measured on a synthetic document.
|
||||
//!
|
||||
//! Each row is the median of several runs. Timings are wall-clock: on a busy machine (load
|
||||
//! average above the core count) they are noise, and the report says so.
|
||||
//! Each row is the median of several runs. Timings are wall-clock: on a busy machine (a load
|
||||
//! average above three quarters of the cores, or on Windows more than three quarters of the
|
||||
//! cores busy over a second) they are noise, and the report says so.
|
||||
|
||||
use std::time::Instant;
|
||||
|
||||
@@ -61,6 +62,7 @@ fn median_ms(runs: usize, mut f: impl FnMut()) -> f64 {
|
||||
}
|
||||
|
||||
/// 1-minute load average (macOS / Linux), if available.
|
||||
#[cfg(not(windows))]
|
||||
fn load_average() -> Option<f64> {
|
||||
let out = std::process::Command::new("sysctl").args(["-n", "vm.loadavg"]).output().ok().filter(|o| o.status.success());
|
||||
let text = match out {
|
||||
@@ -70,6 +72,27 @@ fn load_average() -> Option<f64> {
|
||||
text.split_whitespace().map(|t| t.trim_matches(|c| c == '{' || c == '}')).find_map(|t| t.parse().ok())
|
||||
}
|
||||
|
||||
/// The cores busy on average over one second (Windows), if available: the share of the
|
||||
/// processors' time that `GetSystemTimes` reports as kernel or user time but not as idle time,
|
||||
/// times `cores`. A load average also counts the threads waiting for a core.
|
||||
#[cfg(windows)]
|
||||
fn busy_cores(cores: usize) -> Option<f64> {
|
||||
let times = || winsafe::GetSystemTimes().ok().map(|(idle, kernel, user)| [idle, kernel, user].map(u64::from));
|
||||
let before = times()?;
|
||||
std::thread::sleep(std::time::Duration::from_secs(1));
|
||||
busy_share(before, times()?).map(|share| share * cores as f64)
|
||||
}
|
||||
|
||||
/// The share of the processors' time between two `[idle, kernel, user]` readings of
|
||||
/// `GetSystemTimes` that was busy (kernel time includes idle time). It is None when no time
|
||||
/// passed, a time went back, or the idle time exceeds the kernel and user time.
|
||||
#[cfg(any(windows, test))]
|
||||
fn busy_share([idle0, kernel0, user0]: [u64; 3], [idle1, kernel1, user1]: [u64; 3]) -> Option<f64> {
|
||||
let total = kernel1.checked_sub(kernel0)?.checked_add(user1.checked_sub(user0)?)?;
|
||||
let busy = total.checked_sub(idle1.checked_sub(idle0)?)?;
|
||||
(total > 0).then(|| busy as f64 / total as f64)
|
||||
}
|
||||
|
||||
/// Milliseconds per pointer move of a Direct Selection drag of a handle, then of a segment, of a
|
||||
/// 5,000-anchor wave of smooth anchors added on top of the synthetic `n`-path document.
|
||||
fn edit_drags(n: usize) -> Result<(f64, f64), String> {
|
||||
@@ -114,12 +137,21 @@ fn edit_drags(n: usize) -> Result<(f64, f64), String> {
|
||||
}
|
||||
|
||||
/// Resident memory of this process in MB (`ps`), if available.
|
||||
#[cfg(not(windows))]
|
||||
fn rss_mb() -> Option<f64> {
|
||||
let pid = std::process::id().to_string();
|
||||
let out = std::process::Command::new("ps").args(["-o", "rss=", "-p", &pid]).output().ok().filter(|o| o.status.success())?;
|
||||
String::from_utf8_lossy(&out.stdout).trim().parse::<f64>().ok().map(|kb| kb / 1024.0)
|
||||
}
|
||||
|
||||
/// The working set of this process in MB (`GetProcessMemoryInfo`), if available: its pages that
|
||||
/// are in physical memory, the figure `ps` reports as resident memory elsewhere.
|
||||
#[cfg(windows)]
|
||||
fn rss_mb() -> Option<f64> {
|
||||
let counters = winsafe::HPROCESS::GetCurrentProcess().GetProcessMemoryInfo().ok()?;
|
||||
Some(counters.WorkingSetSize as f64 / (1024.0 * 1024.0))
|
||||
}
|
||||
|
||||
/// `--deep`: tracked rows (no budget yet) for work the budgets above don't reach: editing a whole
|
||||
/// selection, live effects, every Pathfinder operation, typing in a long story, and memory.
|
||||
fn deep(n: usize) -> Result<(), String> {
|
||||
@@ -243,8 +275,10 @@ fn deep(n: usize) -> Result<(), String> {
|
||||
for (name, ms) in &rows {
|
||||
outln!(" {name:<52} {ms:>10.2} ms");
|
||||
}
|
||||
// `rss_mb` reads the working set on Windows.
|
||||
let memory = if cfg!(windows) { "working set" } else { "memory" };
|
||||
for (what, mb) in &mem {
|
||||
outln!(" memory {what:<45} {mb:>8.0} MB");
|
||||
outln!(" {:<52} {mb:>8.0} MB", format!("{memory} {what}"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -261,8 +295,12 @@ pub fn run(args: &[String]) -> Result<(), String> {
|
||||
}
|
||||
}
|
||||
let cores = std::thread::available_parallelism().map_or(1, |c| c.get());
|
||||
let load = load_average();
|
||||
outln!("VectorCraft performance budgets — {n} paths, {cores} cores, load average {}", load.map_or("?".into(), |l| format!("{l:.1}")));
|
||||
// The figure is the 1-minute load average, or on Windows the cores busy over one second.
|
||||
#[cfg(not(windows))]
|
||||
let (load, label) = (load_average(), "load average");
|
||||
#[cfg(windows)]
|
||||
let (load, label) = (busy_cores(cores), "cores busy");
|
||||
outln!("VectorCraft performance budgets — {n} paths, {cores} cores, {label} {}", load.map_or("?".into(), |l| format!("{l:.1}")));
|
||||
let noisy = load.is_some_and(|l| l > cores as f64 * 0.75);
|
||||
if noisy {
|
||||
outln!("WARNING: the machine is busy; wall-clock timings below are not trustworthy.");
|
||||
@@ -358,3 +396,38 @@ pub fn run(args: &[String]) -> Result<(), String> {
|
||||
_ => Err(format!("{over} budget(s) exceeded")),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn busy_share_leaves_out_idle_time() {
|
||||
// In one second four processors log 40,000,000 ticks of 100 ns of kernel and user time,
|
||||
// 10,000,000 of them idle.
|
||||
assert_eq!(busy_share([7, 100, 50], [10_000_007, 25_000_100, 15_000_050]), Some(0.75));
|
||||
// There is no share when no time passed, a time went back, or the idle time exceeds the
|
||||
// kernel and user time.
|
||||
assert_eq!(busy_share([7, 100, 50], [7, 100, 50]), None);
|
||||
assert_eq!(busy_share([7, 100, 50], [8, 99, 60]), None);
|
||||
assert_eq!(busy_share([0, 0, 0], [30, 10, 10]), None);
|
||||
}
|
||||
|
||||
/// On Windows `busy_cores` gives a number between 0 and the cores.
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn windows_reads_how_many_cores_are_busy() {
|
||||
let cores = std::thread::available_parallelism().map_or(1, |c| c.get());
|
||||
let busy = busy_cores(cores);
|
||||
assert!(busy.is_some_and(|b| (0.0..=cores as f64).contains(&b)), "{busy:?} of {cores}");
|
||||
}
|
||||
|
||||
/// On Windows `rss_mb` gives this process's working set: more than 0 MB and less than
|
||||
/// 10,000 MB.
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn windows_reads_the_working_set() {
|
||||
let mb = rss_mb();
|
||||
assert!(mb.is_some_and(|mb| mb > 0.0 && mb < 10_000.0), "{mb:?}");
|
||||
}
|
||||
}
|
||||
@@ -83,6 +83,12 @@ fn help_lists_readable_formats() {
|
||||
assert!(out.contains("Readable formats: .vectorcraft") && out.contains(".ait") && out.contains(".webp"), "{out}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn help_lists_perf_deep() {
|
||||
let out = ok(&["--help"]);
|
||||
assert!(out.contains("vectorcraft-cli perf [--paths N] [--deep]") && out.contains("--deep also times"), "{out}");
|
||||
}
|
||||
|
||||
/// `info` reports the import warnings: an EPS read only up to an error names it.
|
||||
#[test]
|
||||
fn info_reports_why_an_eps_read_partly() {
|
||||
|
||||
@@ -2,20 +2,21 @@
|
||||
//! (`Services::system_clipboard`).
|
||||
//!
|
||||
//! - Windows: text, SVG, PDF, PNG and an opaque bitmap (for apps that don't read PNG) in one go;
|
||||
//! Paste reads each of them, a bitmap as PNG, else as the device-independent bitmap that
|
||||
//! Paste reads each of them and EPS, a bitmap as PNG, else as the device-independent bitmap that
|
||||
//! screenshots and most apps copy (`CF_DIBV5`, `CF_DIB`; the system makes them of a `CF_BITMAP`).
|
||||
//! - macOS and Linux: one format at a time, the text (a type-only copy's text or the SVG markup),
|
||||
//! else the PNG as a bitmap; Paste reads text and bitmaps (on macOS the pasteboard's PNG or TIFF
|
||||
//! as they are, as screenshots copy them). On Wayland, arboard's data-control
|
||||
//! else the PNG as a bitmap; Paste reads text and bitmaps, and on macOS the pasteboard's SVG, PDF
|
||||
//! and EPS as vector art (#1499) and its PNG or TIFF as they are, as screenshots copy them (on
|
||||
//! Linux arboard reads no other formats). On Wayland, arboard's data-control
|
||||
//! backend reads the compositor's clipboard (X11 through XWayland only sees what X11 apps
|
||||
//! copied), falling back to X11 where the compositor lacks the protocol.
|
||||
//! - Everywhere: files copied in a file manager paste as the first one that is art (SVG, PDF, a
|
||||
//! metafile or a bitmap), before the clipboard's other formats (the files' paths as text).
|
||||
//! - Everywhere: files copied in a file manager paste as the first one that is art (SVG, PDF, EPS,
|
||||
//! a metafile or a bitmap), before the clipboard's other formats (the files' paths as text).
|
||||
|
||||
use std::io::{Cursor, Read};
|
||||
use std::path::PathBuf;
|
||||
|
||||
use vectorcraft_engine::cmd::clipboard::{BITMAP, FILE_HEAD, Flavour, PNG, TEXT, file_flavour};
|
||||
use vectorcraft_engine::cmd::clipboard::{BITMAP, EPS, FILE_HEAD, Flavour, PDF, PNG, SVG, TEXT, file_flavour};
|
||||
use vectorcraft_ui_egui::SystemClipboard;
|
||||
|
||||
/// This platform's system clipboard.
|
||||
@@ -27,6 +28,34 @@ pub fn system_clipboard() -> Box<dyn SystemClipboard> {
|
||||
cb
|
||||
}
|
||||
|
||||
/// The registered Windows clipboard formats a flavour is written under (and read from, first held
|
||||
/// first).
|
||||
#[cfg_attr(not(windows), allow(dead_code))]
|
||||
fn windows_formats(mime: &str) -> &'static [&'static str] {
|
||||
match mime {
|
||||
SVG => &["image/svg+xml"],
|
||||
PDF => &["Portable Document Format", "application/pdf"],
|
||||
EPS => &["Encapsulated PostScript", "application/postscript"],
|
||||
PNG => &["PNG"],
|
||||
_ => &[],
|
||||
}
|
||||
}
|
||||
|
||||
/// The macOS pasteboard types Paste reads for `mime` (`image/*`: any bitmap), best first, each with
|
||||
/// the MIME type of its data. They are the system's uniform type identifiers (the values of
|
||||
/// `NSPasteboardTypePDF`, `NSPasteboardTypePNG` and `NSPasteboardTypeTIFF` among them) and the
|
||||
/// older name some apps still give PDF. Text is read through arboard.
|
||||
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
|
||||
fn pasteboard_types(mime: &str) -> &'static [(&'static str, &'static str)] {
|
||||
match mime {
|
||||
SVG => &[("public.svg-image", SVG)],
|
||||
PDF => &[("com.adobe.pdf", PDF), ("Apple PDF pasteboard type", PDF)], // brand-ok: the system's type identifier
|
||||
EPS => &[("com.adobe.encapsulated-postscript", EPS)], // brand-ok: the system's type identifier
|
||||
BITMAP => &[("public.png", PNG), ("public.tiff", "image/tiff")],
|
||||
_ => &[],
|
||||
}
|
||||
}
|
||||
|
||||
/// The text flavour among `flavours`.
|
||||
fn text_of(flavours: &[Flavour]) -> Option<String> {
|
||||
flavours.iter().find(|f| f.mime == TEXT).map(|f| String::from_utf8_lossy(&f.data).into_owned())
|
||||
@@ -127,27 +156,15 @@ mod win {
|
||||
use std::num::NonZeroU32;
|
||||
|
||||
use clipboard_win::{formats, options::NoClear, raw};
|
||||
use vectorcraft_engine::cmd::clipboard::{PDF, SVG};
|
||||
|
||||
use super::*;
|
||||
|
||||
/// The registered clipboard formats a flavour is written under (and read from, first held
|
||||
/// first).
|
||||
fn names(mime: &str) -> &'static [&'static str] {
|
||||
match mime {
|
||||
SVG => &["image/svg+xml"],
|
||||
PDF => &["Portable Document Format", "application/pdf"],
|
||||
PNG => &["PNG"],
|
||||
_ => &[],
|
||||
}
|
||||
}
|
||||
|
||||
/// The device-independent bitmaps, the one that carries alpha first. Each is there whenever the
|
||||
/// other or a `CF_BITMAP` is: the system converts between them.
|
||||
const DIBS: [u32; 2] = [formats::CF_DIBV5, formats::CF_DIB];
|
||||
|
||||
fn formats_of(mime: &str) -> impl Iterator<Item = u32> {
|
||||
names(mime).iter().filter_map(|n| raw::register_format(n)).map(NonZeroU32::get)
|
||||
windows_formats(mime).iter().filter_map(|n| raw::register_format(n)).map(NonZeroU32::get)
|
||||
}
|
||||
|
||||
fn err(e: impl std::fmt::Display) -> String {
|
||||
@@ -350,27 +367,30 @@ mod portable {
|
||||
if let Some(f) = cb.get().file_list().ok().and_then(|paths| copied_file(paths, mimes)) {
|
||||
return Some(f);
|
||||
}
|
||||
mimes.iter().find_map(|m| match *m {
|
||||
TEXT => cb.get_text().ok().filter(|t| !t.is_empty()).map(|t| Flavour { mime: TEXT, data: t.into_bytes() }),
|
||||
BITMAP => {
|
||||
#[cfg(target_os = "macos")]
|
||||
if let Some(f) = super::mac::bitmap() {
|
||||
return Some(f);
|
||||
mimes.iter().find_map(|m| {
|
||||
#[cfg(target_os = "macos")]
|
||||
if let Some(f) = super::mac::read(m) {
|
||||
return Some(f);
|
||||
}
|
||||
match *m {
|
||||
TEXT => cb.get_text().ok().filter(|t| !t.is_empty()).map(|t| Flavour { mime: TEXT, data: t.into_bytes() }),
|
||||
BITMAP => {
|
||||
let img = cb.get_image().ok()?;
|
||||
let rgba =
|
||||
image::RgbaImage::from_raw(u32::try_from(img.width).ok()?, u32::try_from(img.height).ok()?, img.bytes.into_owned())?;
|
||||
png_flavour(&rgba)
|
||||
}
|
||||
let img = cb.get_image().ok()?;
|
||||
let rgba = image::RgbaImage::from_raw(u32::try_from(img.width).ok()?, u32::try_from(img.height).ok()?, img.bytes.into_owned())?;
|
||||
png_flavour(&rgba)
|
||||
_ => None,
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Text, and on macOS a bitmap (by the pasteboard's types). Linux has no cheap way to ask
|
||||
/// for a bitmap (reading one costs as much as pasting it): the paste keys take it, the
|
||||
/// menu item waits for text.
|
||||
/// Text, and on macOS vector art or a bitmap (by the pasteboard's types). Linux has no
|
||||
/// cheap way to ask for a bitmap (reading one costs as much as pasting it): the paste keys
|
||||
/// take it, the menu item waits for text.
|
||||
fn has(&mut self, mimes: &[&'static str]) -> bool {
|
||||
#[cfg(target_os = "macos")]
|
||||
if mimes.contains(&BITMAP) && super::mac::has_bitmap() {
|
||||
if super::mac::has(mimes) {
|
||||
return true;
|
||||
}
|
||||
mimes.contains(&TEXT) && self.cb().is_ok_and(|cb| cb.get_text().is_ok_and(|t| !t.is_empty()))
|
||||
@@ -378,7 +398,8 @@ mod portable {
|
||||
}
|
||||
}
|
||||
|
||||
/// The macOS pasteboard's bitmaps as they are (arboard reads TIFF only, and decodes it).
|
||||
/// The macOS pasteboard's vector art and bitmaps as they are (arboard reads text, and of the
|
||||
/// pictures TIFF only, decoded).
|
||||
#[cfg(target_os = "macos")]
|
||||
mod mac {
|
||||
use objc2::rc::autoreleasepool;
|
||||
@@ -387,24 +408,20 @@ mod mac {
|
||||
|
||||
use super::*;
|
||||
|
||||
/// The bitmap types Paste reads, best first (the values of `NSPasteboardTypePNG` and
|
||||
/// `NSPasteboardTypeTIFF`), with their MIME types.
|
||||
const TYPES: [(&str, &str); 2] = [("public.png", PNG), ("public.tiff", "image/tiff")];
|
||||
|
||||
/// Does the pasteboard hold a bitmap? Asks for its types only.
|
||||
pub fn has_bitmap() -> bool {
|
||||
/// Does the pasteboard hold one of `mimes` ([`pasteboard_types`])? Asks for its types only.
|
||||
pub fn has(mimes: &[&'static str]) -> bool {
|
||||
autoreleasepool(|_| {
|
||||
let types = TYPES.map(|(t, _)| NSString::from_str(t));
|
||||
let types: Vec<_> = mimes.iter().flat_map(|m| pasteboard_types(m)).map(|(t, _)| NSString::from_str(t)).collect();
|
||||
let types: Vec<&NSString> = types.iter().map(|t| &**t).collect();
|
||||
NSPasteboard::generalPasteboard().availableTypeFromArray(&NSArray::from_slice(&types)).is_some()
|
||||
!types.is_empty() && NSPasteboard::generalPasteboard().availableTypeFromArray(&NSArray::from_slice(&types)).is_some()
|
||||
})
|
||||
}
|
||||
|
||||
/// The pasteboard's bitmap, PNG before TIFF.
|
||||
pub fn bitmap() -> Option<Flavour> {
|
||||
/// The pasteboard's data for `mime`, the first of its types held ([`pasteboard_types`]).
|
||||
pub fn read(mime: &str) -> Option<Flavour> {
|
||||
autoreleasepool(|_| {
|
||||
let pasteboard = NSPasteboard::generalPasteboard();
|
||||
TYPES.into_iter().find_map(|(t, mime)| {
|
||||
pasteboard_types(mime).iter().find_map(|&(t, mime)| {
|
||||
let data = pasteboard.dataForType(&NSString::from_str(t))?.to_vec();
|
||||
(!data.is_empty()).then_some(Flavour { mime, data })
|
||||
})
|
||||
@@ -414,7 +431,7 @@ mod mac {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use vectorcraft_engine::cmd::clipboard::{PASTE_ORDER, SVG};
|
||||
use vectorcraft_engine::cmd::clipboard::{EMF, PASTE_ORDER};
|
||||
|
||||
use super::*;
|
||||
|
||||
@@ -448,6 +465,36 @@ mod tests {
|
||||
assert!(copied_file(vec![], &PASTE_ORDER).is_none());
|
||||
}
|
||||
|
||||
/// What another app copied as vector art is asked for under the names the platforms give it,
|
||||
/// and read as the flavour the paste imports (#1499).
|
||||
#[test]
|
||||
fn vector_art_is_read_under_each_platforms_names() {
|
||||
let mac = |m| pasteboard_types(m).iter().map(|t| t.0).collect::<Vec<_>>();
|
||||
assert_eq!(mac(PDF), ["com.adobe.pdf", "Apple PDF pasteboard type"]);
|
||||
assert_eq!(mac(EPS), ["com.adobe.encapsulated-postscript"]);
|
||||
assert_eq!(mac(SVG), ["public.svg-image"]);
|
||||
assert_eq!(pasteboard_types(BITMAP), [("public.png", PNG), ("public.tiff", "image/tiff")]);
|
||||
assert!(windows_formats(EPS).contains(&"Encapsulated PostScript") && windows_formats(PDF).contains(&"Portable Document Format"));
|
||||
for asked in PASTE_ORDER {
|
||||
// Each type's data is the flavour asked for (a bitmap its own type): what
|
||||
// `clipboard.import*` then reads. Text and metafiles come another way.
|
||||
assert!(pasteboard_types(asked).iter().all(|(_, mime)| *mime == asked || asked == BITMAP), "{asked}");
|
||||
assert_eq!(pasteboard_types(asked).is_empty(), [TEXT, EMF].contains(&asked), "{asked}");
|
||||
}
|
||||
// Vector art before text, text before bitmaps.
|
||||
let at = |m| PASTE_ORDER.iter().position(|x| *x == m).unwrap();
|
||||
assert!(at(PDF) < at(EPS) && at(EPS) < at(TEXT) && at(TEXT) < at(BITMAP));
|
||||
}
|
||||
|
||||
/// An .eps file copied in a file manager pastes as EPS.
|
||||
#[test]
|
||||
fn a_copied_eps_file_pastes() {
|
||||
let eps = dir("eps").join("equation.eps");
|
||||
std::fs::write(&eps, "%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 10 10\n0 0 10 10 rectfill\n").unwrap();
|
||||
assert_eq!(copied_file(vec![eps.clone()], &PASTE_ORDER).map(|f| f.mime), Some(EPS));
|
||||
assert!(copied_file(vec![eps], &[BITMAP, TEXT]).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_crlf_left_by_the_uri_list_is_ignored() {
|
||||
let red = dir("crlf").join("red.png");
|
||||
|
||||
@@ -89,3 +89,7 @@ sync_github_name = false
|
||||
|
||||
[people.Nael-Nathanael]
|
||||
sync_github_name = true
|
||||
|
||||
[people.switch87]
|
||||
real_name = "Gert Pellin"
|
||||
sync_github_name = false
|
||||
@@ -207,15 +207,15 @@ pub fn effect_catalog() -> Vec<EffectInfo> {
|
||||
EXTRUDE,
|
||||
"Extrude & Bevel…",
|
||||
&["Effect", "3D and Materials"],
|
||||
"{depth: pt (0..2000, 50), twist: degrees (-360..360, 0; the back turned against the front), taper: % (1..100, 100; the back's size), cap: bool (true: solid; false: hollow), bevel: none|classic|rounded|cove|step|ogee (none), bevelWidth: pt (0..1000, 4), bevelHeight: pt (0..1000, 4), bevelRepeat: integer (1..32, 1), bevelSpace: % (0..90, 0; flat between repeats), bevelInside: bool (false: added around the shape; true: carved into it), bevelBothSides: bool (false), rotationX: degrees (-180..180, -21.88), rotationY: degrees (-180..180, -23.4), rotationZ: degrees (-180..180, 9.06; the defaults are the Off-Axis Front position), perspective: % (0..100, 0), shade: bool (true), lightAzimuth: degrees (-45), lightElevation: degrees (45), lightIntensity: % (80), ambient: % (25)}. The front face is the art, extruded backwards about the centre of its bounds; painted with the top fill (else the top stroke).",
|
||||
"{depth: pt (0..2000, 50), twist: degrees (0..360, 0; the back turned against the front), taper: % (1..100, 100; the back's size), cap: bool (true: solid; false: hollow), bevel: none|classic|rounded|cove|step|ogee (none), bevelWidth: pt (0..1000, 4), bevelHeight: pt (0..1000, 4), bevelRepeat: integer (1..32, 1), bevelSpace: % (0..90, 0; flat between repeats), bevelInside: bool (false: added around the shape; true: carved into it), bevelBothSides: bool (false), rotationX: degrees (-180..180, -21.88), rotationY: degrees (-180..180, -23.4), rotationZ: degrees (-180..180, 9.06; the defaults are the Off-Axis Front position), perspective: % (0..100, 0), shade: bool (true), lightAzimuth: degrees (-45), lightElevation: degrees (45), lightIntensity: % (80), ambient: % (25)}. The front face is the art, extruded backwards about the centre of its bounds; painted with the top fill (else the top stroke).",
|
||||
json!({"depth":50.0,"twist":0.0,"taper":100.0,"cap":true,"bevel":"none","bevelWidth":4.0,"bevelHeight":4.0,"bevelRepeat":1,"bevelSpace":0.0,"bevelInside":false,"bevelBothSides":false,"rotationX":-21.88,"rotationY":-23.4,"rotationZ":9.06,"perspective":0.0,"shade":true,"lightAzimuth":-45.0,"lightElevation":45.0,"lightIntensity":80.0,"ambient":25.0}),
|
||||
),
|
||||
g(
|
||||
INFLATE,
|
||||
"Inflate…",
|
||||
&["Effect", "3D and Materials"],
|
||||
"{depth: pt (0..2000, 0; flat depth between front and back), volume: % (0..100, 50; 50 rounds the edge like a half sphere), bothSides: bool (false: flat back), rotationX: degrees (-180..180, -21.88), rotationY: degrees (-180..180, -23.4), rotationZ: degrees (-180..180, 9.06; the defaults are the Off-Axis Front position), perspective: % (0..100, 0), shade: bool (true), lightAzimuth: degrees (-45), lightElevation: degrees (45), lightIntensity: % (80), ambient: % (25)}. Painted with the top fill (else the top stroke).",
|
||||
json!({"depth":0.0,"volume":50.0,"bothSides":false,"rotationX":-21.88,"rotationY":-23.4,"rotationZ":9.06,"perspective":0.0,"shade":true,"lightAzimuth":-45.0,"lightElevation":45.0,"lightIntensity":80.0,"ambient":25.0}),
|
||||
"{depth: pt (0..2000, 0; flat depth between front and back), twist: degrees (0..360, 0; the back turned against the front), taper: % (1..100, 100; the back's size), cap: bool (true: solid; false: no back, a shell open behind), volume: % (0..100, 50; 50 rounds the edge like a half sphere), bothSides: bool (false: flat back), rotationX: degrees (-180..180, -21.88), rotationY: degrees (-180..180, -23.4), rotationZ: degrees (-180..180, 9.06; the defaults are the Off-Axis Front position), perspective: % (0..100, 0), shade: bool (true), lightAzimuth: degrees (-45), lightElevation: degrees (45), lightIntensity: % (80), ambient: % (25)}. Painted with the top fill (else the top stroke).",
|
||||
json!({"depth":0.0,"twist":0.0,"taper":100.0,"cap":true,"volume":50.0,"bothSides":false,"rotationX":-21.88,"rotationY":-23.4,"rotationZ":9.06,"perspective":0.0,"shade":true,"lightAzimuth":-45.0,"lightElevation":45.0,"lightIntensity":80.0,"ambient":25.0}),
|
||||
),
|
||||
g(
|
||||
ROTATE,
|
||||
|
||||
@@ -90,7 +90,14 @@ pub fn extrude_options(p: &Value) -> Extrude {
|
||||
}
|
||||
|
||||
pub fn inflate_options(p: &Value) -> Inflate {
|
||||
Inflate { depth: num(p, "depth", 0.0), volume: num(p, "volume", 50.0), both_sides: flag(p, "bothSides", false) }
|
||||
Inflate {
|
||||
depth: num(p, "depth", 0.0),
|
||||
volume: num(p, "volume", 50.0),
|
||||
both_sides: flag(p, "bothSides", false),
|
||||
twist: num(p, "twist", 0.0),
|
||||
taper: num(p, "taper", 100.0) / 100.0,
|
||||
cap: flag(p, "cap", true),
|
||||
}
|
||||
}
|
||||
|
||||
/// The shape a solid is made from: a path's or a compound path's outline.
|
||||
|
||||
@@ -120,6 +120,32 @@ fn export_and_expand_bake_faces_with_their_own_ids() {
|
||||
}
|
||||
}
|
||||
|
||||
/// A twisted, tapered, hollow Inflate (both sides) exports and expands like the others: faces with
|
||||
/// their own ids, no 3D effect left on them.
|
||||
#[test]
|
||||
fn a_twisted_tapered_hollow_inflate_exports_and_expands() {
|
||||
let mut d = Document::new(400.0, 400.0);
|
||||
let mut n = square_node();
|
||||
n.id = d.alloc_id();
|
||||
let params = json!({"twist": 120, "taper": 50, "cap": false, "bothSides": true, "rotationX": -20, "rotationY": 35});
|
||||
n.appearance.effects.push(fx(INFLATE, params));
|
||||
let art = solid_art(&n).unwrap();
|
||||
assert_eq!(crate::bake_appearance(&n), Some(art.clone()));
|
||||
assert!(faces(&art).len() > 20, "{} faces", faces(&art).len());
|
||||
let mut stroke = |_: &mut Document, _: &PathData, _: FillRule, _: &StrokeLayer| None;
|
||||
let expanded = crate::expand_leaf(&mut d, &n, &mut stroke).unwrap();
|
||||
let mut ids = std::collections::HashSet::new();
|
||||
fn visit(n: &Node, ids: &mut std::collections::HashSet<NodeId>) {
|
||||
assert!(ids.insert(n.id), "{:?} twice", n.id);
|
||||
assert!(!has_solid(n));
|
||||
for c in n.children().into_iter().flatten() {
|
||||
visit(c, ids);
|
||||
}
|
||||
}
|
||||
visit(&expanded, &mut ids);
|
||||
assert!(ids.len() > 20, "{} nodes", ids.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effects_before_it_shape_the_solid_and_effects_after_it_bend_the_result() {
|
||||
let mut n = square_node();
|
||||
@@ -156,3 +182,12 @@ fn parameters_are_validated_and_lengths_scale_with_the_object() {
|
||||
assert_eq!((b.shape, b.space, b.repeat), (BevelShape::Step, 0.3, 32));
|
||||
assert!(extrude_options(&json!({})).bevel.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inflate_reads_twist_taper_and_cap() {
|
||||
let o = inflate_options(&json!({"twist": 90, "taper": 40, "cap": false}));
|
||||
assert_eq!((o.twist, o.taper, o.cap), (90.0, 0.4, false));
|
||||
let d = inflate_options(&json!({}));
|
||||
assert_eq!((d.twist, d.taper, d.cap), (0.0, 1.0, true));
|
||||
assert_eq!(crate::default_params(INFLATE).unwrap()["cap"], json!(true));
|
||||
}
|
||||
@@ -15,6 +15,7 @@ use vectorcraft_doc::{AppearanceItem, Document, FillLayer, Node, NodeId, NodeKin
|
||||
use vectorcraft_geom::{Affine, Point, Vec2};
|
||||
|
||||
use super::edit::selected_roots;
|
||||
use super::pathops::{remove_each, separate};
|
||||
use super::*;
|
||||
use crate::EngineError;
|
||||
|
||||
@@ -569,6 +570,10 @@ fn symbol_list(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
/// Replace the root nodes `ids` with one instance of `name` (art already stored). Returns the id.
|
||||
fn replace_with_instance(d: &mut Document, ids: &[NodeId], name: &str, xf: Affine) -> Result<NodeId> {
|
||||
let top = *ids.last().ok_or_else(|| EngineError::Other("nothing selected".into()))?;
|
||||
if separate(d, ids) {
|
||||
let id = d.alloc_id();
|
||||
return Ok(d.wrap(ids, |_| Node::new(id, NodeKind::SymbolInstance { symbol: name.into(), xf }))?);
|
||||
}
|
||||
let (par, idx, _) = d.position(top).ok_or(EngineError::NoNode(top))?;
|
||||
let id = d.alloc_id();
|
||||
d.insert(par, idx + 1, Node::new(id, NodeKind::SymbolInstance { symbol: name.into(), xf }))?;
|
||||
@@ -742,9 +747,7 @@ fn symbol_delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if expand {
|
||||
break_links(d, &inst)?;
|
||||
} else {
|
||||
for i in inst {
|
||||
d.remove(i)?;
|
||||
}
|
||||
remove_each(d, &inst)?;
|
||||
}
|
||||
d.symbols.retain(|x| x.name != name);
|
||||
set_natural_size(d, &name, None);
|
||||
@@ -871,12 +874,7 @@ fn symbol_spray(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
.collect();
|
||||
let n = doomed.len();
|
||||
if n > 0 {
|
||||
s.edit("Symbol Sprayer", |d, _| {
|
||||
for id in &doomed {
|
||||
d.remove(*id)?;
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
s.edit("Symbol Sprayer", |d, _| remove_each(d, &doomed))?;
|
||||
}
|
||||
return Ok(json!({ "count": n }));
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ use vectorcraft_trace as tr;
|
||||
|
||||
use super::edit::selected_roots;
|
||||
use super::paint::paint_from;
|
||||
use super::pathops::shape_node;
|
||||
use super::pathops::{Beside, shape_node};
|
||||
use super::*;
|
||||
|
||||
/// Name of an Image Trace group.
|
||||
@@ -341,19 +341,16 @@ fn sb_merge(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
/// Remove the roots and insert `nodes` where the front-most root was. Returns the new ids.
|
||||
/// (Roots go first: the new nodes may reuse their ids, e.g. Live Paint sources.)
|
||||
fn replace_roots(d: &mut Document, roots: &[NodeId], nodes: Vec<Node>) -> Result<Vec<NodeId>> {
|
||||
let top = *roots.last().ok_or_else(|| EngineError::Other("nothing selected".into()))?;
|
||||
let (par, idx, _) = d.position(top).ok_or(EngineError::NoNode(top))?;
|
||||
let before = roots.iter().filter(|r| **r != top && d.position(**r).is_some_and(|(p, i, _)| p == par && i < idx)).count();
|
||||
for r in roots {
|
||||
let Some((&top, rest)) = roots.split_last() else { return Err(EngineError::Other("nothing selected".into())) };
|
||||
let ids = nodes.iter().map(|n| n.id).collect();
|
||||
let mut replaced = Beside::replacing(d, roots);
|
||||
for r in rest {
|
||||
if d.node(*r).is_some() {
|
||||
d.remove(*r)?;
|
||||
replaced.put(d, *r, vec![])?;
|
||||
}
|
||||
}
|
||||
let at = idx - before;
|
||||
let mut ids = vec![];
|
||||
for (k, n) in nodes.into_iter().enumerate() {
|
||||
ids.push(d.insert(par, at + k, n)?);
|
||||
}
|
||||
replaced.put(d, top, nodes)?;
|
||||
replaced.finish(d)?;
|
||||
Ok(ids)
|
||||
}
|
||||
|
||||
@@ -508,14 +505,14 @@ fn lp_merge(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Arc::make_mut(e).appearance = app.into();
|
||||
}
|
||||
}
|
||||
let mut merged = Beside::replacing(d, &roots);
|
||||
for r in roots.iter().filter(|r| **r != target) {
|
||||
if d.node(*r).is_some() {
|
||||
d.remove(*r)?;
|
||||
merged.put(d, *r, vec![])?;
|
||||
}
|
||||
}
|
||||
let (par, idx, _) = d.position(target).ok_or(EngineError::NoNode(target))?;
|
||||
d.remove(target)?;
|
||||
d.insert(par, idx, g)?;
|
||||
merged.put(d, target, vec![g])?;
|
||||
merged.finish(d)?;
|
||||
sel.set([target]);
|
||||
Ok((target, nf, ne))
|
||||
})?;
|
||||
@@ -536,11 +533,14 @@ fn lp_release(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let targets = live_targets(s, p)?;
|
||||
let ids = s.edit("Release Live Paint", |d, sel| {
|
||||
let mut out = vec![];
|
||||
let mut released = Beside::replacing(d, &targets);
|
||||
for g in &targets {
|
||||
let n = d.node(*g).cloned().ok_or(EngineError::NoNode(*g))?;
|
||||
let srcs: Vec<Node> = source_nodes(&n).iter().map(|c| (**c).clone()).collect();
|
||||
out.extend(replace_roots(d, &[*g], srcs)?);
|
||||
out.extend(srcs.iter().map(|c| c.id));
|
||||
released.put(d, *g, srcs)?;
|
||||
}
|
||||
released.finish(d)?;
|
||||
sel.set(out.iter().copied());
|
||||
Ok(out)
|
||||
})?;
|
||||
@@ -836,12 +836,15 @@ fn trace_release(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let targets = trace_targets(s, p)?;
|
||||
let ids = s.edit("Release Image Trace", |d, sel| {
|
||||
let mut out = vec![];
|
||||
let mut released = Beside::replacing(d, &targets);
|
||||
for g in &targets {
|
||||
let n = d.node(*g).cloned().ok_or(EngineError::NoNode(*g))?;
|
||||
let mut img = (**n.children().and_then(|c| c.first()).ok_or(EngineError::NoNode(*g))?).clone();
|
||||
img.visible = true;
|
||||
out.extend(replace_roots(d, &[*g], vec![img])?);
|
||||
out.push(img.id);
|
||||
released.put(d, *g, vec![img])?;
|
||||
}
|
||||
released.finish(d)?;
|
||||
sel.set(out.iter().copied());
|
||||
Ok(out)
|
||||
})?;
|
||||
|
||||
@@ -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, names_a_picture};
|
||||
pub use flavours::{BITMAP, EMF, EPS, FILE_HEAD, Flavour, PASTE_ORDER, PDF, PNG, SVG, TEXT, file_flavour, is_address, names_a_picture};
|
||||
pub(crate) use resources::SwatchChoices;
|
||||
|
||||
use super::*;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//! Clipboard flavours: what Copy offers other apps besides SVG (PNG, PDF and plain text) and what
|
||||
//! Paste takes from them (bitmaps, plain text, PDF and Windows metafiles).
|
||||
//! Paste takes from them (bitmaps, plain text, PDF, EPS and Windows metafiles).
|
||||
//!
|
||||
//! The UI owns the platform clipboard (`Services::system_clipboard` in the egui frontend): on Copy
|
||||
//! it publishes [`Session::clipboard_flavours`]; before a Paste it turns what another app put on
|
||||
@@ -27,9 +27,12 @@ pub const BITMAP: &str = "image/*";
|
||||
/// An EMF (or WMF) picture: what Windows apps copy as vector art (the system clipboard reads it
|
||||
/// where the platform offers it).
|
||||
pub const EMF: &str = "image/emf";
|
||||
/// An EPS (or other PostScript) picture: what drawing and typesetting apps copy as vector art
|
||||
/// beside, or instead of, a PDF (#1499).
|
||||
pub const EPS: &str = "application/postscript";
|
||||
/// What Paste reads from other apps, best first: vector art before text, text before bitmaps
|
||||
/// (word processors offer a picture of copied text too).
|
||||
pub const PASTE_ORDER: [&str; 5] = [SVG, PDF, EMF, TEXT, BITMAP];
|
||||
pub const PASTE_ORDER: [&str; 6] = [SVG, PDF, EPS, EMF, TEXT, BITMAP];
|
||||
/// Is `text` only an address (a web page's, an image's, a file's)? A browser's Copy Image puts the
|
||||
/// image's address as text next to the picture: then the picture is what Paste takes (#597). An
|
||||
/// address alone pastes as text, and nothing is fetched.
|
||||
@@ -67,13 +70,14 @@ fn only_images(html: &str) -> bool {
|
||||
pub const FILE_HEAD: usize = 4096;
|
||||
|
||||
/// What a file copied in a file manager pastes as, if one of `mimes` (`image/*`: any bitmap):
|
||||
/// SVG, PDF, a Windows metafile or a bitmap (its own `image/…` type), told from its first
|
||||
/// SVG, PDF, EPS, a Windows metafile or a bitmap (its own `image/…` type), told from its first
|
||||
/// [`FILE_HEAD`] bytes and its name. The file's bytes are that flavour's data.
|
||||
pub fn file_flavour(name: &str, head: &[u8], mimes: &[&str]) -> Option<&'static str> {
|
||||
let f = fileio::detect(name, head)?;
|
||||
let (asked, mime) = match f.id {
|
||||
"svg" => (SVG, SVG),
|
||||
"pdf" => (PDF, PDF),
|
||||
"eps" => (EPS, EPS),
|
||||
"emf" | "wmf" => (EMF, EMF),
|
||||
_ if f.raster => (BITMAP, f.mime),
|
||||
_ => return None,
|
||||
@@ -167,6 +171,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_doc,
|
||||
import_emf
|
||||
),
|
||||
cmd!(
|
||||
query "clipboard.importEps",
|
||||
"Load EPS into Clipboard",
|
||||
[],
|
||||
None,
|
||||
"{dataBase64 (EPS or other PostScript bytes), center?: [x, y]} replace the clipboard with the picture's objects as document.open reads an EPS (editable paths, type and images, with the images, patterns and swatches they use), centred on `center` (default: the first artboard) → {count, warnings}; then run edit.paste",
|
||||
has_doc,
|
||||
import_eps
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -345,10 +358,26 @@ fn import_emf(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if vectorcraft_metafile::sniff(&bytes).is_none() {
|
||||
return Err(bad(C, "the data isn't an EMF or WMF picture"));
|
||||
}
|
||||
let loaded = fileio::load("Clipboard.emf", &bytes).map_err(|e| bad(C, e.to_string()))?;
|
||||
import_picture(s, p, C, "Clipboard.emf", &bytes)
|
||||
}
|
||||
|
||||
fn import_eps(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "clipboard.importEps";
|
||||
let bytes = data_param(p, C)?;
|
||||
if !vectorcraft_pdf::is_postscript(&bytes) {
|
||||
return Err(bad(C, "the data isn't an EPS or PostScript picture"));
|
||||
}
|
||||
import_picture(s, p, C, "Clipboard.eps", &bytes)
|
||||
}
|
||||
|
||||
/// Replace the clipboard with the objects of the picture `bytes`, read as the file `name` is
|
||||
/// opened (command `cmd`).
|
||||
fn import_picture(s: &mut Session, p: &Value, cmd: &str, name: &str, bytes: &[u8]) -> Result<Value> {
|
||||
let mut loaded = fileio::load(name, bytes).map_err(|e| bad(cmd, e.to_string()))?;
|
||||
loaded.doc.drop_edit_modes();
|
||||
let clip = Clipboard::from_document(&loaded.doc);
|
||||
if clip.is_empty() {
|
||||
return Err(bad(C, "the picture has no objects"));
|
||||
return Err(bad(cmd, "the picture has no objects"));
|
||||
}
|
||||
let count = s.load_clipboard(clip, p)?;
|
||||
Ok(json!({ "count": count, "warnings": loaded.warnings }))
|
||||
|
||||
@@ -14,7 +14,7 @@ use vectorcraft_pathops::BoolOp;
|
||||
|
||||
use super::draw2::{components, erase_outline, ids_json, replace_with_pieces};
|
||||
use super::edit::selected_roots;
|
||||
use super::pathops::node_path;
|
||||
use super::pathops::{Beside, node_path};
|
||||
use super::*;
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
@@ -163,10 +163,12 @@ fn apply(s: &mut Session, label: &str, plan: Plan) -> Result<Value> {
|
||||
}
|
||||
let (ids, removed) = s.edit(label, |d: &mut Document, sel| {
|
||||
let (mut all, mut removed) = (vec![], 0);
|
||||
let mut cut = Beside::replacing(d, &plan.iter().map(|(id, _)| *id).collect::<Vec<_>>());
|
||||
for (id, pieces) in plan {
|
||||
removed += pieces.is_empty() as usize;
|
||||
all.extend(replace_with_pieces(d, id, pieces)?);
|
||||
all.extend(replace_with_pieces(d, &mut cut, id, pieces)?);
|
||||
}
|
||||
cut.finish(d)?;
|
||||
sel.set(all.iter().copied());
|
||||
Ok((all, removed))
|
||||
})?;
|
||||
|
||||
@@ -10,6 +10,7 @@ use vectorcraft_doc::{CharStyle, Guide, Node, NodeId, NodeKind, SavedSelection,
|
||||
use vectorcraft_geom::Vec2;
|
||||
|
||||
use super::edit::selected_roots;
|
||||
use super::pathops::Beside;
|
||||
use super::select::{selectable_ids, walk_selectable};
|
||||
use super::*;
|
||||
|
||||
@@ -504,10 +505,13 @@ fn clear_guides(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
}
|
||||
s.edit("Clear Guides", |d, _| {
|
||||
d.guides.clear();
|
||||
let mut gone = Beside::replacing(d, &paths);
|
||||
for id in &paths {
|
||||
let _ = d.remove(*id);
|
||||
if d.node(*id).is_some() {
|
||||
gone.put(d, *id, vec![])?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
gone.finish(d)
|
||||
})?;
|
||||
Ok(json!({ "count": n }))
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ use vectorcraft_pathops as po;
|
||||
use vectorcraft_pathops::{BoolOp, Cap, Join, SimplifyOptions};
|
||||
|
||||
use super::create::{add_look, add_node};
|
||||
use super::pathops::{Beside, remove_each};
|
||||
use super::*;
|
||||
use crate::EngineError;
|
||||
|
||||
@@ -310,20 +311,25 @@ fn sibling_with(d: &mut Document, id: NodeId, pd: PathData) -> Result<Node> {
|
||||
}
|
||||
|
||||
/// Replace path or compound path `id` with `pieces` (first keeps the id, the rest go right above
|
||||
/// it; none removes it). Returns ids.
|
||||
pub(super) fn replace_with_pieces(d: &mut Document, id: NodeId, pieces: Vec<PathData>) -> Result<Vec<NodeId>> {
|
||||
/// it; none removes it). `cut` removes the path or puts the other pieces above it. Returns ids.
|
||||
pub(super) fn replace_with_pieces(d: &mut Document, cut: &mut Beside, id: NodeId, pieces: Vec<PathData>) -> Result<Vec<NodeId>> {
|
||||
let mut it = pieces.into_iter();
|
||||
let Some(first) = it.next() else {
|
||||
d.remove(id)?;
|
||||
cut.put(d, id, vec![])?;
|
||||
return Ok(vec![]);
|
||||
};
|
||||
let (parent, idx, _) = d.position(id).ok_or(EngineError::NoNode(id))?;
|
||||
let mut ids = vec![id];
|
||||
for (k, pd) in it.enumerate() {
|
||||
let mut above = vec![];
|
||||
for pd in it {
|
||||
let n = sibling_with(d, id, pd)?;
|
||||
ids.push(d.insert(parent, idx + 1 + k, n)?);
|
||||
ids.push(n.id);
|
||||
above.push(n);
|
||||
}
|
||||
set_geometry(d, id, first)?;
|
||||
if !above.is_empty() {
|
||||
let n = d.node(id).cloned().ok_or(EngineError::NoNode(id))?;
|
||||
cut.put(d, id, [n].into_iter().chain(above).collect())?;
|
||||
}
|
||||
Ok(ids)
|
||||
}
|
||||
|
||||
@@ -733,7 +739,10 @@ fn remove_anchor(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if ai >= sp.anchors.len() {
|
||||
return Err(EngineError::Other("no such anchor".into()));
|
||||
}
|
||||
remove_anchors_of(d, sel, id, [(si, ai)])
|
||||
let mut emptied = Beside::replacing(d, &[id]);
|
||||
let removed = remove_anchors_of(d, sel, &mut emptied, id, [(si, ai)])?;
|
||||
emptied.finish(d)?;
|
||||
Ok(removed)
|
||||
})?;
|
||||
Ok(json!({ "removedObject": removed }))
|
||||
}
|
||||
@@ -752,18 +761,26 @@ fn remove_anchors(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
}
|
||||
let removed = s.edit("Remove Anchor Points", |d, sel| {
|
||||
let mut removed = 0;
|
||||
let mut emptied = Beside::replacing(d, &jobs.iter().map(|(id, _)| *id).collect::<Vec<_>>());
|
||||
for (id, anchors) in jobs {
|
||||
removed += usize::from(remove_anchors_of(d, sel, id, anchors)?);
|
||||
removed += usize::from(remove_anchors_of(d, sel, &mut emptied, id, anchors)?);
|
||||
}
|
||||
emptied.finish(d)?;
|
||||
Ok(removed)
|
||||
})?;
|
||||
Ok(json!({ "removedObjects": removed }))
|
||||
}
|
||||
|
||||
/// Remove `anchors` (subpath, anchor) of path `id` without opening it, refitting the curve round
|
||||
/// each one; a subpath left with one anchor goes, and the path with its last subpath. True when
|
||||
/// the path went.
|
||||
fn remove_anchors_of(d: &mut Document, sel: &mut Selection, id: NodeId, anchors: impl IntoIterator<Item = (usize, usize)>) -> Result<bool> {
|
||||
/// each one. A subpath left with one anchor is removed, and a path left with no subpath is removed
|
||||
/// through `emptied`. True when the path went.
|
||||
fn remove_anchors_of(
|
||||
d: &mut Document,
|
||||
sel: &mut Selection,
|
||||
emptied: &mut Beside,
|
||||
id: NodeId,
|
||||
anchors: impl IntoIterator<Item = (usize, usize)>,
|
||||
) -> Result<bool> {
|
||||
let mut anchors: Vec<(usize, usize)> = anchors.into_iter().collect();
|
||||
// The last first, so the indexes still to come stay valid.
|
||||
anchors.sort_unstable_by(|a, b| b.cmp(a));
|
||||
@@ -777,7 +794,7 @@ fn remove_anchors_of(d: &mut Document, sel: &mut Selection, id: NodeId, anchors:
|
||||
path.subpaths.retain(|sp| sp.anchors.len() >= 2);
|
||||
let gone = path.is_empty();
|
||||
if gone {
|
||||
d.remove(id)?;
|
||||
emptied.put(d, id, vec![])?;
|
||||
sel.remove(id);
|
||||
} else {
|
||||
sel.anchors.remove(&id);
|
||||
@@ -920,9 +937,11 @@ fn knife(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
let ids = s.edit("Knife", |d, sel| {
|
||||
let mut all = vec![];
|
||||
let mut cut = Beside::replacing(d, &plan.iter().map(|(id, _)| *id).collect::<Vec<_>>());
|
||||
for (id, pieces) in plan {
|
||||
all.extend(replace_with_pieces(d, id, pieces)?);
|
||||
all.extend(replace_with_pieces(d, &mut cut, id, pieces)?);
|
||||
}
|
||||
cut.finish(d)?;
|
||||
sel.set(all.iter().copied());
|
||||
Ok(all)
|
||||
})?;
|
||||
@@ -960,9 +979,11 @@ fn apply_plan(s: &mut Session, label: &str, plan: Vec<(NodeId, Vec<PathData>)>)
|
||||
}
|
||||
let ids = s.edit(label, |d, sel| {
|
||||
let mut all = vec![];
|
||||
let mut cut = Beside::replacing(d, &plan.iter().map(|(id, _)| *id).collect::<Vec<_>>());
|
||||
for (id, pieces) in plan {
|
||||
all.extend(replace_with_pieces(d, id, pieces)?);
|
||||
all.extend(replace_with_pieces(d, &mut cut, id, pieces)?);
|
||||
}
|
||||
cut.finish(d)?;
|
||||
sel.set(all.iter().copied());
|
||||
Ok(all)
|
||||
})?;
|
||||
@@ -1256,9 +1277,7 @@ fn blob(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let others: Vec<NodeId> = rest.iter().map(|m| m.0).collect();
|
||||
s.edit("Blob Brush", |d, sel| {
|
||||
*path_mut(d, keep)? = merged;
|
||||
for id in others {
|
||||
d.remove(id)?;
|
||||
}
|
||||
remove_each(d, &others)?;
|
||||
sel.set([keep]);
|
||||
Ok(())
|
||||
})?;
|
||||
|
||||
@@ -558,10 +558,20 @@ fn duplicate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>() }))
|
||||
}
|
||||
|
||||
/// Duplicate `roots` directly above each original, transform the copies, select them. The copies
|
||||
/// take new ids in the order of `roots`. Roots listed twice, or inside another root, are copied one
|
||||
/// at a time: each copy is made from the document as the copies before it left it.
|
||||
/// Duplicate `roots` directly above each original, transform the copies, select them
|
||||
/// ([`copy_above`]).
|
||||
pub(crate) fn duplicate_in(d: &mut Document, sel: &mut vectorcraft_doc::Selection, roots: &[NodeId], xf: Affine) -> Result<Vec<NodeId>> {
|
||||
let out = copy_above(d, roots, |_, _, c| c.transform(xf, true))?;
|
||||
sel.set(out.iter().copied());
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Copy `roots` directly above each original and return the copies' ids. `change` gets the
|
||||
/// document, each original and its copy before the copy goes in, and must leave the document's
|
||||
/// tree as it is. The copies take new ids in the order of `roots`. Roots listed twice, or inside
|
||||
/// another root, are copied one at a time: each copy is made from the document as the copies
|
||||
/// before it left it.
|
||||
pub(crate) fn copy_above(d: &mut Document, roots: &[NodeId], mut change: impl FnMut(&mut Document, &Node, &mut Node)) -> Result<Vec<NodeId>> {
|
||||
let paths = d.index_paths(roots.iter().copied());
|
||||
let mut found: Vec<_> = roots.iter().filter_map(|id| paths.get(id)).collect();
|
||||
found.sort_unstable();
|
||||
@@ -570,7 +580,7 @@ pub(crate) fn duplicate_in(d: &mut Document, sel: &mut vectorcraft_doc::Selectio
|
||||
for id in roots {
|
||||
let Some(n) = paths.get(id).and_then(|p| d.node_at(p)).cloned() else { continue };
|
||||
let mut c = d.reid(&n);
|
||||
c.transform(xf, true);
|
||||
change(d, &n, &mut c);
|
||||
copies.push((*id, vec![c]));
|
||||
}
|
||||
let out: Vec<NodeId> = copies.iter().flat_map(|(_, c)| c.iter().map(|n| n.id)).collect();
|
||||
@@ -582,12 +592,11 @@ pub(crate) fn duplicate_in(d: &mut Document, sel: &mut vectorcraft_doc::Selectio
|
||||
let Some((par, idx, _)) = d.position(*id) else { continue };
|
||||
let Some(n) = d.node(*id).cloned() else { continue };
|
||||
let mut c = d.reid(&n);
|
||||
c.transform(xf, true);
|
||||
change(d, &n, &mut c);
|
||||
out.push(d.insert(par, idx + 1, c)?);
|
||||
}
|
||||
out
|
||||
};
|
||||
sel.set(out.iter().copied());
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
|
||||
@@ -382,7 +382,13 @@ pub fn encode_all(doc: &Document, format: &str, p: &Value) -> Result<Encoded> {
|
||||
/// [`encode_all`] of `doc` as it is.
|
||||
fn encode_all_of(doc: &Document, format: &str, p: &Value) -> Result<Encoded> {
|
||||
let f = super::writable(C, Some(format), None)?;
|
||||
let failed = vectorcraft_render::decode_failures();
|
||||
let mut enc = encode_files(doc, f, p)?;
|
||||
// An image the renderer couldn't decode draws nothing: say so rather than write a picture
|
||||
// that silently lacks it.
|
||||
if f.raster && vectorcraft_render::decode_failures() > failed {
|
||||
enc.warnings.push("an image couldn't be decoded and is missing from the picture".into());
|
||||
}
|
||||
// Formats that draw type: missing fonts come out in the fallback font (PDF and SVG say so
|
||||
// themselves: SVG only when it outlines or embeds fonts).
|
||||
if (f.raster || matches!(f.id, "eps" | "emf" | "wmf"))
|
||||
|
||||
@@ -192,7 +192,7 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result<Loaded>
|
||||
let title = file_name(name);
|
||||
let mut converted = false;
|
||||
let mut preview_only = false;
|
||||
let (mut doc, warnings, restored) = match format.id {
|
||||
let (mut doc, mut warnings, restored) = match format.id {
|
||||
// EPS and PostScript .ai: the document our EPS files carry, else the PostScript read.
|
||||
_ if format.id == "eps" || vectorcraft_pdf::is_postscript(bytes) => {
|
||||
let editing = vectorcraft_eps::has_native(bytes).then_some((true, || vectorcraft_eps::native(bytes)));
|
||||
@@ -263,6 +263,7 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result<Loaded>
|
||||
if !matches!(format.id, "vectorcraft" | "template") || doc.title.is_empty() {
|
||||
doc.title = title;
|
||||
}
|
||||
warnings.extend(doc.images.values().filter_map(|b| too_large_note(&b.bytes)));
|
||||
Ok(Loaded { doc, format, warnings, restored, converted, preview_only })
|
||||
}
|
||||
|
||||
@@ -392,7 +393,18 @@ pub(super) fn new_from_template(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
/// The most memory decoding one image may take: a 60 in print sheet at 300 ppi (18000 × 6600 px)
|
||||
/// is ~475 MB as RGBA, past the decoder's 512 MB default once it is converted. The web app keeps
|
||||
/// that default: wasm32 has 4 GB to address and aborts when an allocation fails.
|
||||
const MAX_RASTER_ALLOC: u64 = if cfg!(target_arch = "wasm32") { 512 << 20 } else { 2 << 30 };
|
||||
pub(crate) const MAX_RASTER_ALLOC: u64 = if cfg!(target_arch = "wasm32") { 512 << 20 } else { 2 << 30 };
|
||||
|
||||
/// What to say of encoded image `bytes` that need more than [`MAX_RASTER_ALLOC`] to decode (its
|
||||
/// header says so; the pixels aren't read): the renderer, which has the same allowance, draws
|
||||
/// nothing for them, so Open, Place and the Links panel say it (#1479). `None` for images that
|
||||
/// decode, and for bytes that aren't an image.
|
||||
pub(crate) fn too_large_note(bytes: &[u8]) -> Option<String> {
|
||||
use image::ImageDecoder as _;
|
||||
let d = image::ImageReader::new(Cursor::new(bytes)).with_guessed_format().ok()?.into_decoder().ok()?;
|
||||
let (w, h) = d.dimensions();
|
||||
(d.total_bytes() > MAX_RASTER_ALLOC).then(|| format!("an image of {w} × {h} pixels is too large to draw"))
|
||||
}
|
||||
|
||||
/// Decode an image's header (and, for formats stored as PNG, its pixels). CMYK TIFFs are kept as
|
||||
/// they are, with their ink amounts ([`ImageBlob::cmyk`]); CMYK TIFFs with an alpha channel, which
|
||||
|
||||
@@ -28,6 +28,7 @@ pub mod eps;
|
||||
mod export;
|
||||
pub(crate) mod imagemap;
|
||||
mod load;
|
||||
pub(crate) use load::{MAX_RASTER_ALLOC, too_large_note};
|
||||
mod metafile;
|
||||
mod native;
|
||||
pub mod pdf;
|
||||
|
||||
@@ -163,3 +163,20 @@ fn an_image_opens_at_the_size_its_resolution_declares() {
|
||||
assert!((art.width() - w).abs() < 1e-6 && (art.height() - h).abs() < 1e-6, "{what}: the image fills it, {art:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_raster_export_says_when_an_image_could_not_be_decoded() {
|
||||
// A PNG whose header reads (so it places at its size) but whose pixel data is cut short:
|
||||
// the renderer can't decode it and draws nothing, which the export must say.
|
||||
let mut s = session(72.0, 36.0, 1);
|
||||
let mut png = Vec::new();
|
||||
image::RgbaImage::from_pixel(64, 32, image::Rgba([200, 30, 30, 255]))
|
||||
.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
|
||||
.unwrap();
|
||||
let cut = png_chunk(&png, b"IDAT").map(|d| d.as_ptr() as usize - png.as_ptr() as usize).unwrap() + 4;
|
||||
png.truncate(cut);
|
||||
s.execute("file.place", &json!({"name": "cut.png", "dataBase64": vectorcraft_format::base64_encode(&png)})).unwrap();
|
||||
let r = s.execute("document.export", &json!({"format": "png", "background": "transparent"})).unwrap();
|
||||
let warnings = r["warnings"].as_array().cloned().unwrap_or_default();
|
||||
assert!(warnings.iter().any(|w| w.as_str().is_some_and(|w| w.contains("couldn't be decoded"))), "{r}");
|
||||
}
|
||||
@@ -35,7 +35,7 @@ use vectorcraft_render::effects;
|
||||
|
||||
use super::edit::roots_of;
|
||||
use super::menucmds::{MAX_PIXELS, isolated_doc, unique_key};
|
||||
use super::pathops::{node_path, outline_strokes, outline_strokes_under, shape_node};
|
||||
use super::pathops::{node_path, outline_strokes, outline_strokes_under, separate, shape_node};
|
||||
use super::*;
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
@@ -1038,7 +1038,10 @@ pub(crate) fn flatten_document(doc: &Document, o: &FlattenOptions) -> Result<Opt
|
||||
|
||||
fn apply(d: &mut Document, sel: &mut vectorcraft_doc::Selection, plan: Plan, o: &FlattenOptions) -> Result<(Vec<NodeId>, usize, usize)> {
|
||||
let (mut vector, mut rasterized) = (0, 0);
|
||||
let mut ids = vec![];
|
||||
let (mut ids, mut flat) = (vec![], vec![]);
|
||||
// The groups replace their objects in one pass at the end. When an object is listed twice or
|
||||
// lies inside another listed one, each group replaces its objects as it is made instead.
|
||||
let one_pass = separate(d, &plan.flat.iter().flat_map(|f| f.roots.iter().copied()).collect::<Vec<_>>());
|
||||
for f in plan.flat {
|
||||
// A group always has objects.
|
||||
let Some(&top) = f.roots.last() else { continue };
|
||||
@@ -1060,13 +1063,23 @@ fn apply(d: &mut Document, sel: &mut vectorcraft_doc::Selection, plan: Plan, o:
|
||||
children.push(Arc::new(n));
|
||||
vector += 1;
|
||||
}
|
||||
let (par, idx, _) = d.position(top).ok_or(EngineError::NoNode(top))?;
|
||||
let g = Node::group(d.alloc_id(), children);
|
||||
ids.push(d.insert(par, idx + 1, g)?);
|
||||
for r in &f.roots {
|
||||
d.remove(*r)?;
|
||||
ids.push(g.id);
|
||||
if one_pass {
|
||||
// The group replaces the topmost object, and the other objects are removed.
|
||||
flat.extend(f.roots.iter().filter(|r| **r != top).map(|r| (*r, vec![])));
|
||||
flat.push((top, vec![g]));
|
||||
} else {
|
||||
let (par, idx, _) = d.position(top).ok_or(EngineError::NoNode(top))?;
|
||||
d.insert(par, idx + 1, g)?;
|
||||
for r in &f.roots {
|
||||
d.remove(*r)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
if !flat.is_empty() {
|
||||
d.insert_beside(flat, true)?;
|
||||
}
|
||||
let brushes = if o.strokes_to_outlines { vectorcraft_brush::library(d) } else { vec![] };
|
||||
for mut id in plan.kept {
|
||||
if o.text_to_outlines {
|
||||
@@ -1113,9 +1126,8 @@ fn outline_texts_under(d: &mut Document, root: NodeId) -> Result<NodeId> {
|
||||
for t in texts {
|
||||
let Some(o) = outlined_text(&t) else { continue };
|
||||
let o = d.reid(&o);
|
||||
let (par, idx, _) = d.position(t.id).ok_or(EngineError::NoNode(t.id))?;
|
||||
let nid = d.insert(par, idx, o)?;
|
||||
d.remove(t.id)?;
|
||||
let nid = o.id;
|
||||
*d.node_mut(t.id).ok_or(EngineError::NoNode(t.id))? = o;
|
||||
if t.id == root {
|
||||
out = nid;
|
||||
}
|
||||
|
||||
@@ -232,12 +232,11 @@ fn halftone(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let gid = d.alloc_id();
|
||||
let mut g = Node::group(gid, children);
|
||||
g.name = Some("Vector Halftone".into());
|
||||
let (par, idx, _) = d.position(top).ok_or(EngineError::NoNode(top))?;
|
||||
d.insert(par, idx + 1, g)?;
|
||||
if !keep {
|
||||
for r in &roots {
|
||||
d.remove(*r)?;
|
||||
}
|
||||
if keep {
|
||||
let (par, idx, _) = d.position(top).ok_or(EngineError::NoNode(top))?;
|
||||
d.insert(par, idx + 1, g)?;
|
||||
} else {
|
||||
d.wrap(&roots, |_| g)?;
|
||||
}
|
||||
sel.set([gid]);
|
||||
Ok(gid)
|
||||
|
||||
@@ -234,23 +234,47 @@ pub(crate) fn locked_within(d: &Document, id: NodeId) -> bool {
|
||||
d.ancestry(id).unwrap_or_default().iter().any(|a| d.node(*a).is_some_and(|n| n.locked))
|
||||
}
|
||||
|
||||
/// Every layer and sublayer, depth first.
|
||||
fn all_layers(d: &Document) -> Vec<&Node> {
|
||||
let mut out = vec![];
|
||||
d.walk(|n| {
|
||||
/// The number and the names of a document's layers and sublayers, from which [`NewLayers::make`]
|
||||
/// names and colors a new layer. [`NewLayers::of`] reads them in one walk of the document, and
|
||||
/// [`NewLayers::add`] counts each new layer as it goes in.
|
||||
pub(crate) struct NewLayers {
|
||||
count: usize,
|
||||
taken: HashSet<String>,
|
||||
}
|
||||
|
||||
impl NewLayers {
|
||||
pub(crate) fn of(d: &Document) -> Self {
|
||||
let mut layers = Self { count: 0, taken: HashSet::new() };
|
||||
d.walk(|n| layers.note(n));
|
||||
layers
|
||||
}
|
||||
|
||||
/// A new layer named `name` (default: the next `Layer N`, as [`Document::next_layer_name`]
|
||||
/// gives it) in the next layer color.
|
||||
pub(crate) fn make(&self, d: &mut Document, name: Option<&str>) -> Node {
|
||||
let name = name
|
||||
.map(str::to_string)
|
||||
.unwrap_or_else(|| (self.count + 1..).map(|i| format!("Layer {i}")).find(|name| !self.taken.contains(name)).unwrap_or_default());
|
||||
let id = d.alloc_id();
|
||||
Node::layer(id, &name, LayerColor::Preset((self.count % LAYER_COLORS.len()) as u8))
|
||||
}
|
||||
|
||||
/// Count `n`, a layer going into the document, and the layers inside it.
|
||||
pub(crate) fn add(&mut self, n: &Node) {
|
||||
n.walk(&mut |c| self.note(c));
|
||||
}
|
||||
|
||||
fn note(&mut self, n: &Node) {
|
||||
if n.is_layer() {
|
||||
out.push(n);
|
||||
self.count += 1;
|
||||
self.taken.extend(n.name.clone());
|
||||
}
|
||||
});
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
/// A new layer named `name` (default: the next `Layer N`) in the next layer colour.
|
||||
pub(crate) fn make_layer(d: &mut Document, name: Option<&str>) -> Node {
|
||||
let n = all_layers(d).len();
|
||||
let name = name.map(str::to_string).unwrap_or_else(|| d.next_layer_name());
|
||||
let id = d.alloc_id();
|
||||
Node::layer(id, &name, LayerColor::Preset((n % LAYER_COLORS.len()) as u8))
|
||||
NewLayers::of(d).make(d, name)
|
||||
}
|
||||
|
||||
/// A layer colour: a preset index (0–26), `#rrggbb` or a preset's name (any case).
|
||||
@@ -464,13 +488,12 @@ fn delete_rows(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if ids.is_empty() {
|
||||
return Err(bad("layer.delete", "no such rows"));
|
||||
}
|
||||
if st.doc.layers.iter().all(|l| ids.contains(&l.id)) {
|
||||
let listed: HashSet<NodeId> = ids.iter().copied().collect();
|
||||
if st.doc.layers.iter().all(|l| listed.contains(&l.id)) {
|
||||
return Err(EngineError::Other("a document needs at least one layer".into()));
|
||||
}
|
||||
s.edit("Delete Selection", |d, sel| {
|
||||
for id in &ids {
|
||||
d.remove(*id)?;
|
||||
}
|
||||
d.remove_many(&ids)?;
|
||||
// The guides on the deleted layers go with them.
|
||||
d.drop_guides_of_deleted_layers(sel);
|
||||
sel.prune(d);
|
||||
@@ -491,18 +514,12 @@ fn duplicate_rows(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Err(bad("layer.duplicate", "no such rows"));
|
||||
}
|
||||
let copies = s.edit("Duplicate Selection", |d, _| {
|
||||
let mut out = vec![];
|
||||
for id in &ids {
|
||||
let (par, idx, _) = d.position(*id).ok_or(EngineError::NoNode(*id))?;
|
||||
let n = d.node(*id).cloned().ok_or(EngineError::NoNode(*id))?;
|
||||
let mut c = d.reid(&n);
|
||||
super::edit::copy_above(d, &ids, |d, n, c| {
|
||||
if n.is_layer() {
|
||||
c.name = Some(format!("{} copy", n.display_name()));
|
||||
d.copy_layer_guides(&n, &c);
|
||||
d.copy_layer_guides(n, c);
|
||||
}
|
||||
out.push(d.insert(par, idx + 1, c)?);
|
||||
}
|
||||
Ok(out)
|
||||
})
|
||||
})?;
|
||||
s.doc_mut()?.layer_rows = copies.clone();
|
||||
Ok(json!({ "ids": copies.iter().map(|i| i.0).collect::<Vec<_>>() }))
|
||||
@@ -579,14 +596,23 @@ fn collect(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let lid = s.edit("Collect in New Layer", |d, sel| {
|
||||
// Where the topmost of them is: beside it in its layer, or on top of the layer holding
|
||||
// its group.
|
||||
let (parent, index) = match d.position(top) {
|
||||
Some((par, i, _)) if par.is_none_or(|p| d.node(p).is_some_and(Node::is_layer)) => (par, i + 1),
|
||||
_ => (d.layer_containing(top), usize::MAX),
|
||||
};
|
||||
let layer = make_layer(d, None);
|
||||
let lid = d.insert(parent, index, layer)?;
|
||||
for id in &ids {
|
||||
d.move_node(*id, Some(lid), usize::MAX)?;
|
||||
let beside = d.position(top).is_some_and(|(par, _, _)| par.is_none_or(|p| d.node(p).is_some_and(Node::is_layer)));
|
||||
let holder = d.layer_containing(top);
|
||||
let mut layer = make_layer(d, None);
|
||||
let lid = layer.id;
|
||||
if beside {
|
||||
d.wrap(&ids, |rows| {
|
||||
if let Some(ch) = layer.children_mut() {
|
||||
*ch = rows;
|
||||
}
|
||||
layer
|
||||
})?;
|
||||
} else {
|
||||
let rows = d.remove_many(&ids)?;
|
||||
if let Some(ch) = layer.children_mut() {
|
||||
*ch = rows;
|
||||
}
|
||||
d.insert(holder, usize::MAX, layer)?;
|
||||
}
|
||||
sel.prune(d);
|
||||
sel.deselect_uneditable(d);
|
||||
@@ -683,19 +709,20 @@ fn move_rows(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
let label = if copy { "Duplicate Rows" } else { "Move Rows" };
|
||||
let moved = s.edit(label, |d, sel| {
|
||||
let mut nodes = vec![];
|
||||
for id in &ids {
|
||||
nodes.push(if copy {
|
||||
let nodes = if copy {
|
||||
let mut nodes = vec![];
|
||||
for id in &ids {
|
||||
let n = d.node(*id).cloned().ok_or(EngineError::NoNode(*id))?;
|
||||
let c = d.reid(&n);
|
||||
if n.is_layer() {
|
||||
d.copy_layer_guides(&n, &c);
|
||||
}
|
||||
c
|
||||
} else {
|
||||
Arc::unwrap_or_clone(d.remove(*id)?)
|
||||
});
|
||||
}
|
||||
nodes.push(Arc::new(c));
|
||||
}
|
||||
nodes
|
||||
} else {
|
||||
d.remove_many(&ids)?
|
||||
};
|
||||
let count = d.children(parent).map_or(0, Vec::len);
|
||||
let mut index = match anchor {
|
||||
None => count,
|
||||
@@ -708,11 +735,10 @@ fn move_rows(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if count > 0 && parent.and_then(|p| d.node(p)).is_some_and(Node::clips) {
|
||||
index = index.max(1);
|
||||
}
|
||||
let mut out = vec![];
|
||||
for n in nodes {
|
||||
out.push(d.insert(parent, index, n)?);
|
||||
index += 1;
|
||||
}
|
||||
let out: Vec<NodeId> = nodes.iter().map(|n| n.id).collect();
|
||||
let ch = d.children_mut(parent)?;
|
||||
let index = index.min(ch.len());
|
||||
ch.splice(index..index, nodes);
|
||||
sel.prune(d);
|
||||
sel.deselect_uneditable(d);
|
||||
Ok(out)
|
||||
|
||||
@@ -9,7 +9,7 @@ use std::sync::Arc;
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{Document, Node, NodeId, NodeKind};
|
||||
|
||||
use super::layer::{make_layer, row_roots, rows_param};
|
||||
use super::layer::{NewLayers, row_roots, rows_param};
|
||||
use super::*;
|
||||
use crate::EngineError;
|
||||
|
||||
@@ -234,28 +234,23 @@ fn template(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
/// another) into layer `into`, keeping the document's stacking order, then delete the emptied
|
||||
/// layers.
|
||||
fn merge_into(d: &mut Document, into: NodeId, others: &[NodeId]) -> Result<()> {
|
||||
let listed: HashSet<NodeId> = others.iter().copied().collect();
|
||||
let own: Vec<NodeId> =
|
||||
d.node(into).and_then(|n| n.children()).map(|c| c.iter().map(|c| c.id).filter(|c| !others.contains(c)).collect()).unwrap_or_default();
|
||||
let mut moved = vec![];
|
||||
d.node(into).and_then(|n| n.children()).map(|c| c.iter().map(|c| c.id).filter(|c| !listed.contains(c)).collect()).unwrap_or_default();
|
||||
let (mut moved, mut emptied) = (vec![], vec![]);
|
||||
for id in others {
|
||||
let n = d.node(*id).ok_or(EngineError::NoNode(*id))?;
|
||||
if n.is_layer() {
|
||||
moved.extend(n.children().into_iter().flatten().map(|c| c.id));
|
||||
emptied.push(*id);
|
||||
} else {
|
||||
moved.push(*id);
|
||||
}
|
||||
}
|
||||
// Where everything is now: the merged layer stacks its contents in this order.
|
||||
let keys: HashMap<NodeId, Vec<usize>> = own.iter().chain(&moved).filter_map(|id| Some((*id, d.index_path(*id)?))).collect();
|
||||
let mut taken = vec![];
|
||||
for id in &moved {
|
||||
taken.push(d.remove(*id)?);
|
||||
}
|
||||
for id in others {
|
||||
if d.node(*id).is_some() {
|
||||
d.remove(*id)?;
|
||||
}
|
||||
}
|
||||
let taken = d.remove_many(&moved)?;
|
||||
d.remove_many(&emptied)?;
|
||||
let ch = d.children_mut(Some(into))?;
|
||||
let mut all = std::mem::take(ch);
|
||||
all.extend(taken);
|
||||
@@ -312,9 +307,7 @@ fn flatten(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let others: Vec<NodeId> = d.layers.iter().filter(|l| l.id != into && l.visible && !l.is_template()).map(|l| l.id).collect();
|
||||
let hidden: Vec<NodeId> = d.layers.iter().filter(|l| l.id != into && !l.visible && !l.is_template()).map(|l| l.id).collect();
|
||||
s.edit("Flatten Artwork", |d, sel| {
|
||||
for h in &hidden {
|
||||
d.remove(*h)?;
|
||||
}
|
||||
d.remove_many(&hidden)?;
|
||||
// Hidden layers are discarded with their guides.
|
||||
d.drop_guides_of_deleted_layers(sel);
|
||||
merge_into(d, into, &others)?;
|
||||
@@ -352,39 +345,38 @@ fn release(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
let new = s.edit(if build { "Release to Layers (Build)" } else { "Release to Layers (Sequence)" }, |d, sel| {
|
||||
// A group's objects go into sublayers of the layer holding it, where it was.
|
||||
let (parent, mut index) = if group {
|
||||
let (parent, index) = if group {
|
||||
let layer = d.layer_containing(id).ok_or(EngineError::NoNode(id))?;
|
||||
let (par, i, _) = d.position(id).ok_or(EngineError::NoNode(id))?;
|
||||
if par == Some(layer) { (layer, i) } else { (layer, usize::MAX) }
|
||||
} else {
|
||||
(id, 0)
|
||||
};
|
||||
let mut originals = vec![];
|
||||
for o in &objects {
|
||||
originals.push(Arc::unwrap_or_clone(d.remove(*o)?));
|
||||
}
|
||||
let originals = d.remove_many(&objects)?;
|
||||
if group {
|
||||
d.remove(id)?;
|
||||
}
|
||||
let mut new = vec![];
|
||||
let mut names = NewLayers::of(d);
|
||||
let mut layers = Vec::with_capacity(originals.len());
|
||||
let last = originals.len().saturating_sub(1);
|
||||
for k in 0..originals.len() {
|
||||
let mut layer = make_layer(d, None);
|
||||
let lid = layer.id;
|
||||
let held: Vec<Node> = if build {
|
||||
let mut layer = names.make(d, None);
|
||||
let held: Vec<Arc<Node>> = if build {
|
||||
// Copies of the objects below and itself; the top layer keeps the originals.
|
||||
(0..=k).filter_map(|j| originals.get(j)).map(|o| if k == last { o.clone() } else { d.reid(o) }).collect()
|
||||
(0..=k).filter_map(|j| originals.get(j)).map(|o| if k == last { o.clone() } else { Arc::new(d.reid(o)) }).collect()
|
||||
} else {
|
||||
originals.get(k).cloned().into_iter().collect()
|
||||
};
|
||||
if let Some(ch) = layer.children_mut() {
|
||||
ch.extend(held.into_iter().map(Arc::new));
|
||||
ch.extend(held);
|
||||
}
|
||||
let at = if group { index } else { index.min(d.children(Some(parent)).map_or(0, Vec::len)) };
|
||||
d.insert(Some(parent), at, layer)?;
|
||||
index = at.saturating_add(1);
|
||||
new.push(lid);
|
||||
names.add(&layer);
|
||||
layers.push(Arc::new(layer));
|
||||
}
|
||||
let new: Vec<NodeId> = layers.iter().map(|l| l.id).collect();
|
||||
let ch = d.children_mut(Some(parent))?;
|
||||
let at = index.min(ch.len());
|
||||
ch.splice(at..at, layers);
|
||||
sel.prune(d);
|
||||
Ok(new)
|
||||
})?;
|
||||
|
||||
@@ -75,7 +75,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Links",
|
||||
[],
|
||||
None,
|
||||
"{show?: all (default)|missing|modified|embedded, sort?: name|kind (file format)|status (missing, modified, ok, embedded); default: stacking order; top first} every image object and placed document in the layers, as the Links panel lists them → {links: [{id, name, linked, status: ok|modified|missing|embedded, format, pixelWidth, pixelHeight, path?, found?: path (found away from its path), page?, preview?: true (showing the saved preview), document?: true (a placed document: pageWidth and pageHeight in pt instead of pixels)}], missing, modified, embedded}",
|
||||
"{show?: all (default)|missing|modified|embedded, sort?: name|kind (file format)|status (missing, modified, ok, embedded); default: stacking order; top first} every image object and placed document in the layers, as the Links panel lists them → {links: [{id, name, linked, status: ok|modified|missing|embedded, format, pixelWidth, pixelHeight, path?, found?: path (found away from its path), page?, preview?: true (showing the saved preview), note?: said of an image too large to draw, document?: true (a placed document: pageWidth and pageHeight in pt instead of pixels)}], missing, modified, embedded}",
|
||||
has_doc,
|
||||
list
|
||||
),
|
||||
@@ -1011,7 +1011,14 @@ fn list(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
let (link, mut row) = match &n.kind {
|
||||
NodeKind::Image(im) => {
|
||||
(im.link.as_ref(), json!({ "format": format_label(&st.doc, im), "pixelWidth": im.width, "pixelHeight": im.height }))
|
||||
let mut row = json!({ "format": format_label(&st.doc, im), "pixelWidth": im.width, "pixelHeight": im.height });
|
||||
// Only an image that could be past the decode allowance (at 16 bytes a pixel, the
|
||||
// most a decoder gives) has its header read for this.
|
||||
let large = im.width as u64 * im.height as u64 > fileio::MAX_RASTER_ALLOC / 16;
|
||||
if let Some(note) = st.doc.images.get(&im.key).filter(|_| large).and_then(|b| fileio::too_large_note(&b.bytes)) {
|
||||
row["note"] = json!(note);
|
||||
}
|
||||
(im.link.as_ref(), row)
|
||||
}
|
||||
NodeKind::PlacedDocument(p) => (Some(&p.link), document_row(p)),
|
||||
_ => continue,
|
||||
|
||||
@@ -18,6 +18,7 @@ use vectorcraft_geom::{Affine, PathData, Point, Vec2};
|
||||
|
||||
use super::edit::selected_roots;
|
||||
use super::expand::gradient_sampler;
|
||||
use super::pathops::Beside;
|
||||
use super::*;
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
@@ -360,14 +361,11 @@ fn has_mesh(s: &Session) -> std::result::Result<(), String> {
|
||||
|
||||
/// Wrap `roots` (paint order) into a new node built by `make`, placed where the topmost root was.
|
||||
fn wrap(d: &mut Document, sel: &mut Selection, roots: &[NodeId], make: impl FnOnce(NodeId, Vec<Arc<Node>>) -> Node) -> Result<NodeId> {
|
||||
let top = *roots.last().ok_or_else(|| EngineError::Other("nothing to wrap".into()))?;
|
||||
let (par, idx, _) = d.position(top).ok_or(EngineError::NoNode(top))?;
|
||||
let nodes: Vec<Arc<Node>> = roots.iter().filter_map(|id| d.node(*id).cloned()).map(Arc::new).collect();
|
||||
let id = d.alloc_id();
|
||||
d.insert(par, idx + 1, make(id, nodes))?;
|
||||
for r in roots {
|
||||
d.remove(*r)?;
|
||||
if roots.is_empty() {
|
||||
return Err(EngineError::Other("nothing to wrap".into()));
|
||||
}
|
||||
let id = d.alloc_id();
|
||||
d.wrap(roots, |nodes| make(id, nodes))?;
|
||||
sel.set([id]);
|
||||
Ok(id)
|
||||
}
|
||||
@@ -546,6 +544,7 @@ fn blend_release(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let blends = selected_of(s, is_blend);
|
||||
let (ids, spines, groups) = s.edit("Release Blend", |d, sel| {
|
||||
let (mut keys, mut spines, mut groups) = (vec![], vec![], vec![]);
|
||||
let mut released = Beside::replacing(d, &blends);
|
||||
for b in &blends {
|
||||
let Some(n) = d.node(*b).cloned() else { continue };
|
||||
let NodeKind::Blend { children, spec } = &n.kind else { continue };
|
||||
@@ -572,8 +571,9 @@ fn blend_release(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
groups.push(g.id);
|
||||
nodes = vec![g];
|
||||
}
|
||||
replace_with(d, *b, nodes)?;
|
||||
released.put(d, *b, nodes)?;
|
||||
}
|
||||
released.finish(d)?;
|
||||
if groups.is_empty() {
|
||||
sel.set(spines.iter().chain(&keys).copied());
|
||||
} else {
|
||||
@@ -1171,6 +1171,7 @@ fn env_release(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let envs = selected_of(s, is_envelope);
|
||||
let ids = s.edit("Release Envelope", |d, sel| {
|
||||
let mut out = vec![];
|
||||
let mut released = Beside::replacing(d, &envs);
|
||||
for e in &envs {
|
||||
let Some(n) = d.node(*e).cloned() else { continue };
|
||||
let NodeKind::Envelope { content, kind, .. } = &n.kind else { continue };
|
||||
@@ -1179,8 +1180,10 @@ fn env_release(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
shape.id = d.alloc_id();
|
||||
nodes.push(shape);
|
||||
}
|
||||
out.extend(replace_with(d, *e, nodes)?);
|
||||
out.extend(nodes.iter().map(|c| c.id));
|
||||
released.put(d, *e, nodes)?;
|
||||
}
|
||||
released.finish(d)?;
|
||||
sel.set(out.iter().copied());
|
||||
Ok(out)
|
||||
})?;
|
||||
|
||||
@@ -443,9 +443,10 @@ fn rasterize(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
.map(|(bp, rule)| (PathData::from_bezpath(&bp), rule));
|
||||
let tmp = isolated_doc(&st.doc, nodes);
|
||||
let (png, w, h) = render_png(&tmp, region, scale, &raster.render_options(RasterFormat::Png), look.color_model)?;
|
||||
let top = *roots.last().ok_or_else(|| bad(C, "nothing selected"))?;
|
||||
if roots.is_empty() {
|
||||
return Err(bad(C, "nothing selected"));
|
||||
}
|
||||
let id = s.edit("Rasterize", |d, sel| {
|
||||
let (par, idx, _) = d.position(top).ok_or(EngineError::NoNode(top))?;
|
||||
let key = unique_key(d, "raster");
|
||||
d.images.insert(key.clone(), vectorcraft_doc::ImageBlob::new("image/png", png));
|
||||
let id = d.alloc_id();
|
||||
@@ -455,11 +456,7 @@ fn rasterize(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some((path, rule)) = clip {
|
||||
node = super::rasterfx::clip_group(d, path, rule, node);
|
||||
}
|
||||
let id = node.id;
|
||||
d.insert(par, idx + 1, node)?;
|
||||
for r in &roots {
|
||||
d.remove(*r)?;
|
||||
}
|
||||
let id = d.wrap(&roots, |_| node)?;
|
||||
sel.set([id]);
|
||||
Ok(id)
|
||||
})?;
|
||||
@@ -625,7 +622,18 @@ fn trim_image(s: &mut Session, id: NodeId, im: ImageObject) -> Result<Value> {
|
||||
const C: &str = "object.cropImage";
|
||||
let st = s.doc()?;
|
||||
let blob = st.doc.images.get(&im.key).ok_or_else(|| bad(C, "the image's pixels are missing"))?;
|
||||
let px = image::load_from_memory(&blob.bytes).map_err(|_| bad(C, "the image's pixels can't be read"))?.to_rgba8();
|
||||
// A CMYK image (kept as its ink amounts) has no alpha: nothing to cut.
|
||||
if blob.is_cmyk() {
|
||||
return Ok(json!({ "id": id.0, "width": im.width, "height": im.height, "trimmed": false }));
|
||||
}
|
||||
// Print rasters run to gigabytes decoded: the same allowance opening them has.
|
||||
let mut reader = image::ImageReader::new(std::io::Cursor::new(blob.bytes.as_slice()))
|
||||
.with_guessed_format()
|
||||
.map_err(|_| bad(C, "the image's pixels can't be read"))?;
|
||||
let mut limits = image::Limits::default();
|
||||
limits.max_alloc = Some(crate::cmd::fileio::MAX_RASTER_ALLOC);
|
||||
reader.limits(limits);
|
||||
let px = reader.decode().map_err(|e| bad(C, format!("the image's pixels can't be read: {e}")))?.to_rgba8();
|
||||
let (w, h) = px.dimensions();
|
||||
let (mut x0, mut y0, mut x1, mut y1) = (w, h, 0, 0);
|
||||
for (x, y, p) in px.enumerate_pixels() {
|
||||
@@ -818,7 +826,7 @@ fn convert_to_shape(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
fn convert_to_artboards(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let roots = selected_roots(s)?;
|
||||
let n = s.edit("Convert to Artboards", |d, sel| {
|
||||
let mut n = 0;
|
||||
let mut converted = vec![];
|
||||
for id in &roots {
|
||||
let Some(b) = d.node(*id).and_then(|n| n.geometric_bounds()) else { continue };
|
||||
if b.width() <= 0.0 || b.height() <= 0.0 {
|
||||
@@ -832,14 +840,14 @@ fn convert_to_artboards(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
show_center_mark: false,
|
||||
show_cross_hairs: false,
|
||||
});
|
||||
d.remove(*id)?;
|
||||
n += 1;
|
||||
converted.push(*id);
|
||||
}
|
||||
if n == 0 {
|
||||
if converted.is_empty() {
|
||||
return Err(EngineError::Other("Convert to Artboards: selection has no area".into()));
|
||||
}
|
||||
d.remove_many(&converted)?;
|
||||
sel.clear();
|
||||
Ok(n)
|
||||
Ok(converted.len())
|
||||
})?;
|
||||
Ok(json!({ "artboards": n }))
|
||||
}
|
||||
|
||||
@@ -719,42 +719,39 @@ fn compound_make(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let ids = selected_roots(s)?;
|
||||
let Some(backmost) = ids.first().copied() else { return Err(EngineError::Other("nothing selected".into())) };
|
||||
let id = s.edit("Make Compound Path", |d, sel| {
|
||||
let appearance = d.node(backmost).ok_or(EngineError::NoNode(backmost))?.appearance.clone();
|
||||
let mut paths: Vec<Arc<Node>> = vec![];
|
||||
d.node(backmost).ok_or(EngineError::NoNode(backmost))?;
|
||||
let mut units: Vec<Vec<Arc<Node>>> = vec![];
|
||||
let mut appearance = None;
|
||||
for id in &ids {
|
||||
let n = d.node(*id).cloned().ok_or(EngineError::NoNode(*id))?;
|
||||
match &n.kind {
|
||||
NodeKind::Path { .. } => paths.push(Arc::new(n)),
|
||||
NodeKind::Compound { children, .. } => paths.extend(children.iter().cloned()),
|
||||
NodeKind::Group { .. } => {
|
||||
n.walk(&mut |c| {
|
||||
if matches!(c.kind, NodeKind::Path { .. }) {
|
||||
paths.push(Arc::new(c.clone()))
|
||||
}
|
||||
});
|
||||
}
|
||||
_ => return Err(EngineError::Other("compound paths can only contain paths".into())),
|
||||
let n = d.node(*id).ok_or(EngineError::NoNode(*id))?;
|
||||
if !matches!(n.kind, NodeKind::Path { .. } | NodeKind::Compound { .. } | NodeKind::Group { .. }) {
|
||||
return Err(EngineError::Other("compound paths can only contain paths".into()));
|
||||
}
|
||||
compound_members(n, &mut appearance, &mut units);
|
||||
}
|
||||
let cid = d.alloc_id();
|
||||
let mut c = Node::new(cid, NodeKind::Compound { children: vec![], rule: FillRule::NonZero });
|
||||
c.appearance = appearance;
|
||||
c.appearance = appearance.unwrap_or_default();
|
||||
let ch = c.children_mut().ok_or(EngineError::NoNode(cid))?;
|
||||
// The paths in front run against the backmost one, so where they overlap it they cut holes
|
||||
// under the non-zero fill rule (#1197).
|
||||
// under the non-zero fill rule (#1197). A compound path's paths turn together, by its
|
||||
// first: its own holes (a letter's counters) stay holes.
|
||||
let direction = |n: &Node| n.path_data().and_then(|pd| pd.subpaths.first()).map_or(0.0, |sp| sp.signed_area().signum());
|
||||
let back = paths.first().map_or(0.0, |p| direction(p));
|
||||
for (i, mut p) in paths.into_iter().enumerate() {
|
||||
let pn = Arc::make_mut(&mut p);
|
||||
pn.appearance = Appearance::default().into();
|
||||
let flip = i > 0 && back != 0.0 && direction(pn) == back;
|
||||
if let NodeKind::Path { path, live, .. } = &mut pn.kind {
|
||||
*live = None;
|
||||
if flip {
|
||||
path.reverse();
|
||||
let first = |unit: &[Arc<Node>]| unit.first().map_or(0.0, |p| direction(p));
|
||||
let back = units.first().map_or(0.0, |u| first(u));
|
||||
for (i, unit) in units.into_iter().enumerate() {
|
||||
let flip = i > 0 && back != 0.0 && first(&unit) == back;
|
||||
for mut p in unit {
|
||||
let pn = Arc::make_mut(&mut p);
|
||||
pn.appearance = Appearance::default().into();
|
||||
if let NodeKind::Path { path, live, .. } = &mut pn.kind {
|
||||
*live = None;
|
||||
if flip {
|
||||
path.reverse();
|
||||
}
|
||||
}
|
||||
ch.push(p);
|
||||
}
|
||||
ch.push(p);
|
||||
}
|
||||
d.wrap(&ids, |_| c)?;
|
||||
sel.set([cid]);
|
||||
@@ -763,6 +760,20 @@ fn compound_make(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
Ok(json!({ "id": id.0 }))
|
||||
}
|
||||
|
||||
/// The paths in `n` (a path, a compound path, or a group searched in depth) onto `out`, in paint
|
||||
/// order: each path alone, a compound path's paths together. Into `paint` goes the appearance of
|
||||
/// the backmost found so far: the path's own, or its compound path's, which is what painted it. A
|
||||
/// group has no paint of its own to give: a group of outlined type made a compound path that drew
|
||||
/// nothing.
|
||||
fn compound_members(n: &Node, paint: &mut Option<vectorcraft_doc::Shared<Appearance>>, out: &mut Vec<Vec<Arc<Node>>>) {
|
||||
match (&n.kind, n.children()) {
|
||||
(NodeKind::Path { .. }, _) => out.push(vec![Arc::new(n.clone())]),
|
||||
(NodeKind::Compound { .. }, Some(paths)) if !paths.is_empty() => out.push(paths.clone()),
|
||||
(_, children) => return children.into_iter().flatten().for_each(|c| compound_members(c, paint, out)),
|
||||
}
|
||||
paint.get_or_insert_with(|| n.appearance.clone());
|
||||
}
|
||||
|
||||
/// Is `n` a compound path to release: a compound path, or a path of several subpaths (an SVG `d`
|
||||
/// with several `M`s, a PDF path), which is one in Illustrator too (#1163)?
|
||||
fn releases(n: &Node) -> bool {
|
||||
|
||||
@@ -13,6 +13,7 @@ use vectorcraft_color::BlendMode;
|
||||
use vectorcraft_doc::{Document, Knockout, Node, NodeId, OpacityMask, Selection};
|
||||
|
||||
use super::maskedit::{begin, finish, mask_group, mask_parts, show_editing};
|
||||
use super::pathops::{Beside, separate};
|
||||
use super::*;
|
||||
use crate::EngineError;
|
||||
|
||||
@@ -189,6 +190,9 @@ fn make(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// Several objects are grouped so they share one mask.
|
||||
let target = if let [one] = art {
|
||||
*one
|
||||
} else if separate(d, art) {
|
||||
let gid = d.alloc_id();
|
||||
d.wrap(art, |children| Node::group(gid, children))?
|
||||
} else {
|
||||
let last = art[art.len() - 1];
|
||||
let (par, idx, _) = d.position(last).ok_or(EngineError::NoNode(last))?;
|
||||
@@ -223,16 +227,16 @@ fn release(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// Leave mask editing first: its art goes back into the mask, which is released below.
|
||||
finish(d, sel);
|
||||
let mut out = vec![];
|
||||
let mut released = Beside::above(d, &ids);
|
||||
for id in &ids {
|
||||
let Some(m) = d.node_mut(*id).and_then(|n| n.extras_mut().mask.take()) else { continue };
|
||||
let (par, idx, _) = d.position(*id).ok_or(EngineError::NoNode(*id))?;
|
||||
out.push(*id);
|
||||
for (k, part) in mask_parts(&m.art).iter().enumerate() {
|
||||
// Fresh ids: copies of a masked object share the ids inside their mask art.
|
||||
let art = d.reid(part);
|
||||
out.push(d.insert(par, idx + 1 + k, art)?);
|
||||
}
|
||||
// Fresh ids: copies of a masked object share the ids inside their mask art.
|
||||
let parts: Vec<Node> = mask_parts(&m.art).iter().map(|part| d.reid(part)).collect();
|
||||
out.extend(parts.iter().map(|p| p.id));
|
||||
released.put(d, *id, parts)?;
|
||||
}
|
||||
released.finish(d)?;
|
||||
sel.set(out);
|
||||
Ok(())
|
||||
})?;
|
||||
|
||||
@@ -136,10 +136,7 @@ pub(crate) fn artboard_copy(s: &mut Session, p: &Value, cut: bool) -> Result<Val
|
||||
// Its guides go with it.
|
||||
let gone = d.artboards.remove(i).id;
|
||||
d.retain_guides(sel, |_, g| g.artboard != Some(gone));
|
||||
for id in &art {
|
||||
// Always there: none of the art is inside another of it.
|
||||
let _ = d.remove(*id);
|
||||
}
|
||||
d.remove_many(&art)?;
|
||||
sel.prune(d);
|
||||
Ok(())
|
||||
})?;
|
||||
@@ -161,13 +158,7 @@ pub(crate) fn copy_artboard(d: &mut vectorcraft_doc::Document, i: usize, dv: Vec
|
||||
if let Some(id) = d.artboards.get(index).map(|a| a.id) {
|
||||
d.copy_artboard_guides(src.id, id, dv);
|
||||
}
|
||||
let mut copies = Vec::with_capacity(art.len());
|
||||
for id in art {
|
||||
let (Some((parent, at, _)), Some(n)) = (d.position(*id), d.node(*id).cloned()) else { continue };
|
||||
let mut c = d.reid(&n);
|
||||
c.transform(Affine::translate(dv), sc);
|
||||
copies.push(d.insert(parent, at + 1, c)?);
|
||||
}
|
||||
let copies = super::edit::copy_above(d, art, |_, _, c| c.transform(Affine::translate(dv), sc))?;
|
||||
Ok((index, copies))
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ use vectorcraft_doc::{NodeId, NodeKind, Scaling};
|
||||
use vectorcraft_geom::{AnchorKind, PathData, Point, SubPath, Vec2};
|
||||
|
||||
use super::create::anchor_from_json;
|
||||
use super::pathops::Beside;
|
||||
use super::*;
|
||||
use crate::EngineError;
|
||||
|
||||
@@ -466,6 +467,7 @@ pub(crate) fn delete_anchors(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let segments = st.selection.segments.clone();
|
||||
s.edit("Clear", |d, selection| {
|
||||
let mut survivors = vec![];
|
||||
let mut emptied = Beside::replacing(d, &sel.keys().copied().collect::<Vec<_>>());
|
||||
for (id, set) in &sel {
|
||||
let remove_node = {
|
||||
let path = path_mut(d, *id)?;
|
||||
@@ -499,11 +501,12 @@ pub(crate) fn delete_anchors(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
path.subpaths.is_empty()
|
||||
};
|
||||
if remove_node {
|
||||
d.remove(*id)?;
|
||||
emptied.put(d, *id, vec![])?;
|
||||
} else if segments.contains_key(id) {
|
||||
survivors.push(*id);
|
||||
}
|
||||
}
|
||||
emptied.finish(d)?;
|
||||
// Another Delete removes the remaining path after a segment cut. Anchor Delete keeps
|
||||
// its existing deselection behavior.
|
||||
selection.set(survivors);
|
||||
@@ -625,11 +628,12 @@ fn cut_at_anchors(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
}
|
||||
let ids = s.edit("Cut Path", |d, sel| {
|
||||
let (mut all, mut picked) = (vec![], vec![]);
|
||||
let mut cut = Beside::replacing(d, &cuts.iter().map(|(id, ..)| *id).collect::<Vec<_>>());
|
||||
for (id, subs, starts) in cuts {
|
||||
// A path of one subpath becomes one path per piece (the first keeps the id); the
|
||||
// pieces of a compound shape stay subpaths of it.
|
||||
if d.node(id).and_then(|n| n.path_data()).is_some_and(|pd| pd.subpaths.len() == 1) {
|
||||
let ids = super::draw2::replace_with_pieces(d, id, subs.into_iter().map(PathData::single).collect())?;
|
||||
let ids = super::draw2::replace_with_pieces(d, &mut cut, id, subs.into_iter().map(PathData::single).collect())?;
|
||||
picked.extend(ids.iter().zip(&starts).filter(|(_, s)| **s).map(|(id, _)| (*id, (0, 0))));
|
||||
all.extend(ids);
|
||||
} else {
|
||||
@@ -638,6 +642,7 @@ fn cut_at_anchors(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
all.push(id);
|
||||
}
|
||||
}
|
||||
cut.finish(d)?;
|
||||
// Each cut leaves one of its two coincident anchors selected, so a drag pulls the ends apart.
|
||||
sel.clear();
|
||||
for (id, a) in picked {
|
||||
|
||||
@@ -286,6 +286,73 @@ fn shape_node_styled(d: &mut Document, path: PathData, style: &Style) -> Node {
|
||||
n
|
||||
}
|
||||
|
||||
/// Whether `ids` are separate objects, as [`Document::remove_many`] and [`Document::insert_beside`]
|
||||
/// take them: each one is in the tree and listed once, and none lies inside another listed one.
|
||||
pub(crate) fn separate(d: &Document, ids: &[NodeId]) -> bool {
|
||||
let paths = d.index_paths(ids.iter().copied());
|
||||
let mut found: Vec<_> = ids.iter().filter_map(|id| paths.get(id)).collect();
|
||||
found.sort_unstable();
|
||||
found.len() == ids.len() && found.windows(2).all(|w| !w[1].starts_with(w[0]))
|
||||
}
|
||||
|
||||
/// Lists of nodes that go in place of objects, or directly above them; an empty list in place of
|
||||
/// an object removes it. When the objects are [`separate`], [`Beside::put`] holds each list and
|
||||
/// leaves the tree as it is, and [`Beside::finish`] inserts them all in one pass over the children
|
||||
/// of each parent ([`Document::insert_beside`]). Otherwise [`Beside::put`] inserts each list at
|
||||
/// once, where its object is at that moment.
|
||||
pub(crate) struct Beside {
|
||||
replace: bool,
|
||||
waiting: Option<Vec<(NodeId, Vec<Node>)>>,
|
||||
}
|
||||
|
||||
impl Beside {
|
||||
/// Lists that go in place of objects among `ids`.
|
||||
pub(crate) fn replacing(d: &Document, ids: &[NodeId]) -> Self {
|
||||
Beside { replace: true, waiting: separate(d, ids).then(Vec::new) }
|
||||
}
|
||||
|
||||
/// Lists that go directly above objects among `ids`.
|
||||
pub(crate) fn above(d: &Document, ids: &[NodeId]) -> Self {
|
||||
Beside { replace: false, waiting: separate(d, ids).then(Vec::new) }
|
||||
}
|
||||
|
||||
/// Put `nodes`, in their order, in place of object `id` or directly above it.
|
||||
pub(crate) fn put(&mut self, d: &mut Document, id: NodeId, nodes: Vec<Node>) -> Result<()> {
|
||||
if let Some(waiting) = &mut self.waiting {
|
||||
waiting.push((id, nodes));
|
||||
return Ok(());
|
||||
}
|
||||
let (parent, idx, _) = d.position(id).ok_or(EngineError::NoNode(id))?;
|
||||
let at = if self.replace {
|
||||
d.remove(id)?;
|
||||
idx
|
||||
} else {
|
||||
idx + 1
|
||||
};
|
||||
for (k, n) in nodes.into_iter().enumerate() {
|
||||
d.insert(parent, at + k, n)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Insert the lists that [`Beside::put`] held.
|
||||
pub(crate) fn finish(self, d: &mut Document) -> Result<()> {
|
||||
match self.waiting {
|
||||
Some(waiting) if !waiting.is_empty() => Ok(d.insert_beside(waiting, self.replace)?),
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove `ids` through a [`Beside`], which removes them in one pass when they are [`separate`].
|
||||
pub(crate) fn remove_each(d: &mut Document, ids: &[NodeId]) -> Result<()> {
|
||||
let mut gone = Beside::replacing(d, ids);
|
||||
for id in ids {
|
||||
gone.put(d, *id, vec![])?;
|
||||
}
|
||||
gone.finish(d)
|
||||
}
|
||||
|
||||
// ---------- Pathfinder ----------
|
||||
|
||||
fn pf_label(op: PathfinderOp) -> &'static str {
|
||||
@@ -305,7 +372,9 @@ fn pf_label(op: PathfinderOp) -> &'static str {
|
||||
|
||||
fn run_pf(s: &mut Session, op: PathfinderOp) -> Result<Value> {
|
||||
let roots = selected_roots(s)?;
|
||||
let Some(&top) = roots.last() else { return Err(EngineError::Other("nothing selected".into())) };
|
||||
if roots.is_empty() {
|
||||
return Err(EngineError::Other("nothing selected".into()));
|
||||
}
|
||||
let shape_mode =
|
||||
matches!(op, PathfinderOp::Unite | PathfinderOp::MinusFront | PathfinderOp::Intersect | PathfinderOp::Exclude | PathfinderOp::MinusBack);
|
||||
let label = pf_label(op);
|
||||
@@ -371,7 +440,6 @@ fn run_pf(s: &mut Session, op: PathfinderOp) -> Result<Value> {
|
||||
if results.iter().all(|r| r.path.is_empty()) {
|
||||
return Err(EngineError::Other(format!("{label} produced an empty result")));
|
||||
}
|
||||
let (par, idx, _) = d.position(top).ok_or(EngineError::NoNode(top))?;
|
||||
let new_node = if shape_mode {
|
||||
let r =
|
||||
results.into_iter().find(|r| !r.path.is_empty()).ok_or_else(|| EngineError::Other(format!("{label} produced an empty result")))?;
|
||||
@@ -403,11 +471,7 @@ fn run_pf(s: &mut Session, op: PathfinderOp) -> Result<Value> {
|
||||
let gid = d.alloc_id();
|
||||
Node::group(gid, children)
|
||||
};
|
||||
let nid = new_node.id;
|
||||
d.insert(par, idx + 1, new_node)?;
|
||||
for id in &roots {
|
||||
d.remove(*id)?;
|
||||
}
|
||||
let nid = d.wrap(&roots, |_| new_node)?;
|
||||
sel.set([nid]);
|
||||
Ok(vec![nid])
|
||||
})?;
|
||||
@@ -433,7 +497,7 @@ fn offset_path(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let miter = num_param(p, "miterLimit").unwrap_or(4.0).clamp(1.0, 500.0);
|
||||
let roots = selected_roots(s)?;
|
||||
let ids = s.edit("Offset Path", |d, sel| {
|
||||
let mut out = vec![];
|
||||
let (mut out, mut above) = (vec![], vec![]);
|
||||
for id in &roots {
|
||||
let Some(n) = d.node(*id).cloned() else { continue };
|
||||
let mut lv = vec![];
|
||||
@@ -460,12 +524,13 @@ fn offset_path(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let gid = d.alloc_id();
|
||||
Node::group(gid, made.into_iter().map(Arc::new).collect())
|
||||
};
|
||||
let (par, idx, _) = d.position(*id).ok_or(EngineError::NoNode(*id))?;
|
||||
out.push(d.insert(par, idx + 1, new)?);
|
||||
out.push(new.id);
|
||||
above.push((*id, vec![new]));
|
||||
}
|
||||
if out.is_empty() {
|
||||
return Err(EngineError::Other("Offset Path: select paths".into()));
|
||||
}
|
||||
d.insert_beside(above, false)?;
|
||||
sel.set(out.iter().copied());
|
||||
Ok(out)
|
||||
})?;
|
||||
@@ -614,9 +679,8 @@ pub(crate) fn replace_leaves(d: &mut Document, root: NodeId, mut f: impl FnMut(&
|
||||
let (mut replacement, mut changed) = (root, 0);
|
||||
for l in lv {
|
||||
let Some(new) = f(d, &l) else { continue };
|
||||
let (par, idx, _) = d.position(l.id).ok_or(EngineError::NoNode(l.id))?;
|
||||
let nid = d.insert(par, idx, new)?;
|
||||
d.remove(l.id)?;
|
||||
let nid = new.id;
|
||||
*d.node_mut(l.id).ok_or(EngineError::NoNode(l.id))? = new;
|
||||
changed += 1;
|
||||
if l.id == root {
|
||||
replacement = nid;
|
||||
@@ -840,12 +904,13 @@ fn clean_up(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Ok(json!({ "removed": 0 }));
|
||||
}
|
||||
s.edit("Clean Up", |d, _| {
|
||||
let mut gone = Beside::replacing(d, &found);
|
||||
for id in &found {
|
||||
if d.node(*id).is_some() {
|
||||
d.remove(*id)?;
|
||||
gone.put(d, *id, vec![])?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
gone.finish(d)
|
||||
})?;
|
||||
Ok(json!({ "removed": found.len() }))
|
||||
}
|
||||
@@ -885,6 +950,7 @@ fn divide_objects_below(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
&& d.node(*id).and_then(Node::geometric_bounds).is_some_and(|b| b.intersect(cb).area() > 0.0)
|
||||
});
|
||||
let mut out = vec![];
|
||||
let mut cut = Beside::replacing(d, &[targets.as_slice(), &[cutter_id]].concat());
|
||||
for t in targets {
|
||||
let Some(n) = d.node(t).cloned() else { continue };
|
||||
let Some((tp, tr)) = node_path(&n) else { continue };
|
||||
@@ -893,14 +959,12 @@ fn divide_objects_below(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
if inside.is_empty() || outside.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let (par, idx, _) = d.position(t).ok_or(EngineError::NoNode(t))?;
|
||||
d.remove(t)?;
|
||||
for (k, piece) in [outside, inside].into_iter().enumerate() {
|
||||
let node = shape_node(d, piece, Some(&n));
|
||||
out.push(d.insert(par, idx + k, node)?);
|
||||
}
|
||||
let pieces: Vec<Node> = [outside, inside].into_iter().map(|piece| shape_node(d, piece, Some(&n))).collect();
|
||||
out.extend(pieces.iter().map(|p| p.id));
|
||||
cut.put(d, t, pieces)?;
|
||||
}
|
||||
d.remove(cutter_id)?;
|
||||
cut.put(d, cutter_id, vec![])?;
|
||||
cut.finish(d)?;
|
||||
sel.set(out.iter().copied());
|
||||
Ok(out)
|
||||
})?;
|
||||
|
||||
@@ -18,6 +18,7 @@ use vectorcraft_doc::{Document, Node, NodeId, NodeKind, Selection};
|
||||
use vectorcraft_geom::Rect;
|
||||
|
||||
use super::edit::selected_roots;
|
||||
use super::pathops::Beside;
|
||||
use super::*;
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
@@ -633,15 +634,12 @@ fn make_repeat(s: &mut Session, p: &Value, label: &str, make: impl Fn(Vec<Arc<No
|
||||
return Ok(json!({ "id": r.0 }));
|
||||
}
|
||||
let roots = roots(s, p)?;
|
||||
let Some(&top) = roots.last() else { return Err(EngineError::Other("select the art to repeat".into())) };
|
||||
if roots.is_empty() {
|
||||
return Err(EngineError::Other("select the art to repeat".into()));
|
||||
}
|
||||
let id = s.edit(label, |d, sel| {
|
||||
let (par, idx, _) = d.position(top).ok_or(EngineError::NoNode(top))?;
|
||||
let nodes: Vec<Arc<Node>> = roots.iter().filter_map(|id| d.node(*id).cloned()).map(Arc::new).collect();
|
||||
let id = d.alloc_id();
|
||||
d.insert(par, idx + 1, Node::new(id, NodeKind::Repeat(make(nodes))))?;
|
||||
for r in &roots {
|
||||
d.remove(*r)?;
|
||||
}
|
||||
d.wrap(&roots, |nodes| Node::new(id, NodeKind::Repeat(make(nodes))))?;
|
||||
sel.set([id]);
|
||||
Ok(id)
|
||||
})?;
|
||||
@@ -682,15 +680,14 @@ fn repeat_release(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let reps = selected_repeats(s);
|
||||
let ids = s.edit("Release Repeat", |d, sel| {
|
||||
let mut out = vec![];
|
||||
let mut released = Beside::replacing(d, &reps);
|
||||
for r in &reps {
|
||||
let Some(n) = d.node(*r).cloned() else { continue };
|
||||
let (par, idx, _) = d.position(*r).ok_or(EngineError::NoNode(*r))?;
|
||||
d.remove(*r)?;
|
||||
for (k, c) in n.children().into_iter().flatten().enumerate() {
|
||||
out.push(c.id);
|
||||
d.insert(par, idx + k, (**c).clone())?;
|
||||
}
|
||||
let art: Vec<Node> = n.children().into_iter().flatten().map(|c| (**c).clone()).collect();
|
||||
out.extend(art.iter().map(|c| c.id));
|
||||
released.put(d, *r, art)?;
|
||||
}
|
||||
released.finish(d)?;
|
||||
sel.set(out.iter().copied());
|
||||
Ok(out)
|
||||
})?;
|
||||
|
||||
@@ -183,7 +183,8 @@ fn load(p: &Value, cmd: &str, board: Option<Rect>) -> Result<Loaded> {
|
||||
let (sx, sy) = pt_per_px(img.ppi);
|
||||
let natural = Rect::new(0.0, 0.0, img.width as f64 * sx, img.height as f64 * sy);
|
||||
let link = src.path.map(|path| super::links::link_info(path, &src.bytes));
|
||||
return Ok(Loaded { name, format, art: Art::Image(img), natural, warnings: vec![], link, board: None });
|
||||
let warnings = fileio::too_large_note(&img.blob.bytes).into_iter().collect();
|
||||
return Ok(Loaded { name, format, art: Art::Image(img), natural, warnings, link, board: None });
|
||||
}
|
||||
let anchored = format.id == "dxf" && (opts.dxf.fit || !opts.dxf.center);
|
||||
let (mut doc, warnings) = match format.id {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! Shaper compositions are groups of generated fills/strokes with transparent, editable source
|
||||
//! art retained underneath. Recipes select topology regions; source edits re-evaluate them in
|
||||
//! the same undo transaction. Rendering and exporters only see ordinary groups and paths.
|
||||
use super::pathops::Beside;
|
||||
use super::*;
|
||||
use serde_json::{Value, json};
|
||||
use std::{collections::BTreeMap, sync::Arc};
|
||||
@@ -380,7 +381,10 @@ pub(crate) fn refresh(before: &Document, d: &mut Document) -> Result<()> {
|
||||
ids.push(n.id)
|
||||
}
|
||||
});
|
||||
for id in ids {
|
||||
// Groups whose source art is all gone are removed through `emptied`, which is made when the
|
||||
// first one is found.
|
||||
let mut emptied: Option<Beside> = None;
|
||||
for &id in &ids {
|
||||
let Some(current) = d.node(id).cloned() else { continue };
|
||||
let Some(old) = before.node(id) else { continue };
|
||||
let src = sources(¤t);
|
||||
@@ -395,7 +399,7 @@ pub(crate) fn refresh(before: &Document, d: &mut Document) -> Result<()> {
|
||||
continue;
|
||||
}
|
||||
if src.is_empty() {
|
||||
d.remove(id)?;
|
||||
emptied.get_or_insert_with(|| Beside::replacing(d, &ids)).put(d, id, vec![])?;
|
||||
continue;
|
||||
}
|
||||
let map = map_of(&src)?;
|
||||
@@ -494,7 +498,7 @@ pub(crate) fn refresh(before: &Document, d: &mut Document) -> Result<()> {
|
||||
*ch = children;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
emptied.map_or(Ok(()), |e| e.finish(d))
|
||||
}
|
||||
|
||||
fn candidates(s: &Session, p: &Value, points: &[Point], tolerance: f64) -> Result<Vec<NodeId>> {
|
||||
@@ -670,9 +674,7 @@ fn scribble(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let mut group = Node::group(id, children);
|
||||
group.name = Some("Shaper Group".into());
|
||||
group.extras_mut().shaper = Some(Box::new(spec));
|
||||
for id in &roots {
|
||||
d.remove(*id)?;
|
||||
}
|
||||
d.remove_many(&roots)?;
|
||||
d.insert(parent, index.saturating_sub(earlier), group)?;
|
||||
sel.set([id]);
|
||||
Ok(id)
|
||||
|
||||
@@ -1596,6 +1596,59 @@ fn inspect_reports_compound_paint_and_blend() {
|
||||
assert!(plain.get("blend").is_none(), "Normal is omitted: {plain}");
|
||||
}
|
||||
|
||||
/// A compound path made of a group (outlined type is one) takes the paint of the backmost path in
|
||||
/// it, not the group's own (none): it drew nothing. A compound path in the group gives the paint
|
||||
/// that painted its paths.
|
||||
#[test]
|
||||
fn compound_of_a_group_takes_its_backmost_paths_paint() {
|
||||
let paint = |s: &Session, id: u64| (*s.doc().unwrap().doc.node(NodeId(id)).unwrap().appearance).clone();
|
||||
let made = |s: &mut Session, cmd: &str| s.execute(cmd, &json!({})).unwrap()["id"].as_u64().unwrap();
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 0.0, 0.0, 40.0, 40.0);
|
||||
s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap();
|
||||
let b = rect(&mut s, 10.0, 10.0, 10.0, 10.0);
|
||||
s.execute("paint.setFill", &json!({"color": "#0000ff"})).unwrap();
|
||||
let red = paint(&s, a.0);
|
||||
assert_ne!(red, paint(&s, b.0));
|
||||
assert_ne!(red, Default::default());
|
||||
s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap();
|
||||
made(&mut s, "object.group");
|
||||
let c = made(&mut s, "object.compoundPath.make");
|
||||
assert_eq!(paint(&s, c), red, "the backmost path's paint");
|
||||
// Grouped again and joined with a path in front: the paint the compound path gave its paths.
|
||||
let g = made(&mut s, "object.group");
|
||||
let front = rect(&mut s, 20.0, 20.0, 5.0, 5.0);
|
||||
s.execute("paint.setFill", &json!({"color": "#00ff00"})).unwrap();
|
||||
s.execute("select.set", &json!({"ids": [g, front.0]})).unwrap();
|
||||
let c = made(&mut s, "object.compoundPath.make");
|
||||
assert_eq!(paint(&s, c), red);
|
||||
assert_eq!(s.doc().unwrap().doc.node(NodeId(c)).unwrap().children().unwrap().len(), 3);
|
||||
}
|
||||
/// Compound paths joined into one keep their own holes: each one's paths turn together, so the
|
||||
/// counters of outlined letters stay open (they filled in when every path was turned alone).
|
||||
#[test]
|
||||
fn joined_compound_paths_keep_their_holes() {
|
||||
let made = |s: &mut Session, cmd: &str| s.execute(cmd, &json!({})).unwrap()["id"].as_u64().unwrap();
|
||||
let mut s = session();
|
||||
let mut rings = vec![];
|
||||
for x in [0.0, 100.0, 200.0] {
|
||||
let (outer, inner) = (rect(&mut s, x, 0.0, 40.0, 40.0), rect(&mut s, x + 10.0, 10.0, 10.0, 10.0));
|
||||
s.execute("select.set", &json!({"ids": [outer.0, inner.0]})).unwrap();
|
||||
rings.push(made(&mut s, "object.compoundPath.make"));
|
||||
}
|
||||
s.execute("select.set", &json!({"ids": rings})).unwrap();
|
||||
made(&mut s, "object.group");
|
||||
let c = made(&mut s, "object.compoundPath.make");
|
||||
let d = &s.doc().unwrap().doc;
|
||||
let turns: Vec<f64> =
|
||||
d.node(NodeId(c)).unwrap().children().unwrap().iter().map(|p| p.path_data().unwrap().subpaths[0].signed_area().signum()).collect();
|
||||
assert_eq!(turns.len(), 6);
|
||||
for ring in turns.chunks(2) {
|
||||
assert_eq!(ring[0], -ring[1], "a hole runs against its outline: {turns:?}");
|
||||
}
|
||||
// The rings in front run against the backmost one (#1197).
|
||||
assert_eq!((turns[2], turns[4]), (-turns[0], -turns[0]), "{turns:?}");
|
||||
}
|
||||
/// `document.node {summary: true}` answers the `document.inspect` shape for one node:
|
||||
/// the same fields the full-tree summary carries, without serializing the whole tree.
|
||||
#[test]
|
||||
|
||||
@@ -398,6 +398,25 @@ fn new_symbol_replaces_selection_with_instance_of_same_bounds() {
|
||||
assert_eq!(s.doc().unwrap().doc.symbols.len(), 1);
|
||||
}
|
||||
|
||||
/// New Symbol given `ids` that list an object and then the group that holds it puts the instance
|
||||
/// where the group was and removes both, as removing one object at a time does.
|
||||
#[test]
|
||||
fn new_symbol_of_an_object_and_the_group_that_holds_it_keeps_order_and_ids() {
|
||||
let mut s = session();
|
||||
let (a, b) = (rect(&mut s, 0.0, 0.0, 10.0, 10.0), rect(&mut s, 100.0, 0.0, 10.0, 10.0));
|
||||
select(&mut s, &[a, b]);
|
||||
let group = NodeId(s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap());
|
||||
let c = rect(&mut s, 200.0, 0.0, 10.0, 10.0);
|
||||
let before = s.doc().unwrap().doc.clone();
|
||||
let r = s.execute("symbol.new", &json!({"name": "Pair", "ids": [a.0, group.0]})).unwrap();
|
||||
let inst = NodeId(r["id"].as_u64().unwrap());
|
||||
let ids = |n: &Node| n.children().unwrap().iter().map(|c| c.id).collect::<Vec<_>>();
|
||||
assert_eq!(ids(&s.doc().unwrap().doc.layers[0]), [inst, c]);
|
||||
assert_eq!(s.doc().unwrap().selection.objects, [inst]);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(*s.doc().unwrap().doc == *before, "Undo gives back the document");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn place_and_break_link_round_trip_geometry() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -310,6 +310,23 @@ fn flatten_is_one_undo_step() {
|
||||
assert!(!exists(&s, a));
|
||||
}
|
||||
|
||||
/// Flattening a document for export in which two objects have the same id (a damaged file)
|
||||
/// removes both objects and puts one group in their place.
|
||||
#[test]
|
||||
fn flattening_objects_that_share_an_id_removes_both() {
|
||||
let _settings = settings_lock();
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 10.0, 10.0, 100.0, 100.0, "#ff0000");
|
||||
let b = rect(&mut s, 60.0, 60.0, 100.0, 100.0, "#0000ff");
|
||||
run(&mut s, "transparency.set", json!({"ids": [a, b], "opacity": 50}));
|
||||
let mut doc = (*s.doc().unwrap().doc).clone();
|
||||
doc.node_mut(NodeId(b)).unwrap().id = NodeId(a);
|
||||
let flat = crate::cmd::flatten::flatten_document(&doc, &crate::cmd::FlattenOptions::default()).unwrap().unwrap();
|
||||
let art = flat.layers[0].children().unwrap();
|
||||
assert_eq!(art.len(), 1, "one group where the two objects were");
|
||||
assert!(matches!(art[0].kind, NodeKind::Group { .. }) && art[0].id != NodeId(a));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn objects_without_transparency_stay_unless_outlined() {
|
||||
let _settings = settings_lock();
|
||||
|
||||
@@ -6,7 +6,7 @@ use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{Appearance, Document, Node, NodeId, NodeKind};
|
||||
use vectorcraft_doc::{Appearance, Document, LAYER_COLORS, LayerColor, Node, NodeId, NodeKind};
|
||||
use vectorcraft_geom::{FillRule, Rect, shapes};
|
||||
use vectorcraft_tools::{Mods, PointerEvent, PointerKind};
|
||||
|
||||
@@ -112,6 +112,28 @@ fn delete_of_100000_objects_finishes_within_20_s() {
|
||||
undo_gives_back(&mut s, &before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_selection_of_100000_rows_finishes_within_20_s() {
|
||||
let s = session(100_000, square);
|
||||
let (before, ids) = (s.doc().unwrap().doc.clone(), layer(&s, 0));
|
||||
let (mut s, r) = run_soon(s, "layer.delete", json!({"ids": ids}));
|
||||
assert_eq!(r["deleted"], json!(100_000));
|
||||
assert!(layer(&s, 0).is_empty());
|
||||
assert!(selected(&s, &[]));
|
||||
undo_gives_back(&mut s, &before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clean_up_of_100000_unpainted_paths_finishes_within_20_s() {
|
||||
let s = session(100_000, |d, k| Node::path(d.alloc_id(), shapes::rectangle(square_at(k)), Appearance::default()));
|
||||
let before = s.doc().unwrap().doc.clone();
|
||||
let (mut s, r) = run_soon(s, "object.path.cleanUp", json!({}));
|
||||
assert_eq!(r["removed"], json!(100_000));
|
||||
assert!(layer(&s, 0).is_empty());
|
||||
assert!(selected(&s, &[]));
|
||||
undo_gives_back(&mut s, &before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn group_of_50000_objects_finishes_within_20_s() {
|
||||
let s = session(50_000, square);
|
||||
@@ -184,6 +206,21 @@ fn transform_each_with_copy_of_50000_objects_finishes_within_20_s() {
|
||||
undo_gives_back(&mut s, &before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offset_path_of_50000_objects_finishes_within_20_s() {
|
||||
let s = session(50_000, square);
|
||||
let (before, ids, first) = (s.doc().unwrap().doc.clone(), layer(&s, 0), s.doc().unwrap().doc.peek_next_id());
|
||||
let (mut s, r) = run_soon(s, "object.path.offsetPath", json!({"offset": 1}));
|
||||
// Each offset path goes directly above its square and takes the next id, in the squares' order.
|
||||
let offsets = ids_from(first, ids.len());
|
||||
assert!(ids_in(&r) == offsets);
|
||||
assert!(layer(&s, 0) == in_turn(&ids, &offsets), "each offset path directly above its square");
|
||||
assert!(selected(&s, &offsets));
|
||||
let grown = bounds(&s, offsets[1]);
|
||||
assert!((grown.width() - 12.0).abs() < 1e-6 && (grown.x0 - 19.0).abs() < 1e-6, "{grown:?}");
|
||||
undo_gives_back(&mut s, &before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compound_path_of_100000_objects_finishes_within_20_s() {
|
||||
let s = session(100_000, square);
|
||||
@@ -211,6 +248,34 @@ fn send_to_current_layer_of_100000_objects_finishes_within_20_s() {
|
||||
undo_gives_back(&mut s, &before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn row_drag_of_100000_rows_finishes_within_20_s() {
|
||||
let mut s = session(100_000, square);
|
||||
let ids = layer(&s, 0);
|
||||
let to = NodeId(s.execute("layer.new", &json!({})).unwrap()["id"].as_u64().unwrap());
|
||||
let before = s.doc().unwrap().doc.clone();
|
||||
let (mut s, r) = run_soon(s, "layer.move", json!({"ids": ids, "target": to}));
|
||||
assert!(ids_in(&r) == ids);
|
||||
assert!(layer(&s, 0).is_empty());
|
||||
assert!(children(&s, to) == ids, "the layer holds the rows in their order");
|
||||
assert!(selected(&s, &ids));
|
||||
undo_gives_back(&mut s, &before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_selected_of_100000_objects_finishes_within_20_s() {
|
||||
let mut s = session(100_000, square);
|
||||
let (from, ids) = (s.doc().unwrap().doc.layers[0].id, layer(&s, 0));
|
||||
let into = NodeId(s.execute("layer.new", &json!({})).unwrap()["id"].as_u64().unwrap());
|
||||
let before = s.doc().unwrap().doc.clone();
|
||||
let (mut s, r) = run_soon(s, "layer.merge", json!({"ids": [from, into]}));
|
||||
assert_eq!(r["id"], json!(into));
|
||||
assert!(s.doc().unwrap().doc.node(from).is_none(), "the emptied layer goes");
|
||||
assert!(children(&s, into) == ids, "the layer holds the objects in their order");
|
||||
assert!(selected(&s, &ids));
|
||||
undo_gives_back(&mut s, &before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ungroup_of_25000_groups_finishes_within_20_s() {
|
||||
// Each group holds two squares, and ungrouping one adds an object to the layer.
|
||||
@@ -240,6 +305,45 @@ fn compound_path_release_of_25000_compound_paths_finishes_within_20_s() {
|
||||
undo_gives_back(&mut s, &before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn release_to_layers_of_100000_objects_finishes_within_20_s() {
|
||||
let s = session(100_000, square);
|
||||
let doc = &s.doc().unwrap().doc;
|
||||
let (before, l, ids, first) = (doc.clone(), doc.layers[0].id, layer(&s, 0), doc.peek_next_id());
|
||||
let (mut s, r) = run_soon(s, "layer.releaseToLayers", json!({"id": l}));
|
||||
// Each object goes into a sublayer of its own, in their order. The sublayers take the next ids,
|
||||
// and with Layer 1 counted, sublayer k is named `Layer k + 2` and takes layer color k + 1,
|
||||
// wrapping around after the last preset.
|
||||
let subs = ids_from(first, ids.len());
|
||||
assert!(ids_in(&r) == subs);
|
||||
assert!(layer(&s, 0) == subs);
|
||||
let doc = &s.doc().unwrap().doc;
|
||||
for (k, (sub, id)) in subs.iter().zip(&ids).enumerate() {
|
||||
let n = doc.node(*sub).unwrap();
|
||||
assert_eq!(ids_of(n), [*id]);
|
||||
assert_eq!(n.name.as_deref(), Some(format!("Layer {}", k + 2).as_str()));
|
||||
assert!(matches!(n.kind, NodeKind::Layer { color: LayerColor::Preset(c), .. } if usize::from(c) == (k + 1) % LAYER_COLORS.len()));
|
||||
}
|
||||
assert!(selected(&s, &ids));
|
||||
undo_gives_back(&mut s, &before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outline_stroke_of_25000_objects_finishes_within_20_s() {
|
||||
let s = session(25_000, square);
|
||||
let (before, first) = (s.doc().unwrap().doc.clone(), s.doc().unwrap().doc.peek_next_id());
|
||||
let (mut s, r) = run_soon(s, "object.path.outlineStroke", json!({}));
|
||||
// Each square, filled and stroked, becomes a group of its fill and the outline of its stroke,
|
||||
// where the square was, and the groups take new ids in the squares' order.
|
||||
let groups = ids_in(&r);
|
||||
assert_eq!(groups.len(), 25_000);
|
||||
assert!(layer(&s, 0) == groups, "each group where its square was");
|
||||
assert!(groups[0].0 >= first && groups.windows(2).all(|w| w[0] < w[1]));
|
||||
assert!(groups.iter().all(|g| children(&s, *g).len() == 2));
|
||||
assert!(selected(&s, &groups));
|
||||
undo_gives_back(&mut s, &before);
|
||||
}
|
||||
|
||||
/// With Alt held, a Selection tool press on one of 30,000 selected squares, four moves of 20 pt
|
||||
/// and the release copy the squares once, as one undo step.
|
||||
#[test]
|
||||
@@ -339,6 +443,193 @@ fn group_delete_and_duplicate_across_layers_and_inside_a_group_keep_order_and_id
|
||||
undo_gives_back(&mut s, &before);
|
||||
}
|
||||
|
||||
/// After the Layers panel's Delete Selection, Duplicate Selection, row drags, Collect in New Layer
|
||||
/// and Merge Selected on rows in two layers and inside a group, the layers hold the rows and the
|
||||
/// new ones in the order, and with the ids, that each check lists, and Undo gives back the
|
||||
/// document.
|
||||
#[test]
|
||||
fn layers_panel_rows_across_layers_and_inside_a_group_keep_order_and_ids() {
|
||||
let (mut s, [a, b, c, d, e, f, g, h, group]) = two_layers();
|
||||
let before = s.doc().unwrap().doc.clone();
|
||||
let (n, l1, l2) = (before.peek_next_id(), before.layers[0].id.0, before.layers[1].id.0);
|
||||
|
||||
let r = s.execute("layer.delete", &json!({"ids": [a, e, h]})).unwrap();
|
||||
assert_eq!(r["deleted"], 3);
|
||||
assert_eq!(layers(&s), [json!([b, c, [group, d, f]]), json!([g])]);
|
||||
assert!(selected(&s, &[NodeId(c), NodeId(g)]));
|
||||
undo_gives_back(&mut s, &before);
|
||||
|
||||
// Each copy goes directly above its row and takes the next id; a layer's copy is renamed.
|
||||
let r = s.execute("layer.duplicate", &json!({"ids": [a, e, h]})).unwrap();
|
||||
assert_eq!(r["ids"], json!([n, n + 1, n + 2]));
|
||||
assert_eq!(layers(&s), [json!([a, n, b, c, [group, d, e, n + 1, f]]), json!([g, h, n + 2])]);
|
||||
undo_gives_back(&mut s, &before);
|
||||
let r = s.execute("layer.duplicate", &json!({"ids": [l2]})).unwrap();
|
||||
assert_eq!(r["ids"], json!([n]));
|
||||
assert_eq!(layers(&s), [json!([a, b, c, [group, d, e, f]]), json!([g, h]), json!([n + 1, n + 2])]);
|
||||
assert_eq!(s.doc().unwrap().doc.layers[2].name.as_deref(), Some("Layer 2 copy"));
|
||||
undo_gives_back(&mut s, &before);
|
||||
|
||||
// Rows are dragged below c, then above d in the group, and copies of rows are dragged above g.
|
||||
let r = s.execute("layer.move", &json!({"ids": [a, e, h], "target": c, "place": "below"})).unwrap();
|
||||
assert_eq!(r["ids"], json!([a, e, h]));
|
||||
assert_eq!(layers(&s), [json!([b, a, e, h, c, [group, d, f]]), json!([g])]);
|
||||
undo_gives_back(&mut s, &before);
|
||||
s.execute("layer.move", &json!({"ids": [a, g], "target": d, "place": "above"})).unwrap();
|
||||
assert_eq!(layers(&s), [json!([b, c, [group, d, a, g, e, f]]), json!([h])]);
|
||||
undo_gives_back(&mut s, &before);
|
||||
let r = s.execute("layer.move", &json!({"ids": [a, e], "target": g, "place": "above", "copy": true})).unwrap();
|
||||
assert_eq!(r["ids"], json!([n, n + 1]));
|
||||
assert_eq!(layers(&s), [json!([a, b, c, [group, d, e, f]]), json!([g, n, n + 1, h])]);
|
||||
undo_gives_back(&mut s, &before);
|
||||
|
||||
// The new layer goes where the topmost row was when that row is in a layer, and on top of the
|
||||
// layer holding the topmost row's group when it is in a group.
|
||||
assert_eq!(s.execute("layer.collectInNew", &json!({"ids": [a, e, g]})).unwrap()["id"], json!(n));
|
||||
assert_eq!(layers(&s), [json!([b, c, [group, d, f]]), json!([[n, a, e, g], h])]);
|
||||
assert_eq!(s.doc().unwrap().doc.node(NodeId(n)).unwrap().name.as_deref(), Some("Layer 3"));
|
||||
undo_gives_back(&mut s, &before);
|
||||
assert_eq!(s.execute("layer.collectInNew", &json!({"ids": [a, e]})).unwrap()["id"], json!(n));
|
||||
assert_eq!(layers(&s), [json!([b, c, [group, d, f], [n, a, e]]), json!([g, h])]);
|
||||
undo_gives_back(&mut s, &before);
|
||||
|
||||
// Rows merge into the layer listed last, in the stacking order, and so does a merged layer's
|
||||
// art.
|
||||
s.execute("layer.merge", &json!({"ids": [e, g, l1]})).unwrap();
|
||||
assert_eq!(layers(&s), [json!([a, b, c, [group, d, f], e, g]), json!([h])]);
|
||||
undo_gives_back(&mut s, &before);
|
||||
s.execute("layer.merge", &json!({"ids": [l1, l2]})).unwrap();
|
||||
assert_eq!(layers(&s), [json!([a, b, c, [group, d, e, f], g, h])]);
|
||||
undo_gives_back(&mut s, &before);
|
||||
}
|
||||
|
||||
/// Release to Layers (Sequence on a layer and on a group, Build on a layer) and Flatten Artwork
|
||||
/// leave the layers, sublayers and objects in the order, and with the ids and names, that each
|
||||
/// check lists, and Undo gives back the document.
|
||||
#[test]
|
||||
fn release_to_layers_and_flatten_artwork_keep_order_and_ids() {
|
||||
let (mut s, [a, b, c, d, e, f, g, h, group]) = two_layers();
|
||||
let l2 = s.doc().unwrap().doc.layers[1].id.0;
|
||||
// Layer 2 also holds a sublayer, on top of g and h.
|
||||
let sub = s.execute("layer.newSublayer", &json!({"parent": l2})).unwrap()["id"].as_u64().unwrap();
|
||||
let before = s.doc().unwrap().doc.clone();
|
||||
let (n, l1) = (before.peek_next_id(), before.layers[0].id.0);
|
||||
let names = |s: &Session, r: &Value| ids_in(r).iter().map(|id| s.doc().unwrap().doc.node(*id).unwrap().display_name()).collect::<Vec<_>>();
|
||||
|
||||
// A layer's objects go into sublayers of their own, below its sublayers.
|
||||
let r = s.execute("layer.releaseToLayers", &json!({"id": l2})).unwrap();
|
||||
assert_eq!(r["ids"], json!([n, n + 1]));
|
||||
assert_eq!(layers(&s), [json!([a, b, c, [group, d, e, f]]), json!([[n, g], [n + 1, h], [sub]])]);
|
||||
assert_eq!(names(&s, &r), ["Layer 4", "Layer 5"]);
|
||||
undo_gives_back(&mut s, &before);
|
||||
|
||||
// Build: a sublayer holds copies of the objects below its own and a copy of its own, and the
|
||||
// top one holds the objects.
|
||||
let r = s.execute("layer.releaseToLayersBuild", &json!({"id": l2})).unwrap();
|
||||
assert_eq!(r["ids"], json!([n, n + 2]));
|
||||
assert_eq!(layers(&s), [json!([a, b, c, [group, d, e, f]]), json!([[n, n + 1], [n + 2, g, h], [sub]])]);
|
||||
assert_eq!(names(&s, &r), ["Layer 4", "Layer 5"]);
|
||||
undo_gives_back(&mut s, &before);
|
||||
|
||||
// A group's objects go into sublayers of its layer, where the group was.
|
||||
let r = s.execute("layer.releaseToLayers", &json!({"id": group})).unwrap();
|
||||
assert_eq!(r["ids"], json!([n, n + 1, n + 2]));
|
||||
assert_eq!(layers(&s), [json!([a, b, c, [n, d], [n + 1, e], [n + 2, f]]), json!([g, h, [sub]])]);
|
||||
assert_eq!(names(&s, &r), ["Layer 4", "Layer 5", "Layer 6"]);
|
||||
undo_gives_back(&mut s, &before);
|
||||
|
||||
// Flatten Artwork into Layer 1 deletes the two hidden layers and keeps the template layer.
|
||||
for p in [json!({"top": true, "visible": false}), json!({"top": true, "visible": false}), json!({"top": true, "template": true})] {
|
||||
s.execute("layer.new", &p).unwrap();
|
||||
}
|
||||
let before = s.doc().unwrap().doc.clone();
|
||||
assert_eq!(s.execute("layer.flatten", &json!({"id": l1})).unwrap()["discarded"], 2);
|
||||
assert_eq!(layers(&s), [json!([a, b, c, [group, d, e, f], g, h, [sub]]), json!([])]);
|
||||
undo_gives_back(&mut s, &before);
|
||||
}
|
||||
|
||||
/// Duplicate Artboard and `artboard.move` with `copy` and `moveArt` (the Artboard tool's Alt-drag
|
||||
/// with Move/Copy Artwork with Artboard on) put a copy of each object on the artboard directly
|
||||
/// above it, moved with the artboard's copy, and Cut Artboard removes those objects. An object off
|
||||
/// the artboard stays. The layers then hold the objects in the order, and with the ids, that each
|
||||
/// check lists, and Undo gives back the document.
|
||||
#[test]
|
||||
fn artboard_copies_and_cut_keep_order_and_ids() {
|
||||
let (mut s, [a, b, c, d, e, f, g, h, group]) = two_layers();
|
||||
let off = s.execute("shape.rectangle", &json!({"x": 900, "y": 10, "width": 10, "height": 10})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("artboard.new", &json!({})).unwrap();
|
||||
let before = s.doc().unwrap().doc.clone();
|
||||
let n = before.peek_next_id();
|
||||
let copies = json!([n, n + 1, n + 2, n + 3, n + 7, n + 8]);
|
||||
let copied = [json!([a, n, b, n + 1, c, n + 2, [group, d, e, f], [n + 3, n + 4, n + 5, n + 6]]), json!([g, n + 7, h, n + 8, off])];
|
||||
// The copy of h, the last object on the artboard, sits where h sits, moved by `dx`.
|
||||
let moved_by = |s: &Session, dx: f64| bounds(s, NodeId(n + 8)) == bounds(s, NodeId(h)) + vectorcraft_geom::Vec2::new(dx, 0.0);
|
||||
|
||||
// Duplicate Artboard places the copy 20 pt right of the last artboard.
|
||||
let right = before.artboards.iter().map(|ab| ab.rect.x1).fold(f64::MIN, f64::max);
|
||||
assert_eq!(s.execute("artboard.duplicate", &json!({"index": 0, "art": true})).unwrap()["ids"], copies);
|
||||
assert_eq!(layers(&s), copied);
|
||||
assert!(moved_by(&s, right + 20.0));
|
||||
undo_gives_back(&mut s, &before);
|
||||
|
||||
let r = s.execute("artboard.move", &json!({"index": 0, "dx": 900, "dy": 0, "copy": true, "moveArt": true})).unwrap();
|
||||
assert_eq!(r["moved"], copies);
|
||||
assert_eq!(layers(&s), copied);
|
||||
assert!(moved_by(&s, 900.0));
|
||||
undo_gives_back(&mut s, &before);
|
||||
|
||||
assert_eq!(s.execute("artboard.cut", &json!({"index": 0, "art": true})).unwrap()["copied"], 6);
|
||||
assert_eq!(layers(&s), [json!([]), json!([off])]);
|
||||
undo_gives_back(&mut s, &before);
|
||||
}
|
||||
|
||||
/// After Offset Path, Outline Stroke, Unite and Clean Up of objects in two layers and inside a
|
||||
/// group, the layers hold the objects and the new ones in the order, and with the ids, that each
|
||||
/// check lists, and Undo gives back the document.
|
||||
#[test]
|
||||
fn path_commands_across_layers_and_inside_a_group_keep_order_and_ids() {
|
||||
let (mut s, [a, b, c, d, e, f, g, h, group]) = two_layers();
|
||||
let before = s.doc().unwrap().doc.clone();
|
||||
let n = before.peek_next_id();
|
||||
|
||||
let r = s.execute("object.path.offsetPath", &json!({"offset": 1})).unwrap();
|
||||
assert_eq!(r["ids"], json!([n, n + 1, n + 2, n + 3, n + 4]));
|
||||
assert_eq!(layers(&s), [json!([a, n, b, c, n + 1, [group, d, e, n + 2, f]]), json!([g, n + 3, h, n + 4])]);
|
||||
assert!(selected(&s, &ids_from(n, 5)));
|
||||
undo_gives_back(&mut s, &before);
|
||||
|
||||
// Each object becomes a group of its fill and the outline of its stroke, where it was.
|
||||
let r = s.execute("object.path.outlineStroke", &json!({})).unwrap();
|
||||
let [a2, c2, e2, g2, h2] = <[NodeId; 5]>::try_from(ids_in(&r)).unwrap();
|
||||
assert!(n <= a2.0 && a2 < c2 && c2 < e2 && e2 < g2 && g2 < h2);
|
||||
assert_eq!(layer(&s, 0), [a2, NodeId(b), c2, NodeId(group)]);
|
||||
assert_eq!(children(&s, NodeId(group)), [NodeId(d), e2, NodeId(f)]);
|
||||
assert_eq!(layer(&s, 1), [g2, h2]);
|
||||
assert!(selected(&s, &[a2, c2, e2, g2, h2]));
|
||||
undo_gives_back(&mut s, &before);
|
||||
|
||||
// The result goes where the topmost object, h, was.
|
||||
let r = s.execute("object.pathfinder.unite", &json!({})).unwrap();
|
||||
assert_eq!(r["ids"], json!([n]));
|
||||
assert_eq!(layers(&s)[0], json!([b, [group, d, f]]));
|
||||
assert_eq!(layer(&s, 1), [NodeId(n)]);
|
||||
assert!(selected(&s, &[NodeId(n)]));
|
||||
undo_gives_back(&mut s, &before);
|
||||
|
||||
s.edit("Paint nothing", |d, _| {
|
||||
for id in [a, e, h] {
|
||||
d.node_mut(NodeId(id)).unwrap().appearance = Appearance::default().into();
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
let before = s.doc().unwrap().doc.clone();
|
||||
let r = s.execute("object.path.cleanUp", &json!({})).unwrap();
|
||||
assert_eq!(r["removed"], json!(3));
|
||||
assert_eq!(layers(&s), [json!([b, c, [group, d, f]]), json!([g])]);
|
||||
undo_gives_back(&mut s, &before);
|
||||
}
|
||||
|
||||
/// `object.transform` copies ids listed twice, and an object listed before a group around it.
|
||||
/// Each copy goes directly above its original, a later copy of the same object goes below an
|
||||
/// earlier one, and the copy of the group holds a copy of the copy made inside the group before it.
|
||||
|
||||
@@ -17,6 +17,38 @@ fn place_embedded(s: &mut Session, path: &str) -> NodeId {
|
||||
NodeId(r["ids"][0].as_u64().unwrap())
|
||||
}
|
||||
|
||||
/// A PNG that says it is `w` × `h` 1-bit grey pixels (its header and no rows, which is all that
|
||||
/// placing and listing read).
|
||||
fn png_header(w: u32, h: u32) -> Vec<u8> {
|
||||
let mut out = b"\x89PNG\r\n\x1a\n".to_vec();
|
||||
let ihdr: Vec<u8> = [&b"IHDR"[..], &w.to_be_bytes(), &h.to_be_bytes(), &[1, 0, 0, 0, 0]].concat();
|
||||
for chunk in [&ihdr[..], b"IDAT\x78\x9c", b"IEND"] {
|
||||
out.extend((chunk.len() as u32 - 4).to_be_bytes());
|
||||
out.extend(chunk);
|
||||
out.extend(crc32fast::hash(chunk).to_be_bytes());
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// An image past what a decode may take isn't drawn: Place and Open say so and the Links panel's
|
||||
/// row carries the note, where the image used to be blank with no word (#1479).
|
||||
#[test]
|
||||
fn an_image_too_large_to_draw_is_reported() {
|
||||
const NOTE: &str = "an image of 200000 × 200000 pixels is too large to draw";
|
||||
let dir = Folder::new("huge");
|
||||
let (big, small) = (dir.file("wall.png"), dir.file("small.png"));
|
||||
write(&big, &png_header(200_000, 200_000));
|
||||
write(&small, &png(40, 20, BLUE));
|
||||
let mut s = session();
|
||||
assert_eq!(run(&mut s, "file.place", json!({"path": big, "at": [200, 150], "link": false}))["warnings"], json!([NOTE]));
|
||||
assert_eq!(run(&mut s, "file.place", json!({"path": small, "at": [20, 20], "link": false}))["warnings"], json!([]));
|
||||
let l = run(&mut s, "links.list", json!({}));
|
||||
let notes: Vec<&Value> = l["links"].as_array().unwrap().iter().map(|r| &r["note"]).collect();
|
||||
assert_eq!(notes, [&Value::Null, &json!(NOTE)], "top first: the small image has nothing to say");
|
||||
// Opened as a document: said too. An image that decodes (16,000 px square) has no note.
|
||||
assert_eq!(crate::cmd::fileio::load("wall.png", &png_header(200_000, 200_000)).unwrap().warnings, [NOTE]);
|
||||
assert!(crate::cmd::fileio::load("fine.png", &png_header(16_000, 16_000)).unwrap().warnings.is_empty());
|
||||
}
|
||||
fn close(a: f64, b: f64) -> bool {
|
||||
(a - b).abs() < 1e-6
|
||||
}
|
||||
|
||||
@@ -217,6 +217,35 @@ fn blend_release_restores_keys() {
|
||||
assert!(s.execute("object.blend.release", &json!({})).is_ok() || s.execute("object.blend.release", &json!({})).is_err());
|
||||
}
|
||||
|
||||
/// Release Blend of a blend and of a blend inside a group that is a key object of the first puts
|
||||
/// the outer blend's spine and keys where it was, then the inner blend's spine and keys where it
|
||||
/// was inside that group, as releasing one blend at a time does.
|
||||
#[test]
|
||||
fn blend_release_of_a_blend_and_a_blend_inside_it_keeps_order_and_ids() {
|
||||
let mut s = session();
|
||||
let (r1, r2) = (rect(&mut s, 0.0, 0.0, 10.0, 10.0), rect(&mut s, 100.0, 0.0, 10.0, 10.0));
|
||||
sel(&mut s, &[r1, r2]);
|
||||
let inner = id_of(&s.execute("object.blend.make", &json!({})).unwrap());
|
||||
let r3 = rect(&mut s, 200.0, 0.0, 10.0, 10.0);
|
||||
sel(&mut s, &[inner, r3]);
|
||||
let group = id_of(&s.execute("object.group", &json!({})).unwrap());
|
||||
let r4 = rect(&mut s, 400.0, 0.0, 10.0, 10.0);
|
||||
sel(&mut s, &[group, r4]);
|
||||
let outer = id_of(&s.execute("object.blend.make", &json!({})).unwrap());
|
||||
sel(&mut s, &[outer, inner]);
|
||||
let before = s.doc().unwrap().doc.clone();
|
||||
let n = before.peek_next_id();
|
||||
let r = s.execute("object.blend.release", &json!({})).unwrap();
|
||||
assert_eq!(r["ids"], json!([group.0, r4.0, r1.0, r2.0]));
|
||||
assert_eq!(r["spines"], json!([n, n + 1]));
|
||||
let ids = |n: &Node| n.children().unwrap().iter().map(|c| c.id).collect::<Vec<_>>();
|
||||
assert_eq!(ids(&s.doc().unwrap().doc.layers[0]), [NodeId(n), group, r4]);
|
||||
assert_eq!(ids(&node(&s, group)), [NodeId(n + 1), r1, r2, r3]);
|
||||
assert_eq!(s.doc().unwrap().selection.objects, [NodeId(n), NodeId(n + 1), group, r4, r1, r2]);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(*s.doc().unwrap().doc == *before, "Undo gives back the document");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blend_expand_makes_group_with_unique_ids() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -1826,3 +1826,15 @@ fn crop_image_trim_cuts_to_the_opaque_pixels_as_they_are() {
|
||||
assert_eq!((r["trimmed"].as_bool(), r["width"].as_u64()), (Some(false), Some(30)), "{r}");
|
||||
assert_eq!(bounds(&s, img), b);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crop_image_trim_leaves_a_cmyk_image_alone() {
|
||||
// CMYK images keep their ink amounts and have no alpha: nothing to cut, and not an error.
|
||||
let inks = vectorcraft_doc::cmyk::Inks::new(4, 2, [255, 0, 0, 0].repeat(8)).unwrap();
|
||||
let tiff = vectorcraft_doc::ImageBlob::cmyk_tiff(&inks).unwrap();
|
||||
let mut s = Session::new();
|
||||
s.execute("document.open", &json!({"name": "inks.tif", "dataBase64": vectorcraft_format::base64_encode(&tiff.bytes)})).unwrap();
|
||||
s.execute("select.all", &json!({})).unwrap();
|
||||
let r = s.execute("object.cropImage", &json!({"trim": true})).unwrap();
|
||||
assert_eq!((r["trimmed"].as_bool(), r["width"].as_u64(), r["height"].as_u64()), (Some(false), Some(4), Some(2)), "{r}");
|
||||
}
|
||||
@@ -152,6 +152,30 @@ fn release_while_editing_leaves_editing_first() {
|
||||
assert!(mask(&s, obj).is_some() && doc(&s).mask_edit.is_some());
|
||||
}
|
||||
|
||||
/// Releasing the masks of a group and of an object inside it puts each mask's art directly above
|
||||
/// its object, as releasing one mask at a time does.
|
||||
#[test]
|
||||
fn release_of_a_masked_group_and_a_masked_object_inside_it_keeps_order_and_ids() {
|
||||
let mut s = session();
|
||||
let (a, m1) = (rect(&mut s, 0.0, 100.0), rect(&mut s, 0.0, 50.0));
|
||||
s.execute("transparency.makeOpacityMask", &json!({"ids": [a.0, m1.0]})).unwrap();
|
||||
let b = rect(&mut s, 150.0, 100.0);
|
||||
s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap();
|
||||
let group = NodeId(s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap());
|
||||
let m2 = rect(&mut s, 0.0, 300.0);
|
||||
s.execute("transparency.makeOpacityMask", &json!({"ids": [group.0, m2.0]})).unwrap();
|
||||
let before = doc(&s).clone();
|
||||
let n = before.peek_next_id();
|
||||
s.execute("transparency.releaseOpacityMask", &json!({"ids": [group.0, a.0]})).unwrap();
|
||||
let ids = |n: &vectorcraft_doc::Node| n.children().unwrap().iter().map(|c| c.id).collect::<Vec<_>>();
|
||||
assert_eq!(ids(&doc(&s).layers[0]), [group, NodeId(n)]);
|
||||
assert_eq!(ids(doc(&s).node(group).unwrap()), [a, NodeId(n + 1), b]);
|
||||
assert!(mask(&s, group).is_none() && mask(&s, a).is_none());
|
||||
assert_eq!(s.doc().unwrap().selection.objects, [group, NodeId(n), a, NodeId(n + 1)]);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(*doc(&s) == before, "Undo gives back the document");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_object_gets_an_empty_mask_drawn_in_editing_mode() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -1290,6 +1290,13 @@ proptest! {
|
||||
survive("mutated EPS PostScript", || vectorcraft_eps::import(&bytes).ok().map(|r| r.document))?;
|
||||
}
|
||||
|
||||
/// A damaged EPS another app copied: Paste reads it as an error or as objects.
|
||||
#[test]
|
||||
fn pasted_eps_never_panics(cut in 0usize..400_000, edits in prop::collection::vec((0usize..400_000, any::<u8>()), 0..12)) {
|
||||
static SAMPLE: std::sync::OnceLock<Vec<u8>> = std::sync::OnceLock::new();
|
||||
paste_from_elsewhere("clipboard.importEps", json!({"dataBase64": mutated_b64(SAMPLE.get_or_init(foreign_eps_sample).clone(), cut, &edits)}))?;
|
||||
}
|
||||
|
||||
/// Hostile PostScript programs: operators in any order with any operands, opened and placed;
|
||||
/// as files in the legacy Illustrator format too (the creator line is what turns on its
|
||||
/// documented `u` … `U` groups), the groups unbalanced, deep and among clips; in a layer, which
|
||||
|
||||
@@ -243,13 +243,32 @@ impl SaveOptions {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The defaults for saving `doc` to disk: pretty when small (see [`PRETTY_MAX_OBJECTS`]).
|
||||
/// The defaults for saving `doc` to disk: pretty when small (see [`PRETTY_MAX_OBJECTS`] and
|
||||
/// [`PRETTY_MAX_BYTES`]).
|
||||
pub fn for_doc(doc: &Document) -> Self {
|
||||
let objects: usize = doc.layers.iter().map(|l| l.count()).sum();
|
||||
Self { pretty: objects <= PRETTY_MAX_OBJECTS, ..Self::default() }
|
||||
Self { pretty: objects <= PRETTY_MAX_OBJECTS && compact_size_at_most(doc, PRETTY_MAX_BYTES), ..Self::default() }
|
||||
}
|
||||
}
|
||||
|
||||
/// Is `doc` at most `limit` bytes as compact JSON? Serializing stops as soon as it is past the
|
||||
/// limit, so a large document costs no more than `limit` bytes of work.
|
||||
fn compact_size_at_most(doc: &Document, limit: usize) -> bool {
|
||||
struct Capped {
|
||||
left: usize,
|
||||
}
|
||||
impl Write for Capped {
|
||||
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
|
||||
self.left = self.left.checked_sub(buf.len()).ok_or_else(|| std::io::Error::other("over the limit"))?;
|
||||
Ok(buf.len())
|
||||
}
|
||||
fn flush(&mut self) -> std::io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
serde_json::to_writer(Capped { left: limit }, doc).is_ok()
|
||||
}
|
||||
|
||||
/// Serialize a document (pretty = human-diffable) in the current version. Editing-mode working
|
||||
/// copies are left out ([`Document::without_edit_modes`]).
|
||||
pub fn save(doc: &Document, pretty: bool) -> Vec<u8> {
|
||||
@@ -260,6 +279,11 @@ pub fn save(doc: &Document, pretty: bool) -> Vec<u8> {
|
||||
/// compact, where indentation would multiply the file size and slow opening down.
|
||||
pub const PRETTY_MAX_OBJECTS: usize = 2000;
|
||||
|
||||
/// Documents whose compact JSON is larger than this are saved compact even with few objects:
|
||||
/// pretty-printing spreads every path anchor over several lines, so traced or generated art (in
|
||||
/// layers, symbols or patterns alike) would grow about tenfold.
|
||||
pub const PRETTY_MAX_BYTES: usize = 1 << 20;
|
||||
|
||||
/// Serialize for saving to disk: pretty when small, compact when large.
|
||||
pub fn save_file(doc: &Document) -> Vec<u8> {
|
||||
save_with(doc, &SaveOptions::for_doc(doc)).unwrap_or_default()
|
||||
|
||||
@@ -8,9 +8,11 @@ use std::sync::Arc;
|
||||
|
||||
use serde_json::Value;
|
||||
use vectorcraft_doc::links::LinkInfo;
|
||||
use vectorcraft_doc::{Document, ImageBlob, ImageObject, Node, NodeKind};
|
||||
use vectorcraft_format::{ICC_MIME, SaveOptions, load, load_file, pdf_content, save_with};
|
||||
use vectorcraft_geom::Affine;
|
||||
use vectorcraft_doc::{Document, ImageBlob, ImageObject, Node, NodeKind, Symbol};
|
||||
use vectorcraft_format::{ICC_MIME, PRETTY_MAX_BYTES, PRETTY_MAX_OBJECTS, SaveOptions, load, load_file, pdf_content, save_file, save_with};
|
||||
use vectorcraft_geom::path::{PathData, SubPath};
|
||||
use vectorcraft_geom::{Affine, Point};
|
||||
use vectorcraft_testkit::fixtures::DocBuilder;
|
||||
|
||||
fn json_of(bytes: &[u8]) -> Value {
|
||||
serde_json::from_slice(bytes).unwrap()
|
||||
@@ -82,3 +84,66 @@ fn embedded_profiles_and_pdf_round_trip() {
|
||||
let damaged = String::from_utf8(plain).unwrap().replacen("AAECA/o=", "not base64!", 1);
|
||||
assert!(load_file(damaged.as_bytes()).unwrap().profiles.is_empty());
|
||||
}
|
||||
|
||||
/// A document of `paths` open paths with `anchors` anchors each.
|
||||
fn paths_doc(paths: usize, anchors: usize) -> Document {
|
||||
let mut b = DocBuilder::new(1000.0, 1000.0);
|
||||
for i in 0..paths {
|
||||
let points: Vec<Point> = (0..anchors).map(|j| Point::new(j as f64, i as f64 + (j % 2) as f64)).collect();
|
||||
let node = b.path_node(PathData::single(SubPath::polyline(&points, false)), Default::default());
|
||||
b.add(node);
|
||||
}
|
||||
b.doc
|
||||
}
|
||||
|
||||
fn is_pretty(bytes: &[u8]) -> bool {
|
||||
bytes.windows(3).any(|w| w == b"\n ")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn small_documents_are_saved_pretty() {
|
||||
let d = paths_doc(10, 10);
|
||||
assert!(SaveOptions::for_doc(&d).pretty);
|
||||
assert!(is_pretty(&save_file(&d)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn many_objects_are_saved_compact() {
|
||||
let d = paths_doc(PRETTY_MAX_OBJECTS + 1, 2);
|
||||
assert!(!SaveOptions::for_doc(&d).pretty);
|
||||
}
|
||||
|
||||
/// Anchors per path that make a document of `paths` paths about `bytes` of compact JSON.
|
||||
fn anchors_for(paths: usize, bytes: usize) -> usize {
|
||||
let per_anchor = serde_json::to_vec(&paths_doc(1, 1000)).unwrap().len() / 1000;
|
||||
bytes / per_anchor / paths
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn few_objects_with_many_anchors_are_saved_compact() {
|
||||
let d = paths_doc(3, anchors_for(3, 2 * PRETTY_MAX_BYTES));
|
||||
assert!(!SaveOptions::for_doc(&d).pretty, "3 objects, but about {} bytes", 2 * PRETTY_MAX_BYTES);
|
||||
let bytes = save_file(&d);
|
||||
assert!(!is_pretty(&bytes));
|
||||
assert_eq!(load(&bytes).unwrap().layers[0].count(), d.layers[0].count());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn documents_under_the_size_limit_stay_pretty() {
|
||||
let d = paths_doc(2, anchors_for(2, PRETTY_MAX_BYTES / 2));
|
||||
assert!(SaveOptions::for_doc(&d).pretty);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn art_outside_the_layers_counts_too() {
|
||||
// Generated art kept in a symbol: one instance-sized document, a symbol full of anchors.
|
||||
let mut d = paths_doc(1, 4);
|
||||
let mut art = paths_doc(1, anchors_for(1, 2 * PRETTY_MAX_BYTES));
|
||||
let path = Arc::clone(&art.layers[0]);
|
||||
art.layers.clear();
|
||||
d.symbols.push(Symbol { name: "Leaves".into(), art: path });
|
||||
assert!(!SaveOptions::for_doc(&d).pretty);
|
||||
let bytes = save_file(&d);
|
||||
assert!(!is_pretty(&bytes));
|
||||
assert_eq!(load(&bytes).unwrap().symbols.len(), 1);
|
||||
}
|
||||
@@ -16,6 +16,7 @@ mod ink;
|
||||
mod live;
|
||||
mod lru;
|
||||
mod paint;
|
||||
pub use paint::decode_failures;
|
||||
mod pattern;
|
||||
pub mod placed_document;
|
||||
pub mod proof;
|
||||
|
||||
@@ -52,14 +52,53 @@ pub(crate) fn set_paint(ctx: &mut RenderContext, p: &Paint, bounds: Rect, f: &Fr
|
||||
}
|
||||
}
|
||||
|
||||
/// Decode encoded image bytes into a premultiplied pixmap.
|
||||
/// The most memory decoding one image may take: print rasters run to gigabytes (a 300 ppi roll,
|
||||
/// 6614 x 59055 px, is 1.56 GB as RGBA), past the decoder's 512 MB default. The web keeps it.
|
||||
const MAX_DECODE_ALLOC: u64 = if cfg!(target_arch = "wasm32") { 512 << 20 } else { 2 << 30 };
|
||||
|
||||
/// Images that couldn't be decoded (and so drew nothing), counted for exports to report.
|
||||
static DECODE_FAILURES: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
|
||||
|
||||
/// How many images have failed to decode so far (process-wide): an export compares the count
|
||||
/// before and after to say it left images out.
|
||||
pub fn decode_failures() -> u64 {
|
||||
DECODE_FAILURES.load(std::sync::atomic::Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// `bytes` decoded within [`MAX_DECODE_ALLOC`]; a failure is counted in [`decode_failures`].
|
||||
fn decode(bytes: &[u8]) -> Option<image::DynamicImage> {
|
||||
let img = image::ImageReader::new(std::io::Cursor::new(bytes)).with_guessed_format().ok().and_then(|mut r| {
|
||||
let mut limits = image::Limits::default();
|
||||
limits.max_alloc = Some(MAX_DECODE_ALLOC);
|
||||
r.limits(limits);
|
||||
r.decode().ok()
|
||||
});
|
||||
if img.is_none() {
|
||||
DECODE_FAILURES.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
}
|
||||
img
|
||||
}
|
||||
|
||||
/// `img` shrunk, keeping its proportions, to fit a pixmap's sides (at most `u16::MAX` pixels).
|
||||
fn fit_pixmap(img: image::DynamicImage) -> image::DynamicImage {
|
||||
let (w, h) = (img.width(), img.height());
|
||||
let max = u16::MAX as u32;
|
||||
if w <= max && h <= max {
|
||||
return img;
|
||||
}
|
||||
let s = max as f64 / w.max(h) as f64;
|
||||
let (nw, nh) = (((w as f64 * s).round() as u32).clamp(1, max), ((h as f64 * s).round() as u32).clamp(1, max));
|
||||
img.resize_exact(nw, nh, image::imageops::FilterType::Triangle)
|
||||
}
|
||||
|
||||
/// Decode encoded image bytes into a premultiplied pixmap (shrunk to fit a pixmap's sides).
|
||||
pub fn decode_pixmap(bytes: &[u8]) -> Option<vello_cpu::Pixmap> {
|
||||
pixmap_of(image::load_from_memory(bytes).ok()?.to_rgba8())
|
||||
pixmap_of(fit_pixmap(decode(bytes)?).to_rgba8())
|
||||
}
|
||||
|
||||
/// [`decode_pixmap`] shrunk to `w`×`h` pixels when the image is larger (never enlarged).
|
||||
pub fn decode_pixmap_fit(bytes: &[u8], w: u16, h: u16) -> Option<vello_cpu::Pixmap> {
|
||||
let img = image::load_from_memory(bytes).ok()?;
|
||||
let img = decode(bytes)?;
|
||||
let img = if img.width() > w as u32 || img.height() > h as u32 {
|
||||
img.resize_exact(w as u32, h as u32, image::imageops::FilterType::Triangle)
|
||||
} else {
|
||||
|
||||
@@ -65,13 +65,15 @@ fn revolve_canvas_matches_baked_art_and_shades_its_surface() {
|
||||
assert_eq!(live.pixels, render_threads(&d, 4).pixels);
|
||||
}
|
||||
|
||||
/// Extrude & Bevel, Inflate and Rotate draw on the canvas exactly as they export, filling more
|
||||
/// than the flat square with faces of differing shades (Rotate: one shade, narrower).
|
||||
/// Extrude & Bevel, Inflate (also twisted, tapered and hollow) and Rotate draw on the canvas exactly
|
||||
/// as they export, filling more than the flat square with faces of differing shades (Rotate: one
|
||||
/// shade, narrower).
|
||||
#[test]
|
||||
fn solid_effects_canvas_matches_baked_art_and_shades_their_faces() {
|
||||
for (effect, params) in [
|
||||
("threeD.extrude", json!({"depth": 30, "bevel": "rounded"})),
|
||||
("threeD.inflate", json!({"depth": 10})),
|
||||
("threeD.inflate", json!({"depth": 10, "twist": 90, "taper": 60, "cap": false, "rotationX": -20, "rotationY": 35})),
|
||||
("threeD.rotate", json!({"rotationX": 0, "rotationY": 55, "rotationZ": 0})),
|
||||
] {
|
||||
let mut n = Node::path(
|
||||
|
||||
+66
-29
@@ -161,7 +161,7 @@ impl Default for Bevel {
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Extrude {
|
||||
pub depth: f64,
|
||||
/// The turn of the back face against the front, about the shape's centre.
|
||||
/// The turn of the back face against the front, about the shape's centre: 0° to 360°.
|
||||
pub twist: f64,
|
||||
/// The size of the back face, as a share (0.01..1) of the front's.
|
||||
pub taper: f64,
|
||||
@@ -185,11 +185,17 @@ pub struct Inflate {
|
||||
pub volume: f64,
|
||||
/// Puff the back out too; otherwise it stays flat.
|
||||
pub both_sides: bool,
|
||||
/// The turn of the back against the front, about the shape's centre: 0° to 360°.
|
||||
pub twist: f64,
|
||||
/// The size of the back, as a share (0.01..1) of the front's.
|
||||
pub taper: f64,
|
||||
/// A closed back (a solid) or none, so the inflated front is a shell open behind.
|
||||
pub cap: bool,
|
||||
}
|
||||
|
||||
impl Default for Inflate {
|
||||
fn default() -> Self {
|
||||
Self { depth: 0.0, volume: 50.0, both_sides: false }
|
||||
Self { depth: 0.0, volume: 50.0, both_sides: false, twist: 0.0, taper: 1.0, cap: true }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -233,7 +239,7 @@ impl Extrude {
|
||||
fn sanitized(self) -> Self {
|
||||
Self {
|
||||
depth: finite(self.depth, 50.0, 0.0, 2000.0),
|
||||
twist: finite(self.twist, 0.0, -360.0, 360.0),
|
||||
twist: finite(self.twist, 0.0, 0.0, 360.0),
|
||||
taper: finite(self.taper, 1.0, 0.01, 1.0),
|
||||
bevel: self.bevel.map(|b| Bevel {
|
||||
width: finite(b.width, 4.0, 0.0, 1000.0),
|
||||
@@ -249,7 +255,13 @@ impl Extrude {
|
||||
|
||||
impl Inflate {
|
||||
fn sanitized(self) -> Self {
|
||||
Self { depth: finite(self.depth, 0.0, 0.0, 2000.0), volume: finite(self.volume, 50.0, 0.0, 100.0), ..self }
|
||||
Self {
|
||||
depth: finite(self.depth, 0.0, 0.0, 2000.0),
|
||||
volume: finite(self.volume, 50.0, 0.0, 100.0),
|
||||
twist: finite(self.twist, 0.0, 0.0, 360.0),
|
||||
taper: finite(self.taper, 1.0, 0.01, 1.0),
|
||||
..self
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -464,10 +476,9 @@ impl Builder<'_> {
|
||||
for i in 0..n {
|
||||
let j = (i + 1) % n;
|
||||
let quad = vec![a[i], a[j], b[j], b[i]];
|
||||
let mut normal = quad[1].sub(quad[0]).cross(quad[2].sub(quad[0]));
|
||||
if normal.dot(normal) < 1e-18 {
|
||||
normal = quad[2].sub(quad[0]).cross(quad[3].sub(quad[0]));
|
||||
}
|
||||
// A twist bends the strip between two slices into a saddle: its normal is the whole
|
||||
// quad's average (Newell's), not one corner's triangle, which leans far to one side.
|
||||
let mut normal = newell(std::slice::from_ref(&quad));
|
||||
if normal.dot(normal) < 1e-18 {
|
||||
continue;
|
||||
}
|
||||
@@ -517,15 +528,29 @@ fn outward(ring: &[Point], i: usize, z: f64) -> V3 {
|
||||
/// The cross-section of a side: each ring of the shape at depth fraction `t` (0 front, 1 back),
|
||||
/// turned by the twist and scaled by the taper about the centre of `bounds`.
|
||||
fn section(ring: &[Point], bounds: Rect, o: &Extrude, t: f64) -> Vec<Point> {
|
||||
let c = bounds.center();
|
||||
let (s, cos) = (o.twist * t).to_radians().sin_cos();
|
||||
let k = 1.0 + (o.taper - 1.0) * t;
|
||||
ring.iter()
|
||||
.map(|p| {
|
||||
let (x, y) = ((p.x - c.x) * k, (p.y - c.y) * k);
|
||||
Point::new(c.x + x * cos - y * s, c.y + x * s + y * cos)
|
||||
})
|
||||
.collect()
|
||||
let turn = Turn::new(bounds, o.twist, o.taper, t);
|
||||
ring.iter().map(|p| turn.apply(*p)).collect()
|
||||
}
|
||||
|
||||
/// The twist and taper at depth fraction `t` (0 front, 1 back): a turn by `twist · t` and a
|
||||
/// scale from 1 to `taper`, about the centre of the bounds.
|
||||
#[derive(Clone, Copy)]
|
||||
struct Turn {
|
||||
centre: Point,
|
||||
sin: f64,
|
||||
cos: f64,
|
||||
scale: f64,
|
||||
}
|
||||
|
||||
impl Turn {
|
||||
fn new(bounds: Rect, twist: f64, taper: f64, t: f64) -> Self {
|
||||
let (sin, cos) = (twist * t).to_radians().sin_cos();
|
||||
Self { centre: bounds.center(), sin, cos, scale: 1.0 + (taper - 1.0) * t }
|
||||
}
|
||||
fn apply(&self, p: Point) -> Point {
|
||||
let (x, y) = ((p.x - self.centre.x) * self.scale, (p.y - self.centre.y) * self.scale);
|
||||
Point::new(self.centre.x + x * self.cos - y * self.sin, self.centre.y + x * self.sin + y * self.cos)
|
||||
}
|
||||
}
|
||||
|
||||
/// One step of an extrusion's outline: how far it is inset (negative: outset) and its depth
|
||||
@@ -997,8 +1022,11 @@ pub fn inflate(path: &PathData, bounds: Rect, options: Inflate, view: View) -> R
|
||||
height * (m.at(p).max(0.0) / m.peak).sqrt()
|
||||
};
|
||||
let camera = Camera::new(view, bounds, o.depth + 2.0 * height);
|
||||
let mut b = Builder { camera: &camera, closed: true, facets: vec![] };
|
||||
// Without its back cap the object is a shell open behind, its inside showing.
|
||||
let mut b = Builder { camera: &camera, closed: o.cap, facets: vec![] };
|
||||
let half = o.depth / 2.0;
|
||||
// The back: turned and scaled by the twist and taper, its puff too.
|
||||
let back_turn = Turn::new(bounds, o.twist, o.taper, 1.0);
|
||||
// The grid's cells, those the surface rises steeply across (near the outline) cut into
|
||||
// halves or quarters each way, so the steep rim is drawn in facets as fine as its curve.
|
||||
// Every corner sits on one fine lattice (a quarter cell), computed the same way wherever it
|
||||
@@ -1119,16 +1147,18 @@ pub fn inflate(path: &PathData, bounds: Rect, options: Inflate, view: View) -> R
|
||||
// Each piece lifted onto the surface. Its normal is the lifted piece's own average
|
||||
// (Newell's), not the slope at one point: near the edge the surface turns
|
||||
// vertical, and a point sample there misjudges which way a whole piece faces.
|
||||
let lifted = |sign: f64| -> Vec<Vec<V3>> {
|
||||
pieces.iter().zip(&lifts).map(|(p, h)| p.iter().zip(h).map(|(q, h)| camera.place(*q, sign * (half + h))).collect()).collect()
|
||||
};
|
||||
let front = lifted(1.0);
|
||||
let front: Vec<Vec<V3>> =
|
||||
pieces.iter().zip(&lifts).map(|(p, h)| p.iter().zip(h).map(|(q, h)| camera.place(*q, half + h)).collect()).collect();
|
||||
let n = newell(&front);
|
||||
b.face(front, n)?;
|
||||
if o.both_sides {
|
||||
if o.both_sides && o.cap {
|
||||
// The back winds the same way round as the front, so its outward normal is
|
||||
// reversed.
|
||||
let back = lifted(-1.0);
|
||||
let back: Vec<Vec<V3>> = pieces
|
||||
.iter()
|
||||
.zip(&lifts)
|
||||
.map(|(p, h)| p.iter().zip(h).map(|(q, h)| camera.place(back_turn.apply(*q), -half - h * back_turn.scale)).collect())
|
||||
.collect();
|
||||
let n = newell(&back);
|
||||
b.face(back, V3(-n.0, -n.1, -n.2))?;
|
||||
}
|
||||
@@ -1136,15 +1166,22 @@ pub fn inflate(path: &PathData, bounds: Rect, options: Inflate, view: View) -> R
|
||||
}
|
||||
}
|
||||
}
|
||||
if !o.both_sides {
|
||||
let back: Vec<Vec<V3>> = rings.iter().map(|r| r.iter().map(|p| camera.place(*p, -half)).collect()).collect();
|
||||
if !o.both_sides && o.cap {
|
||||
let back: Vec<Vec<V3>> = rings.iter().map(|r| r.iter().map(|p| camera.place(back_turn.apply(*p), -half)).collect()).collect();
|
||||
b.face(back, camera.rotate(V3(0.0, 0.0, -1.0)))?;
|
||||
}
|
||||
if o.depth > 0.0 {
|
||||
// The sides, cut into slices that follow a twist or taper.
|
||||
let slices = if o.twist != 0.0 || o.taper != 1.0 { ((o.twist.abs() / 6.0).ceil() as usize).clamp(8, 64) } else { 1 };
|
||||
for ring in &rings {
|
||||
let front: Vec<V3> = ring.iter().map(|p| camera.place(*p, half)).collect();
|
||||
let back: Vec<V3> = ring.iter().map(|p| camera.place(*p, -half)).collect();
|
||||
b.band(&front, &back, |i| outward(ring, i, 0.0))?;
|
||||
let at = |k: usize| -> Vec<V3> {
|
||||
let t = k as f64 / slices as f64;
|
||||
let turn = Turn::new(bounds, o.twist, o.taper, t);
|
||||
ring.iter().map(|p| camera.place(turn.apply(*p), half - o.depth * t)).collect()
|
||||
};
|
||||
for k in 0..slices {
|
||||
b.band(&at(k), &at(k + 1), |i| outward(ring, i, 0.0))?;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(b.finish())
|
||||
|
||||
@@ -218,7 +218,10 @@ fn malformed_and_degenerate_input_is_bounded() {
|
||||
};
|
||||
let f = extrude(&p, b, wild, View { rotation_x: f64::NAN, perspective: 9.0, ..front() }).unwrap();
|
||||
assert!(f.iter().flat_map(|f| f.contours.iter().flatten()).all(|q| q.x.is_finite() && q.y.is_finite()));
|
||||
assert!(inflate(&p, b, Inflate { depth: -5.0, volume: f64::NAN, both_sides: true }, front()).is_ok());
|
||||
assert!(
|
||||
inflate(&p, b, Inflate { depth: -5.0, volume: f64::NAN, both_sides: true, twist: f64::INFINITY, taper: f64::NAN, cap: true }, front())
|
||||
.is_ok()
|
||||
);
|
||||
assert!(extrude(&PathData::default(), b, Extrude::default(), front()).unwrap().is_empty());
|
||||
// A line has no area to extrude.
|
||||
let mut line = BezPath::new();
|
||||
@@ -362,3 +365,113 @@ fn the_edge_index_looks_along_the_side_from_outside() {
|
||||
let (fast, slow) = (edges.distance(q), edge_distance(&rings, q));
|
||||
assert!((fast - slow).abs() < 1e-9, "{fast} against {slow}");
|
||||
}
|
||||
|
||||
/// Twist runs 0° to 360°, as documented: beyond either end it holds at that end.
|
||||
#[test]
|
||||
fn twist_holds_between_0_and_360_degrees() {
|
||||
let (p, b) = shape(&square(0.0, 0.0, 100.0));
|
||||
let v = View { rotation_x: -20.0, rotation_y: 30.0, ..front() };
|
||||
let at = |twist: f64| extrude(&p, b, Extrude { twist, ..Extrude::default() }, v).unwrap();
|
||||
assert_eq!(at(-45.0), at(0.0));
|
||||
assert_eq!(at(500.0), at(360.0));
|
||||
assert_ne!(at(45.0), at(0.0));
|
||||
}
|
||||
|
||||
/// A twisted extrusion shows every wall facing the viewer: a twist bends each strip between two
|
||||
/// slices into a saddle, and its normal from one corner's triangle leaned so far back that from
|
||||
/// 60° on every wall was dropped. Checked against each strip's average normal, oriented away from
|
||||
/// the square's axis.
|
||||
#[test]
|
||||
fn twisted_walls_face_the_way_they_turn() {
|
||||
let (p, b) = shape(&square(0.0, 0.0, 100.0));
|
||||
let c = b.center();
|
||||
for twist in [60.0, 120.0, 180.0, 300.0] {
|
||||
for (x, y) in [(-20.0, 30.0), (0.0, 90.0), (30.0, -60.0), (-45.0, 150.0)] {
|
||||
let v = View { rotation_x: x, rotation_y: y, ..front() };
|
||||
let o = Extrude { twist, ..Extrude::default() };
|
||||
let got = extrude(&p, b, o, v).unwrap();
|
||||
// Expected: walls (and caps) whose outward normal faces the viewer.
|
||||
let o = o.sanitized();
|
||||
let camera = Camera::new(v, b, o.depth);
|
||||
let ring = &rings(&p, b.width().max(b.height()), MAX_SOLID_POINTS).unwrap()[0];
|
||||
let slices = ((twist / 6.0).ceil() as usize).clamp(8, 64);
|
||||
let z = |t: f64| o.depth / 2.0 - o.depth * t;
|
||||
let mut expected = 0;
|
||||
for k in 0..slices {
|
||||
let (t0, t1) = (k as f64 / slices as f64, (k + 1) as f64 / slices as f64);
|
||||
let (r0, r1) = (section(ring, b, &o, t0), section(ring, b, &o, t1));
|
||||
for i in 0..ring.len() {
|
||||
let j = (i + 1) % ring.len();
|
||||
let q = [camera.place(r0[i], z(t0)), camera.place(r0[j], z(t0)), camera.place(r1[j], z(t1)), camera.place(r1[i], z(t1))];
|
||||
let mut n = newell(&[q.to_vec()]).unit();
|
||||
let mid = r0[i].midpoint(r1[j]);
|
||||
let away = camera.rotate(V3(mid.x - c.x, mid.y - c.y, 0.0));
|
||||
if n.dot(away) < 0.0 {
|
||||
n = V3(-n.0, -n.1, -n.2);
|
||||
}
|
||||
let centre = V3((q[0].0 + q[2].0) / 2.0, (q[0].1 + q[2].1) / 2.0, (q[0].2 + q[2].2) / 2.0);
|
||||
// Kept as the builder keeps them: facing, or seen almost edge-on, with an area.
|
||||
let projected: Vec<Point> = q.iter().map(|v| camera.project(*v)).collect();
|
||||
if camera.facing(centre, n) > -0.02 && signed_area(&projected).abs() >= 1e-10 {
|
||||
expected += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
for (n, t) in [(1.0, 0.0), (-1.0, 1.0)] {
|
||||
let cap: Vec<Point> = section(ring, b, &o, t).iter().map(|p| camera.project(camera.place(*p, z(t)))).collect();
|
||||
if camera.facing(V3::default(), camera.rotate(V3(0.0, 0.0, n))) > -0.02 && signed_area(&cap).abs() >= 1e-10 {
|
||||
expected += 1;
|
||||
}
|
||||
}
|
||||
assert_eq!(got.len(), expected, "twist {twist}, view ({x}, {y})");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Inflate takes Twist, Taper and Cap like the other types: the back turned and scaled (its puff
|
||||
/// too), the sides following, and without a cap no back at all.
|
||||
#[test]
|
||||
fn inflate_twists_tapers_and_opens_its_back() {
|
||||
let (p, b) = shape(&square(0.0, 0.0, 100.0));
|
||||
let side = View { rotation_y: 90.0, ..front() };
|
||||
let o = Inflate { depth: 60.0, volume: 0.0, ..Inflate::default() };
|
||||
// From the side, a flat (0 % volume) cushion is the extrusion: front 100 high, the back 50
|
||||
// at a 50 % taper.
|
||||
let tapered = inflate(&p, b, Inflate { taper: 0.5, ..o }, side).unwrap();
|
||||
let e = extent(&tapered);
|
||||
assert!(close(e.width(), 60.0, 1e-6) && close(e.height(), 100.0, 1e-6), "{e:?}");
|
||||
let back: Vec<Point> = tapered.iter().flat_map(|f| f.contours.iter().flatten().copied()).filter(|q| close(q.x, e.x0, 1e-6)).collect();
|
||||
let (lo, hi) = back.iter().fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), q| (lo.min(q.y), hi.max(q.y)));
|
||||
assert!(close(hi - lo, 50.0, 1e-6), "back {}", hi - lo);
|
||||
// From behind, a 45° twist shows the flat back as a diamond.
|
||||
let behind = View { rotation_y: 180.0, ..front() };
|
||||
let twisted = inflate(&p, b, Inflate { twist: 45.0, ..o }, behind).unwrap();
|
||||
assert!(close(extent(&twisted).width(), 100.0 * 2f64.sqrt(), 1e-6), "{:?}", extent(&twisted));
|
||||
// Hollow: no back; from behind the inside of the inflated front shows instead of a flat face.
|
||||
let solid = inflate(&p, b, Inflate { volume: 50.0, ..o }, behind).unwrap();
|
||||
assert_eq!(solid.len(), 1, "the flat back alone");
|
||||
let hollow = inflate(&p, b, Inflate { volume: 50.0, cap: false, ..o }, behind).unwrap();
|
||||
assert!(hollow.len() > 100 && hollow.iter().all(|f| f.contours.len() == 1 && f.contours[0].len() < 12));
|
||||
// With both sides puffed, the back puff is scaled with the taper: lower and smaller.
|
||||
let both = |taper: f64| extent(&inflate(&p, b, Inflate { both_sides: true, volume: 50.0, taper, depth: 0.0, ..o }, side).unwrap());
|
||||
assert!(both(0.5).width() < both(1.0).width() && both(0.5).height() <= both(1.0).height() + 1e-6);
|
||||
// Out of range values hold at the documented ends.
|
||||
let at = |i: Inflate| inflate(&p, b, i, View { rotation_x: -20.0, rotation_y: 30.0, ..front() }).unwrap();
|
||||
assert_eq!(at(Inflate { twist: -30.0, ..o }), at(o));
|
||||
assert_eq!(at(Inflate { taper: 0.0, ..o }), at(Inflate { taper: 0.01, ..o }));
|
||||
}
|
||||
|
||||
/// At 0 % volume an inflated shape is the extruded one: twisted and tapered, their outlines
|
||||
/// agree from every side (no wall goes missing in either).
|
||||
#[test]
|
||||
fn a_flat_twisted_inflate_matches_the_extrusion() {
|
||||
let (p, b) = shape(&square(0.0, 0.0, 100.0));
|
||||
for (x, y) in [(-20.0, 30.0), (0.0, 90.0), (30.0, -60.0), (-45.0, 150.0), (60.0, 200.0)] {
|
||||
let v = View { rotation_x: x, rotation_y: y, ..front() };
|
||||
let e = extent(&extrude(&p, b, Extrude { depth: 60.0, twist: 120.0, taper: 0.6, ..Extrude::default() }, v).unwrap());
|
||||
let i = extent(&inflate(&p, b, Inflate { depth: 60.0, twist: 120.0, taper: 0.6, volume: 0.0, ..Inflate::default() }, v).unwrap());
|
||||
for (a, c) in [(e.x0, i.x0), (e.y0, i.y0), (e.x1, i.x1), (e.y1, i.y1)] {
|
||||
assert!(close(a, c, 1e-6), "view ({x}, {y}): extrusion {e:?}, inflate {i:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -235,10 +235,13 @@ fn anchor_owners(cx: &ToolContext, f: fn(&Node) -> bool) -> Vec<NodeId> {
|
||||
v
|
||||
}
|
||||
|
||||
/// The anchor within `tol` of `p` nearest to it, of the topmost visible path or area type frame
|
||||
/// that has one: (id, si, ai, where it is).
|
||||
/// The anchor within `tol` of `p` nearest to it, of the topmost selected path or area type frame
|
||||
/// that has one, else of the topmost visible one: (id, si, ai, where it is). Selected objects come
|
||||
/// first so an object above can't take the press from the one being edited (#1508), as
|
||||
/// [`hovered_anchor`] marks it.
|
||||
fn hit_anchor(cx: &ToolContext, p: Point, tol: f64) -> Option<(NodeId, usize, usize, Point)> {
|
||||
let owners = anchor_owners(cx, |n| matches!(n.kind, NodeKind::Path { .. }) || is_area_type(n));
|
||||
let mut owners = anchor_owners(cx, |n| matches!(n.kind, NodeKind::Path { .. }) || is_area_type(n));
|
||||
owners.sort_by_key(|id| !cx.selection.contains(*id));
|
||||
owners.into_iter().find_map(|id| {
|
||||
let pd = cx.doc.node(id).and_then(editable_path)?;
|
||||
nearest_anchor(&pd, p, tol).map(|(si, ai, a)| (id, si, ai, a))
|
||||
@@ -1224,6 +1227,32 @@ mod tests {
|
||||
(d, a, b)
|
||||
}
|
||||
|
||||
/// An anchor of a selected path is picked before the one of an unselected path on a layer
|
||||
/// above that covers it, as Highlight anchors on mouse over already marks it (#1508); with
|
||||
/// nothing selected, the topmost path's anchor is picked.
|
||||
#[test]
|
||||
fn a_selected_path_s_anchor_is_picked_before_one_above_it() {
|
||||
let (mut d, a) = doc_with_rect();
|
||||
let top = d.add_layer(None);
|
||||
let b = d.alloc_id();
|
||||
let sq = vectorcraft_geom::shapes::rectangle(Rect::new(101.0, 100.0, 201.0, 200.0));
|
||||
d.insert(Some(top), 0, vectorcraft_doc::Node::path(b, sq, vectorcraft_doc::Appearance::default_art())).unwrap();
|
||||
let p = paint();
|
||||
let pick = |id: NodeId| vec![Action::Exec("select.anchors".into(), json!({"id": id.0, "anchors": [[0, 0]], "mode": "set"}))];
|
||||
let s = anchors_of(a, &[(0, 2)]);
|
||||
let mut t = DirectSelectionTool::new(false);
|
||||
assert_eq!(t.pointer(&cx(&d, &s, &p), &PointerEvent::new(PointerKind::Down, 101.0, 100.0)), pick(a));
|
||||
let mut whole = Selection::default();
|
||||
whole.set([a]);
|
||||
let mut t = DirectSelectionTool::new(false);
|
||||
let cx2 = cx(&d, &whole, &p);
|
||||
assert!(t.pointer(&cx2, &PointerEvent::new(PointerKind::Down, 101.0, 100.0)).is_empty());
|
||||
assert_eq!(t.pointer(&cx2, &PointerEvent::new(PointerKind::Up, 101.0, 100.0)), pick(a));
|
||||
let none = Selection::default();
|
||||
let mut t = DirectSelectionTool::new(false);
|
||||
assert_eq!(t.pointer(&cx(&d, &none, &p), &PointerEvent::new(PointerKind::Down, 101.0, 100.0)), pick(b));
|
||||
}
|
||||
|
||||
/// The anchors `anchors` of path `id` direct-selected.
|
||||
fn anchors_of(id: NodeId, anchors: &[AnchorRef]) -> Selection {
|
||||
let mut s = Selection::default();
|
||||
|
||||
+86
-15
@@ -4,7 +4,7 @@ use vectorcraft_geom::{Anchor, AnchorKind, CubicBez, ParamCurve, PathData, Point
|
||||
use vectorcraft_pathops::{SimplifyOptions, simplify_with};
|
||||
|
||||
use crate::contour::Loop;
|
||||
use crate::logo::{Cubic, Stats, Style, all_within, fit_outline, resample_closed, sample_cubics};
|
||||
use crate::logo::{Cubic, Stats, Style, all_within, fit_open, fit_outline, resample_closed, resample_open, sample_cubics};
|
||||
use crate::subpixel::{self, Crack};
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
@@ -48,27 +48,98 @@ fn rdp(pts: &[Point], a: usize, b: usize, tol: f64, keep: &mut [bool]) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Fit one loop of a `w × h` image into a closed Bézier subpath (pixel coordinates): its points
|
||||
/// are placed to a fraction of a pixel ([`subpixel::outline`], which explains `shift`) and fitted as
|
||||
/// Flat Logo mode fits its outlines (corners restored, straight runs as lines, the rest as few
|
||||
/// curves as `style.tol` allows).
|
||||
pub(crate) fn fit_loop(l: &Loop, w: usize, h: usize, shift: &dyn Fn(Crack) -> f64, style: &Style) -> Option<SubPath> {
|
||||
let (pts, hard) = subpixel::outline(l, w, h, shift);
|
||||
let mut style = *style;
|
||||
if hard {
|
||||
// No anti-aliasing: the edge is only known to about half a pixel (as in Flat Logo mode).
|
||||
style.tol *= 2.5;
|
||||
style.line_tol *= 1.8;
|
||||
style.circle_tol *= 1.8;
|
||||
style.sigma = 0.9;
|
||||
fn reversed(mut segs: Vec<Cubic>) -> Vec<Cubic> {
|
||||
segs.reverse();
|
||||
for c in &mut segs {
|
||||
c.reverse();
|
||||
}
|
||||
segs
|
||||
}
|
||||
|
||||
fn before(a: Point, b: Point) -> std::cmp::Ordering {
|
||||
a.x.total_cmp(&b.x).then(a.y.total_cmp(&b.y))
|
||||
}
|
||||
|
||||
/// A whole loop fitted. The same loop gives the same curves whichever way it runs and wherever it
|
||||
/// starts (it is fitted clockwise from its leftmost point): the outline of a shape and the hole it
|
||||
/// leaves in the shape around it are one curve.
|
||||
fn fit_closed(pts: &[Point], style: &Style) -> Option<Vec<Cubic>> {
|
||||
let n = pts.len();
|
||||
let area2: f64 = (0..n).map(|i| pts[i].x * pts[(i + 1) % n].y - pts[(i + 1) % n].x * pts[i].y).sum();
|
||||
let flip = area2 < 0.0;
|
||||
let mut pts = pts.to_vec();
|
||||
if flip {
|
||||
pts.reverse();
|
||||
}
|
||||
let start = (0..n).min_by(|&i, &j| before(pts[i], pts[j])).unwrap_or(0);
|
||||
pts.rotate_left(start);
|
||||
let mut stats = Stats::default();
|
||||
let mut segs = fit_outline(&pts, &style, &mut stats)?;
|
||||
let mut segs = fit_outline(&pts, style, &mut stats)?;
|
||||
// A fit that strayed was replaced by a plain one that nothing has checked: one that strays too
|
||||
// (a wild handle on a tiny or ragged loop) gives way to the outline's own points.
|
||||
if stats.reverted > 0 && !all_within(&sample_cubics(&segs, 0.25), &resample_closed(&pts, 0.5).map_or_else(|| pts.clone(), |(p, _)| p), 1.5) {
|
||||
segs = polygon(&pts, style.tol);
|
||||
}
|
||||
Some(if flip { reversed(segs) } else { segs })
|
||||
}
|
||||
|
||||
/// A piece of a loop between two junctions fitted, from its first point to its last. The same
|
||||
/// piece gives the same curve whichever way it runs (it is fitted from its end further left): the
|
||||
/// edge two regions share is one curve.
|
||||
fn fit_piece(pts: &[Point], style: &Style) -> Vec<Cubic> {
|
||||
let n = pts.len();
|
||||
let (Some(&first), Some(&last)) = (pts.first(), pts.last()) else { return vec![] };
|
||||
let flip = match before(first, last) {
|
||||
std::cmp::Ordering::Equal => n > 3 && before(pts[1], pts[n - 2]).is_gt(),
|
||||
order => order.is_gt(),
|
||||
};
|
||||
let mut pts = pts.to_vec();
|
||||
if flip {
|
||||
pts.reverse();
|
||||
}
|
||||
let mut segs = fit_open(&pts, style, &mut Stats::default());
|
||||
// Nothing else checks this fit: one that strays gives way to the piece's own points.
|
||||
if !all_within(&sample_cubics(&segs, 0.25), &resample_open(&pts, 0.5).map_or_else(|| pts.clone(), |(p, _)| p), 1.5) {
|
||||
let mut keep = vec![false; n];
|
||||
rdp(&pts, 0, n - 1, style.tol, &mut keep);
|
||||
keep[0] = true;
|
||||
keep[n - 1] = true;
|
||||
let kept: Vec<Point> = (0..n).filter(|&i| keep[i]).map(|i| pts[i]).collect();
|
||||
segs = kept.windows(2).filter(|p| p[0] != p[1]).map(|p| [p[0], p[0], p[1], p[1]]).collect();
|
||||
}
|
||||
if flip { reversed(segs) } else { segs }
|
||||
}
|
||||
|
||||
/// Fit one loop of a `w × h` image into a closed Bézier subpath (pixel coordinates): its points
|
||||
/// are placed to a fraction of a pixel ([`subpixel::outline`], which explains `across`) and fitted
|
||||
/// as Flat Logo mode fits its outlines (corners restored, straight runs as lines, the rest as few
|
||||
/// curves as `style.tol` allows), piece by piece between its junctions, so that the regions on
|
||||
/// either side of an edge get the same curve for it.
|
||||
pub(crate) fn fit_loop(
|
||||
l: &Loop,
|
||||
w: usize,
|
||||
h: usize,
|
||||
across: &dyn Fn(Crack) -> (f64, u32),
|
||||
junction_at: &dyn Fn(i32, i32) -> Option<Point>,
|
||||
style: &Style,
|
||||
) -> Option<SubPath> {
|
||||
let pieces = subpixel::outline(l, w, h, across, junction_at);
|
||||
let mut segs: Vec<Cubic> = Vec::new();
|
||||
for piece in &pieces {
|
||||
let mut style = *style;
|
||||
if piece.hard {
|
||||
// No anti-aliasing: the edge is only known to about half a pixel (as in Flat Logo mode).
|
||||
style.tol *= 2.5;
|
||||
style.line_tol *= 1.8;
|
||||
style.circle_tol *= 1.8;
|
||||
style.sigma = 0.9;
|
||||
}
|
||||
if piece.closed {
|
||||
segs = fit_closed(&piece.pts, &style)?;
|
||||
} else {
|
||||
segs.extend(fit_piece(&piece.pts, &style));
|
||||
}
|
||||
}
|
||||
let n = segs.len();
|
||||
// A straight piece has no handles.
|
||||
let straight = |c: &Cubic| seg_dist(c[1], c[0], c[3]) < 1e-6 && seg_dist(c[2], c[0], c[3]) < 1e-6;
|
||||
|
||||
+44
-1
@@ -25,6 +25,9 @@
|
||||
//! is (an anti-aliased or blurred edge pixel is part one tone, part the other), so the outline
|
||||
//! is right to a fraction of a pixel before any fitting; hard-edged images are read as
|
||||
//! staircases instead (see `subpixel`).
|
||||
//! Outlines are cut at their junctions (where three regions meet, and at the image's border)
|
||||
//! and each piece between two is fitted on its own, the same way from either side, so that two
|
||||
//! abutting regions have one and the same curve along the edge they share.
|
||||
//! 5. **Curves**: the outline is fitted as Flat Logo mode fits its own (`logo::fit_outline`):
|
||||
//! corners the pixels rounded are put back, straight runs become lines, a round outline a
|
||||
//! true circle, and the rest as few cubic Béziers as the Paths tolerance allows; Snap Curves To
|
||||
@@ -563,6 +566,46 @@ impl Layers<'_> {
|
||||
fn ignored(&self, label: u16) -> bool {
|
||||
self.params.ignore_white && self.q.palette.get(label as usize).is_some_and(|c| is_white(*c))
|
||||
}
|
||||
/// The region across a crack, for cutting outlines at their junctions: the outside pixel's
|
||||
/// colour. Stacked layers end on many colours at once and share no edge: one neighbour.
|
||||
fn neighbour(&self, c: Crack) -> u32 {
|
||||
if self.params.method == Method::Overlapping {
|
||||
return 0;
|
||||
}
|
||||
c.at(1).and_then(|i| self.q.labels.get(i)).map_or(u32::from(TRANSPARENT), |&l| u32::from(l))
|
||||
}
|
||||
/// The junction at pixel corner `(x, y)`, if there is one: where three or four regions
|
||||
/// meet, or two parts of a region touch by the corner only, or an edge reaches the image's
|
||||
/// border. It lies at the corner, moved across by what the edges that run up and down from it
|
||||
/// say, and up or down by what those to its left and right say (the mean, where there are
|
||||
/// two). It depends on the corner alone, so every region around it gets the same point; on the
|
||||
/// image's border it stays on the border.
|
||||
fn junction(&self, x: i32, y: i32) -> Option<vectorcraft_geom::Point> {
|
||||
let label = |px: i32, py: i32| Crack::new((px, py), (1, 0), self.w, self.h).at(0).and_then(|i| self.q.labels.get(i).copied());
|
||||
// The four pixels around the corner (`None` off the image), and the edges between them.
|
||||
let around = [label(x - 1, y - 1), label(x, y - 1), label(x, y), label(x - 1, y)];
|
||||
let edges = (0..4).filter(|&i| around[i] != around[(i + 1) % 4]).count();
|
||||
let apart = |a: usize, b: usize, c: usize, d: usize| around[a] == around[c] && around[b] != around[a] && around[d] != around[a];
|
||||
let touching = apart(0, 1, 2, 3) || apart(1, 2, 3, 0);
|
||||
// (Stacked layers share no edges: they are cut at the image's border only.)
|
||||
if edges < 3 && !touching || self.params.method == Method::Overlapping {
|
||||
return None;
|
||||
}
|
||||
// The shift of the crack between `a` and the pixel `step` from it, if both are in the
|
||||
// image and of different regions.
|
||||
let shift = |a: (i32, i32), step: (i32, i32)| {
|
||||
let (la, lb) = (label(a.0, a.1)?, label(a.0 + step.0, a.1 + step.1)?);
|
||||
(la != lb).then(|| self.shift(Crack::new(a, step, self.w, self.h)).clamp(-0.5, 0.5))
|
||||
};
|
||||
let mean = |a: Option<f64>, b: Option<f64>| match (a, b) {
|
||||
(Some(a), Some(b)) => (a + b) / 2.0,
|
||||
(Some(v), None) | (None, Some(v)) => v,
|
||||
(None, None) => 0.0,
|
||||
};
|
||||
let dx = mean(shift((x - 1, y - 1), (1, 0)), shift((x - 1, y), (1, 0)));
|
||||
let dy = mean(shift((x - 1, y - 1), (0, 1)), shift((x, y - 1), (0, 1)));
|
||||
Some(vectorcraft_geom::Point::new(f64::from(x) + dx, f64::from(y) + dy))
|
||||
}
|
||||
/// How far the edge lies from a crack between two pixels, towards the outside one (−0.5 to 0.5).
|
||||
///
|
||||
/// Each pixel of the column across the crack is read as a blend of the tones on its two sides
|
||||
@@ -690,7 +733,7 @@ impl Layers<'_> {
|
||||
true
|
||||
};
|
||||
for comp in trace_mask(&mask, w, h) {
|
||||
let fit = |l: &Loop| fit::fit_loop(l, w, h, &|c| self.shift(c), &self.style);
|
||||
let fit = |l: &Loop| fit::fit_loop(l, w, h, &|c| (self.shift(c), self.neighbour(c)), &|x, y| self.junction(x, y), &self.style);
|
||||
let Some(outer) = fit(&comp.outer) else { continue };
|
||||
let mut subs = vec![outer];
|
||||
for hole in &comp.holes {
|
||||
|
||||
@@ -52,6 +52,41 @@ pub(crate) fn resample_closed(p: &[Point], step: f64) -> Option<(Vec<Point>, f64
|
||||
Some((out, total / m as f64))
|
||||
}
|
||||
|
||||
/// An open polyline resampled at (about) `step` spacing, both ends kept; also returns the exact
|
||||
/// spacing used.
|
||||
pub(crate) fn resample_open(p: &[Point], step: f64) -> Option<(Vec<Point>, f64)> {
|
||||
let n = p.len();
|
||||
let (first, last) = (*p.first()?, *p.last()?);
|
||||
if n < 2 || step.is_nan() || step <= 0.0 || !p.iter().all(|&q| finite(q)) {
|
||||
return None;
|
||||
}
|
||||
let mut cum = Vec::with_capacity(n);
|
||||
cum.push(0.0);
|
||||
for pair in p.windows(2) {
|
||||
cum.push(cum.last().copied().unwrap_or(0.0) + pair[0].distance(pair[1]));
|
||||
}
|
||||
let total = cum.last().copied().unwrap_or(0.0);
|
||||
if total.is_nan() || total <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
let m = ((total / step).round() as usize).clamp(1, MAX_POINTS);
|
||||
let mut out = Vec::with_capacity(m + 1);
|
||||
out.push(first);
|
||||
let mut j = 0usize;
|
||||
for i in 1..m {
|
||||
let s = total * i as f64 / m as f64;
|
||||
while cum.get(j + 1).is_some_and(|&c| c < s) {
|
||||
j += 1;
|
||||
}
|
||||
let (c0, c1) = (*cum.get(j)?, *cum.get(j + 1)?);
|
||||
let t = if c1 > c0 { (s - c0) / (c1 - c0) } else { 0.0 };
|
||||
let (a, b) = (*p.get(j)?, *p.get(j + 1)?);
|
||||
out.push(a + (b - a) * t);
|
||||
}
|
||||
out.push(last);
|
||||
Some((out, total / m as f64))
|
||||
}
|
||||
|
||||
fn kernel(sigma: f64) -> Vec<f64> {
|
||||
let s = sigma.max(0.3);
|
||||
let r = ((3.0 * s).ceil() as i64).max(1);
|
||||
|
||||
@@ -24,7 +24,7 @@ mod tests;
|
||||
mod testutil;
|
||||
mod unmix;
|
||||
|
||||
pub(crate) use geom::{Cubic, all_within, resample_closed, sample_cubics};
|
||||
pub(crate) use outline::{Stats, Style, fit_outline};
|
||||
pub(crate) use geom::{Cubic, all_within, resample_closed, resample_open, sample_cubics};
|
||||
pub(crate) use outline::{Stats, Style, fit_open, fit_outline};
|
||||
pub(crate) use pipeline::trace_logo;
|
||||
pub(crate) use restore::RestoreParams;
|
||||
@@ -1,7 +1,9 @@
|
||||
//! One outline: smooth, restore corners, snap to lines and arcs, fit Béziers, and verify.
|
||||
|
||||
use super::bezier::{fit_cubics, tangent};
|
||||
use super::geom::{Cubic, all_within, arc_cubics, circle_fit, find_corners, finite, resample_closed, sample_cubics, smooth_closed, smooth_open};
|
||||
use super::geom::{
|
||||
Cubic, all_within, arc_cubics, circle_fit, find_corners, finite, resample_closed, resample_open, sample_cubics, smooth_closed, smooth_open,
|
||||
};
|
||||
use super::restore::{RestoreParams, restore_corners};
|
||||
use vectorcraft_geom::Point;
|
||||
|
||||
@@ -95,6 +97,105 @@ fn plain_fit(p: &[Point], tol: f64) -> Vec<Cubic> {
|
||||
fit_cubics(&closed, t, -t, tol)
|
||||
}
|
||||
|
||||
/// One run between two corners, added to `segs`: a line or an arc where it is one, else fitted
|
||||
/// curves. `sg` is the smoothing, in samples.
|
||||
fn fit_run(run: &[Point], sg: f64, st: &Style, stats: &mut Stats, segs: &mut Vec<Cubic>) {
|
||||
let (Some(&first), Some(&last)) = (run.first(), run.last()) else { return };
|
||||
if run.len() < 4 {
|
||||
if run.len() >= 2 && first.distance(last) > 1e-9 {
|
||||
segs.push(line_cubic(first, last));
|
||||
}
|
||||
return;
|
||||
}
|
||||
let sm = smooth_open(run, sg);
|
||||
let (Some(&a), Some(&b)) = (sm.first(), sm.last()) else { return };
|
||||
let len = a.distance(b);
|
||||
if st.snap && len > 1.5 {
|
||||
let d = b - a;
|
||||
let nrm = kurbo::Vec2::new(-d.y, d.x) / len;
|
||||
if sm.iter().all(|q| (*q - a).dot(nrm).abs() < st.line_tol) {
|
||||
stats.lines += 1;
|
||||
segs.push(line_cubic(a, b));
|
||||
return;
|
||||
}
|
||||
}
|
||||
if st.snap
|
||||
&& st.arcs
|
||||
&& sm.len() > 12
|
||||
&& len > 1.0
|
||||
&& let Some(c) = circle_fit(&sm)
|
||||
&& c.resid < st.circle_tol
|
||||
&& c.r > 2.5
|
||||
&& c.r < 400.0
|
||||
&& let Some((a0, a1)) = sweep(&sm, c.c)
|
||||
&& (a1 - a0).abs() > 0.35
|
||||
{
|
||||
stats.arcs += 1;
|
||||
let first = segs.len();
|
||||
segs.extend(arc_cubics(c.c, c.r, a0, a1));
|
||||
// The fitted circle may miss the corners by up to the snap tolerance: end exactly on them.
|
||||
if let Some(s) = segs.get_mut(first) {
|
||||
s[0] = a;
|
||||
}
|
||||
if let Some(s) = segs.last_mut() {
|
||||
s[3] = b;
|
||||
}
|
||||
return;
|
||||
}
|
||||
segs.extend(fit_cubics(&sm, tangent(&sm, 0, 4, 1), tangent(&sm, sm.len() - 1, 4, -1), st.tol));
|
||||
}
|
||||
|
||||
/// The open run `p` as cubic Béziers from its first point to its last, both kept exactly: cut at
|
||||
/// its corners, each stretch a line, an arc or fitted curves as in a closed outline. (No corner is
|
||||
/// put back here: a piece of an outline between two junctions ends on pixel corners anyway.)
|
||||
pub(crate) fn fit_open(p: &[Point], st: &Style, stats: &mut Stats) -> Vec<Cubic> {
|
||||
let mut segs = Vec::new();
|
||||
let (Some(&first), Some(&last)) = (p.first(), p.last()) else { return segs };
|
||||
let whole = |segs: &mut Vec<Cubic>| {
|
||||
if first.distance(last) > 1e-9 {
|
||||
segs.push(line_cubic(first, last));
|
||||
}
|
||||
};
|
||||
let Some((r, step)) = resample_open(p, st.step) else {
|
||||
whole(&mut segs);
|
||||
return segs;
|
||||
};
|
||||
let n = r.len();
|
||||
let s0 = smooth_open(&r, (0.18 / step).max(1.0));
|
||||
let w = ((st.corner_win / step).round() as usize).max(2);
|
||||
let angle = |i: usize| {
|
||||
let (Some(&c), Some(&a), Some(&b)) = (s0.get(i), i.checked_sub(w).and_then(|j| s0.get(j)), s0.get(i + w)) else { return 0.0 };
|
||||
let (v1, v2) = (c - a, b - c);
|
||||
v1.cross(v2).atan2(v1.dot(v2)).abs().to_degrees()
|
||||
};
|
||||
let mut cuts = vec![0];
|
||||
for i in w..n.saturating_sub(w) {
|
||||
let here = angle(i);
|
||||
if here >= st.corner_deg && here >= angle(i - 1) && here > angle(i + 1) && cuts.last().is_none_or(|&l| i - l > w) {
|
||||
cuts.push(i);
|
||||
}
|
||||
}
|
||||
cuts.push(n - 1);
|
||||
let sg = st.sigma / step;
|
||||
for pair in cuts.windows(2) {
|
||||
let Some(run) = r.get(pair[0]..=pair[1]) else { continue };
|
||||
let before = segs.len();
|
||||
fit_run(run, sg, st, stats, &mut segs);
|
||||
// Every stretch must lead on to the next.
|
||||
if segs.len() == before
|
||||
&& let (Some(&a), Some(&b)) = (run.first(), run.last())
|
||||
&& a.distance(b) > 1e-9
|
||||
{
|
||||
segs.push(line_cubic(a, b));
|
||||
}
|
||||
}
|
||||
if !sane(&segs) {
|
||||
segs.clear();
|
||||
whole(&mut segs);
|
||||
}
|
||||
segs
|
||||
}
|
||||
|
||||
fn styled_fit(p: &[Point], st: &Style, stats: &mut Stats) -> Option<Vec<Cubic>> {
|
||||
let (mut r, step) = resample_closed(p, st.step)?;
|
||||
let sg = st.sigma / step;
|
||||
@@ -147,49 +248,7 @@ fn styled_fit(p: &[Point], st: &Style, stats: &mut Stats) -> Option<Vec<Cubic>>
|
||||
let i1 = *corners.get((ci + 1) % corners.len())?;
|
||||
let count = if i1 <= i0 { i1 + n - i0 } else { i1 - i0 } + 1;
|
||||
let run: Vec<Point> = (0..count).filter_map(|k| r.get((i0 + k) % n).copied()).collect();
|
||||
let (Some(&first), Some(&last)) = (run.first(), run.last()) else { continue };
|
||||
if run.len() < 4 {
|
||||
if run.len() >= 2 && first.distance(last) > 1e-9 {
|
||||
segs.push(line_cubic(first, last));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let sm = smooth_open(&run, sg);
|
||||
let (Some(&a), Some(&b)) = (sm.first(), sm.last()) else { continue };
|
||||
let len = a.distance(b);
|
||||
if st.snap && len > 1.5 {
|
||||
let d = b - a;
|
||||
let nrm = kurbo::Vec2::new(-d.y, d.x) / len;
|
||||
if sm.iter().all(|q| (*q - a).dot(nrm).abs() < st.line_tol) {
|
||||
stats.lines += 1;
|
||||
segs.push(line_cubic(a, b));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if st.snap
|
||||
&& st.arcs
|
||||
&& sm.len() > 12
|
||||
&& len > 1.0
|
||||
&& let Some(c) = circle_fit(&sm)
|
||||
&& c.resid < st.circle_tol
|
||||
&& c.r > 2.5
|
||||
&& c.r < 400.0
|
||||
&& let Some((a0, a1)) = sweep(&sm, c.c)
|
||||
&& (a1 - a0).abs() > 0.35
|
||||
{
|
||||
stats.arcs += 1;
|
||||
let first = segs.len();
|
||||
segs.extend(arc_cubics(c.c, c.r, a0, a1));
|
||||
// The fitted circle may miss the corners by up to the snap tolerance: end exactly on them.
|
||||
if let Some(s) = segs.get_mut(first) {
|
||||
s[0] = a;
|
||||
}
|
||||
if let Some(s) = segs.last_mut() {
|
||||
s[3] = b;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
segs.extend(fit_cubics(&sm, tangent(&sm, 0, 4, 1), tangent(&sm, sm.len() - 1, 4, -1), st.tol));
|
||||
fit_run(&run, sg, st, stats, &mut segs);
|
||||
}
|
||||
(!segs.is_empty()).then_some(segs)
|
||||
}
|
||||
|
||||
+192
-44
@@ -40,6 +40,10 @@ pub(crate) struct Crack {
|
||||
}
|
||||
|
||||
impl Crack {
|
||||
/// The crack between pixel `inside` and the pixel `step` (one pixel right or down) from it.
|
||||
pub fn new(inside: (i32, i32), step: (i32, i32), w: usize, h: usize) -> Self {
|
||||
Self { inside, step, w, h }
|
||||
}
|
||||
/// The index of the pixel `k` steps out from the inside pixel (0: the inside pixel, 1: the
|
||||
/// outside one, −1: the one behind the inside pixel), if the image has it.
|
||||
pub fn at(&self, k: i32) -> Option<usize> {
|
||||
@@ -52,20 +56,58 @@ fn pixel(p: (i32, i32), w: usize, h: usize) -> Option<usize> {
|
||||
(x < w && y < h).then(|| y * w + x)
|
||||
}
|
||||
|
||||
/// The outline of `l` (a loop of a `w × h` image) as points, and whether its edge is hard.
|
||||
/// A stretch of an outline with one neighbour along it: its points, and whether its edge is hard.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub(crate) struct Piece {
|
||||
pub pts: Vec<Point>,
|
||||
pub hard: bool,
|
||||
/// The piece is a whole loop without a junction.
|
||||
pub closed: bool,
|
||||
}
|
||||
|
||||
/// What lies across a crack on the image's border: one neighbour per side, so that the image's
|
||||
/// corners are junctions and stay exact.
|
||||
fn border(dir: (i32, i32)) -> u32 {
|
||||
u32::MAX
|
||||
- match dir {
|
||||
(1, 0) => 0,
|
||||
(0, 1) => 1,
|
||||
(-1, 0) => 2,
|
||||
_ => 3,
|
||||
}
|
||||
}
|
||||
|
||||
/// The outline of `l` (a loop of a `w × h` image) as points.
|
||||
///
|
||||
/// `shift` says how far (in pixels, −0.5 to 0.5, positive towards the outside pixel) the edge lies
|
||||
/// from a crack.
|
||||
pub(crate) fn outline(l: &Loop, w: usize, h: usize, shift: &dyn Fn(Crack) -> f64) -> (Vec<Point>, bool) {
|
||||
/// `across` says, for a crack, how far (in pixels, −0.5 to 0.5, positive towards the outside
|
||||
/// pixel) the edge lies from it, and which region is on its other side.
|
||||
///
|
||||
/// The outline comes in pieces, cut at its junctions: the pixel corners where the region on the
|
||||
/// other side changes (three or more regions meet there, or the image's border begins), and
|
||||
/// those where two parts of a region touch by a corner only (there its outline and its
|
||||
/// neighbour's part ways without the neighbour changing). `junction_at` says of a pixel corner
|
||||
/// whether it is a junction of that second kind, when asked without `must`, and where the
|
||||
/// junction really lies, the same for every region around it. A piece
|
||||
/// is the whole edge between two regions, and both get exactly the same points for it (a crack
|
||||
/// gives the same point from either side, shifts are rounded to 1/1024 px so that they agree to
|
||||
/// the last bit, and whether a piece is hard is decided by the piece alone), so that fitted the
|
||||
/// same way it is one curve for both. A loop without junctions is one closed piece.
|
||||
pub(crate) fn outline(
|
||||
l: &Loop,
|
||||
w: usize,
|
||||
h: usize,
|
||||
across: &dyn Fn(Crack) -> (f64, u32),
|
||||
junction_at: &dyn Fn(i32, i32) -> Option<Point>,
|
||||
) -> Vec<Piece> {
|
||||
let v = &l.pts;
|
||||
let n = v.len();
|
||||
let at = |i: usize| Point::new(f64::from(v[i % n].0), f64::from(v[i % n].1));
|
||||
let raw = || (0..n).map(at).collect::<Vec<_>>();
|
||||
if n < 4 {
|
||||
return (raw(), true);
|
||||
return vec![Piece { pts: (0..n).map(at).collect(), hard: true, closed: true }];
|
||||
}
|
||||
let mut edges = Vec::with_capacity(n);
|
||||
let mut shifts: Vec<f64> = Vec::new();
|
||||
// Per crack: its shift, the region across it, and the edge it is on.
|
||||
let (mut shifts, mut others, mut edge_of): (Vec<f64>, Vec<u32>, Vec<usize>) = (vec![], vec![], vec![]);
|
||||
for i in 0..n {
|
||||
let (a, b) = (v[i], v[(i + 1) % n]);
|
||||
let (dx, dy) = (b.0 - a.0, b.1 - a.1);
|
||||
@@ -82,43 +124,95 @@ pub(crate) fn outline(l: &Loop, w: usize, h: usize, shift: &dyn Fn(Crack) -> f64
|
||||
_ => ((sx, sy - 1), (sx - 1, sy - 1)),
|
||||
};
|
||||
let crack = Crack { inside, step: (outside.0 - inside.0, outside.1 - inside.1), w, h };
|
||||
let s = match crack.at(1) {
|
||||
Some(_) => shift(crack),
|
||||
let (s, other) = match crack.at(1) {
|
||||
Some(_) => across(crack),
|
||||
// The edge of the image is where it is.
|
||||
_ => 0.0,
|
||||
_ => (0.0, border(dir)),
|
||||
};
|
||||
shifts.push(if s.is_finite() { s.clamp(-0.5, 0.5) } else { 0.0 });
|
||||
shifts.push(if s.is_finite() { (s.clamp(-0.5, 0.5) * 1024.0).round() / 1024.0 } else { 0.0 });
|
||||
others.push(other);
|
||||
edge_of.push(i);
|
||||
}
|
||||
}
|
||||
// A hard edge: nearly every crack moves by the same amount.
|
||||
let mut sorted = shifts.clone();
|
||||
let mid = sorted.len() / 2;
|
||||
let median = if sorted.is_empty() { 0.0 } else { *sorted.select_nth_unstable_by(mid, f64::total_cmp).1 };
|
||||
let hard = shifts.iter().filter(|s| (*s - median).abs() > 0.03).count() * 4 < shifts.len();
|
||||
let cracks = shifts.len();
|
||||
if cracks == 0 {
|
||||
return vec![Piece { pts: (0..n).map(at).collect(), hard: true, closed: true }];
|
||||
}
|
||||
// The pixel corner each crack starts at.
|
||||
let corner_of = |c: usize| {
|
||||
let (i, e) = (edge_of[c], &edges[edge_of[c]]);
|
||||
(v[i].0 + e.dir.0 * (c - e.first) as i32, v[i].1 + e.dir.1 * (c - e.first) as i32)
|
||||
};
|
||||
// A junction lies at the start of each crack whose neighbour is not the crack before's, and
|
||||
// where the callback says one is.
|
||||
let junctions: Vec<Option<Point>> = (0..cracks)
|
||||
.map(|c| {
|
||||
let (x, y) = corner_of(c);
|
||||
let changed = others[c] != others[(c + cracks - 1) % cracks];
|
||||
junction_at(x, y).or_else(|| changed.then(|| Point::new(f64::from(x), f64::from(y))))
|
||||
})
|
||||
.collect();
|
||||
let junction = |c: usize| junctions[c].is_some();
|
||||
let first = (0..cracks).find(|&c| junction(c));
|
||||
// A hard edge: nearly every crack of the piece moves by the same amount. (The middle of the
|
||||
// two middle shifts, so that the piece is as hard read from its other side, shifts negated.)
|
||||
let is_hard = |piece: &[f64]| {
|
||||
let mut sorted = piece.to_vec();
|
||||
sorted.sort_by(f64::total_cmp);
|
||||
let median = (sorted[sorted.len() / 2] + sorted[(sorted.len() - 1) / 2]) / 2.0;
|
||||
piece.iter().filter(|s| (*s - median).abs() > 0.03).count() * 4 < piece.len()
|
||||
};
|
||||
|
||||
// Which way the loop turns at vertex `i`, and whether edge `i` is a step of a staircase: one
|
||||
// pixel long, turning one way where it starts and the other where it ends.
|
||||
// (At a junction the loop goes its own way, its neighbour another: a turn there says nothing
|
||||
// about the edge they share, and counts as none.)
|
||||
let turn = |i: usize| {
|
||||
let (a, b) = (edges[(i + n - 1) % n].dir, edges[i % n].dir);
|
||||
(a.0 * b.1 - a.1 * b.0).signum()
|
||||
if junctions[edges[i % n].first].is_some() { 0 } else { (a.0 * b.1 - a.1 * b.0).signum() }
|
||||
};
|
||||
let step: Vec<bool> = (0..n).map(|i| edges[i].len == 1 && turn(i) * turn(i + 1) < 0).collect();
|
||||
let outward = |d: (i32, i32)| Vec2::new(f64::from(d.1), f64::from(-d.0));
|
||||
let shift_of = |e: &Edge, k: usize| shifts.get(e.first + k).copied().unwrap_or(0.0);
|
||||
|
||||
let mut out = Vec::with_capacity(if hard { n } else { shifts.len() + n });
|
||||
for i in 0..n {
|
||||
let (before, e) = (&edges[(i + n - 1) % n], &edges[i]);
|
||||
if !step[i] && !step[(i + n - 1) % n] {
|
||||
// A true corner: where its two edges, each moved by its own shift, meet.
|
||||
out.push(at(i) + outward(before.dir) * shift_of(before, before.len.saturating_sub(1)) + outward(e.dir) * shift_of(e, 0));
|
||||
let mut pieces: Vec<Piece> = Vec::new();
|
||||
let start = first.unwrap_or(0);
|
||||
let mut c = 0;
|
||||
while c < cracks {
|
||||
// The cracks of one piece: up to the next junction.
|
||||
let len = (1..cracks - c).find(|&k| junction((start + c + k) % cracks)).unwrap_or(cracks - c);
|
||||
let own: Vec<f64> = (0..len).map(|k| shifts[(start + c + k) % cracks]).collect();
|
||||
let hard = is_hard(&own);
|
||||
let mut pts = Vec::with_capacity(len + 2);
|
||||
for k in 0..len {
|
||||
let crack = (start + c + k) % cracks;
|
||||
let i = edge_of[crack];
|
||||
let (e, along_edge) = (&edges[i], crack - edges[i].first);
|
||||
let corner = at(i) + Vec2::new(f64::from(e.dir.0), f64::from(e.dir.1)) * along_edge as f64;
|
||||
if k == 0 && first.is_some() {
|
||||
// The junction itself: the one point every region around it has.
|
||||
pts.push(junctions[crack].unwrap_or(corner));
|
||||
} else if along_edge == 0 && !step[i] && !step[(i + n - 1) % n] {
|
||||
// A true corner: where its two edges, each moved by its own shift, meet.
|
||||
let before = &edges[(i + n - 1) % n];
|
||||
pts.push(corner + outward(before.dir) * shifts[(crack + cracks - 1) % cracks] + outward(e.dir) * shifts[crack]);
|
||||
}
|
||||
if step[i] || !hard {
|
||||
pts.push(corner + Vec2::new(f64::from(e.dir.0), f64::from(e.dir.1)) * 0.5 + outward(e.dir) * shifts[crack]);
|
||||
}
|
||||
}
|
||||
if step[i] || !hard {
|
||||
let along = Vec2::new(f64::from(e.dir.0), f64::from(e.dir.1));
|
||||
out.extend((0..e.len).map(|k| at(i) + along * (k as f64 + 0.5) + outward(e.dir) * shift_of(e, k)));
|
||||
if first.is_some() {
|
||||
// It ends on the next junction: the corner the next piece starts on.
|
||||
let next = (start + c + len) % cracks;
|
||||
let (x, y) = corner_of(next);
|
||||
pts.push(junctions[next].unwrap_or(Point::new(f64::from(x), f64::from(y))));
|
||||
}
|
||||
pieces.push(Piece { pts, hard, closed: first.is_none() });
|
||||
c += len;
|
||||
}
|
||||
if out.len() < 3 { (raw(), hard) } else { (out, hard) }
|
||||
if first.is_none() && pieces.first().is_some_and(|p| p.pts.len() < 3) {
|
||||
return vec![Piece { pts: (0..n).map(at).collect(), hard: true, closed: true }];
|
||||
}
|
||||
pieces
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -130,11 +224,27 @@ mod tests {
|
||||
trace_mask(mask, w, h).remove(0).outer
|
||||
}
|
||||
|
||||
/// No junctions but where the neighbour changes, and those on the pixel grid.
|
||||
fn grid(_: i32, _: i32) -> Option<Point> {
|
||||
None
|
||||
}
|
||||
|
||||
/// The outline with one neighbour all round (the image's border too): its points, and whether
|
||||
/// it is hard.
|
||||
fn whole(l: &Loop, w: usize, h: usize, shift: &dyn Fn(Crack) -> f64) -> (Vec<Point>, bool) {
|
||||
let mut pieces = outline(l, w, h, &|c| (shift(c), 0), &grid);
|
||||
let hard = pieces.iter().all(|p| p.hard);
|
||||
// Only the image's border makes junctions here: drop each piece's closing point.
|
||||
let split = pieces.len() > 1;
|
||||
let pts = pieces.iter_mut().flat_map(|p| p.pts.drain(..p.pts.len() - usize::from(split))).collect();
|
||||
(pts, hard)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_hard_rectangle_keeps_its_four_corners() {
|
||||
let (w, h) = (8, 6);
|
||||
let mask: Vec<bool> = (0..w * h).map(|i| (2..6).contains(&(i % w)) && (1..5).contains(&(i / w))).collect();
|
||||
let (pts, hard) = outline(&loop_of(&mask, w, h), w, h, &|_| 0.0);
|
||||
let (pts, hard) = whole(&loop_of(&mask, w, h), w, h, &|_| 0.0);
|
||||
assert!(hard);
|
||||
assert_eq!(pts, [(2.0, 1.0), (6.0, 1.0), (6.0, 5.0), (2.0, 5.0)].map(Point::from));
|
||||
}
|
||||
@@ -144,7 +254,7 @@ mod tests {
|
||||
// Below the line y = x / 4 + 2: treads four pixels long.
|
||||
let (w, h) = (16, 8);
|
||||
let mask: Vec<bool> = (0..w * h).map(|i| (i / w) as f64 + 0.5 > (i % w) as f64 / 4.0 + 2.0).collect();
|
||||
let (pts, hard) = outline(&loop_of(&mask, w, h), w, h, &|_| 0.0);
|
||||
let (pts, hard) = whole(&loop_of(&mask, w, h), w, h, &|_| 0.0);
|
||||
assert!(hard);
|
||||
// Every step's middle lies on one line, a quarter of a pixel's rise per pixel.
|
||||
let steps: Vec<Point> = pts.iter().copied().filter(|p| p.y.fract() == 0.5 && p.x > 0.0 && p.x < w as f64).collect();
|
||||
@@ -157,28 +267,66 @@ mod tests {
|
||||
let (w, h) = (6, 4);
|
||||
// The left half is inside; its right edge really lies 0.3 px further right.
|
||||
let mask: Vec<bool> = (0..w * h).map(|i| i % w < 3).collect();
|
||||
let shift = |c: Crack| if c.at(0).is_some_and(|i| i % w == 2) && c.at(1).is_some_and(|i| i % w == 3) { 0.3 } else { 0.0 };
|
||||
// (0.1 px in the lower rows: a shift that is the same all along makes a hard edge.)
|
||||
let shift = |c: Crack| match (c.at(0), c.at(1)) {
|
||||
(Some(a), Some(b)) if a % w == 2 && b % w == 3 => {
|
||||
if a / w < 2 {
|
||||
0.3
|
||||
} else {
|
||||
0.1
|
||||
}
|
||||
}
|
||||
_ => 0.0,
|
||||
};
|
||||
let l = loop_of(&mask, w, h);
|
||||
let (pts, hard) = outline(&l, w, h, &shift);
|
||||
assert!(!hard);
|
||||
let (pts, _) = whole(&l, w, h, &shift);
|
||||
// Each crack comes with the column of pixels across it.
|
||||
outline(&l, w, h, &|c| {
|
||||
let (a, b) = (c.at(0).unwrap(), c.at(1).unwrap());
|
||||
assert_eq!((c.at(-1), c.at(2)), (Some(a - 1), Some(b + 1)));
|
||||
assert_eq!((c.at(-3), c.at(4)), (None, None));
|
||||
0.0
|
||||
});
|
||||
let right: Vec<&Point> = pts.iter().filter(|p| p.x > 2.5).collect();
|
||||
// The four cracks' middles and the two corners, all at x = 3.3.
|
||||
assert_eq!(right.len(), 6);
|
||||
assert!(right.iter().all(|p| (p.x - 3.3).abs() < 1e-12), "{right:?}");
|
||||
outline(
|
||||
&l,
|
||||
w,
|
||||
h,
|
||||
&|c| {
|
||||
let (a, b) = (c.at(0).unwrap(), c.at(1).unwrap());
|
||||
assert_eq!((c.at(-1), c.at(2)), (Some(a - 1), Some(b + 1)));
|
||||
assert_eq!((c.at(-3), c.at(4)), (None, None));
|
||||
(0.0, 0)
|
||||
},
|
||||
&grid,
|
||||
);
|
||||
// The four cracks' middles, at x = 3.3 (rounded to 1/1024); the edge ends on the image's
|
||||
// border, at junctions on the pixel grid.
|
||||
let right: Vec<&Point> = pts.iter().filter(|p| p.x > 3.05).collect();
|
||||
assert_eq!(right.len(), 4);
|
||||
assert!(right.iter().all(|p| (p.x - if p.y < 2.0 { 3.3 } else { 3.1 }).abs() < 1e-3), "{right:?}");
|
||||
assert!(pts.contains(&Point::new(3.0, 0.0)) && pts.contains(&Point::new(3.0, 4.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_shift_that_is_not_a_number_moves_nothing() {
|
||||
let mask = [true, true, false, false, true, true, false, false];
|
||||
let (pts, _) = outline(&loop_of(&mask, 4, 2), 4, 2, &|_| f64::NAN);
|
||||
let (pts, _) = whole(&loop_of(&mask, 4, 2), 4, 2, &|_| f64::NAN);
|
||||
assert_eq!(pts.len(), 4);
|
||||
assert!(pts.iter().all(|p| p.x.is_finite() && p.y.is_finite()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_regions_get_the_same_points_for_their_shared_edge() {
|
||||
// Left and right halves meet along a staircase; each sees the other's shifts negated.
|
||||
let (w, h) = (12, 10);
|
||||
let left: Vec<bool> = (0..w * h).map(|i| (i % w) as f64 + 0.5 < 4.0 + (i / w) as f64 * 0.4).collect();
|
||||
let right: Vec<bool> = left.iter().map(|b| !b).collect();
|
||||
let wave = |c: Crack| 0.4 * ((c.at(0).unwrap() + c.at(1).unwrap()) as f64).sin();
|
||||
let a = outline(&loop_of(&left, w, h), w, h, &|c| (if left[c.at(0).unwrap()] { wave(c) } else { -wave(c) }, 1), &grid);
|
||||
let b = outline(&loop_of(&right, w, h), w, h, &|c| (if left[c.at(0).unwrap()] { wave(c) } else { -wave(c) }, 2), &grid);
|
||||
// The piece that is not on the image's border, from either side.
|
||||
let inner = |pieces: &[Piece]| {
|
||||
pieces.iter().find(|p| p.pts.iter().any(|q| q.x > 0.0 && q.x < w as f64 && q.y > 0.0 && q.y < h as f64)).unwrap().clone()
|
||||
};
|
||||
let (mut ours, theirs) = (inner(&a), inner(&b));
|
||||
ours.pts.reverse();
|
||||
assert_eq!(ours, theirs);
|
||||
assert!(ours.pts.len() > 12);
|
||||
// Each side of the image is a piece of its own: its corners are exact.
|
||||
assert!(a.iter().chain(&b).all(|p| p.pts.first().is_some_and(|q| q.x.fract() == 0.0 && q.y.fract() == 0.0)));
|
||||
}
|
||||
}
|
||||
@@ -986,3 +986,54 @@ fn stacked_layers_go_by_lightness_and_leave_ignored_white_alone() {
|
||||
assert!(b.x1 <= 20.5 || b.x0 >= 39.5, "a {} layer covers the white stripe: {b:?}", p.color[0]);
|
||||
}
|
||||
}
|
||||
|
||||
/// The cubic pieces of a path: (from, handle, handle, to).
|
||||
fn pieces_of(p: &vectorcraft_geom::PathData) -> Vec<[Point; 4]> {
|
||||
p.subpaths
|
||||
.iter()
|
||||
.flat_map(|sp| {
|
||||
(0..sp.segment_count()).map(move |i| {
|
||||
let c = sp.segment(i);
|
||||
[c.p0, c.p1, c.p2, c.p3]
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn abutting_regions_share_one_curve_along_their_edge() {
|
||||
// A disc of two halves on a field: three colours, two junctions where all three meet.
|
||||
let color = |x: f64, y: f64| {
|
||||
if Point::new(x, y).distance(Point::new(50.3, 49.6)) > 35.2 {
|
||||
[255.0, 255.0, 255.0]
|
||||
} else if x < 50.3 + 0.2 * (y - 50.0) {
|
||||
[200.0, 40.0, 50.0]
|
||||
} else {
|
||||
[30.0, 90.0, 200.0]
|
||||
}
|
||||
};
|
||||
let img = Raster::from_fn(100, 100, |x, y| {
|
||||
let mut sum = [0.0f64; 3];
|
||||
for i in 0..16 {
|
||||
let c = color(f64::from(x) + (f64::from(i % 4) + 0.5) / 4.0, f64::from(y) + (f64::from(i / 4) + 0.5) / 4.0);
|
||||
for k in 0..3 {
|
||||
sum[k] += c[k] / 16.0;
|
||||
}
|
||||
}
|
||||
[sum[0].round() as u8, sum[1].round() as u8, sum[2].round() as u8, 255]
|
||||
});
|
||||
let res = trace(&img, &TraceParams { mode: Mode::Color, colors: 3, ..TraceParams::default() });
|
||||
assert_eq!(res.paths.len(), 3);
|
||||
let all: Vec<Vec<[Point; 4]>> = res.paths.iter().map(|p| pieces_of(&p.path)).collect();
|
||||
// Every curve that is not on the image's border is another path's curve too, run the other way.
|
||||
let inner = |c: &[Point; 4]| c.iter().all(|p| p.x > 0.5 && p.x < 99.5 && p.y > 0.5 && p.y < 99.5);
|
||||
let mut shared = 0;
|
||||
for (i, path) in all.iter().enumerate() {
|
||||
for c in path.iter().filter(|c| inner(c)) {
|
||||
let back = [c[3], c[2], c[1], c[0]];
|
||||
assert!(all.iter().enumerate().any(|(j, other)| j != i && other.contains(&back)), "path {i} has an edge of its own: {c:?}");
|
||||
shared += 1;
|
||||
}
|
||||
}
|
||||
assert!(shared >= 12, "{shared} shared curves");
|
||||
}
|
||||
@@ -739,7 +739,7 @@ mod tests {
|
||||
let fr = crate::i18n::Lang::from_code("fr").unwrap();
|
||||
let shown = crate::i18n::message(fr, "2 font file(s) are now in /Fonts; 1 couldn't be added: a.otf: larger than 256 MB");
|
||||
assert!(shown.contains("se trouvent maintenant dans /Fonts") && shown.contains("a.otf") && shown.contains("plus de 256 Mo"), "{shown}");
|
||||
for code in ["de", "es", "fr", "hu", "it", "ru"] {
|
||||
for code in ["de", "es", "fr", "hu", "it", "pl", "ru"] {
|
||||
let lang = crate::i18n::Lang::from_code(code).unwrap();
|
||||
for s in [
|
||||
"every search thread stopped on an internal error (please report this bug)",
|
||||
|
||||
@@ -155,6 +155,9 @@ fn solid_controls(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog, id: &
|
||||
} else {
|
||||
heading(ui, tl!("Object"));
|
||||
number(ui, d, "depth", tl!("Depth"), (0.0, 2000.0), "", 1, Some(unit));
|
||||
number(ui, d, "twist", tl!("Twist"), (0.0, 360.0), "°", 1, None);
|
||||
number(ui, d, "taper", tl!("Taper"), (1.0, 100.0), "%", 1, None);
|
||||
choice(ui, d, "cap", c3("Cap"), &[(json!(true), c3("Solid")), (json!(false), c3("Hollow"))]);
|
||||
number(ui, d, "volume", tl!("Volume"), (0.0, 100.0), "%", 1, None);
|
||||
toggle(ui, d, "bothSides", tl!("Both sides"), false);
|
||||
}
|
||||
@@ -167,7 +170,7 @@ fn solid_controls(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog, id: &
|
||||
fn extrude_controls(ui: &mut egui::Ui, d: &mut Dialog, unit: Unit) {
|
||||
heading(ui, tl!("Object"));
|
||||
number(ui, d, "depth", tl!("Depth"), (0.0, 2000.0), "", 1, Some(unit));
|
||||
number(ui, d, "twist", tl!("Twist"), (-360.0, 360.0), "°", 1, None);
|
||||
number(ui, d, "twist", tl!("Twist"), (0.0, 360.0), "°", 1, None);
|
||||
number(ui, d, "taper", tl!("Taper"), (1.0, 100.0), "%", 1, None);
|
||||
choice(ui, d, "cap", c3("Cap"), &[(json!(true), c3("Solid")), (json!(false), c3("Hollow"))]);
|
||||
ui.add_space(10.0);
|
||||
|
||||
@@ -213,7 +213,7 @@ fn the_other_3d_effects_show_their_options_and_position_presets() {
|
||||
let square = "M100 100 L200 100 L200 200 L100 200 Z";
|
||||
for (effect, own) in [
|
||||
(EXTRUDE, &["Depth", "Twist", "Taper", "Cap", "Bevel", "Bevel Shape", "Space", "Bevel Inside", "Bevel both sides"][..]),
|
||||
(INFLATE, &["Depth", "Volume", "Both sides"][..]),
|
||||
(INFLATE, &["Depth", "Twist", "Taper", "Cap", "Volume", "Both sides"][..]),
|
||||
(ROTATE, &[][..]),
|
||||
] {
|
||||
let mut h = Harness::with(square, effect, vec2(1400.0, 1000.0));
|
||||
|
||||
@@ -4683,3 +4683,5 @@ mark-style Japanese Japonské
|
||||
@msg unknown parameter `{key}`; did you mean `{near}`? neznámý parametr `{key}`; mysleli jste `{near}`?
|
||||
@msg unknown parameter `{key}`; the command takes no parameters neznámý parametr `{key}`; příkaz nemá žádné parametry
|
||||
@msg unknown parameter `{key}`; the command takes: {keys} neznámý parametr `{key}`; příkaz přijímá: {keys}
|
||||
Load EPS into Clipboard Načíst EPS do schránky
|
||||
@msg an image of {w} × {h} pixels is too large to draw obraz o velikosti {w} × {h} obrazových bodů je příliš velký na vykreslení
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -4206,3 +4206,5 @@ theme Appearance Erscheinungsbild
|
||||
@msg unknown parameter `{key}`; did you mean `{near}`? unbekannter Parameter `{key}`; meinten Sie `{near}`?
|
||||
@msg unknown parameter `{key}`; the command takes no parameters unbekannter Parameter `{key}`; der Befehl hat keine Parameter
|
||||
@msg unknown parameter `{key}`; the command takes: {keys} unbekannter Parameter `{key}`; der Befehl akzeptiert: {keys}
|
||||
Load EPS into Clipboard EPS in die Zwischenablage laden
|
||||
@msg an image of {w} × {h} pixels is too large to draw ein Bild mit {w} × {h} Pixeln ist zu groß, um gezeichnet zu werden
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -4623,3 +4623,5 @@ mark-style Japanese Japonesas
|
||||
@msg unknown parameter `{key}`; did you mean `{near}`? parámetro desconocido `{key}`; ¿quiso decir `{near}`?
|
||||
@msg unknown parameter `{key}`; the command takes no parameters parámetro desconocido `{key}`; el comando no tiene parámetros
|
||||
@msg unknown parameter `{key}`; the command takes: {keys} parámetro desconocido `{key}`; el comando acepta: {keys}
|
||||
Load EPS into Clipboard Cargar EPS en el portapapeles
|
||||
@msg an image of {w} × {h} pixels is too large to draw una imagen de {w} × {h} píxeles es demasiado grande para dibujarse
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
+605
-180
File diff suppressed because it is too large.
Load diff
@@ -4649,3 +4649,5 @@ theme Appearance Kinézet
|
||||
@msg unknown parameter `{key}`; did you mean `{near}`? ismeretlen paraméter: `{key}`; erre gondolt: `{near}`?
|
||||
@msg unknown parameter `{key}`; the command takes no parameters ismeretlen paraméter: `{key}`; a parancsnak nincs paramétere
|
||||
@msg unknown parameter `{key}`; the command takes: {keys} ismeretlen paraméter: `{key}`; a parancs ezeket fogadja el: {keys}
|
||||
Load EPS into Clipboard EPS betöltése a vágólapra
|
||||
@msg an image of {w} × {h} pixels is too large to draw egy {w} × {h} pixeles kép túl nagy a kirajzoláshoz
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -4251,3 +4251,5 @@ lighting Light Luce
|
||||
@msg unknown parameter `{key}`; did you mean `{near}`? parametro sconosciuto `{key}`; forse intendevate `{near}`?
|
||||
@msg unknown parameter `{key}`; the command takes no parameters parametro sconosciuto `{key}`; il comando non ha parametri
|
||||
@msg unknown parameter `{key}`; the command takes: {keys} parametro sconosciuto `{key}`; il comando accetta: {keys}
|
||||
Load EPS into Clipboard Carica EPS negli appunti
|
||||
@msg an image of {w} × {h} pixels is too large to draw un'immagine di {w} × {h} pixel è troppo grande per essere disegnata
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -4245,3 +4245,5 @@ lighting Light ライト
|
||||
@msg unknown parameter `{key}`; did you mean `{near}`? `{key}` は不明なパラメーターです。`{near}` のことですか?
|
||||
@msg unknown parameter `{key}`; the command takes no parameters `{key}` は不明なパラメーターです。このコマンドにパラメーターはありません
|
||||
@msg unknown parameter `{key}`; the command takes: {keys} `{key}` は不明なパラメーターです。このコマンドで使えるパラメーター: {keys}
|
||||
Load EPS into Clipboard EPS をクリップボードに読み込む
|
||||
@msg an image of {w} × {h} pixels is too large to draw {w} × {h} ピクセルの画像は大きすぎて描画できません
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -4215,3 +4215,5 @@ lighting Light 광원
|
||||
@msg unknown parameter `{key}`; did you mean `{near}`? 알 수 없는 매개변수 `{key}`: `{near}`을(를) 의미하셨습니까?
|
||||
@msg unknown parameter `{key}`; the command takes no parameters 알 수 없는 매개변수 `{key}`: 이 명령에는 매개변수가 없습니다
|
||||
@msg unknown parameter `{key}`; the command takes: {keys} 알 수 없는 매개변수 `{key}`: 이 명령이 받는 매개변수: {keys}
|
||||
Load EPS into Clipboard EPS를 클립보드로 불러오기
|
||||
@msg an image of {w} × {h} pixels is too large to draw {w} × {h}픽셀 이미지는 너무 커서 그릴 수 없습니다
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -81,8 +81,20 @@ fn plural_east_slavic(n: u64) -> usize {
|
||||
}
|
||||
}
|
||||
|
||||
/// Polish: only 1 is `one`; 2–4 (but not 12–14) `few`; everything else (0, 5–21, 25…) `many`
|
||||
/// (unlike Russian and Ukrainian, 21, 31 … take `many`: "21 warstw").
|
||||
fn plural_polish(n: u64) -> usize {
|
||||
if n == 1 {
|
||||
return 0;
|
||||
}
|
||||
match (n % 10, n % 100) {
|
||||
(2..=4, r) if !(12..=14).contains(&r) => 1,
|
||||
_ => 2,
|
||||
}
|
||||
}
|
||||
|
||||
/// The registry. English first: it is the fallback and the source language.
|
||||
pub static LANGUAGES: [LangInfo; 14] = [
|
||||
pub static LANGUAGES: [LangInfo; 16] = [
|
||||
LangInfo { code: "en", name: "English", source: "", plural: plural_one_other, complete_menus: false, catalog: OnceLock::new() },
|
||||
// Japanese: the whole interface (every menu string and `tl!` literal) and the status and error
|
||||
// messages, keeping the product, workspace and perspective preset names in English (`MENU_KEEP_AS_IS`).
|
||||
@@ -91,6 +103,11 @@ pub static LANGUAGES: [LangInfo; 14] = [
|
||||
// English as Japanese; a count takes no plural, so plurals have one form. Hangul is drawn with an
|
||||
// installed system font (craft-fonts has no Korean UI face yet); every `ko-*` locale resolves here.
|
||||
LangInfo { code: "ko", name: "한국어", source: include_str!("ko.tsv"), plural: plural_none, complete_menus: true, catalog: OnceLock::new() },
|
||||
// Vietnamese: complete menus and messages, with one plural form.
|
||||
// Every `vi-*` locale resolves here.
|
||||
LangInfo {
|
||||
code: "vi", name: "Tiếng Việt", source: include_str!("vi.tsv"), plural: plural_none, complete_menus: true, catalog: OnceLock::new()
|
||||
},
|
||||
// Czech: the whole interface (every menu string and `tl!` literal) and the status and error
|
||||
// messages, with one/few/other plurals, keeping the same names in English as Spanish.
|
||||
LangInfo { code: "cs", name: "Čeština", source: include_str!("cs.tsv"), plural: plural_czech, complete_menus: true, catalog: OnceLock::new() },
|
||||
@@ -151,6 +168,10 @@ pub static LANGUAGES: [LangInfo; 14] = [
|
||||
// English as Spanish; a count takes the singular ("3 réteg"), so plurals have one form. Every
|
||||
// `hu-*` locale (including `hu-HU`) resolves here.
|
||||
LangInfo { code: "hu", name: "Magyar", source: include_str!("hu.tsv"), plural: plural_none, complete_menus: true, catalog: OnceLock::new() },
|
||||
// Polish: the whole interface and the status and error messages, with one/few/many plurals,
|
||||
// keeping the same names in English as Spanish; every `pl-*` locale (including `pl-PL`)
|
||||
// resolves here.
|
||||
LangInfo { code: "pl", name: "Polski", source: include_str!("pl.tsv"), plural: plural_polish, complete_menus: true, catalog: OnceLock::new() },
|
||||
// Brazilian Portuguese: every menu label (`menu_catalogs_translate_every_menu_label`), every
|
||||
// `tl!` literal and the plural messages; left English on purpose are the `MENU_KEEP_AS_IS`
|
||||
// strings (product name, language names, workspace names, perspective grid presets).
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -3692,3 +3692,4 @@ axis Both Ambos
|
||||
@msg unknown parameter `{key}`; did you mean `{near}`? parâmetro desconhecido `{key}`; você quis dizer `{near}`?
|
||||
@msg unknown parameter `{key}`; the command takes no parameters parâmetro desconhecido `{key}`; o comando não tem parâmetros
|
||||
@msg unknown parameter `{key}`; the command takes: {keys} parâmetro desconhecido `{key}`; o comando aceita: {keys}
|
||||
Load EPS into Clipboard Carregar EPS na Área de Transferência
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -4247,3 +4247,5 @@ lighting Light Свет
|
||||
@msg unknown parameter `{key}`; did you mean `{near}`? неизвестный параметр `{key}`; возможно, имелся в виду `{near}`?
|
||||
@msg unknown parameter `{key}`; the command takes no parameters неизвестный параметр `{key}`; у команды нет параметров
|
||||
@msg unknown parameter `{key}`; the command takes: {keys} неизвестный параметр `{key}`; команда принимает: {keys}
|
||||
Load EPS into Clipboard Загрузить EPS в буфер обмена
|
||||
@msg an image of {w} × {h} pixels is too large to draw изображение размером {w} × {h} пикселей слишком велико для отрисовки
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -32,6 +32,9 @@ fn tags_map_to_languages() {
|
||||
for tag in ["ko", "ko-KR", "ko_KR.UTF-8", "KO-kr"] {
|
||||
assert_eq!(lang_from_tag(tag), Some(ko()), "{tag}");
|
||||
}
|
||||
for tag in ["vi", "vi-VN", "vi_VN.UTF-8", "VI-vn"] {
|
||||
assert_eq!(lang_from_tag(tag), Some(vi()), "{tag}");
|
||||
}
|
||||
// Traditional Chinese: by region, by script, and with a region after the script.
|
||||
assert_eq!(lang_from_tag("zh_TW.UTF-8"), Some(ZH()));
|
||||
assert_eq!(lang_from_tag("zh-TW"), Some(ZH()));
|
||||
@@ -449,7 +452,7 @@ fn cs() -> Lang {
|
||||
}
|
||||
|
||||
/// Languages whose catalogs leave [`MENU_KEEP_AS_IS`] in English.
|
||||
const KEEPS_MENU_NAMES: [&str; 11] = ["cs", "de", "es", "fr", "hu", "it", "ja", "ko", "pt-br", "ru", "uk"];
|
||||
const KEEPS_MENU_NAMES: [&str; 13] = ["cs", "de", "es", "fr", "hu", "it", "ja", "ko", "pl", "pt-br", "ru", "uk", "vi"];
|
||||
|
||||
/// Menu labels the menu-complete catalogs (Czech, German, Spanish, Italian, Japanese, Brazilian Portuguese) show as they are: the product name, a format name, the built-in workspace
|
||||
/// names and the perspective grid presets (names, shown untranslated wherever else they appear).
|
||||
@@ -662,6 +665,63 @@ fn korean_reads_as_korean() {
|
||||
assert_eq!(message(ko(), nested), "명령 `object.group`은(는) 지금 사용할 수 없습니다: 선택된 항목이 없습니다");
|
||||
}
|
||||
|
||||
fn vi() -> Lang {
|
||||
Lang::from_code("vi").expect("vi registered")
|
||||
}
|
||||
|
||||
/// Vietnamese has one plural form and covers the complete interface and messages.
|
||||
#[test]
|
||||
fn vietnamese_reads_as_vietnamese() {
|
||||
assert_eq!(vi().name(), "Tiếng Việt");
|
||||
assert!(vi().complete_menus());
|
||||
assert_eq!(Lang::from_pref("vi"), vi());
|
||||
assert_eq!(Lang::from_pref("VI"), vi());
|
||||
for (en, want) in [
|
||||
("Artboard Tool", "Công cụ Bảng vẽ"),
|
||||
("Swatches", "Mẫu màu"),
|
||||
("Stroke", "Nét"),
|
||||
("Fill", "Tô"),
|
||||
("Direct Selection Tool", "Công cụ Chọn trực tiếp"),
|
||||
("Save As…", "Lưu thành…"),
|
||||
("Undo", "Hoàn tác"),
|
||||
("Close", "Đóng"),
|
||||
] {
|
||||
assert_eq!(tr(vi(), en), want);
|
||||
}
|
||||
for n in [0, 1, 3, 21] {
|
||||
assert_eq!(trn(vi(), n, "{n} Layer", "{n} Layers"), format!("{n} lớp"));
|
||||
}
|
||||
let nested = "command `object.group` is not available right now: nothing selected";
|
||||
assert_eq!(message(vi(), nested), "lệnh `object.group` hiện không khả dụng: chưa chọn gì");
|
||||
}
|
||||
|
||||
/// Ambiguous English terms keep their intended Vietnamese meaning across UI contexts.
|
||||
#[test]
|
||||
fn vietnamese_domain_terms_are_consistent() {
|
||||
for (en, want) in [
|
||||
("All Scripts", "Tất cả hệ chữ"),
|
||||
("Pointillize", "Hiệu ứng chấm"),
|
||||
("Object Highlighting", "Đánh dấu đối tượng"),
|
||||
("Color Theme", "Bộ màu"),
|
||||
("Color Themes", "Bộ màu"),
|
||||
("Asset Export", "Xuất asset"),
|
||||
("Envelope Distort", "Biến dạng Envelope"),
|
||||
("Move Spine Point", "Di chuyển điểm trục"),
|
||||
("Dashed Line", "Đường nét đứt"),
|
||||
("Eyedropper Tool", "Công cụ Ống hút màu"),
|
||||
("Character Alignment", "Căn chỉnh ký tự"),
|
||||
] {
|
||||
assert_eq!(tr(vi(), en), want, "{en}");
|
||||
}
|
||||
assert_eq!(tr(vi(), "Appearance"), "Diện mạo");
|
||||
assert_eq!(tr_ctx(vi(), "theme", "Appearance"), "Giao diện");
|
||||
assert_eq!(message(vi(), "no saved themes"), "chưa có bộ màu nào được lưu");
|
||||
assert_eq!(message(vi(), r#"choice must be "merge" or "add", not `foo`"#), r#"lựa chọn phải là "merge" hoặc "add", không phải `foo`"#);
|
||||
assert_eq!(message(vi(), "select a blend"), "hãy chọn một đối tượng hòa trộn");
|
||||
assert_eq!(message(vi(), "select a repeat"), "hãy chọn một đối tượng lặp");
|
||||
assert_eq!(message(vi(), "Replace Spine: select a blend and a path"), "Thay thế trục: chọn một đối tượng hòa trộn và một đường dẫn");
|
||||
}
|
||||
|
||||
fn uk() -> Lang {
|
||||
Lang::from_code("uk").expect("uk registered")
|
||||
}
|
||||
@@ -921,7 +981,7 @@ fn russian_plurals_have_three_forms() {
|
||||
assert_eq!(forms, [2, 0, 1, 1, 2, 2, 0, 1, 2, 2, 0]);
|
||||
}
|
||||
|
||||
/// Czech, German, Spanish, French, Italian and Hungarian letters (and the punctuation their text uses) come from each family's own first font, not
|
||||
/// Czech, German, Spanish, French, Italian, Hungarian and Polish letters (and the punctuation their text uses) come from each family's own first font, not
|
||||
/// from a fallback further down the stack. (`has_glyph` can't tell: it counts characters of the
|
||||
/// face that draws missing glyphs, the first one, as missing.)
|
||||
#[test]
|
||||
@@ -936,7 +996,7 @@ fn latin_script_glyphs_are_available_without_system_fonts() {
|
||||
let first = first.unwrap();
|
||||
let mut font = fonts.fonts.font(&family);
|
||||
let chars = font.characters();
|
||||
for ch in "áčďéěíňóřšťúůýžÁČĎÉĚÍŇÓŘŠŤÚŮÝŽ„“‚‘…–ñÑüÜ¿¡”àèìòùÀÈÌÒÙ«»’âêîôûçëïÿœæÂÊÎÔÛÇËÏŒÆäöÄÖßőűŐŰ\u{a0}".chars()
|
||||
for ch in "áčďéěíňóřšťúůýžÁČĎÉĚÍŇÓŘŠŤÚŮÝŽ„“‚‘…–ñÑüÜ¿¡”àèìòùÀÈÌÒÙ«»’âêîôûçëïÿœæÂÊÎÔÛÇËÏŒÆäöÄÖßőűŐŰąćęłńśźżĄĆĘŁŃŚŹŻ\u{a0}".chars()
|
||||
{
|
||||
assert!(chars.get(&ch).is_some_and(|fonts| fonts.contains(&first)), "{first} ({family:?}) has no {ch}");
|
||||
}
|
||||
@@ -1103,7 +1163,7 @@ fn select_key_param_error_is_translated() {
|
||||
}
|
||||
|
||||
/// Languages whose catalogs cover every status and error message.
|
||||
const COMPLETE_MESSAGES: &[&str] = &["cs", "de", "es", "fr", "hu", "it", "ja", "ko", "ru", "uk"];
|
||||
const COMPLETE_MESSAGES: &[&str] = &["cs", "de", "es", "fr", "hu", "it", "ja", "ko", "pl", "ru", "uk", "vi"];
|
||||
|
||||
/// Crates whose error and status messages reach the status bar.
|
||||
const MESSAGE_CRATES: &[&str] = &["ui-egui", "engine", "doc", "format", "svg", "pdf", "eps", "text", "plugins", "metafile", "cad", "trace"];
|
||||
@@ -1235,3 +1295,56 @@ fn czech_render_site_follow_up() {
|
||||
assert_eq!(trn(cs(), n, "{n} PR", "{n} PRs"), format!("{n} PR"));
|
||||
}
|
||||
}
|
||||
|
||||
fn pl() -> Lang {
|
||||
Lang::from_code("pl").expect("pl registered")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn polish_plurals_have_three_forms() {
|
||||
let forms: Vec<usize> = [0, 1, 2, 4, 5, 11, 12, 14, 21, 22, 25, 101, 112, 122].into_iter().map(plural_polish).collect();
|
||||
assert_eq!(forms, [2, 0, 1, 1, 2, 2, 2, 2, 2, 1, 2, 2, 2, 1]);
|
||||
// Every Polish plural row has the three forms, and each count picks its own.
|
||||
let (entries, _) = parse_entries(pl().0.source);
|
||||
let mut count = 0;
|
||||
for (ctx, src, tr) in &entries {
|
||||
if ctx != "@plural" {
|
||||
continue;
|
||||
}
|
||||
count += 1;
|
||||
let forms: Vec<&str> = tr.split('|').collect();
|
||||
assert_eq!(forms.len(), 3, "{src}");
|
||||
let (one, other) = src.split_once('|').unwrap();
|
||||
for (n, form) in [(1, 0), (3, 1), (5, 2), (21, 2), (22, 1)] {
|
||||
assert_eq!(trn(pl(), n, one, other), forms[form].replace("{n}", &n.to_string()), "{src}: {n}");
|
||||
}
|
||||
}
|
||||
assert!(count >= 13, "Polish plural rows missing");
|
||||
}
|
||||
|
||||
/// Polish uses its vector-illustration vocabulary (obszar kompozycji, ścieżka, obrys…), resolves
|
||||
/// every `pl-*` locale, counts with one/few/many and translates the reason inside a message too.
|
||||
#[test]
|
||||
fn polish_reads_as_polish() {
|
||||
for tag in ["pl", "pl-PL", "pl_PL.UTF-8", "PL-pl"] {
|
||||
assert_eq!(lang_from_tag(tag), Some(pl()), "{tag}");
|
||||
}
|
||||
for (en, want) in [
|
||||
("File", "Plik"),
|
||||
("Artboard Tool", "Narzędzie Obszar kompozycji"),
|
||||
("Swatches", "Próbki"),
|
||||
("Pathfinder", "Odnajdywanie ścieżek"),
|
||||
("Stroke", "Obrys"),
|
||||
("Fill", "Wypełnienie"),
|
||||
("Workspace", "Obszar roboczy"),
|
||||
] {
|
||||
assert_eq!(tr(pl(), en), want);
|
||||
}
|
||||
for (n, word) in [(1, "warstwa"), (3, "warstwy"), (5, "warstw"), (22, "warstwy"), (21, "warstw")] {
|
||||
assert_eq!(trn(pl(), n, "{n} Layer", "{n} Layers"), format!("{n} {word}"));
|
||||
}
|
||||
assert_eq!(tr_ctx(pl(), "axis", "Both"), "Obie");
|
||||
assert_eq!(tr_id(pl(), "pathfinder.add", "Add"), "Połącz");
|
||||
let nested = "command `object.group` is not available right now: nothing selected";
|
||||
assert_eq!(message(pl(), nested), "polecenie `object.group` jest teraz niedostępne: nic nie zaznaczono");
|
||||
}
|
||||
@@ -4231,3 +4231,5 @@ lighting Light Світло
|
||||
@msg unknown parameter `{key}`; did you mean `{near}`? невідомий параметр `{key}`; можливо, ви мали на увазі `{near}`?
|
||||
@msg unknown parameter `{key}`; the command takes no parameters невідомий параметр `{key}`; команда не має параметрів
|
||||
@msg unknown parameter `{key}`; the command takes: {keys} невідомий параметр `{key}`; команда приймає: {keys}
|
||||
Load EPS into Clipboard Завантажити EPS у буфер обміну
|
||||
@msg an image of {w} × {h} pixels is too large to draw зображення розміром {w} × {h} пікселів завелике для відображення
|
||||
|
Can't render this file because it contains an unexpected character in line 659 and column 40.
|
File diff suppressed because it is too large.
Load diff
@@ -3842,3 +3842,5 @@ lighting Light 光源
|
||||
@msg unknown parameter `{key}`; did you mean `{near}`? 未知参数 `{key}`;您是否想输入 `{near}`?
|
||||
@msg unknown parameter `{key}`; the command takes no parameters 未知参数 `{key}`;该命令没有参数
|
||||
@msg unknown parameter `{key}`; the command takes: {keys} 未知参数 `{key}`;该命令接受:{keys}
|
||||
Load EPS into Clipboard 将 EPS 载入剪贴板
|
||||
@msg an image of {w} × {h} pixels is too large to draw {w} × {h} 像素的图像过大,无法绘制
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -3623,3 +3623,5 @@ lighting Light 光源
|
||||
@msg unknown parameter `{key}`; did you mean `{near}`? 未知的參數 `{key}`;您是指 `{near}` 嗎?
|
||||
@msg unknown parameter `{key}`; the command takes no parameters 未知的參數 `{key}`;此指令沒有參數
|
||||
@msg unknown parameter `{key}`; the command takes: {keys} 未知的參數 `{key}`;此指令接受:{keys}
|
||||
Load EPS into Clipboard 將 EPS 載入剪貼簿
|
||||
@msg an image of {w} × {h} pixels is too large to draw {w} × {h} 像素的影像過大,無法繪製
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -560,7 +560,9 @@ pub fn run(app: &mut VectorcraftApp, ctx: &egui::Context) {
|
||||
|
||||
/// Bring the native menu up to date when anything it shows may have changed: a command ran, the
|
||||
/// document, its history or selection changed, the user clicked or pressed a key. Reading the rows
|
||||
/// asks every command whether it is enabled, so frames that only animate or scroll skip it.
|
||||
/// asks every command whether it is enabled, so frames that only animate or scroll skip it, and so
|
||||
/// do the previews of an open interaction (the steps of a move, scale or shape drag, a dialog's
|
||||
/// preview), whose new revisions [`state_hash`] leaves out.
|
||||
pub fn sync(app: &mut VectorcraftApp, ctx: &egui::Context) {
|
||||
let Some(menu) = app.services.native_menu.as_ref() else { return };
|
||||
let input = ctx
|
||||
@@ -585,7 +587,10 @@ pub fn sync(app: &mut VectorcraftApp, ctx: &egui::Context) {
|
||||
/// What the menus' rows depend on, cheaply (no allocation): commands run, the active document,
|
||||
/// its revision (edits, selection and view changes), history and clipboard, the language,
|
||||
/// shortcuts, plug-ins and an open file dialog. `input` forces a change on frames with a click or
|
||||
/// a key press, which covers what the hash doesn't list (panels shown, a tool picked).
|
||||
/// a key press, which covers what the hash doesn't list (panels shown, a tool picked). While an
|
||||
/// interaction is open (for example a move, scale or shape drag, a dialog's or the font menu's
|
||||
/// preview, or a typing session), the revision, which each of its previews changes, is left out:
|
||||
/// the hash changes as the interaction begins and as it ends.
|
||||
fn state_hash(app: &VectorcraftApp, input: Option<u64>) -> u64 {
|
||||
let mut h = DefaultHasher::new();
|
||||
input.hash(&mut h);
|
||||
@@ -593,7 +598,8 @@ fn state_hash(app: &VectorcraftApp, input: Option<u64>) -> u64 {
|
||||
let s = &app.session;
|
||||
(s.journal.len(), s.active_index(), s.clipboard.is_empty(), s.tool_composing()).hash(&mut h);
|
||||
if let Some(d) = s.active() {
|
||||
(d.uid, d.revision, d.history.undo.len(), d.history.redo.len(), d.transparency_grid, d.print_tiling).hash(&mut h);
|
||||
let revision = d.interaction.is_none().then_some(d.revision);
|
||||
(d.uid, revision, d.history.undo.len(), d.history.redo.len(), d.transparency_grid, d.print_tiling).hash(&mut h);
|
||||
}
|
||||
(app.system_paste, app.last_effect.is_some(), app.ui.recent_files.len(), app.ui.recent_files.first(), app.recent_fonts().len()).hash(&mut h);
|
||||
app.file_dialog_open().hash(&mut h);
|
||||
|
||||
@@ -219,11 +219,16 @@ fn badge(ui: &Ui, r: egui::Rect, status: &str) {
|
||||
|
||||
fn status_tip(r: &Value) -> String {
|
||||
let path = r["found"].as_str().or(r["path"].as_str()).unwrap_or_default();
|
||||
match r["status"].as_str().unwrap_or_default() {
|
||||
let tip = match r["status"].as_str().unwrap_or_default() {
|
||||
"missing" => crate::i18n::fmt(tl!("Missing: {path}"), &[("path", path)]),
|
||||
"modified" => crate::i18n::fmt(tl!("Modified since it was read: {path}"), &[("path", path)]),
|
||||
"embedded" => tl!("Embedded").into(),
|
||||
_ => path.to_string(),
|
||||
};
|
||||
// An image too large to draw (`links.list`'s note).
|
||||
match r["note"].as_str() {
|
||||
Some(note) => format!("{tip}\n{}", crate::i18n::msg(note)),
|
||||
None => tip,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! The system clipboard's formats: Copy and Cut publish every flavour the engine makes
|
||||
//! ([`Session::clipboard_flavours`](vectorcraft_engine::Session::clipboard_flavours): text, SVG,
|
||||
//! PDF, PNG), and a Paste first turns what another app copied (SVG, PDF, an EMF, text, a bitmap,
|
||||
//! PDF, PNG), and a Paste first turns what another app copied (SVG, PDF, EPS, an EMF, text, a bitmap,
|
||||
//! or a copied file that is one of them) into the internal clipboard with the `clipboard.import*`
|
||||
//! commands. The host installs the platform side as
|
||||
//! [`Services::system_clipboard`](crate::Services::system_clipboard); without it (the web) Copy
|
||||
@@ -8,7 +8,7 @@
|
||||
//! reads from a paste itself ([`VectorcraftApp::paste_from_host`]).
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_engine::cmd::clipboard::{BITMAP, EMF, Flavour, PASTE_ORDER, PDF, SVG, TEXT, looks_like_svg, names_a_picture};
|
||||
use vectorcraft_engine::cmd::clipboard::{BITMAP, EMF, EPS, Flavour, PASTE_ORDER, PDF, SVG, TEXT, looks_like_svg, names_a_picture};
|
||||
|
||||
use crate::VectorcraftApp;
|
||||
|
||||
@@ -43,6 +43,7 @@ pub(crate) fn import_command(f: &Flavour, center: Option<[f64; 2]>) -> (&'static
|
||||
}
|
||||
SVG => ("clipboard.importSvg", json!({ "dataBase64": b64(), "center": center })),
|
||||
PDF => ("clipboard.importPdf", json!({ "dataBase64": b64(), "center": center })),
|
||||
EPS => ("clipboard.importEps", json!({ "dataBase64": b64(), "center": center })),
|
||||
EMF => ("clipboard.importEmf", json!({ "dataBase64": b64(), "center": center })),
|
||||
mime => ("clipboard.importImage", json!({ "dataBase64": b64(), "mime": mime, "center": center })),
|
||||
}
|
||||
|
||||
@@ -334,6 +334,71 @@ impl Mac {
|
||||
fn selected(&self) -> usize {
|
||||
self.app.session.active().unwrap().selection.objects.len()
|
||||
}
|
||||
|
||||
/// Where the document's point (`x`, `y`) is on the screen.
|
||||
fn on_canvas(&self, x: f64, y: f64) -> Pos2 {
|
||||
crate::canvas::Xf::new(self.app.canvas_rect.unwrap(), self.app.view().unwrap()).to_screen(vectorcraft_geom::Point::new(x, y))
|
||||
}
|
||||
|
||||
/// Run `n` frames, each with the events that `step` returns (`step` may act on the app first),
|
||||
/// and return the number of frames in which the app read the menus.
|
||||
fn reads(&mut self, n: usize, mut step: impl FnMut(&mut Mac, usize) -> Vec<Event>) -> usize {
|
||||
let synced = self.fake.synced();
|
||||
for i in 0..n {
|
||||
let events = step(self, i);
|
||||
self.frame(events);
|
||||
}
|
||||
self.fake.synced() - synced
|
||||
}
|
||||
|
||||
/// Press on the selected rectangle and move the pointer 8 points to the right: the Selection
|
||||
/// tool's move begins. Returns where the press was.
|
||||
fn start_move(&mut self) -> Pos2 {
|
||||
let start = self.on_canvas(38.5, 40.0);
|
||||
self.frame(vec![Event::PointerMoved(start), click(start, true)]);
|
||||
self.frame(vec![Event::PointerMoved(start + vec2(8.0, 0.0))]);
|
||||
assert!(self.app.session.in_interaction(), "the move began");
|
||||
start
|
||||
}
|
||||
|
||||
/// Open the Rotate dialog with Preview on: the rotation previews on the canvas.
|
||||
fn rotate_preview(&mut self) {
|
||||
menus::invoke(&mut self.app, "object.rotate", json!({}));
|
||||
self.frame(vec![]);
|
||||
self.app.ui.dialog.as_mut().unwrap().fields.insert("preview".into(), json!(true));
|
||||
self.frame(vec![]);
|
||||
assert!(self.app.session.in_interaction(), "the rotation previews");
|
||||
}
|
||||
|
||||
/// Set `n` new angles in the open dialog through the control port, one a frame, and return the
|
||||
/// number of frames in which the app read the menus.
|
||||
fn angles(&mut self, n: usize) -> usize {
|
||||
self.reads(n, |m, i| {
|
||||
crate::tests_synthetic::control(&mut m.app, &m.ctx, "ui.dialog.set", json!({"field": "angle", "value": 10 * (i + 1)}));
|
||||
vec![]
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn click(at: Pos2, pressed: bool) -> Event {
|
||||
Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }
|
||||
}
|
||||
|
||||
/// Run `drag` on a new app with the fake native menu, and run it again on another new app until
|
||||
/// the shortcut and plug-in generations hold still through a run (at most 20 runs). Return that app
|
||||
/// and what `drag` returned. The generations are process-wide and only grow. A test running
|
||||
/// alongside can bump them, and the app then reads the menus again.
|
||||
fn undisturbed<R>(drag: impl Fn(&mut Mac) -> R) -> (Mac, R) {
|
||||
let generations = || (crate::shortcut_editor::GENERATION.load(std::sync::atomic::Ordering::Relaxed), crate::menus::plugin_revision());
|
||||
for _ in 0..20 {
|
||||
let mut m = Mac::new(true);
|
||||
let before = generations();
|
||||
let r = drag(&mut m);
|
||||
if generations() == before {
|
||||
return (m, r);
|
||||
}
|
||||
}
|
||||
panic!("tests running alongside kept bumping the generations");
|
||||
}
|
||||
|
||||
/// A key equivalent runs through the app's own key handling, once: ⌘A selects every object, ⌘Z
|
||||
@@ -365,6 +430,122 @@ fn key_equivalents_run_through_the_normal_shortcut_path_once() {
|
||||
assert_eq!(crate::control::inspect(&m.app, &m.ctx)["nativeMenuBar"], json!(true));
|
||||
}
|
||||
|
||||
/// Each step of a drag previews the move as a new revision: the menus aren't read again for it
|
||||
/// while the button is held, and are read when the button is released, with Undo naming the move.
|
||||
#[test]
|
||||
fn a_drag_reads_the_menus_when_it_is_released() {
|
||||
let (mut m, (start, read)) = undisturbed(|m| {
|
||||
let start = m.start_move();
|
||||
(start, m.reads(6, |_, i| vec![Event::PointerMoved(start + vec2(8.0 * (i + 2) as f32, 0.0))]))
|
||||
});
|
||||
assert_eq!(read, 0, "the menus weren't read at any step of the drag");
|
||||
let synced = m.fake.synced();
|
||||
m.frame(vec![click(start + vec2(56.0, 0.0), false)]);
|
||||
assert!(!m.app.session.in_interaction());
|
||||
assert_eq!(m.fake.synced(), synced + 1, "the menus were read on the release");
|
||||
assert_eq!(find(&m.fake.last(), "edit.undo").label, "Undo Move");
|
||||
}
|
||||
|
||||
/// Escape ends a Rectangle tool drag while its button is still held: the menus are read then, with
|
||||
/// the preview's rectangle gone (nothing selected, so Cut is off), and not at the steps before.
|
||||
#[test]
|
||||
fn escape_during_a_drag_reads_the_menus() {
|
||||
let (mut m, read) = undisturbed(|m| {
|
||||
m.app.run("select.none", json!({})).unwrap();
|
||||
m.app.run("tool.select", json!({"tool": "rectangle"})).unwrap();
|
||||
let start = m.on_canvas(200.0, 150.0);
|
||||
m.frame(vec![Event::PointerMoved(start), click(start, true)]);
|
||||
m.frame(vec![Event::PointerMoved(start + vec2(10.0, 10.0))]);
|
||||
assert!(m.app.session.in_interaction(), "the rectangle is being drawn");
|
||||
m.reads(6, |_, i| vec![Event::PointerMoved(start + vec2(10.0, 10.0) * (i + 2) as f32)])
|
||||
});
|
||||
assert_eq!(read, 0, "the menus weren't read at any step of the drag");
|
||||
assert!(find(&m.fake.last(), "edit.cut").enabled, "the preview's rectangle is selected");
|
||||
let synced = m.fake.synced();
|
||||
m.frame(vec![Event::Key { key: Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }]);
|
||||
assert!(!m.app.session.in_interaction(), "Escape dropped the rectangle");
|
||||
assert_eq!(m.fake.synced(), synced + 1, "the menus were read");
|
||||
assert!(!find(&m.fake.last(), "edit.cut").enabled, "nothing is selected");
|
||||
}
|
||||
|
||||
/// A move dragged off the canvas onto a panel stops being a move with its button still held
|
||||
/// (`canvas::drag_art_out`): the menus are read then, without a release or a key, and Undo still
|
||||
/// names the step before the drag.
|
||||
#[test]
|
||||
fn a_move_dragged_onto_a_panel_reads_the_menus() {
|
||||
let (mut m, (start, undo, read)) = undisturbed(|m| {
|
||||
let undo = find(&m.fake.last(), "edit.undo").label.clone();
|
||||
let start = m.start_move();
|
||||
(start, undo, m.reads(4, |_, i| vec![Event::PointerMoved(start + vec2(8.0 * (i + 2) as f32, 0.0))]))
|
||||
});
|
||||
assert_eq!(read, 0, "the menus weren't read at any step of the drag");
|
||||
let synced = m.fake.synced();
|
||||
let panel = Pos2::new(m.app.canvas_rect.unwrap().right() + 40.0, start.y);
|
||||
m.frame(vec![Event::PointerMoved(panel)]);
|
||||
assert!(!m.app.session.in_interaction(), "the move was dropped");
|
||||
assert_eq!(m.fake.synced(), synced + 1, "the menus were read");
|
||||
assert_eq!(find(&m.fake.last(), "edit.undo").label, undo);
|
||||
}
|
||||
|
||||
/// A dialog previews each angle that the control port sets, and the menus aren't read for the
|
||||
/// angles: `ui.dialog.cancel` on the control port ends the preview, and the menus are read then,
|
||||
/// without a release or a key.
|
||||
#[test]
|
||||
fn a_control_port_cancel_of_a_dialog_preview_reads_the_menus() {
|
||||
let (mut m, read) = undisturbed(|m| {
|
||||
m.rotate_preview();
|
||||
m.angles(4)
|
||||
});
|
||||
assert_eq!(read, 0, "the menus weren't read while the dialog previewed the angles");
|
||||
let synced = m.fake.synced();
|
||||
crate::tests_synthetic::control(&mut m.app, &m.ctx, "ui.dialog.cancel", json!({}));
|
||||
m.frame(vec![]);
|
||||
assert!(!m.app.session.in_interaction() && m.app.ui.dialog.is_none());
|
||||
assert_eq!(m.fake.synced(), synced + 1, "the menus were read");
|
||||
}
|
||||
|
||||
/// OK applies the rotation a dialog previews as one undo step: the menus are read then, with Undo
|
||||
/// naming it, and not for the angles previewed before.
|
||||
#[test]
|
||||
fn applying_a_dialog_preview_reads_the_menus() {
|
||||
let (mut m, read) = undisturbed(|m| {
|
||||
m.rotate_preview();
|
||||
m.angles(4)
|
||||
});
|
||||
assert_eq!(read, 0, "the menus weren't read while the dialog previewed the angles");
|
||||
let synced = m.fake.synced();
|
||||
crate::tests_synthetic::control(&mut m.app, &m.ctx, "ui.dialog.confirm", json!({}));
|
||||
m.frame(vec![]);
|
||||
assert!(!m.app.session.in_interaction() && m.app.ui.dialog.is_none());
|
||||
assert_eq!(m.fake.synced(), synced + 1, "the menus were read");
|
||||
assert_eq!(find(&m.fake.last(), "edit.undo").label, "Undo Rotate");
|
||||
}
|
||||
|
||||
/// The font menu previews each font it highlights on the selected type, in one interaction: the
|
||||
/// menus are read as the preview begins and as it ends, and not for the fonts between.
|
||||
#[test]
|
||||
fn the_font_menu_preview_reads_the_menus_when_it_ends() {
|
||||
use crate::font_menu::FontPick;
|
||||
// The app reads the menus after each pick as at the end of a frame. A whole frame would start
|
||||
// with `font_menu::end_stale_preview`, which ends a preview whose menu isn't drawn.
|
||||
let apply = |m: &mut Mac, pick: FontPick| {
|
||||
let synced = m.fake.synced();
|
||||
crate::font_menu::apply(&mut m.app, &m.ctx, pick);
|
||||
native_menu::sync(&mut m.app, &m.ctx);
|
||||
m.fake.synced() - synced
|
||||
};
|
||||
let (mut m, read) = undisturbed(|m| {
|
||||
m.app.run("select.none", json!({})).unwrap();
|
||||
m.app.run("text.create", json!({"x": 50, "y": 50, "text": "Hi"})).unwrap();
|
||||
m.frame(vec![]);
|
||||
assert_eq!(apply(m, FontPick::Preview("Source Serif 4".into(), None)), 1, "the menus were read as the preview began");
|
||||
apply(m, FontPick::Preview("Source Sans 3".into(), None)) + apply(m, FontPick::Preview("Inter".into(), None))
|
||||
});
|
||||
assert_eq!(read, 0, "the menus weren't read for the fonts after the first");
|
||||
assert_eq!(apply(&mut m, FontPick::EndPreview), 1, "the menus were read as the preview ended");
|
||||
assert!(!m.app.session.in_interaction());
|
||||
}
|
||||
|
||||
/// A click runs its command like an in-window click, and the menus follow: View › Show Rulers
|
||||
/// reads Hide Rulers afterwards; the app menu's Appearance items run with their params.
|
||||
#[test]
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
use serde_json::json;
|
||||
use vectorcraft_doc::NodeKind;
|
||||
use vectorcraft_engine::Session;
|
||||
use vectorcraft_engine::cmd::clipboard::{EMF, PNG};
|
||||
use vectorcraft_engine::cmd::clipboard::{EMF, EPS, Flavour, PASTE_ORDER, PDF, PNG, TEXT, file_flavour};
|
||||
|
||||
use crate::sysclip::import_command;
|
||||
use crate::tests_sysclip::{app, blue_png, copy_elsewhere, pasted, run};
|
||||
@@ -17,7 +17,7 @@ fn an_emf_from_another_app_pastes_as_vectors_ahead_of_its_bitmap() {
|
||||
other.execute("shape.ellipse", &json!({"x": 10, "y": 10, "width": 30, "height": 30})).unwrap();
|
||||
let emf = other.execute("document.export", &json!({"format": "emf"})).unwrap();
|
||||
let emf = vectorcraft_format::base64_decode(emf["dataBase64"].as_str().unwrap()).unwrap();
|
||||
let flavour = vectorcraft_engine::cmd::clipboard::Flavour { mime: EMF, data: emf.clone() };
|
||||
let flavour = Flavour { mime: EMF, data: emf.clone() };
|
||||
assert_eq!(import_command(&flavour, None).0, "clipboard.importEmf");
|
||||
copy_elsewhere(&board, vec![(PNG, blue_png()), (EMF, emf)]);
|
||||
run(&mut app, "edit.paste", json!({}));
|
||||
@@ -26,3 +26,35 @@ fn an_emf_from_another_app_pastes_as_vectors_ahead_of_its_bitmap() {
|
||||
let b = p[0].geometric_bounds().unwrap();
|
||||
assert!((b.width() - 30.0).abs() < 0.05, "{b:?}");
|
||||
}
|
||||
|
||||
/// A 30 pt red square, as an EPS written by hand from the EPSF specification.
|
||||
const SQUARE_EPS: &str = "%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 100 100\n%%EndComments\n1 0 0 setrgbcolor\nnewpath 10 10 moveto 40 10 lineto 40 40 lineto 10 40 lineto closepath fill\nshowpage\n%%EOF\n";
|
||||
|
||||
/// An EPS another app copied (an equation editor's, a drawing app's) pastes as editable paths, not
|
||||
/// as the bitmap or the text beside it, and not as what was copied in the app before (#1499).
|
||||
#[test]
|
||||
fn an_eps_from_another_app_pastes_as_vectors() {
|
||||
let (mut app, board) = app();
|
||||
crate::tests_sysclip::copy_rect(&mut app);
|
||||
let eps = SQUARE_EPS.as_bytes().to_vec();
|
||||
assert_eq!(import_command(&Flavour { mime: EPS, data: eps.clone() }, None).0, "clipboard.importEps");
|
||||
copy_elsewhere(&board, vec![(PNG, blue_png()), (TEXT, b"x = 1".to_vec()), (EPS, eps.clone())]);
|
||||
run(&mut app, "edit.paste", json!({}));
|
||||
let p = pasted(&app);
|
||||
assert!(!p.is_empty(), "something pasted");
|
||||
let path = |n: &vectorcraft_doc::Node| matches!(n.kind, NodeKind::Path { .. });
|
||||
let mut paths = vec![];
|
||||
p.iter().for_each(|n| n.walk(&mut |c| paths.extend(path(c).then(|| c.geometric_bounds().unwrap()))));
|
||||
assert_eq!(paths.len(), 1, "{p:?}");
|
||||
assert!((paths[0].width() - 30.0).abs() < 0.05 && (paths[0].height() - 30.0).abs() < 0.05, "{paths:?}");
|
||||
assert!(p.iter().all(|n| !matches!(n.kind, NodeKind::Image(_) | NodeKind::Text(_))), "{p:?}");
|
||||
// A PDF beside it is the better copy: the apps that offer both mean the same picture.
|
||||
assert!(PASTE_ORDER.iter().position(|m| *m == PDF) < PASTE_ORDER.iter().position(|m| *m == EPS));
|
||||
// Not PostScript: refused, nothing pasted over.
|
||||
let pdf = vectorcraft_format::base64_encode(b"%PDF-1.7");
|
||||
assert!(app.session.execute("clipboard.importEps", &json!({"dataBase64": pdf})).is_err());
|
||||
// An .eps file copied in a file manager is that flavour too (told by its bytes, not its name).
|
||||
assert_eq!(file_flavour("equation.eps", &eps, &PASTE_ORDER), Some(EPS));
|
||||
assert_eq!(file_flavour("equation.dat", &eps, &PASTE_ORDER), Some(EPS));
|
||||
assert_eq!(file_flavour("equation.eps", &eps, &[TEXT]), None);
|
||||
}
|
||||
@@ -507,7 +507,8 @@ type with those options (`file.place {text: {…}}`).
|
||||
|
||||
System clipboard formats: through the desktop app, `edit.copy` and `edit.cut` publish what `clipboard.flavours`
|
||||
lists to the system clipboard, and every Paste command first loads what another app copied (`clipboard.importSvg`,
|
||||
`importPdf`, `importText` or `importImage`, centred in the view); an import error answers the Paste with that error
|
||||
`importPdf`, `importEps`, `importText` or `importImage`, centred in the view; the desktop app reads PDF and EPS from
|
||||
the Windows clipboard and, with SVG, from the macOS pasteboard, and an `.eps` file copied in a file manager anywhere); an import error answers the Paste with that error
|
||||
and pastes nothing. Cmd+V also pastes a bitmap or PDF with no text beside it (on the key's release, as egui sends no
|
||||
Paste event for it). Screenshots paste on every platform: Windows reads `PNG`, else `CF_DIBV5`/`CF_DIB`, macOS the
|
||||
pasteboard's PNG or TIFF, Linux `image/png`, and the web the picture the browser's paste event carries (#457).
|
||||
|
||||
+6
-6
@@ -92,7 +92,7 @@ By default VectorCraft's own crates log at `info` and everything else at `warn`.
|
||||
On macOS the menus live in the system menu bar, not in the window's app bar (which keeps the brand mark, Home, the search icon and the workspace switcher next to the traffic lights, centred on the bar by `apps/vectorcraft/src/mac_window.rs`). `--in-window-menus` or `VECTORCRAFT_IN_WINDOW_MENUS=1` keeps them in the window, as on Windows, Linux and the web; so does a menu bar AppKit refuses to build (logged). `ui.inspect` says which with `nativeMenuBar`.
|
||||
|
||||
- **One menu model.** `crates/ui-egui/src/native_menu.rs` (platform-free, tested on every platform) builds the menu bar from the in-window menus (`menus::menu_tree`, each item through `menus::entry`, which the in-window menus draw too) and lays it out the Mac way: the VectorCraft menu holds About, Settings ▸ (one item per Preferences page, General… ⌘K), Language ▸, Appearance ▸ (UI brightness), Services, Hide VectorCraft ⌃⌘H (⌘H stays View › Hide Edges), Hide Others ⌥⌘H, Show All and Quit ⌘Q (the app's own `app.quit`, so unsaved documents are asked about); those leave Edit and Help. The in-window bar itself never shows the VectorCraft menu (it starts at File, as in Illustrator, on every platform): Language and Appearance have no in-window menu and are synthesized in `mac_layout`, and Quit is File › Exit in the window, relabelled to Quit in the App menu. Window starts with Minimize ⌃⌘M and Zoom and ends with Bring All to Front, and Help is the system's Help menu (its search field). A system key a command (or a user's shortcut) already has stays the command's, and the log says so.
|
||||
- **The native side** is `apps/vectorcraft/src/mac_menu.rs`: muda (`=0.21.1`, default features off, macOS only, no `unsafe`) builds the `NSMenu`s. Labels, enabled and checked are updated in place when anything they show may have changed (a command ran, the document, its history or selection changed, a click or a key press); a change of structure (a recent file, the language, a shortcut) rebuilds the menus.
|
||||
- **The native side** is `apps/vectorcraft/src/mac_menu.rs`: muda (`=0.21.1`, default features off, macOS only, no `unsafe`) builds the `NSMenu`s. Labels, enabled and checked are updated in place when anything they show may have changed (a command ran, the document, its history or selection changed, a click or a key press); a change of structure (a recent file, the language, a shortcut) rebuilds the menus. While an interaction is open (for example a move, scale or shape drag, a dialog's preview or the font menu's preview), the app updates them for the document's changes only when the interaction begins and when it ends.
|
||||
- **Keys stay with egui.** AppKit runs a menu's key equivalents before the window sees the key, so the item's key goes back to egui as the key press it was (⌘X/⌘C/⌘V as egui's Cut/Copy/Paste events), through `raw_input_hook`: `shortcuts::handle` runs it once, user shortcuts apply, and a focused text field keeps ⌘A, ⌘C, ⌘V and ⌘Z. A click runs the item like an in-window click. Bare keys and ⌥/⇧ chords are never key equivalents (they type).
|
||||
- Built and clippy-checked for `aarch64-apple-darwin` and `x86_64-apple-darwin` from any host: `cargo clippy -p vectorcraft --target aarch64-apple-darwin --all-targets -- -D warnings`.
|
||||
|
||||
@@ -171,14 +171,15 @@ always use the English ids and labels, so agents and scripts never see translate
|
||||
Languages shipped: English (`en`, the source), Traditional Chinese (`zh-hant`, complete, in the vocabulary used
|
||||
in Taiwan; `zh-TW`, `zh-HK`, `zh-MO` and `zh-Hant-*` locales all resolve to it), Simplified Chinese (`zh-hans`,
|
||||
complete, in the vocabulary used in mainland China; `zh-CN`, `zh-SG`, `zh-Hans-*` and a bare `zh` resolve to it,
|
||||
so the two scripts never mix), Japanese (`ja`, complete), Korean (`ko`, complete; every `ko-*` locale resolves to it; a count takes no plural, and Hangul is drawn with an installed system font), German (`de`, complete; `de-DE`, `de-AT`, `de-CH` and every
|
||||
so the two scripts never mix), Japanese (`ja`, complete), Korean (`ko`, complete; every `ko-*` locale resolves to it; a count takes no plural, and Hangul is drawn with an installed system font), Vietnamese (`vi`, complete; every `vi-*` locale resolves to it; a count takes no plural, so plurals have one form), German (`de`, complete; `de-DE`, `de-AT`, `de-CH` and every
|
||||
other `de-*` locale resolve to it), Spanish (`es`, complete, in neutral
|
||||
international Spanish; every `es-*` locale such as `es-ES`, `es-MX`, `es-AR` or `es-419` resolves to it), French (`fr`,
|
||||
complete; `fr-FR`, `fr-BE`, `fr-CA`, `fr-CH` and every other `fr-*` locale resolve to it), Italian (`it`,
|
||||
complete; `it-IT`, `it-CH` and every other `it-*` locale resolve to it), Russian (`ru`, complete; every `ru-*`
|
||||
locale such as `ru-RU`, `ru-BY` or `ru-KZ` resolves to it), Ukrainian (`uk`, complete; `uk-UA`,
|
||||
`uk_UA.UTF-8` and every other `uk-*` locale resolve to it; integer plurals use one/few/many), Hungarian (`hu`, complete;
|
||||
`hu-HU` and every other `hu-*` locale resolve to it; a count takes the singular, so plurals have one form), Czech (`cs`, every menu label) and Brazilian Portuguese
|
||||
`hu-HU` and every other `hu-*` locale resolve to it; a count takes the singular, so plurals have one form), Polish (`pl`, complete; `pl-PL` and every other `pl-*`
|
||||
locale resolve to it; plurals use one/few/many, with 21, 31 … taking many), Czech (`cs`, every menu label) and Brazilian Portuguese
|
||||
(`pt-br`, every menu label and every `tl!` literal). Untranslated text falls back to English until its rows are added.
|
||||
|
||||
- `tl!("…")` translates a literal into the language the UI is drawn in; `i18n::t(s)` is the same for a
|
||||
@@ -206,10 +207,9 @@ locale such as `ru-RU`, `ru-BY` or `ru-KZ` resolves to it), Ukrainian (`uk`, com
|
||||
control channel, MCP and tests read them) and are translated only where the status bar draws them
|
||||
(`i18n::msg`). `@msg` catalog rows hold a whole message or a template such as
|
||||
`Couldn't open {name}: {e}`; `{_1}`, `{_2}` … stand for the format string's `{}`, and the values in the
|
||||
placeholders are translated in turn (the reason after `: {e}` is often a message too). Spanish, French, Hungarian, Italian, Japanese, Russian and Ukrainian cover every
|
||||
placeholders are translated in turn (the reason after `: {e}` is often a message too). Czech, German, Spanish, French, Hungarian, Italian, Japanese, Korean, Polish, Russian, Ukrainian and Vietnamese cover every
|
||||
message literal the test scan finds (`complete_languages_translate_every_message`, languages listed in
|
||||
`COMPLETE_MESSAGES`): a new `Err("…")`, `Other(…)`, `#[error(…)]` or `status(…)` message needs an `es.tsv`,
|
||||
a `fr.tsv`, a `hu.tsv`, an `it.tsv`, a `ja.tsv`, a `ru.tsv` and a `uk.tsv` row (`VECTORCRAFT_I18N_DUMP_MESSAGES=messages.txt cargo test -p vectorcraft-ui-egui
|
||||
`COMPLETE_MESSAGES`): a new `Err("…")`, `Other(…)`, `#[error(…)]` or `status(…)` message needs a row in each of those catalogs (`VECTORCRAFT_I18N_DUMP_MESSAGES=messages.txt cargo test -p vectorcraft-ui-egui
|
||||
complete_languages_translate_every_message` lists them all). Other languages show messages in English
|
||||
until they add `@msg` rows.
|
||||
- Not translated on purpose: names that are user data (layers, swatches, fonts, documents), the tab
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Effects parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (3D and Materials: Extrude & Bevel, Inflate and Rotate added, ~8% → ~25%; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain) · **Target:** Adobe Illustrator 2026 (30.x)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (3D and Materials on outlined type and other shapes of many small curves, #1481; Twist 0° to 360°, #1524; twisted walls keep their faces, #1525; score unchanged; 3D and Materials: Inflate takes Twist, Taper and Cap; score unchanged) · **Target:** Adobe Illustrator 2026 (30.x)
|
||||
|
||||
Every entry of Illustrator's Effect menu, by its public feature name, against VectorCraft's effects catalogue
|
||||
(`effect_catalog()` in `crates/effects/src/lib.rs`) and the Effect menu built from it
|
||||
@@ -85,7 +85,7 @@ not an accuracy guarantee for arbitrary artwork. Tests: `pixel::tests::film_grai
|
||||
| Item | Ours |
|
||||
|---|---|
|
||||
| Revolve | initial: live, editable profile, rotation and light gizmos, flat shading, SVG/PDF output, Expand Appearance ([revolve.md](revolve.md), #846) |
|
||||
| Extrude & Bevel, Inflate, Rotate | initial: live, depth, twist, taper, solid or hollow caps, five original bevel shapes, Inflate volume and both sides, Rotate of every fill and stroke, Position presets, rotation and light gizmos, SVG/PDF output, Expand Appearance ([three-d.md](three-d.md)) |
|
||||
| Extrude & Bevel, Inflate, Rotate | initial: live, depth, twist, taper, solid or hollow caps, five original bevel shapes, Inflate volume, both sides, twist, taper and cap, Rotate of every fill and stroke, Position presets, rotation and light gizmos, SVG/PDF output, Expand Appearance ([three-d.md](three-d.md)) |
|
||||
| Materials panel, lighting, ray-traced render | one directional light with flat shading only; no materials, several lights, light colour, shadows or ray tracing |
|
||||
| 3D (Classic): Extrude & Bevel, Revolve, Rotate | none |
|
||||
| Turntable (30.0 beta) | none |
|
||||
@@ -99,6 +99,8 @@ Revolve caps, 3D Classic, Turntable, 3D export) is 25–45 h.
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-13 | minor | 3D and Materials: outlined words and other shapes of many small curves take Rotate, Extrude & Bevel and Inflate (curves are sampled by a tolerance; Rotate takes up to 65,536 outline points; #1481); Extrude & Bevel's Twist runs 0° to 360° as the public panel reference gives it, a negative value drawing untwisted (#1524); a twisted extrusion keeps its walls, which were dropped from about 45° (#1525); details in [three-d.md](three-d.md); 3D stays ~25%, 25–45 h (estimated) |
|
||||
| 2026-10-13 | minor | Inflate takes Twist, Taper and Cap ([three-d.md](three-d.md)); 3D stays ~25%, 25–45 h: materials, lights and shadows are the bulk of what remains |
|
||||
| 2026-10-12 | minor | Extrude & Bevel, Inflate and Rotate (initial, [three-d.md](three-d.md)); 3D ~8% → ~25%, 45–75 → 25–45 h |
|
||||
| 2026-10-12 | minor | G2: Artistic › Film Grain added with live controls and raster exports; measured Photoshop filters 29/57, 28 remaining (23–39 h). |
|
||||
| 2026-10-11 | minor | PDF cap warnings identify original objects after appearance splitting and report the lowest achieved ppi; cap/rendering and scores unchanged |
|
||||
|
||||
+8
-6
@@ -1,6 +1,6 @@
|
||||
# Gaps
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (G1: Split Into Grid options, #1462, Transform Objects and Reflect's angle, #1448 and #1460, Pathfinder on a group, #1450, value fields, #1457 and #1465 in part; G9: grayscale TIFFs with alpha, #1466 in part; G6: unknown parameter keys refused, #1442; G10: window surfaces past 8192 px, #1459 in part; G15: many-object edits, #1429 in part; estimates unchanged; G3: Extrude & Bevel, Inflate and Rotate added, 45–75 → 25–45 h; G9: `.ai` compound path paint, #1410, overprint kept on import, #1388, text-only PDF parts, #1387; G1: Transform dialogs preview, #1412, transforms of direct-selected anchors, #1408, corner drags, #1229; G11: strokes wider than their shape, #1396; estimates unchanged; G9: `.ai` editing-data follow-ups (gradient stops, type placement, short area-type frames), #1390, #1391, #1392; G15: symbol instance art kept between frames, #1386; G1: dialog keyboard focus, #1367 and #1395; estimates unchanged; G9: placed (linked) files read from `.ai` editing data, #1264; G9: gradient shapes import as paths, #1304, and white constant alpha masks, #1286 in part; G15: linear deselect on hide or lock, #1353; estimates unchanged; G7: custom ruler origin done, #1212; G1: Artboard object clicks create a fitted artboard (#1227); remaining interaction pass and estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; G16: Korean catalog added, 70–110 → 60–100 h; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; G11: valid selection after Simplify and Add Anchor Points (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; G10: RPM runtime dependency metadata (#1275); estimates unchanged; G15: texture-effect distance searches optimized (#1257); idle-machine/platform drag QA still pending; estimate unchanged; G1: Curvature point display improved (#1236, in part); estimates unchanged; G4: full-name font matching (#1258); Windows font-service QA pending; estimate unchanged; G11: no-op anchor Align keeps history and saved state (#1180 follow-up); estimates unchanged; G1: dedicated Eyedropper cursor and precise-cursor checks (#1269); estimates unchanged; G1: Eyedropper reads visible locked and outside-isolation sources (#1251); estimate unchanged; G11: Direct Selection segment Delete (#1278); estimates unchanged; G1: painted strokes regain the existing default after None/zero-width inheritance (#1253); estimate unchanged; G15: large-selection edits, cached live effects and faster Pathfinder, #1173; G4: proportional vertical metrics done, #966; G11: Shape Builder merge seams on grids of lines fixed, #893; scaled text controls corrected, #1034; group font-size preservation deferred; G9: `.ai` gradient matrices and meshes from the editing data; estimate unchanged; G9: `.ai` art styles from the editing data; estimate unchanged; G9: `.ai` editing-data follow-ups (gradient stops, type placement, short area-type frames), #1390, #1391, #1392; G15: symbol instance art kept between frames, #1386; G1: dialog keyboard focus, #1367 and #1395; estimates unchanged; G9: placed (linked) files read from `.ai` editing data, #1264; G9: gradient shapes import as paths, #1304, and white constant alpha masks, #1286 in part; G15: linear deselect on hide or lock, #1353; estimates unchanged; G7: custom ruler origin done, #1212; G1: Artboard object clicks create a fitted artboard (#1227); remaining interaction pass and estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; G16: Korean catalog added, 70–110 → 60–100 h; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; G11: valid selection after Simplify and Add Anchor Points (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; G10: RPM runtime dependency metadata (#1275); estimates unchanged; G15: texture-effect distance searches optimized (#1257); idle-machine/platform drag QA still pending; estimate unchanged; G1: Curvature point display improved (#1236, in part); estimates unchanged; G4: full-name font matching (#1258); Windows font-service QA pending; estimate unchanged; G11: no-op anchor Align keeps history and saved state (#1180 follow-up); estimates unchanged; G1: dedicated Eyedropper cursoLine truncated
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (Direct Selection anchor precedence, #1508; dialog slider rails, #1483; template layers in Outline view, #1486; Tabs panel stop drags, #1458; type that does not fit its frame is reported, #1471; 3D on outlined type, #1481, Twist range, #1524, twisted walls, #1525; print-size images, #1517; French catalog, #1511; estimates unchanged; G1: Split Into Grid options, #1462, Transform Objects and Reflect's angle, #1448 and #1460, Pathfinder on a group, #1450, value fields, #1457 and #1465 in part; G9: grayscale TIFFs with alpha, #1466 in part; G6: unknown parameter keys refused, #1442; G10: window surfaces past 8192 px, #1459 in part; G15: many-object edits, #1429 in part; estimates unchanged; G16: Vietnamese catalog added, complete under the tests; three key languages remain, 60–100 → 55–90 h; G3: Extrude & Bevel, Inflate and Rotate added, 45–75 → 25–45 h; G9: `.ai` compound path paint, #1410, overprint kept on import, #1388, text-only PDF parts, #1387; G1: Transform dialogs preview, #1412, transforms of direct-selected anchors, #1408, corner drags, #1229; G11: strokes wider than their shape, #1396; estimates unchanged; G9: `.ai` editing-data follow-ups (gradient stops, type placement, short area-type frames), #1390, #1391, #1392; G15: symbol instance art kept between frames, #1386; G1: dialog keyboard focus, #1367 and #1395; estimates unchanged; G9: placed (linked) files read from `.ai` editing data, #1264; G9: gradient shapes import as paths, #1304, and white constant alpha masks, #1286 in part; G15: linear deselect on hide or lock, #1353; estimates unchanged; G7: custom ruler origin done, #1212; G1: Artboard object clicks create a fitted artboard (#1227); remaining interaction pass and estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; G16: Korean catalog added, 70–110 → 60–100 h; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; G11: valid selection after Simplify and Add Anchor Points (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; G10: RPM runtime dependency metadata (#1275); estimates unchanged; G15: texture-effect distance searches optimized (#1257); idle-machine/platform drag QA still pending; estimate unchanged; G1: Curvature point display improved (#1236, in part); estimates unchanged; G4: full-name font matching (#1258); Windows font-service QA pending; estimate unchanged; G11: no-op anchor Align keeps history and saved state (#1180 follow-up); estimates unchanged; G1: dedicated Eyedropper cursor and precise-cursor checks (#1269); estimates unchanged; G1: Eyedropper reads visible locked and outside-isolation sources (#1251); estimate unchanged; G11: Direct Selection segment Delete (#1278); estimates unchanged; G1: painted strokes regain the existing default after None/zero-width inheritance (#1253); estimate unchanged; G15: large-selection edits, cached live effects and faster Pathfinder, #1173; G4: proportional vertical metrics done, #966; G11: Shape Builder merge seams on grids of lines fixed, #893; scaled text controls corrected, #1034; group font-size preservation deferred; G9: `.ai` gradient matrices and meshes from the editing data; estimate unchanged; G9: `.ai` art styles from the editing data; estimate unchanged; G9: `.ai` editing-data follow-ups (gradient stops, type placement, short area-type frames), #1390, #1391, #1392; G15: symbol instance art kept between frames, #1386; G1: dialog keyboard focus, #1367 and #1395; estimates unchanged; G9: placed (linked) files read from `.ai` editing data, #1264; G9: gradient shapes import as paths, #1304, and white constant alpha masks, #1286 in part; G15: linear deselect on hide or lock, #1353; estimates unchanged; G7: custom ruler origin done, #1212; G1: Artboard object clicks create a fitted artboard (#1227); remaining interaction pass and estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; G16: Korean catalog added, 70–110 → 60–100 h; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; G11: valid selection after Simplify and Add Anchor Points (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; G10: RPM runtime dependency metadataLine truncated
|
||||
|
||||
Every known shortfall against Illustrator, one entry each, **ranked by how much it stops a professional from
|
||||
switching**. This is the work list: unless you were given a task, pick the highest-ranked gap you can make
|
||||
@@ -30,7 +30,7 @@ stage ([ROADMAP.md](../ROADMAP.md)).
|
||||
| 13 | [G7 Views and windows](#g7-views-and-windows) | 15–25 | no | [ui-parity.md](ui-parity.md) |
|
||||
| 14 | [G6 Automation and scripting](#g6-automation-and-scripting) | 8–14 | no | [target-app-parity.md](target-app-parity.md) |
|
||||
| 15 | [G15 Performance budgets and a GPU renderer](#g15-performance-budgets-and-a-gpu-renderer) | 15–25 | budgets only | [hardware-parity.md](hardware-parity.md) |
|
||||
| 16 | [G16 Localization: the four missing key languages](#g16-localization-the-four-missing-key-languages) | 60–100 | no | [localization-parity.md](localization-parity.md) |
|
||||
| 16 | [G16 Localization: the three missing key languages](#g16-localization-the-three-missing-key-languages) | 55–90 | no | [localization-parity.md](localization-parity.md) |
|
||||
| 17 | [G17 Ecosystem: third-party plug-ins](#g17-ecosystem-third-party-plug-ins) | 20–45 | no | — |
|
||||
| 18 | [G18 Generative AI features](#g18-generative-ai-features) | 30–60 | no | — |
|
||||
| 19 | [G19 Accessibility](#g19-accessibility) | 15–25 | no | [ui-parity.md](ui-parity.md) |
|
||||
@@ -202,14 +202,14 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall
|
||||
Crop and Outline landed in #1173.
|
||||
- **Estimate:** 15–25 h (the budget run and fixes block beta).
|
||||
|
||||
## G16 Localization: the four missing key languages
|
||||
## G16 Localization: the three missing key languages
|
||||
|
||||
- **Evidence:** Czech render-site bypasses reduced in file dialogs, diagnostics, preset names and contributor tooltips; gradient grammar and printer-mark context corrected (see [localization-parity.md](localization-parity.md)). Native review and the missing-language work remain; estimate unchanged.
|
||||
- **Evidence:** Vietnamese catalog complete under the tests (menus, literals, messages; one plural form). Czech render-site bypasses reduced in file dialogs, diagnostics, preset names and contributor tooltips; gradient grammar and printer-mark context corrected (see [localization-parity.md](localization-parity.md)). Native review and the remaining missing-language work remain.
|
||||
|
||||
- **Missing:** Hindi, Arabic, Indonesian, Vietnamese catalogs; a Korean UI font for the web build; a right-to-left interface and interface
|
||||
- **Missing:** Hindi, Arabic, Indonesian catalogs; a Korean UI font for the web build; a right-to-left interface and interface
|
||||
shaping for Arabic and Devanagari; messages for Simplified Chinese and Brazilian Portuguese; native review of
|
||||
every catalog. Measured in [localization-parity.md](localization-parity.md).
|
||||
- **Estimate:** 60–100 h plus human review (Korean landed 2026-10-12).
|
||||
- **Estimate:** 55–90 h plus human review (Vietnamese landed 2026-10-12).
|
||||
|
||||
## G17 Ecosystem: third-party plug-ins
|
||||
|
||||
@@ -236,6 +236,7 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-13 | minor | Interaction: Direct Selection takes the selected object's anchor where anchors of overlapping objects coincide (#1508), sliders in dialogs show their rail (#1483), a template layer stays in Preview in Outline view (#1486), a tab stop in the Tabs panel drags from where the press landed (#1458); type: commands and MCP `add_text` report area type that does not fit its frame (#1471); 3D and Materials on outlined type and other shapes of many small curves (#1481), Twist 0° to 360° (#1524), twisted walls keep their faces (#1525); print-size images draw, export and crop (#1517); `vectorcraft-cli perf` reports load and memory on Windows (#1502; the idle-machine run is still missing); French catalog complete again (#1511); `graph.setData`'s CSV keeps each value exactly (#1485); estimates unchanged, no gap entry closed |
|
||||
| 2026-10-13 | minor | G1: Split Into Grid has its documented options (#1462), Transform Objects in the Transform dialogs and Transform Each and Reflect across an angle (#1448, #1460), the Pathfinder panel works on a group selected alone (#1450; one plain path alone is not settled), a focused field follows its value (#1457), selected text in value fields is readable (#1465 in part); G9: grayscale TIFFs with an alpha channel or extra channels place (#1466 in part), strokes kept apart from a faded or masked fill on PDF and `.ai` import (#1434); G4: Fit Headline in a frame one line tall (#1444), frames beyond a billion points hold no text (#1451); G6: commands refuse a parameter key they don't take (#1442); G10: a window surface past 8192 px no longer closes the app (#1459 in part); G15: edits and Alt-drags of thousands of objects (#1429 in part); G16: 128 more Czech strings (#1461); estimates unchanged |
|
||||
| 2026-10-12 | minor | G3: Extrude & Bevel, Inflate and Rotate with Position presets ([three-d.md](three-d.md), #1407); materials, lights, shadows, Classic, Turntable and export remain, 45–75 → 25–45 h |
|
||||
| 2026-10-12 | minor | G9: a `.ai` compound path takes its last path's paint and keeps paths grouped inside it (#1410); PDF and `.ai` import keep overprint (#1388); a `.ai` whose PDF part draws only text opens from it (#1387); G1: the Transform dialogs preview and step with the arrow keys (#1412), the transform tools and commands act on direct-selected anchors (#1408), the bounding box hides during a corner drag (#1229); G11: a stroke wider than its closed shape paints solid on the canvas (#1396); estimates unchanged |
|
||||
@@ -251,6 +252,7 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall
|
||||
| 2026-10-12 | minor | G1: Artboard object clicks create a fitted artboard (#1227); remaining interaction pass and estimate unchanged |
|
||||
| 2026-10-12 | minor | Pathfinder single-result booleans preserve source names and SVG IDs (#1293); paint-order, grouped/compound sources, unchanged geometry/appearance and Undo/Redo regression tests; scores and estimates unchanged |
|
||||
| 2026-10-12 | minor | DXF reads control-point SPLINE closure (#1299 follow-up after #1320); open curves stay open and curves retained; rational/non-rational import and export versions regression-tested; scores/hours unchanged |
|
||||
| 2026-10-12 | minor | G16: Vietnamese catalog added (complete under the tests); three key languages remain (Hindi, Arabic, Indonesian), 55–90 h |
|
||||
| 2026-10-12 | minor | G16: Korean catalog added (complete under the tests); four key languages remain, 60–100 h |
|
||||
| 2026-10-11 | minor | Czech render-site follow-up: file-dialog filters, diagnostics, preset numbering, contributor tooltips/plurals, gradient grammar and contextual printer mark styles; estimates unchanged |
|
||||
| 2026-10-12 | minor | Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged |
|
||||
|
||||
+219
-218
@@ -1,6 +1,6 @@
|
||||
# Image Trace quality
|
||||
|
||||
> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (photos: High Fidelity Photo 3,653 paths to 121 with its colour error down from 2.30 to 1.75)
|
||||
> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (shared edges: gaps and overlaps between abutting paths from 22 to 50 px down to under 1)
|
||||
|
||||
What Image Trace (`crates/trace`) makes of images whose right answer is known, measured by a harness and
|
||||
compared with the numbers before each change. Nothing here is a judgement by eye, and nothing is compared
|
||||
@@ -72,25 +72,25 @@ Each preset on the inputs it is meant for, all sizes and variants.
|
||||
| Technical Drawing | polygons, star, letter A, lines | 2.66 | 13.45 | 0.201 | 1.37 | 105.76 | 4.8 | 32 | 2.23 | 100 | 85 | 5.9 | – | – | – | – | 0.1 | 0.0 |
|
||||
| Flat Logo | two touching halves, four-colour badge | 0.43 | 3.53 | 3.106 | 88.50 | 0.78 | 69.9 | 352 | 38.92 | 38 | 80 | 237.0 | 0.0 | 0 | 13.8 | – | 59.1 | 0.2 |
|
||||
|
||||
### Now (sub-pixel edges, the palette, photo smoothing)
|
||||
### Now (sub-pixel edges, the palette, photo smoothing, shared edges)
|
||||
|
||||
| preset | inputs | ΔE | edge ΔE | off mean | off max | missed max | paths | anchors | × art | lines % | corners % | kinks | odd colours | odd px | gap px | overlap px | specks | outside px |
|
||||
|:--|:--|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|
|
||||
| Default | circle, ellipses, badge with a hole, letter D, polygons, star, letter A | 2.60 | 10.12 | 0.091 | 1.02 | 2.17 | 1.9 | 18 | 1.81 | 29 | 68 | 0.4 | – | – | 22.2 | 22.4 | 0.0 | 0.0 |
|
||||
| High Fidelity Photo | gradient, photo-like | 1.75 | 1.98 | – | – | – | 120.7 | 9818 | – | 43 | – | – | – | – | 0.0 | – | 10.3 | 0.1 |
|
||||
| Low Fidelity Photo | gradient, photo-like | 2.45 | 2.69 | – | – | – | 37.1 | 2132 | – | 44 | – | – | – | – | 0.0 | – | 0.0 | 0.2 |
|
||||
| 3 Colors | two touching halves | 0.43 | 4.32 | 0.087 | 0.74 | 0.56 | 2.0 | 9 | 1.57 | 39 | 100 | 0.0 | 0.0 | 0 | 48.6 | 29.4 | 0.0 | 0.0 |
|
||||
| 6 Colors | two touching halves, four-colour badge | 0.56 | 5.01 | 0.137 | 1.09 | 1.14 | 4.0 | 50 | 3.15 | 40 | 88 | 3.6 | 0.0 | 0 | 39.0 | 35.7 | 0.0 | 0.0 |
|
||||
| 16 Colors | two touching halves, four-colour badge | 0.55 | 4.89 | 0.133 | 1.09 | 1.15 | 4.2 | 66 | 3.97 | 41 | 88 | 4.4 | 0.0 | 0 | 34.5 | 26.4 | 0.0 | 0.0 |
|
||||
| Shades of Gray | gradient, photo-like | 9.26 | 9.06 | – | – | – | 16.5 | 879 | – | 53 | – | – | – | – | 0.0 | – | 0.4 | 0.8 |
|
||||
| Black and White Logo | circle, ellipses, badge with a hole, letter D, polygons, star, letter A | 2.58 | 9.39 | 0.064 | 1.00 | 2.18 | 1.4 | 27 | 2.73 | 40 | 67 | 0.3 | – | – | – | – | 0.0 | 0.0 |
|
||||
| Sketched Art | circle, ellipses, badge with a hole, letter D, lines | 2.71 | 12.11 | 0.195 | 1.00 | 105.54 | 1.8 | 17 | 1.49 | 16 | 86 | 0.0 | – | – | – | – | 0.0 | 0.0 |
|
||||
| Silhouettes | circle, ellipses, badge with a hole, letter D | 2.98 | 19.28 | 0.532 | 2.24 | 2.24 | 1.2 | 14 | 1.65 | 12 | 79 | 0.1 | – | – | – | – | 0.0 | 0.0 |
|
||||
| Line Art | lines | 2.65 | 9.87 | 0.093 | 0.89 | 105.71 | 5.4 | 77 | 3.86 | 37 | – | – | – | – | – | – | 0.1 | 0.0 |
|
||||
| Technical Drawing | polygons, star, letter A, lines | 2.53 | 9.47 | 0.086 | 0.99 | 105.85 | 4.8 | 57 | 3.73 | 69 | 74 | 0.7 | – | – | – | – | 0.3 | 0.0 |
|
||||
| Default | circle, ellipses, badge with a hole, letter D, polygons, star, letter A | 2.60 | 10.04 | 0.092 | 1.02 | 2.17 | 1.9 | 18 | 1.78 | 29 | 68 | 0.4 | – | – | 0.8 | 0.5 | 0.0 | 0.0 |
|
||||
| High Fidelity Photo | gradient, photo-like | 1.75 | 1.97 | – | – | – | 120.7 | 10001 | – | 38 | – | – | – | – | 0.0 | – | 10.3 | 0.0 |
|
||||
| Low Fidelity Photo | gradient, photo-like | 2.45 | 2.69 | – | – | – | 37.1 | 2118 | – | 41 | – | – | – | – | 0.0 | – | 0.0 | 0.0 |
|
||||
| 3 Colors | two touching halves | 0.42 | 3.98 | 0.104 | 0.91 | 0.91 | 2.0 | 11 | 1.81 | 39 | 100 | 0.0 | 0.0 | 0 | 0.0 | 0.0 | 0.0 | 0.0 |
|
||||
| 6 Colors | two touching halves, four-colour badge | 0.55 | 4.98 | 0.140 | 1.34 | 1.19 | 4.0 | 50 | 3.13 | 38 | 86 | 5.2 | 0.0 | 0 | 0.2 | 0.3 | 0.0 | 0.0 |
|
||||
| 16 Colors | two touching halves, four-colour badge | 0.55 | 4.90 | 0.141 | 1.31 | 1.19 | 4.2 | 62 | 3.79 | 39 | 86 | 6.4 | 0.0 | 0 | 0.2 | 0.2 | 0.0 | 0.0 |
|
||||
| Shades of Gray | gradient, photo-like | 9.26 | 9.06 | – | – | – | 16.5 | 695 | – | 51 | – | – | – | – | 0.0 | – | 0.5 | 0.0 |
|
||||
| Black and White Logo | circle, ellipses, badge with a hole, letter D, polygons, star, letter A | 2.58 | 9.37 | 0.063 | 1.02 | 2.17 | 1.4 | 27 | 2.69 | 39 | 68 | 0.2 | – | – | – | – | 0.0 | 0.0 |
|
||||
| Sketched Art | circle, ellipses, badge with a hole, letter D, lines | 2.71 | 12.12 | 0.197 | 1.00 | 105.53 | 1.8 | 17 | 1.48 | 15 | 86 | 0.0 | – | – | – | – | 0.0 | 0.0 |
|
||||
| Silhouettes | circle, ellipses, badge with a hole, letter D | 2.98 | 19.22 | 0.523 | 2.23 | 2.21 | 1.2 | 14 | 1.64 | 11 | 75 | 0.2 | – | – | – | – | 0.0 | 0.0 |
|
||||
| Line Art | lines | 2.65 | 9.88 | 0.093 | 0.91 | 105.75 | 5.4 | 78 | 3.92 | 36 | – | – | – | – | – | – | 0.1 | 0.0 |
|
||||
| Technical Drawing | polygons, star, letter A, lines | 2.55 | 9.72 | 0.092 | 1.06 | 105.75 | 4.8 | 56 | 3.67 | 71 | 74 | 0.7 | – | – | – | – | 1.3 | 0.0 |
|
||||
| Flat Logo | two touching halves, four-colour badge | 0.43 | 3.53 | 3.106 | 88.50 | 0.78 | 69.9 | 352 | 38.92 | 38 | 80 | 237.0 | 0.0 | 0 | 13.8 | – | 59.1 | 0.2 |
|
||||
|
||||
The sections below are the numbers now (sub-pixel edges, the palette, photo smoothing).
|
||||
The sections below are the numbers now (sub-pixel edges, the palette, photo smoothing, shared edges).
|
||||
|
||||
## 1. Each preset on the inputs it is meant for
|
||||
|
||||
@@ -98,84 +98,84 @@ All sizes and variants (clean, aliased, blurred, noisy, scanned) of those inputs
|
||||
|
||||
| preset | inputs | ΔE | edge ΔE | off mean | off max | missed max | paths | anchors | × art | lines % | corners % | kinks | odd colours | odd px | gap px | overlap px | specks | outside px |
|
||||
|:--|:--|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|
|
||||
| Default | circle, ellipses, badge with a hole, letter D, polygons, star, letter A | 2.60 | 10.12 | 0.091 | 1.02 | 2.17 | 1.9 | 18 | 1.81 | 29 | 68 | 0.4 | – | – | 22.2 | 22.4 | 0.0 | 0.0 |
|
||||
| High Fidelity Photo | gradient, photo-like | 1.75 | 1.98 | – | – | – | 120.7 | 9818 | – | 43 | – | – | – | – | 0.0 | – | 10.3 | 0.1 |
|
||||
| Low Fidelity Photo | gradient, photo-like | 2.45 | 2.69 | – | – | – | 37.1 | 2132 | – | 44 | – | – | – | – | 0.0 | – | 0.0 | 0.2 |
|
||||
| 3 Colors | two touching halves | 0.43 | 4.32 | 0.087 | 0.74 | 0.56 | 2.0 | 9 | 1.57 | 39 | 100 | 0.0 | 0.0 | 0 | 48.6 | 29.4 | 0.0 | 0.0 |
|
||||
| 6 Colors | two touching halves, four-colour badge | 0.56 | 5.01 | 0.137 | 1.09 | 1.14 | 4.0 | 50 | 3.15 | 40 | 88 | 3.6 | 0.0 | 0 | 39.0 | 35.7 | 0.0 | 0.0 |
|
||||
| 16 Colors | two touching halves, four-colour badge | 0.55 | 4.89 | 0.133 | 1.09 | 1.15 | 4.2 | 66 | 3.97 | 41 | 88 | 4.4 | 0.0 | 0 | 34.5 | 26.4 | 0.0 | 0.0 |
|
||||
| Shades of Gray | gradient, photo-like | 9.26 | 9.06 | – | – | – | 16.5 | 879 | – | 53 | – | – | – | – | 0.0 | – | 0.4 | 0.8 |
|
||||
| Black and White Logo | circle, ellipses, badge with a hole, letter D, polygons, star, letter A | 2.58 | 9.39 | 0.064 | 1.00 | 2.18 | 1.4 | 27 | 2.73 | 40 | 67 | 0.3 | – | – | – | – | 0.0 | 0.0 |
|
||||
| Sketched Art | circle, ellipses, badge with a hole, letter D, lines | 2.71 | 12.11 | 0.195 | 1.00 | 105.54 | 1.8 | 17 | 1.49 | 16 | 86 | 0.0 | – | – | – | – | 0.0 | 0.0 |
|
||||
| Silhouettes | circle, ellipses, badge with a hole, letter D | 2.98 | 19.28 | 0.532 | 2.24 | 2.24 | 1.2 | 14 | 1.65 | 12 | 79 | 0.1 | – | – | – | – | 0.0 | 0.0 |
|
||||
| Line Art | lines | 2.65 | 9.87 | 0.093 | 0.89 | 105.71 | 5.4 | 77 | 3.86 | 37 | – | – | – | – | – | – | 0.1 | 0.0 |
|
||||
| Technical Drawing | polygons, star, letter A, lines | 2.53 | 9.47 | 0.086 | 0.99 | 105.85 | 4.8 | 57 | 3.73 | 69 | 74 | 0.7 | – | – | – | – | 0.3 | 0.0 |
|
||||
| Default | circle, ellipses, badge with a hole, letter D, polygons, star, letter A | 2.60 | 10.04 | 0.092 | 1.02 | 2.17 | 1.9 | 18 | 1.78 | 29 | 68 | 0.4 | – | – | 0.8 | 0.5 | 0.0 | 0.0 |
|
||||
| High Fidelity Photo | gradient, photo-like | 1.75 | 1.97 | – | – | – | 120.7 | 10001 | – | 38 | – | – | – | – | 0.0 | – | 10.3 | 0.0 |
|
||||
| Low Fidelity Photo | gradient, photo-like | 2.45 | 2.69 | – | – | – | 37.1 | 2118 | – | 41 | – | – | – | – | 0.0 | – | 0.0 | 0.0 |
|
||||
| 3 Colors | two touching halves | 0.42 | 3.98 | 0.104 | 0.91 | 0.91 | 2.0 | 11 | 1.81 | 39 | 100 | 0.0 | 0.0 | 0 | 0.0 | 0.0 | 0.0 | 0.0 |
|
||||
| 6 Colors | two touching halves, four-colour badge | 0.55 | 4.98 | 0.140 | 1.34 | 1.19 | 4.0 | 50 | 3.13 | 38 | 86 | 5.2 | 0.0 | 0 | 0.2 | 0.3 | 0.0 | 0.0 |
|
||||
| 16 Colors | two touching halves, four-colour badge | 0.55 | 4.90 | 0.141 | 1.31 | 1.19 | 4.2 | 62 | 3.79 | 39 | 86 | 6.4 | 0.0 | 0 | 0.2 | 0.2 | 0.0 | 0.0 |
|
||||
| Shades of Gray | gradient, photo-like | 9.26 | 9.06 | – | – | – | 16.5 | 695 | – | 51 | – | – | – | – | 0.0 | – | 0.5 | 0.0 |
|
||||
| Black and White Logo | circle, ellipses, badge with a hole, letter D, polygons, star, letter A | 2.58 | 9.37 | 0.063 | 1.02 | 2.17 | 1.4 | 27 | 2.69 | 39 | 68 | 0.2 | – | – | – | – | 0.0 | 0.0 |
|
||||
| Sketched Art | circle, ellipses, badge with a hole, letter D, lines | 2.71 | 12.12 | 0.197 | 1.00 | 105.53 | 1.8 | 17 | 1.48 | 15 | 86 | 0.0 | – | – | – | – | 0.0 | 0.0 |
|
||||
| Silhouettes | circle, ellipses, badge with a hole, letter D | 2.98 | 19.22 | 0.523 | 2.23 | 2.21 | 1.2 | 14 | 1.64 | 11 | 75 | 0.2 | – | – | – | – | 0.0 | 0.0 |
|
||||
| Line Art | lines | 2.65 | 9.88 | 0.093 | 0.91 | 105.75 | 5.4 | 78 | 3.92 | 36 | – | – | – | – | – | – | 0.1 | 0.0 |
|
||||
| Technical Drawing | polygons, star, letter A, lines | 2.55 | 9.72 | 0.092 | 1.06 | 105.75 | 4.8 | 56 | 3.67 | 71 | 74 | 0.7 | – | – | – | – | 1.3 | 0.0 |
|
||||
| Flat Logo | two touching halves, four-colour badge | 0.43 | 3.53 | 3.106 | 88.50 | 0.78 | 69.9 | 352 | 38.92 | 38 | 80 | 237.0 | 0.0 | 0 | 13.8 | – | 59.1 | 0.2 |
|
||||
|
||||
## 2. Each preset on each kind of input (clean, three sizes)
|
||||
|
||||
| preset | inputs | ΔE | edge ΔE | off mean | off max | missed max | paths | anchors | × art | lines % | corners % | kinks | odd colours | odd px | gap px | overlap px | specks | outside px |
|
||||
|:--|:--|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|
|
||||
| Default | curved shapes | 0.08 | 3.24 | 0.057 | 0.51 | 0.59 | 1.8 | 16 | 1.72 | 15 | 83 | 0.0 | – | – | 22.7 | 21.1 | 0.0 | 0.0 |
|
||||
| Default | polygons | 0.05 | 2.11 | 0.036 | 0.46 | 1.23 | 2.0 | 14 | 1.24 | 89 | 90 | 0.0 | – | – | 3.1 | 3.3 | 0.0 | 0.0 |
|
||||
| Default | thin lines | 0.56 | 4.77 | 0.059 | 0.48 | 105.57 | 3.3 | 22 | 1.12 | 42 | – | – | – | – | 49.1 | 34.0 | 0.0 | 0.0 |
|
||||
| Default | flat colour art | 19.95 | 18.92 | 0.197 | 0.64 | 179.52 | 1.5 | 22 | 1.32 | 15 | 0 | 0.0 | – | – | 24.3 | 23.0 | 0.0 | 0.0 |
|
||||
| Default | gradient, photo | 39.13 | 38.78 | – | – | – | 3.2 | 698 | – | 57 | – | – | – | – | 31.2 | 40.7 | 0.0 | 0.3 |
|
||||
| High Fidelity Photo | curved shapes | 0.04 | 1.74 | 0.026 | 0.16 | 0.16 | 1.2 | 20 | 2.17 | 21 | 100 | 0.0 | 0.0 | 0 | 0.0 | – | 0.0 | 0.0 |
|
||||
| High Fidelity Photo | polygons | 0.05 | 1.92 | 0.031 | 0.51 | 1.34 | 1.7 | 17 | 1.50 | 87 | 88 | 0.0 | 0.0 | 0 | 0.0 | – | 0.0 | 0.0 |
|
||||
| High Fidelity Photo | thin lines | 0.34 | 6.00 | 0.076 | 0.85 | 4.31 | 40.3 | 333 | 16.67 | 37 | – | – | 27.3 | 4489 | 0.0 | – | 19.0 | 0.0 |
|
||||
| High Fidelity Photo | flat colour art | 0.14 | 4.17 | 0.028 | 0.20 | 0.20 | 3.5 | 71 | 4.44 | 35 | 100 | 0.0 | 0.0 | 0 | 0.0 | – | 0.0 | 0.0 |
|
||||
| High Fidelity Photo | gradient, photo | 1.80 | 2.05 | – | – | – | 134.7 | 11186 | – | 43 | – | – | – | – | 0.0 | – | 7.7 | 0.1 |
|
||||
| Low Fidelity Photo | curved shapes | 0.06 | 2.65 | 0.045 | 0.27 | 0.27 | 1.2 | 14 | 1.53 | 19 | 100 | 0.0 | 0.0 | 0 | 0.0 | – | 0.0 | 0.0 |
|
||||
| Low Fidelity Photo | polygons | 0.06 | 2.25 | 0.037 | 0.51 | 1.34 | 1.7 | 14 | 1.25 | 86 | 88 | 0.0 | 0.0 | 0 | 0.0 | – | 0.0 | 0.0 |
|
||||
| Low Fidelity Photo | thin lines | 0.51 | 5.73 | 0.081 | 0.86 | 45.38 | 7.0 | 56 | 2.80 | 47 | – | – | 4.0 | 3974 | 0.0 | – | 0.0 | 0.0 |
|
||||
| Low Fidelity Photo | flat colour art | 0.15 | 4.33 | 0.046 | 0.27 | 0.27 | 3.5 | 55 | 3.38 | 32 | 100 | 0.0 | 0.0 | 0 | 0.0 | – | 0.0 | 0.0 |
|
||||
| Low Fidelity Photo | gradient, photo | 2.53 | 2.80 | – | – | – | 42.7 | 2289 | – | 43 | – | – | – | – | 0.0 | – | 0.0 | 0.2 |
|
||||
| 3 Colors | curved shapes | 0.06 | 2.65 | 0.046 | 0.33 | 0.24 | 1.8 | 16 | 1.77 | 16 | 100 | 0.0 | 0.0 | 0 | 18.7 | 15.2 | 0.0 | 0.0 |
|
||||
| 3 Colors | polygons | 0.06 | 2.25 | 0.037 | 0.51 | 1.34 | 2.0 | 15 | 1.35 | 86 | 88 | 0.0 | 0.0 | 0 | 5.1 | 4.7 | 0.0 | 0.0 |
|
||||
| 3 Colors | thin lines | 0.44 | 4.40 | 0.107 | 0.90 | 45.38 | 5.0 | 44 | 2.22 | 49 | – | – | 2.0 | 290 | 23.8 | 27.4 | 0.0 | 0.0 |
|
||||
| 3 Colors | flat colour art | 1.32 | 3.85 | 0.113 | 0.90 | 60.22 | 2.5 | 24 | 1.77 | 46 | 100 | 0.0 | 1.0 | 9919 | 30.3 | 31.0 | 0.0 | 0.0 |
|
||||
| 3 Colors | gradient, photo | 7.49 | 7.60 | – | – | – | 5.7 | 532 | – | 48 | – | – | – | – | 25.5 | 26.9 | 0.0 | 0.2 |
|
||||
| 6 Colors | curved shapes | 0.06 | 2.38 | 0.042 | 0.28 | 0.24 | 1.8 | 17 | 1.86 | 17 | 100 | 0.0 | 0.0 | 0 | 17.9 | 14.3 | 0.0 | 0.0 |
|
||||
| 6 Colors | polygons | 0.05 | 2.07 | 0.034 | 0.51 | 1.34 | 2.0 | 16 | 1.42 | 86 | 88 | 0.0 | 0.0 | 0 | 3.9 | 3.9 | 0.0 | 0.0 |
|
||||
| 6 Colors | thin lines | 0.50 | 4.54 | 0.096 | 0.86 | 45.38 | 4.0 | 32 | 1.60 | 53 | – | – | 1.7 | 398 | 18.5 | 22.8 | 0.0 | 0.0 |
|
||||
| 6 Colors | flat colour art | 0.12 | 2.66 | 0.044 | 0.25 | 0.20 | 3.0 | 28 | 2.00 | 43 | 100 | 0.0 | 0.0 | 0 | 21.9 | 21.2 | 0.0 | 0.0 |
|
||||
| 6 Colors | gradient, photo | 5.01 | 5.30 | – | – | – | 13.0 | 833 | – | 36 | – | – | – | – | 42.6 | 37.0 | 0.0 | 0.2 |
|
||||
| 16 Colors | curved shapes | 0.05 | 2.26 | 0.038 | 0.26 | 0.23 | 1.8 | 18 | 1.93 | 17 | 100 | 0.0 | 0.0 | 0 | 14.9 | 12.5 | 0.0 | 0.0 |
|
||||
| 16 Colors | polygons | 0.05 | 2.02 | 0.034 | 0.51 | 1.34 | 2.0 | 16 | 1.44 | 86 | 88 | 0.0 | 0.0 | 0 | 3.3 | 4.2 | 0.0 | 0.0 |
|
||||
| 16 Colors | thin lines | 0.41 | 3.87 | 0.103 | 0.86 | 23.65 | 8.7 | 50 | 2.52 | 51 | – | – | 5.7 | 305 | 18.4 | 25.4 | 0.0 | 0.0 |
|
||||
| 16 Colors | flat colour art | 0.12 | 2.53 | 0.041 | 0.24 | 0.20 | 3.0 | 28 | 2.07 | 44 | 100 | 0.0 | 0.0 | 0 | 19.8 | 19.5 | 0.0 | 0.0 |
|
||||
| 16 Colors | gradient, photo | 3.02 | 3.33 | – | – | – | 47.2 | 2154 | – | 29 | – | – | – | – | 89.7 | 80.2 | 0.2 | 0.2 |
|
||||
| Shades of Gray | curved shapes | 0.06 | 2.72 | 0.046 | 0.48 | 0.65 | 1.2 | 14 | 1.56 | 18 | 92 | 0.0 | 0.0 | 0 | 0.0 | – | 0.0 | 0.0 |
|
||||
| Shades of Gray | polygons | 0.05 | 1.97 | 0.033 | 0.42 | 1.26 | 1.7 | 14 | 1.21 | 89 | 91 | 0.0 | 0.0 | 0 | 0.0 | – | 0.0 | 0.0 |
|
||||
| Shades of Gray | thin lines | 0.56 | 6.69 | 0.078 | 0.86 | 45.38 | 10.3 | 71 | 3.57 | 49 | – | – | 8.0 | 4283 | 0.0 | – | 0.0 | 0.0 |
|
||||
| Shades of Gray | flat colour art | 7.33 | 11.25 | 0.264 | 1.47 | 179.38 | 4.7 | 214 | 19.86 | 24 | 50 | 0.3 | 3.7 | 173356 | 0.0 | – | 0.0 | 0.0 |
|
||||
| Shades of Gray | gradient, photo | 9.71 | 9.55 | – | – | – | 18.2 | 955 | – | 53 | – | – | – | – | 0.0 | – | 0.5 | 0.8 |
|
||||
| Black and White Logo | curved shapes | 0.04 | 1.52 | 0.024 | 0.50 | 0.60 | 1.2 | 21 | 2.25 | 21 | 75 | 0.0 | – | – | – | – | 0.0 | 0.0 |
|
||||
| Black and White Logo | polygons | 0.04 | 1.83 | 0.030 | 0.45 | 1.23 | 1.7 | 15 | 1.34 | 89 | 90 | 0.0 | – | – | – | – | 0.0 | 0.0 |
|
||||
| Black and White Logo | thin lines | 0.50 | 4.22 | 0.065 | 0.50 | 77.21 | 10.3 | 66 | 3.30 | 65 | – | – | – | – | – | – | 0.0 | 0.0 |
|
||||
| Black and White Logo | flat colour art | 19.94 | 18.72 | 0.192 | 0.31 | 179.52 | 1.0 | 19 | 1.18 | 24 | 0 | 0.0 | – | – | – | – | 0.0 | 0.0 |
|
||||
| Black and White Logo | gradient, photo | 39.11 | 38.71 | – | – | – | 9.0 | 676 | – | 72 | – | – | – | – | – | – | 0.0 | 0.0 |
|
||||
| Sketched Art | curved shapes | 0.13 | 5.54 | 0.107 | 0.41 | 0.41 | 1.2 | 13 | 1.43 | 17 | 100 | 0.0 | – | – | – | – | 0.0 | 0.0 |
|
||||
| Sketched Art | polygons | 0.13 | 5.00 | 0.092 | 0.51 | 1.07 | 1.7 | 14 | 1.24 | 82 | 86 | 0.0 | – | – | – | – | 0.0 | 0.0 |
|
||||
| Sketched Art | thin lines | 0.38 | 4.99 | 0.102 | 0.80 | 52.57 | 4.3 | 37 | 1.87 | 35 | – | – | – | – | – | – | 0.0 | 0.0 |
|
||||
| Sketched Art | flat colour art | 20.32 | 20.95 | 0.175 | 0.89 | 179.64 | 1.5 | 22 | 1.34 | 18 | 0 | 0.0 | – | – | – | – | 0.0 | 0.0 |
|
||||
| Sketched Art | gradient, photo | 45.53 | 45.37 | – | – | – | 1.0 | 251 | – | 47 | – | – | – | – | – | – | 0.0 | 0.3 |
|
||||
| Silhouettes | curved shapes | 0.34 | 14.15 | 0.274 | 0.66 | 0.66 | 1.2 | 12 | 1.36 | 17 | 100 | 0.0 | – | – | – | – | 0.0 | 0.0 |
|
||||
| Silhouettes | polygons | 0.35 | 14.03 | 0.263 | 0.67 | 0.70 | 1.7 | 14 | 1.26 | 76 | 85 | 0.0 | – | – | – | – | 0.0 | 0.0 |
|
||||
| Silhouettes | thin lines | 1.05 | 16.01 | 0.308 | 1.09 | 18.10 | 5.0 | 42 | 2.08 | 40 | – | – | – | – | – | – | 0.0 | 0.0 |
|
||||
| Silhouettes | flat colour art | 23.86 | 34.06 | 0.226 | 0.77 | 188.38 | 1.0 | 10 | 0.76 | 26 | 0 | 0.0 | – | – | – | – | 0.0 | 0.0 |
|
||||
| Silhouettes | gradient, photo | 61.56 | 61.35 | – | – | – | 0.2 | 2 | – | 47 | – | – | – | – | – | – | 0.0 | 0.6 |
|
||||
| Line Art | curved shapes | 0.05 | 1.93 | 0.033 | 0.53 | 0.63 | 1.2 | 17 | 1.86 | 19 | 75 | 0.0 | – | – | – | – | 0.0 | 0.0 |
|
||||
| Line Art | polygons | 0.05 | 1.87 | 0.031 | 0.46 | 1.23 | 1.7 | 14 | 1.27 | 89 | 90 | 0.0 | – | – | – | – | 0.0 | 0.0 |
|
||||
| Line Art | thin lines | 0.54 | 4.38 | 0.049 | 0.47 | 105.71 | 3.3 | 33 | 1.67 | 42 | – | – | – | – | – | – | 0.0 | 0.0 |
|
||||
| Line Art | flat colour art | 19.94 | 18.69 | 0.192 | 0.49 | 179.52 | 1.0 | 46 | 2.55 | 23 | 0 | 0.0 | – | – | – | – | 0.0 | 0.0 |
|
||||
| Line Art | gradient, photo | 39.11 | 38.71 | – | – | – | 7.7 | 490 | – | 59 | – | – | – | – | – | – | 0.0 | 0.1 |
|
||||
| Technical Drawing | curved shapes | 0.04 | 1.52 | 0.024 | 0.50 | 0.60 | 1.2 | 21 | 2.25 | 21 | 75 | 0.0 | – | – | – | – | 0.0 | 0.0 |
|
||||
| Technical Drawing | polygons | 0.04 | 1.83 | 0.030 | 0.45 | 1.23 | 1.7 | 15 | 1.34 | 89 | 90 | 0.0 | – | – | – | – | 0.0 | 0.0 |
|
||||
| Technical Drawing | thin lines | 0.39 | 3.88 | 0.075 | 0.50 | 55.75 | 15.3 | 81 | 4.05 | 76 | – | – | – | – | – | – | 0.3 | 0.0 |
|
||||
| Technical Drawing | flat colour art | 19.94 | 18.72 | 0.192 | 0.31 | 179.52 | 1.0 | 19 | 1.18 | 24 | 0 | 0.0 | – | – | – | – | 0.0 | 0.0 |
|
||||
| Technical Drawing | gradient, photo | 39.08 | 38.69 | – | – | – | 20.0 | 964 | – | 79 | – | – | – | – | – | – | 2.0 | 0.1 |
|
||||
| Default | curved shapes | 0.09 | 3.51 | 0.062 | 0.64 | 0.78 | 1.8 | 15 | 1.64 | 13 | 83 | 0.0 | – | – | 0.0 | 0.0 | 0.0 | 0.0 |
|
||||
| Default | polygons | 0.05 | 2.13 | 0.036 | 0.44 | 1.24 | 2.0 | 14 | 1.26 | 89 | 90 | 0.0 | – | – | 0.0 | 0.0 | 0.0 | 0.0 |
|
||||
| Default | thin lines | 0.56 | 4.77 | 0.061 | 0.47 | 105.57 | 3.3 | 24 | 1.18 | 43 | – | – | – | – | 0.0 | 0.0 | 0.0 | 0.0 |
|
||||
| Default | flat colour art | 19.95 | 19.00 | 0.199 | 0.53 | 179.52 | 1.5 | 19 | 1.18 | 14 | 0 | 0.0 | – | – | 0.0 | 0.0 | 0.0 | 0.0 |
|
||||
| Default | gradient, photo | 39.14 | 38.78 | – | – | – | 3.2 | 593 | – | 55 | – | – | – | – | 18.6 | 30.6 | 0.0 | 0.0 |
|
||||
| High Fidelity Photo | curved shapes | 0.04 | 1.66 | 0.026 | 0.18 | 0.20 | 1.2 | 19 | 2.12 | 20 | 100 | 0.0 | 0.0 | 0 | 0.0 | – | 0.0 | 0.0 |
|
||||
| High Fidelity Photo | polygons | 0.05 | 1.88 | 0.031 | 0.51 | 1.34 | 1.7 | 16 | 1.43 | 88 | 89 | 0.0 | 0.0 | 0 | 0.0 | – | 0.0 | 0.0 |
|
||||
| High Fidelity Photo | thin lines | 0.34 | 5.96 | 0.075 | 0.85 | 4.31 | 40.3 | 339 | 16.93 | 37 | – | – | 27.3 | 4490 | 0.0 | – | 19.0 | 0.0 |
|
||||
| High Fidelity Photo | flat colour art | 0.14 | 4.16 | 0.030 | 0.20 | 0.20 | 3.5 | 73 | 4.62 | 35 | 100 | 0.0 | 0.0 | 0 | 0.0 | – | 0.0 | 0.0 |
|
||||
| High Fidelity Photo | gradient, photo | 1.80 | 2.04 | – | – | – | 134.7 | 11321 | – | 39 | – | – | – | – | 0.0 | – | 7.3 | 0.0 |
|
||||
| Low Fidelity Photo | curved shapes | 0.06 | 2.65 | 0.047 | 0.31 | 0.31 | 1.2 | 13 | 1.47 | 17 | 100 | 0.0 | 0.0 | 0 | 0.0 | – | 0.0 | 0.0 |
|
||||
| Low Fidelity Photo | polygons | 0.05 | 2.15 | 0.036 | 0.51 | 1.34 | 1.7 | 14 | 1.22 | 88 | 89 | 0.0 | 0.0 | 0 | 0.0 | – | 0.0 | 0.0 |
|
||||
| Low Fidelity Photo | thin lines | 0.51 | 5.69 | 0.083 | 0.86 | 45.41 | 7.0 | 56 | 2.80 | 47 | – | – | 4.0 | 3966 | 0.0 | – | 0.0 | 0.0 |
|
||||
| Low Fidelity Photo | flat colour art | 0.15 | 4.29 | 0.045 | 0.28 | 0.28 | 3.5 | 54 | 3.38 | 31 | 100 | 0.0 | 0.0 | 0 | 0.0 | – | 0.0 | 0.0 |
|
||||
| Low Fidelity Photo | gradient, photo | 2.53 | 2.80 | – | – | – | 42.7 | 2282 | – | 41 | – | – | – | – | 0.0 | – | 0.0 | 0.0 |
|
||||
| 3 Colors | curved shapes | 0.06 | 2.65 | 0.047 | 0.31 | 0.31 | 1.8 | 16 | 1.70 | 15 | 100 | 0.0 | 0.0 | 0 | 0.0 | 0.0 | 0.0 | 0.0 |
|
||||
| 3 Colors | polygons | 0.05 | 2.15 | 0.036 | 0.51 | 1.34 | 2.0 | 15 | 1.32 | 88 | 89 | 0.0 | 0.0 | 0 | 0.0 | 0.0 | 0.0 | 0.0 |
|
||||
| 3 Colors | thin lines | 0.45 | 4.38 | 0.113 | 0.90 | 45.41 | 5.0 | 44 | 2.18 | 50 | – | – | 2.0 | 290 | 0.0 | 0.0 | 0.0 | 0.0 |
|
||||
| 3 Colors | flat colour art | 1.31 | 3.61 | 0.125 | 0.90 | 60.25 | 2.5 | 25 | 1.86 | 45 | 100 | 0.0 | 1.0 | 9914 | 0.0 | 0.0 | 0.0 | 0.0 |
|
||||
| 3 Colors | gradient, photo | 7.49 | 7.60 | – | – | – | 5.7 | 530 | – | 47 | – | – | – | – | 6.3 | 7.4 | 0.0 | 0.0 |
|
||||
| 6 Colors | curved shapes | 0.06 | 2.45 | 0.043 | 0.27 | 0.28 | 1.8 | 17 | 1.78 | 15 | 100 | 0.0 | 0.0 | 0 | 0.0 | 0.0 | 0.0 | 0.0 |
|
||||
| 6 Colors | polygons | 0.05 | 2.08 | 0.035 | 0.51 | 1.34 | 2.0 | 15 | 1.33 | 88 | 89 | 0.0 | 0.0 | 0 | 0.0 | 0.0 | 0.0 | 0.0 |
|
||||
| 6 Colors | thin lines | 0.50 | 4.49 | 0.102 | 0.86 | 45.41 | 4.0 | 33 | 1.65 | 53 | – | – | 1.7 | 396 | 0.0 | 0.0 | 0.0 | 0.0 |
|
||||
| 6 Colors | flat colour art | 0.12 | 2.70 | 0.057 | 0.31 | 0.30 | 3.0 | 28 | 2.00 | 43 | 100 | 0.0 | 0.0 | 0 | 0.0 | 0.0 | 0.0 | 0.0 |
|
||||
| 6 Colors | gradient, photo | 5.00 | 5.28 | – | – | – | 13.0 | 832 | – | 36 | – | – | – | – | 6.1 | 8.2 | 0.0 | 0.0 |
|
||||
| 16 Colors | curved shapes | 0.05 | 2.21 | 0.038 | 0.24 | 0.25 | 1.8 | 17 | 1.85 | 16 | 100 | 0.0 | 0.0 | 0 | 0.0 | 0.0 | 0.0 | 0.0 |
|
||||
| 16 Colors | polygons | 0.05 | 1.97 | 0.032 | 0.51 | 1.34 | 2.0 | 16 | 1.39 | 88 | 89 | 0.0 | 0.0 | 0 | 0.0 | 0.0 | 0.0 | 0.0 |
|
||||
| 16 Colors | thin lines | 0.41 | 3.77 | 0.110 | 0.86 | 23.65 | 8.7 | 50 | 2.52 | 52 | – | – | 5.7 | 305 | 0.0 | 0.0 | 0.0 | 0.0 |
|
||||
| 16 Colors | flat colour art | 0.12 | 2.52 | 0.052 | 0.31 | 0.30 | 3.0 | 28 | 2.11 | 43 | 100 | 0.0 | 0.0 | 0 | 0.0 | 0.0 | 0.0 | 0.0 |
|
||||
| 16 Colors | gradient, photo | 3.00 | 3.30 | – | – | – | 47.2 | 2132 | – | 27 | – | – | – | – | 12.3 | 18.0 | 0.0 | 0.0 |
|
||||
| Shades of Gray | curved shapes | 0.06 | 2.67 | 0.047 | 0.31 | 0.31 | 1.2 | 14 | 1.49 | 17 | 100 | 0.0 | 0.0 | 0 | 0.0 | – | 0.0 | 0.0 |
|
||||
| Shades of Gray | polygons | 0.05 | 2.08 | 0.035 | 0.47 | 1.25 | 1.7 | 14 | 1.22 | 88 | 90 | 0.0 | 0.0 | 0 | 0.0 | – | 0.0 | 0.0 |
|
||||
| Shades of Gray | thin lines | 0.55 | 6.55 | 0.083 | 0.86 | 45.40 | 10.3 | 73 | 3.67 | 50 | – | – | 8.0 | 4272 | 0.0 | – | 0.0 | 0.0 |
|
||||
| Shades of Gray | flat colour art | 7.33 | 11.24 | 0.257 | 1.43 | 179.39 | 4.7 | 200 | 18.45 | 22 | 50 | 0.3 | 3.7 | 173367 | 0.0 | – | 0.0 | 0.0 |
|
||||
| Shades of Gray | gradient, photo | 9.71 | 9.55 | – | – | – | 18.2 | 748 | – | 51 | – | – | – | – | 0.0 | – | 0.5 | 0.0 |
|
||||
| Black and White Logo | curved shapes | 0.04 | 1.51 | 0.024 | 0.50 | 0.59 | 1.2 | 21 | 2.29 | 20 | 83 | 0.0 | – | – | – | – | 0.0 | 0.0 |
|
||||
| Black and White Logo | polygons | 0.04 | 1.83 | 0.030 | 0.43 | 1.24 | 1.7 | 16 | 1.39 | 90 | 90 | 0.0 | – | – | – | – | 0.0 | 0.0 |
|
||||
| Black and White Logo | thin lines | 0.60 | 6.10 | 0.111 | 0.50 | 77.21 | 10.3 | 48 | 2.38 | 83 | – | – | – | – | – | – | 3.3 | 0.0 |
|
||||
| Black and White Logo | flat colour art | 19.94 | 18.71 | 0.192 | 0.32 | 179.52 | 1.0 | 20 | 1.22 | 23 | 0 | 0.0 | – | – | – | – | 0.0 | 0.0 |
|
||||
| Black and White Logo | gradient, photo | 39.11 | 38.71 | – | – | – | 9.0 | 574 | – | 67 | – | – | – | – | – | – | 0.0 | 0.0 |
|
||||
| Sketched Art | curved shapes | 0.14 | 5.77 | 0.112 | 0.47 | 0.47 | 1.2 | 12 | 1.38 | 16 | 100 | 0.0 | – | – | – | – | 0.0 | 0.0 |
|
||||
| Sketched Art | polygons | 0.12 | 4.81 | 0.088 | 0.52 | 1.05 | 1.7 | 13 | 1.18 | 85 | 88 | 0.0 | – | – | – | – | 0.0 | 0.0 |
|
||||
| Sketched Art | thin lines | 0.40 | 5.10 | 0.106 | 0.80 | 52.93 | 4.3 | 39 | 1.93 | 35 | – | – | – | – | – | – | 0.0 | 0.0 |
|
||||
| Sketched Art | flat colour art | 20.32 | 21.13 | 0.179 | 0.87 | 179.64 | 1.5 | 24 | 1.43 | 16 | 0 | 0.0 | – | – | – | – | 0.0 | 0.0 |
|
||||
| Sketched Art | gradient, photo | 45.54 | 45.39 | – | – | – | 1.0 | 237 | – | 43 | – | – | – | – | – | – | 0.0 | 0.0 |
|
||||
| Silhouettes | curved shapes | 0.36 | 14.54 | 0.282 | 0.71 | 0.71 | 1.2 | 12 | 1.34 | 15 | 100 | 0.0 | – | – | – | – | 0.0 | 0.0 |
|
||||
| Silhouettes | polygons | 0.35 | 13.93 | 0.261 | 0.67 | 0.69 | 1.7 | 14 | 1.26 | 76 | 85 | 0.0 | – | – | – | – | 0.0 | 0.0 |
|
||||
| Silhouettes | thin lines | 1.03 | 15.78 | 0.304 | 1.09 | 18.10 | 5.0 | 47 | 2.33 | 34 | – | – | – | – | – | – | 0.0 | 0.0 |
|
||||
| Silhouettes | flat colour art | 23.86 | 33.99 | 0.224 | 0.77 | 188.38 | 1.0 | 11 | 0.78 | 24 | 0 | 0.0 | – | – | – | – | 0.0 | 0.0 |
|
||||
| Silhouettes | gradient, photo | 61.57 | 61.34 | – | – | – | 0.2 | 2 | – | 42 | – | – | – | – | – | – | 0.0 | 0.0 |
|
||||
| Line Art | curved shapes | 0.04 | 1.86 | 0.032 | 0.54 | 0.62 | 1.2 | 16 | 1.75 | 19 | 83 | 0.0 | – | – | – | – | 0.0 | 0.0 |
|
||||
| Line Art | polygons | 0.05 | 1.90 | 0.031 | 0.40 | 1.24 | 1.7 | 14 | 1.26 | 90 | 90 | 0.0 | – | – | – | – | 0.0 | 0.0 |
|
||||
| Line Art | thin lines | 0.54 | 4.39 | 0.049 | 0.47 | 105.75 | 3.3 | 35 | 1.77 | 41 | – | – | – | – | – | – | 0.0 | 0.0 |
|
||||
| Line Art | flat colour art | 19.94 | 18.73 | 0.193 | 0.50 | 179.52 | 1.0 | 43 | 2.40 | 22 | 0 | 0.0 | – | – | – | – | 0.0 | 0.0 |
|
||||
| Line Art | gradient, photo | 39.12 | 38.72 | – | – | – | 7.7 | 448 | – | 57 | – | – | – | – | – | – | 0.0 | 0.0 |
|
||||
| Technical Drawing | curved shapes | 0.04 | 1.51 | 0.024 | 0.50 | 0.59 | 1.2 | 21 | 2.29 | 20 | 83 | 0.0 | – | – | – | – | 0.0 | 0.0 |
|
||||
| Technical Drawing | polygons | 0.04 | 1.83 | 0.030 | 0.43 | 1.24 | 1.7 | 16 | 1.38 | 91 | 91 | 0.0 | – | – | – | – | 0.0 | 0.0 |
|
||||
| Technical Drawing | thin lines | 0.53 | 5.83 | 0.125 | 0.50 | 55.75 | 15.3 | 61 | 3.03 | 88 | – | – | – | – | – | – | 8.3 | 0.0 |
|
||||
| Technical Drawing | flat colour art | 19.94 | 18.71 | 0.192 | 0.32 | 179.52 | 1.0 | 20 | 1.22 | 23 | 0 | 0.0 | – | – | – | – | 0.0 | 0.0 |
|
||||
| Technical Drawing | gradient, photo | 39.08 | 38.68 | – | – | – | 20.0 | 593 | – | 68 | – | – | – | – | – | – | 2.7 | 0.0 |
|
||||
| Flat Logo | curved shapes | 0.06 | 2.14 | 0.036 | 0.25 | 0.25 | 1.8 | 31 | 3.38 | 19 | 100 | 0.0 | 0.0 | 0 | 6.8 | – | 0.0 | 0.0 |
|
||||
| Flat Logo | polygons | 0.09 | 3.44 | 0.060 | 0.35 | 1.03 | 2.0 | 35 | 3.17 | 67 | 94 | 0.0 | 0.0 | 0 | 6.8 | – | 0.0 | 0.0 |
|
||||
| Flat Logo | thin lines | 1.17 | 11.78 | 0.050 | 0.49 | all | 3.0 | 189 | 9.45 | 28 | – | – | 0.0 | 0 | 5467.1 | – | 0.0 | 0.0 |
|
||||
@@ -186,66 +186,66 @@ All sizes and variants (clean, aliased, blurred, noisy, scanned) of those inputs
|
||||
|
||||
| preset | variant | ΔE | edge ΔE | off mean | off max | missed max | paths | anchors | × art | lines % | corners % | kinks | odd colours | odd px | gap px | overlap px | specks | outside px |
|
||||
|:--|:--|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|
|
||||
| Default | clean | 0.07 | 2.76 | 0.048 | 0.51 | 1.23 | 1.9 | 15 | 1.52 | 46 | 88 | 0.0 | – | – | 14.3 | 13.5 | 0.0 | 0.0 |
|
||||
| Default | aliased | 0.39 | 21.13 | 0.229 | 1.02 | 1.34 | 1.9 | 14 | 1.42 | 21 | 98 | 0.0 | – | – | 52.7 | 52.9 | 0.0 | 0.0 |
|
||||
| Default | blurred | 0.82 | 16.65 | 0.078 | 0.86 | 2.17 | 1.9 | 26 | 2.50 | 7 | 19 | 1.6 | – | – | 23.0 | 23.6 | 0.0 | 0.0 |
|
||||
| Default | noisy | 1.74 | 4.55 | 0.052 | 0.57 | 1.23 | 1.9 | 16 | 1.64 | 38 | 67 | 0.0 | – | – | 16.2 | 15.1 | 0.0 | 0.0 |
|
||||
| Default | scanned | 15.06 | 20.24 | 0.134 | 0.87 | 2.17 | 1.9 | 26 | 2.53 | 0 | 25 | 1.0 | – | – | 20.8 | 24.7 | 0.0 | 0.0 |
|
||||
| High Fidelity Photo | clean | 1.80 | 2.05 | – | – | – | 134.7 | 11186 | – | 43 | – | – | – | – | 0.0 | – | 7.7 | 0.1 |
|
||||
| High Fidelity Photo | aliased | 1.78 | 2.01 | – | – | – | 73.0 | 7695 | – | 45 | – | – | – | – | 0.0 | – | 5.5 | 0.1 |
|
||||
| High Fidelity Photo | blurred | 1.59 | 1.95 | – | – | – | 72.0 | 7184 | – | 45 | – | – | – | – | 0.0 | – | 0.5 | 0.1 |
|
||||
| High Fidelity Photo | noisy | 2.02 | 2.19 | – | – | – | 155.5 | 10562 | – | 38 | – | – | – | – | 0.0 | – | 18.5 | 0.1 |
|
||||
| High Fidelity Photo | scanned | 1.45 | 1.55 | – | – | – | 140.5 | 9726 | – | 41 | – | – | – | – | 0.0 | – | 24.5 | 0.1 |
|
||||
| Low Fidelity Photo | clean | 2.53 | 2.80 | – | – | – | 42.7 | 2289 | – | 43 | – | – | – | – | 0.0 | – | 0.0 | 0.2 |
|
||||
| Low Fidelity Photo | aliased | 2.53 | 2.78 | – | – | – | 36.5 | 1504 | – | 46 | – | – | – | – | 0.0 | – | 0.0 | 0.1 |
|
||||
| Low Fidelity Photo | blurred | 2.35 | 2.75 | – | – | – | 29.0 | 2004 | – | 45 | – | – | – | – | 0.0 | – | 0.0 | 0.2 |
|
||||
| Low Fidelity Photo | noisy | 2.70 | 2.85 | – | – | – | 32.0 | 1960 | – | 46 | – | – | – | – | 0.0 | – | 0.0 | 0.2 |
|
||||
| Low Fidelity Photo | scanned | 1.95 | 2.05 | – | – | – | 34.5 | 2588 | – | 42 | – | – | – | – | 0.0 | – | 0.0 | 0.2 |
|
||||
| 3 Colors | clean | 0.07 | 2.53 | 0.051 | 0.24 | 0.14 | 2.0 | 10 | 1.61 | 39 | 100 | 0.0 | 0.0 | 0 | 40.0 | 25.5 | 0.0 | 0.0 |
|
||||
| 3 Colors | aliased | 0.18 | 9.81 | 0.236 | 0.74 | 0.56 | 2.0 | 7 | 1.17 | 39 | 100 | 0.0 | 0.0 | 0 | 123.2 | 82.4 | 0.0 | 0.0 |
|
||||
| 3 Colors | blurred | 0.33 | 7.80 | 0.088 | 0.21 | 0.16 | 2.0 | 10 | 1.67 | 39 | 100 | 0.0 | 0.0 | 0 | 29.7 | 18.6 | 0.0 | 0.0 |
|
||||
| 3 Colors | noisy | 0.71 | 1.81 | 0.049 | 0.23 | 0.08 | 2.0 | 9 | 1.50 | 39 | 100 | 0.0 | 0.0 | 0 | 35.7 | 9.7 | 0.0 | 0.0 |
|
||||
| 3 Colors | scanned | 1.57 | 3.23 | 0.082 | 0.34 | 0.22 | 2.0 | 11 | 1.83 | 39 | 100 | 0.0 | 0.0 | 0 | 31.9 | 18.9 | 0.0 | 0.0 |
|
||||
| 6 Colors | clean | 0.12 | 2.66 | 0.044 | 0.25 | 0.20 | 3.0 | 28 | 2.00 | 43 | 100 | 0.0 | 0.0 | 0 | 21.9 | 21.2 | 0.0 | 0.0 |
|
||||
| 6 Colors | aliased | 0.31 | 10.53 | 0.291 | 1.00 | 1.00 | 3.0 | 26 | 1.91 | 42 | 100 | 0.0 | 0.0 | 0 | 82.6 | 74.0 | 0.0 | 0.0 |
|
||||
| 6 Colors | blurred | 0.62 | 9.41 | 0.249 | 1.09 | 1.14 | 6.0 | 115 | 6.33 | 42 | 50 | 9.5 | 0.0 | 0 | 32.6 | 48.3 | 0.0 | 0.0 |
|
||||
| 6 Colors | noisy | 0.85 | 2.50 | 0.048 | 0.26 | 0.18 | 3.0 | 28 | 2.01 | 33 | 100 | 0.0 | 0.0 | 0 | 29.7 | 22.5 | 0.0 | 0.0 |
|
||||
| 6 Colors | scanned | 1.76 | 4.63 | 0.240 | 1.09 | 0.94 | 7.0 | 101 | 5.81 | 31 | 62 | 15.5 | 0.0 | 0 | 62.7 | 41.7 | 0.0 | 0.0 |
|
||||
| 16 Colors | clean | 0.12 | 2.53 | 0.041 | 0.24 | 0.20 | 3.0 | 28 | 2.07 | 44 | 100 | 0.0 | 0.0 | 0 | 19.8 | 19.5 | 0.0 | 0.0 |
|
||||
| 16 Colors | aliased | 0.30 | 10.39 | 0.274 | 1.00 | 1.00 | 3.0 | 34 | 2.34 | 43 | 100 | 0.0 | 0.0 | 0 | 77.3 | 51.7 | 0.0 | 0.0 |
|
||||
| 16 Colors | blurred | 0.62 | 9.37 | 0.250 | 1.08 | 1.15 | 7.0 | 157 | 8.43 | 44 | 50 | 11.5 | 0.0 | 0 | 27.8 | 38.2 | 0.0 | 0.0 |
|
||||
| 16 Colors | noisy | 0.85 | 2.34 | 0.042 | 0.27 | 0.22 | 3.0 | 32 | 2.27 | 34 | 100 | 0.0 | 0.0 | 0 | 23.4 | 15.6 | 0.0 | 0.0 |
|
||||
| 16 Colors | scanned | 1.74 | 4.56 | 0.243 | 1.09 | 0.96 | 7.5 | 156 | 8.53 | 32 | 62 | 19.0 | 0.0 | 0 | 53.3 | 20.8 | 0.0 | 0.0 |
|
||||
| Shades of Gray | clean | 9.71 | 9.55 | – | – | – | 18.2 | 955 | – | 53 | – | – | – | – | 0.0 | – | 0.5 | 0.8 |
|
||||
| Shades of Gray | aliased | 9.70 | 9.52 | – | – | – | 11.0 | 788 | – | 58 | – | – | – | – | 0.0 | – | 0.5 | 0.7 |
|
||||
| Shades of Gray | blurred | 9.66 | 9.32 | – | – | – | 11.5 | 729 | – | 56 | – | – | – | – | 0.0 | – | 0.5 | 0.7 |
|
||||
| Shades of Gray | noisy | 9.74 | 9.44 | – | – | – | 19.5 | 823 | – | 47 | – | – | – | – | 0.0 | – | 0.0 | 0.2 |
|
||||
| Shades of Gray | scanned | 6.58 | 6.46 | – | – | – | 19.0 | 947 | – | 49 | – | – | – | – | 0.0 | – | 0.5 | 0.2 |
|
||||
| Black and White Logo | clean | 0.04 | 1.65 | 0.027 | 0.50 | 1.23 | 1.4 | 18 | 1.86 | 50 | 86 | 0.0 | – | – | – | – | 0.0 | 0.0 |
|
||||
| Black and White Logo | aliased | 0.39 | 20.41 | 0.176 | 1.00 | 1.34 | 1.4 | 24 | 2.47 | 53 | 98 | 0.0 | – | – | – | – | 0.0 | 0.0 |
|
||||
| Black and White Logo | blurred | 0.81 | 16.37 | 0.047 | 0.80 | 2.18 | 1.4 | 39 | 3.78 | 23 | 19 | 1.0 | – | – | – | – | 0.0 | 0.0 |
|
||||
| Black and White Logo | noisy | 1.72 | 3.90 | 0.028 | 0.50 | 1.23 | 1.4 | 21 | 2.14 | 41 | 67 | 0.0 | – | – | – | – | 0.0 | 0.0 |
|
||||
| Black and White Logo | scanned | 15.05 | 20.10 | 0.116 | 0.82 | 2.17 | 1.4 | 53 | 5.13 | 11 | 25 | 0.9 | – | – | – | – | 0.0 | 0.0 |
|
||||
| Sketched Art | clean | 0.18 | 5.43 | 0.106 | 0.80 | 52.57 | 1.9 | 18 | 1.52 | 20 | 100 | 0.0 | – | – | – | – | 0.0 | 0.0 |
|
||||
| Sketched Art | aliased | 0.49 | 20.73 | 0.237 | 1.00 | 105.54 | 1.6 | 10 | 1.02 | 27 | 100 | 0.0 | – | – | – | – | 0.0 | 0.0 |
|
||||
| Sketched Art | blurred | 1.07 | 19.51 | 0.264 | 0.66 | 105.48 | 1.6 | 18 | 1.57 | 13 | 50 | 0.0 | – | – | – | – | 0.0 | 0.0 |
|
||||
| Sketched Art | noisy | 1.75 | 5.86 | 0.108 | 0.95 | 1.03 | 2.0 | 18 | 1.53 | 13 | 100 | 0.0 | – | – | – | – | 0.0 | 0.0 |
|
||||
| Sketched Art | scanned | 15.11 | 22.41 | 0.438 | 0.85 | 105.28 | 1.6 | 20 | 1.73 | 1 | 50 | 0.0 | – | – | – | – | 0.0 | 0.0 |
|
||||
| Silhouettes | clean | 0.34 | 14.15 | 0.274 | 0.66 | 0.66 | 1.2 | 12 | 1.36 | 17 | 100 | 0.0 | – | – | – | – | 0.0 | 0.0 |
|
||||
| Silhouettes | aliased | 0.42 | 23.04 | 0.353 | 1.03 | 1.03 | 1.2 | 9 | 1.05 | 9 | 100 | 0.0 | – | – | – | – | 0.0 | 0.0 |
|
||||
| Silhouettes | blurred | 1.23 | 28.51 | 0.803 | 1.42 | 1.84 | 1.2 | 17 | 1.91 | 13 | 50 | 0.0 | – | – | – | – | 0.0 | 0.0 |
|
||||
| Silhouettes | noisy | 2.10 | 10.67 | 0.274 | 0.64 | 0.64 | 1.2 | 12 | 1.34 | 7 | 100 | 0.0 | – | – | – | – | 0.0 | 0.0 |
|
||||
| Silhouettes | scanned | 16.05 | 30.27 | 1.472 | 2.24 | 2.24 | 1.2 | 23 | 3.15 | 3 | 0 | 1.0 | – | – | – | – | 0.0 | 0.0 |
|
||||
| Line Art | clean | 0.54 | 4.38 | 0.049 | 0.47 | 105.71 | 3.3 | 33 | 1.67 | 42 | – | – | – | – | – | – | 0.0 | 0.0 |
|
||||
| Line Art | aliased | 0.66 | 14.39 | 0.110 | 0.74 | 55.44 | 14.0 | 60 | 3.00 | 87 | – | – | – | – | – | – | 0.0 | 0.0 |
|
||||
| Line Art | blurred | 1.63 | 17.25 | 0.137 | 0.89 | 105.61 | 3.0 | 139 | 6.95 | 3 | – | – | – | – | – | – | 0.0 | 0.0 |
|
||||
| Line Art | noisy | 1.03 | 4.27 | 0.043 | 0.48 | 56.06 | 8.0 | 56 | 2.80 | 41 | – | – | – | – | – | – | 1.0 | 0.0 |
|
||||
| Line Art | scanned | 13.60 | 20.04 | 0.212 | 0.84 | 105.58 | 3.0 | 186 | 9.30 | 0 | – | – | – | – | – | – | 0.0 | 0.0 |
|
||||
| Technical Drawing | clean | 0.13 | 2.34 | 0.041 | 0.50 | 55.75 | 5.1 | 32 | 2.02 | 86 | 90 | 0.0 | – | – | – | – | 0.1 | 0.0 |
|
||||
| Technical Drawing | aliased | 0.43 | 18.55 | 0.206 | 0.99 | 55.44 | 6.8 | 34 | 2.08 | 95 | 97 | 0.0 | – | – | – | – | 0.0 | 0.0 |
|
||||
| Technical Drawing | blurred | 1.05 | 16.72 | 0.088 | 0.95 | 105.75 | 2.0 | 97 | 6.62 | 28 | 25 | 2.7 | – | – | – | – | 0.0 | 0.0 |
|
||||
| Technical Drawing | noisy | 1.39 | 3.88 | 0.042 | 0.50 | 31.23 | 7.8 | 46 | 2.76 | 81 | 90 | 0.0 | – | – | – | – | 1.8 | 0.0 |
|
||||
| Technical Drawing | scanned | 14.45 | 20.15 | 0.145 | 0.92 | 105.85 | 2.0 | 126 | 8.57 | 22 | 36 | 2.3 | – | – | – | – | 0.0 | 0.0 |
|
||||
| Default | clean | 0.07 | 2.92 | 0.051 | 0.64 | 1.24 | 1.9 | 15 | 1.48 | 46 | 89 | 0.0 | – | – | 0.0 | 0.0 | 0.0 | 0.0 |
|
||||
| Default | aliased | 0.37 | 20.23 | 0.233 | 1.02 | 1.35 | 1.9 | 15 | 1.45 | 21 | 96 | 0.0 | – | – | 0.0 | 0.0 | 0.0 | 0.0 |
|
||||
| Default | blurred | 0.82 | 16.64 | 0.078 | 0.97 | 2.17 | 1.9 | 25 | 2.42 | 5 | 21 | 1.4 | – | – | 0.0 | 0.0 | 0.0 | 0.0 |
|
||||
| Default | noisy | 1.74 | 4.50 | 0.050 | 0.54 | 1.24 | 1.9 | 16 | 1.64 | 39 | 67 | 0.0 | – | – | 2.3 | 0.6 | 0.0 | 0.0 |
|
||||
| Default | scanned | 15.06 | 20.19 | 0.132 | 0.86 | 2.17 | 1.9 | 25 | 2.50 | 0 | 25 | 1.1 | – | – | 3.6 | 2.9 | 0.0 | 0.0 |
|
||||
| High Fidelity Photo | clean | 1.80 | 2.04 | – | – | – | 134.7 | 11321 | – | 39 | – | – | – | – | 0.0 | – | 7.3 | 0.0 |
|
||||
| High Fidelity Photo | aliased | 1.77 | 2.00 | – | – | – | 73.0 | 8004 | – | 40 | – | – | – | – | 0.0 | – | 5.5 | 0.0 |
|
||||
| High Fidelity Photo | blurred | 1.59 | 1.95 | – | – | – | 72.0 | 7388 | – | 40 | – | – | – | – | 0.0 | – | 0.5 | 0.0 |
|
||||
| High Fidelity Photo | noisy | 2.02 | 2.18 | – | – | – | 155.5 | 10856 | – | 33 | – | – | – | – | 0.0 | – | 18.5 | 0.0 |
|
||||
| High Fidelity Photo | scanned | 1.45 | 1.55 | – | – | – | 140.5 | 9795 | – | 36 | – | – | – | – | 0.0 | – | 25.5 | 0.0 |
|
||||
| Low Fidelity Photo | clean | 2.53 | 2.80 | – | – | – | 42.7 | 2282 | – | 41 | – | – | – | – | 0.0 | – | 0.0 | 0.0 |
|
||||
| Low Fidelity Photo | aliased | 2.53 | 2.78 | – | – | – | 36.5 | 1498 | – | 44 | – | – | – | – | 0.0 | – | 0.0 | 0.0 |
|
||||
| Low Fidelity Photo | blurred | 2.35 | 2.75 | – | – | – | 29.0 | 2005 | – | 42 | – | – | – | – | 0.0 | – | 0.0 | 0.0 |
|
||||
| Low Fidelity Photo | noisy | 2.70 | 2.85 | – | – | – | 32.0 | 1926 | – | 41 | – | – | – | – | 0.0 | – | 0.0 | 0.0 |
|
||||
| Low Fidelity Photo | scanned | 1.95 | 2.04 | – | – | – | 34.5 | 2548 | – | 39 | – | – | – | – | 0.0 | – | 0.0 | 0.0 |
|
||||
| 3 Colors | clean | 0.06 | 2.00 | 0.070 | 0.31 | 0.30 | 2.0 | 11 | 1.78 | 39 | 100 | 0.0 | 0.0 | 0 | 0.0 | 0.0 | 0.0 | 0.0 |
|
||||
| 3 Colors | aliased | 0.13 | 8.77 | 0.242 | 0.91 | 0.91 | 2.0 | 12 | 2.00 | 39 | 100 | 0.0 | 0.0 | 0 | 0.0 | 0.0 | 0.0 | 0.0 |
|
||||
| 3 Colors | blurred | 0.34 | 7.83 | 0.073 | 0.24 | 0.24 | 2.0 | 9 | 1.50 | 39 | 100 | 0.0 | 0.0 | 0 | 0.0 | 0.0 | 0.0 | 0.0 |
|
||||
| 3 Colors | noisy | 0.71 | 2.03 | 0.114 | 0.32 | 0.32 | 2.0 | 11 | 1.83 | 39 | 100 | 0.0 | 0.0 | 0 | 0.0 | 0.0 | 0.0 | 0.0 |
|
||||
| 3 Colors | scanned | 1.57 | 3.22 | 0.089 | 0.30 | 0.30 | 2.0 | 12 | 2.00 | 39 | 100 | 0.0 | 0.0 | 0 | 0.0 | 0.0 | 0.0 | 0.0 |
|
||||
| 6 Colors | clean | 0.12 | 2.70 | 0.057 | 0.31 | 0.30 | 3.0 | 28 | 2.00 | 43 | 100 | 0.0 | 0.0 | 0 | 0.0 | 0.0 | 0.0 | 0.0 |
|
||||
| 6 Colors | aliased | 0.29 | 10.43 | 0.298 | 1.00 | 1.00 | 3.0 | 27 | 2.05 | 43 | 100 | 0.0 | 0.0 | 0 | 0.0 | 0.0 | 0.0 | 0.0 |
|
||||
| 6 Colors | blurred | 0.62 | 9.33 | 0.204 | 1.34 | 1.19 | 6.0 | 98 | 5.46 | 39 | 50 | 16.0 | 0.0 | 0 | 0.6 | 0.0 | 0.0 | 0.0 |
|
||||
| 6 Colors | noisy | 0.85 | 2.58 | 0.076 | 0.52 | 0.58 | 3.0 | 34 | 2.34 | 28 | 88 | 0.0 | 0.0 | 0 | 0.3 | 0.3 | 0.0 | 0.0 |
|
||||
| 6 Colors | scanned | 1.74 | 4.42 | 0.228 | 1.20 | 0.94 | 7.0 | 108 | 6.10 | 28 | 62 | 20.5 | 0.0 | 0 | 0.8 | 1.6 | 0.0 | 0.0 |
|
||||
| 16 Colors | clean | 0.12 | 2.52 | 0.052 | 0.31 | 0.30 | 3.0 | 28 | 2.11 | 43 | 100 | 0.0 | 0.0 | 0 | 0.0 | 0.0 | 0.0 | 0.0 |
|
||||
| 16 Colors | aliased | 0.29 | 10.38 | 0.290 | 1.00 | 1.00 | 3.0 | 31 | 2.25 | 43 | 100 | 0.0 | 0.0 | 0 | 0.0 | 0.0 | 0.0 | 0.0 |
|
||||
| 16 Colors | blurred | 0.63 | 9.38 | 0.215 | 1.31 | 1.19 | 7.0 | 132 | 7.21 | 42 | 50 | 21.0 | 0.0 | 0 | 0.0 | 0.0 | 0.0 | 0.0 |
|
||||
| 16 Colors | noisy | 0.85 | 2.56 | 0.075 | 0.52 | 0.58 | 3.0 | 35 | 2.45 | 27 | 88 | 0.0 | 0.0 | 0 | 0.2 | 0.2 | 0.0 | 0.0 |
|
||||
| 16 Colors | scanned | 1.72 | 4.43 | 0.256 | 1.09 | 0.96 | 7.5 | 151 | 8.31 | 33 | 62 | 24.0 | 0.0 | 0 | 1.2 | 1.2 | 0.0 | 0.0 |
|
||||
| Shades of Gray | clean | 9.71 | 9.55 | – | – | – | 18.2 | 748 | – | 51 | – | – | – | – | 0.0 | – | 0.5 | 0.0 |
|
||||
| Shades of Gray | aliased | 9.71 | 9.52 | – | – | – | 11.0 | 642 | – | 57 | – | – | – | – | 0.0 | – | 0.5 | 0.0 |
|
||||
| Shades of Gray | blurred | 9.66 | 9.32 | – | – | – | 11.5 | 577 | – | 55 | – | – | – | – | 0.0 | – | 0.5 | 0.0 |
|
||||
| Shades of Gray | noisy | 9.74 | 9.45 | – | – | – | 19.5 | 643 | – | 45 | – | – | – | – | 0.0 | – | 0.0 | 0.0 |
|
||||
| Shades of Gray | scanned | 6.58 | 6.46 | – | – | – | 19.0 | 762 | – | 47 | – | – | – | – | 0.0 | – | 1.0 | 0.0 |
|
||||
| Black and White Logo | clean | 0.04 | 1.64 | 0.027 | 0.50 | 1.24 | 1.4 | 19 | 1.90 | 50 | 89 | 0.0 | – | – | – | – | 0.0 | 0.0 |
|
||||
| Black and White Logo | aliased | 0.39 | 20.35 | 0.172 | 1.02 | 1.35 | 1.4 | 22 | 2.32 | 53 | 96 | 0.0 | – | – | – | – | 0.0 | 0.0 |
|
||||
| Black and White Logo | blurred | 0.80 | 16.29 | 0.045 | 0.82 | 2.15 | 1.4 | 38 | 3.62 | 21 | 21 | 0.9 | – | – | – | – | 0.0 | 0.0 |
|
||||
| Black and White Logo | noisy | 1.72 | 3.90 | 0.028 | 0.51 | 1.24 | 1.4 | 22 | 2.18 | 40 | 67 | 0.0 | – | – | – | – | 0.0 | 0.0 |
|
||||
| Black and White Logo | scanned | 15.05 | 20.10 | 0.115 | 0.86 | 2.17 | 1.4 | 51 | 5.02 | 11 | 25 | 0.9 | – | – | – | – | 0.0 | 0.0 |
|
||||
| Sketched Art | clean | 0.19 | 5.64 | 0.111 | 0.80 | 52.93 | 1.9 | 18 | 1.49 | 19 | 100 | 0.0 | – | – | – | – | 0.0 | 0.0 |
|
||||
| Sketched Art | aliased | 0.46 | 19.57 | 0.213 | 1.00 | 105.53 | 1.6 | 11 | 1.10 | 26 | 100 | 0.0 | – | – | – | – | 0.0 | 0.0 |
|
||||
| Sketched Art | blurred | 1.07 | 19.72 | 0.274 | 0.70 | 105.49 | 1.6 | 17 | 1.53 | 10 | 50 | 0.0 | – | – | – | – | 0.0 | 0.0 |
|
||||
| Sketched Art | noisy | 1.76 | 6.16 | 0.119 | 0.95 | 1.03 | 2.0 | 19 | 1.58 | 12 | 100 | 0.0 | – | – | – | – | 0.0 | 0.0 |
|
||||
| Sketched Art | scanned | 15.12 | 22.50 | 0.442 | 0.86 | 105.27 | 1.6 | 20 | 1.65 | 2 | 50 | 0.0 | – | – | – | – | 0.0 | 0.0 |
|
||||
| Silhouettes | clean | 0.36 | 14.54 | 0.282 | 0.71 | 0.71 | 1.2 | 12 | 1.34 | 15 | 100 | 0.0 | – | – | – | – | 0.0 | 0.0 |
|
||||
| Silhouettes | aliased | 0.39 | 21.69 | 0.289 | 1.00 | 1.00 | 1.2 | 9 | 0.99 | 12 | 100 | 0.0 | – | – | – | – | 0.0 | 0.0 |
|
||||
| Silhouettes | blurred | 1.21 | 28.11 | 0.785 | 1.45 | 1.92 | 1.2 | 15 | 1.82 | 11 | 25 | 0.0 | – | – | – | – | 0.0 | 0.0 |
|
||||
| Silhouettes | noisy | 2.11 | 10.96 | 0.284 | 0.69 | 0.69 | 1.2 | 11 | 1.27 | 7 | 100 | 0.0 | – | – | – | – | 0.0 | 0.0 |
|
||||
| Silhouettes | scanned | 16.05 | 30.19 | 1.458 | 2.23 | 2.21 | 1.2 | 25 | 3.37 | 3 | 0 | 1.2 | – | – | – | – | 0.0 | 0.0 |
|
||||
| Line Art | clean | 0.54 | 4.39 | 0.049 | 0.47 | 105.75 | 3.3 | 35 | 1.77 | 41 | – | – | – | – | – | – | 0.0 | 0.0 |
|
||||
| Line Art | aliased | 0.67 | 14.44 | 0.112 | 0.73 | 55.44 | 14.0 | 59 | 2.95 | 87 | – | – | – | – | – | – | 0.0 | 0.0 |
|
||||
| Line Art | blurred | 1.63 | 17.15 | 0.136 | 0.89 | 105.57 | 3.0 | 143 | 7.15 | 4 | – | – | – | – | – | – | 0.0 | 0.0 |
|
||||
| Line Art | noisy | 1.04 | 4.35 | 0.045 | 0.48 | 56.06 | 8.0 | 53 | 2.65 | 38 | – | – | – | – | – | – | 1.0 | 0.0 |
|
||||
| Line Art | scanned | 13.60 | 20.06 | 0.214 | 0.91 | 105.64 | 3.0 | 188 | 9.40 | 1 | – | – | – | – | – | – | 0.0 | 0.0 |
|
||||
| Technical Drawing | clean | 0.17 | 2.83 | 0.054 | 0.50 | 55.75 | 5.1 | 27 | 1.79 | 90 | 91 | 0.0 | – | – | – | – | 2.1 | 0.0 |
|
||||
| Technical Drawing | aliased | 0.43 | 18.51 | 0.204 | 1.02 | 55.44 | 6.8 | 33 | 2.06 | 95 | 94 | 0.0 | – | – | – | – | 0.0 | 0.0 |
|
||||
| Technical Drawing | blurred | 1.04 | 16.59 | 0.082 | 1.06 | 105.75 | 2.0 | 114 | 7.50 | 24 | 28 | 2.3 | – | – | – | – | 0.0 | 0.0 |
|
||||
| Technical Drawing | noisy | 1.42 | 4.30 | 0.055 | 0.50 | 31.23 | 7.8 | 38 | 2.40 | 85 | 90 | 0.0 | – | – | – | – | 2.8 | 0.0 |
|
||||
| Technical Drawing | scanned | 14.45 | 20.15 | 0.144 | 0.91 | 105.74 | 2.0 | 123 | 8.33 | 23 | 34 | 2.3 | – | – | – | – | 0.0 | 0.0 |
|
||||
| Flat Logo | clean | 0.07 | 1.53 | 0.065 | 0.31 | 0.27 | 3.0 | 36 | 3.51 | 47 | 100 | 0.0 | 0.0 | 0 | 6.8 | – | 0.0 | 0.0 |
|
||||
| Flat Logo | aliased | 0.13 | 5.87 | 0.218 | 0.65 | 0.65 | 3.0 | 31 | 1.96 | 44 | 50 | 0.0 | 0.0 | 0 | 5.8 | – | 0.0 | 0.0 |
|
||||
| Flat Logo | blurred | 0.54 | 8.33 | 0.052 | 0.62 | 0.78 | 3.0 | 28 | 1.78 | 43 | 50 | 0.0 | 0.0 | 0 | 5.8 | – | 0.0 | 0.0 |
|
||||
@@ -282,89 +282,90 @@ One trace at a time on this machine (all cores); memory is the peak allocated ab
|
||||
|
||||
| preset | image | megapixels | result |
|
||||
|:--|:--|--:|:--|
|
||||
| Default | photo-like | 1 | 0.05 s, 29 paths, 7947 anchors, 15 MB |
|
||||
| Default | four-colour badge | 1 | 0.03 s, 3 paths, 106 anchors, 15 MB |
|
||||
| High Fidelity Photo | photo-like | 1 | 0.25 s, 968 paths, 113205 anchors, 238 MB |
|
||||
| High Fidelity Photo | four-colour badge | 1 | 0.06 s, 6 paths, 192 anchors, 34 MB |
|
||||
| Low Fidelity Photo | photo-like | 1 | 0.13 s, 129 paths, 22788 anchors, 141 MB |
|
||||
| Low Fidelity Photo | four-colour badge | 1 | 0.05 s, 6 paths, 144 anchors, 36 MB |
|
||||
| 3 Colors | photo-like | 1 | 0.07 s, 9 paths, 3391 anchors, 26 MB |
|
||||
| 3 Colors | four-colour badge | 1 | 0.05 s, 4 paths, 79 anchors, 21 MB |
|
||||
| 6 Colors | photo-like | 1 | 0.08 s, 78 paths, 9239 anchors, 42 MB |
|
||||
| 6 Colors | four-colour badge | 1 | 0.05 s, 5 paths, 89 anchors, 33 MB |
|
||||
| 16 Colors | photo-like | 1 | 0.11 s, 178 paths, 21069 anchors, 102 MB |
|
||||
| 16 Colors | four-colour badge | 1 | 0.05 s, 5 paths, 92 anchors, 33 MB |
|
||||
| Shades of Gray | photo-like | 1 | 0.09 s, 82 paths, 8511 anchors, 65 MB |
|
||||
| Shades of Gray | four-colour badge | 1 | 0.06 s, 10 paths, 780 anchors, 56 MB |
|
||||
| Black and White Logo | photo-like | 1 | 0.05 s, 71 paths, 9067 anchors, 9 MB |
|
||||
| Black and White Logo | four-colour badge | 1 | 0.02 s, 1 paths, 58 anchors, 8 MB |
|
||||
| Sketched Art | photo-like | 1 | 0.05 s, 5 paths, 3207 anchors, 9 MB |
|
||||
| Sketched Art | four-colour badge | 1 | 0.02 s, 2 paths, 145 anchors, 8 MB |
|
||||
| Silhouettes | photo-like | 1 | 0.04 s, 2 paths, 851 anchors, 11 MB |
|
||||
| Silhouettes | four-colour badge | 1 | 0.02 s, 1 paths, 29 anchors, 9 MB |
|
||||
| Line Art | photo-like | 1 | 0.05 s, 54 paths, 4751 anchors, 9 MB |
|
||||
| Line Art | four-colour badge | 1 | 0.02 s, 1 paths, 243 anchors, 8 MB |
|
||||
| Technical Drawing | photo-like | 1 | 0.06 s, 221 paths, 10646 anchors, 9 MB |
|
||||
| Technical Drawing | four-colour badge | 1 | 0.02 s, 1 paths, 58 anchors, 8 MB |
|
||||
| Default | photo-like | 1 | 0.04 s, 29 paths, 6888 anchors, 15 MB |
|
||||
| Default | four-colour badge | 1 | 0.02 s, 3 paths, 96 anchors, 15 MB |
|
||||
| High Fidelity Photo | photo-like | 1 | 0.21 s, 968 paths, 114383 anchors, 238 MB |
|
||||
| High Fidelity Photo | four-colour badge | 1 | 0.06 s, 6 paths, 172 anchors, 36 MB |
|
||||
| Low Fidelity Photo | photo-like | 1 | 0.11 s, 129 paths, 22929 anchors, 144 MB |
|
||||
| Low Fidelity Photo | four-colour badge | 1 | 0.05 s, 6 paths, 136 anchors, 36 MB |
|
||||
| 3 Colors | photo-like | 1 | 0.08 s, 9 paths, 3367 anchors, 26 MB |
|
||||
| 3 Colors | four-colour badge | 1 | 0.05 s, 4 paths, 88 anchors, 21 MB |
|
||||
| 6 Colors | photo-like | 1 | 0.08 s, 78 paths, 9008 anchors, 42 MB |
|
||||
| 6 Colors | four-colour badge | 1 | 0.05 s, 5 paths, 84 anchors, 33 MB |
|
||||
| 16 Colors | photo-like | 1 | 0.10 s, 178 paths, 20466 anchors, 102 MB |
|
||||
| 16 Colors | four-colour badge | 1 | 0.05 s, 5 paths, 88 anchors, 33 MB |
|
||||
| Shades of Gray | photo-like | 1 | 0.08 s, 81 paths, 6698 anchors, 65 MB |
|
||||
| Shades of Gray | four-colour badge | 1 | 0.06 s, 10 paths, 733 anchors, 56 MB |
|
||||
| Black and White Logo | photo-like | 1 | 0.04 s, 71 paths, 5465 anchors, 9 MB |
|
||||
| Black and White Logo | four-colour badge | 1 | 0.02 s, 1 paths, 52 anchors, 8 MB |
|
||||
| Sketched Art | photo-like | 1 | 0.04 s, 5 paths, 2971 anchors, 9 MB |
|
||||
| Sketched Art | four-colour badge | 1 | 0.02 s, 2 paths, 131 anchors, 8 MB |
|
||||
| Silhouettes | photo-like | 1 | 0.04 s, 2 paths, 791 anchors, 11 MB |
|
||||
| Silhouettes | four-colour badge | 1 | 0.02 s, 1 paths, 25 anchors, 9 MB |
|
||||
| Line Art | photo-like | 1 | 0.04 s, 54 paths, 4421 anchors, 9 MB |
|
||||
| Line Art | four-colour badge | 1 | 0.02 s, 1 paths, 232 anchors, 8 MB |
|
||||
| Technical Drawing | photo-like | 1 | 0.04 s, 221 paths, 7680 anchors, 9 MB |
|
||||
| Technical Drawing | four-colour badge | 1 | 0.02 s, 1 paths, 52 anchors, 8 MB |
|
||||
| Flat Logo | photo-like | 1 | refused after 0.09 s (not flat art), 40 MB |
|
||||
| Flat Logo | four-colour badge | 1 | 0.64 s, 5 paths, 78 anchors, 146 MB |
|
||||
| Default | photo-like | 4 | 0.19 s, 48 paths, 20717 anchors, 60 MB |
|
||||
| Default | four-colour badge | 4 | 0.10 s, 3 paths, 148 anchors, 64 MB |
|
||||
| High Fidelity Photo | photo-like | 4 | 0.88 s, 4475 paths, 344985 anchors, 964 MB |
|
||||
| High Fidelity Photo | four-colour badge | 4 | 0.22 s, 6 paths, 216 anchors, 152 MB |
|
||||
| Low Fidelity Photo | photo-like | 4 | 0.38 s, 304 paths, 61608 anchors, 592 MB |
|
||||
| Low Fidelity Photo | four-colour badge | 4 | 0.23 s, 6 paths, 169 anchors, 152 MB |
|
||||
| 3 Colors | photo-like | 4 | 0.28 s, 20 paths, 8647 anchors, 104 MB |
|
||||
| Default | photo-like | 4 | 0.18 s, 48 paths, 17801 anchors, 60 MB |
|
||||
| Default | four-colour badge | 4 | 0.09 s, 3 paths, 178 anchors, 64 MB |
|
||||
| High Fidelity Photo | photo-like | 4 | 0.72 s, 4475 paths, 341272 anchors, 981 MB |
|
||||
| High Fidelity Photo | four-colour badge | 4 | 0.24 s, 6 paths, 200 anchors, 152 MB |
|
||||
| Low Fidelity Photo | photo-like | 4 | 0.38 s, 304 paths, 61093 anchors, 568 MB |
|
||||
| Low Fidelity Photo | four-colour badge | 4 | 0.23 s, 6 paths, 152 anchors, 152 MB |
|
||||
| 3 Colors | photo-like | 4 | 0.28 s, 20 paths, 8453 anchors, 104 MB |
|
||||
| 3 Colors | four-colour badge | 4 | 0.18 s, 4 paths, 122 anchors, 84 MB |
|
||||
| 6 Colors | photo-like | 4 | 0.27 s, 115 paths, 24075 anchors, 165 MB |
|
||||
| 6 Colors | four-colour badge | 4 | 0.19 s, 5 paths, 116 anchors, 128 MB |
|
||||
| 16 Colors | photo-like | 4 | 0.33 s, 353 paths, 54215 anchors, 380 MB |
|
||||
| 16 Colors | four-colour badge | 4 | 0.18 s, 5 paths, 124 anchors, 128 MB |
|
||||
| Shades of Gray | photo-like | 4 | 0.29 s, 137 paths, 22704 anchors, 232 MB |
|
||||
| Shades of Gray | four-colour badge | 4 | 0.27 s, 10 paths, 1234 anchors, 220 MB |
|
||||
| Black and White Logo | photo-like | 4 | 0.21 s, 208 paths, 25916 anchors, 36 MB |
|
||||
| Black and White Logo | four-colour badge | 4 | 0.08 s, 1 paths, 64 anchors, 36 MB |
|
||||
| Sketched Art | photo-like | 4 | 0.21 s, 9 paths, 6875 anchors, 36 MB |
|
||||
| Sketched Art | four-colour badge | 4 | 0.08 s, 2 paths, 279 anchors, 36 MB |
|
||||
| Silhouettes | photo-like | 4 | 0.18 s, 2 paths, 2133 anchors, 44 MB |
|
||||
| 6 Colors | photo-like | 4 | 0.37 s, 115 paths, 23128 anchors, 168 MB |
|
||||
| 6 Colors | four-colour badge | 4 | 0.19 s, 5 paths, 92 anchors, 128 MB |
|
||||
| 16 Colors | photo-like | 4 | 0.32 s, 353 paths, 52749 anchors, 408 MB |
|
||||
| 16 Colors | four-colour badge | 4 | 0.19 s, 5 paths, 96 anchors, 128 MB |
|
||||
| Shades of Gray | photo-like | 4 | 0.29 s, 138 paths, 18794 anchors, 224 MB |
|
||||
| Shades of Gray | four-colour badge | 4 | 0.27 s, 10 paths, 1265 anchors, 220 MB |
|
||||
| Black and White Logo | photo-like | 4 | 0.20 s, 208 paths, 17188 anchors, 36 MB |
|
||||
| Black and White Logo | four-colour badge | 4 | 0.09 s, 1 paths, 64 anchors, 36 MB |
|
||||
| Sketched Art | photo-like | 4 | 0.20 s, 9 paths, 6884 anchors, 36 MB |
|
||||
| Sketched Art | four-colour badge | 4 | 0.09 s, 2 paths, 236 anchors, 36 MB |
|
||||
| Silhouettes | photo-like | 4 | 0.19 s, 2 paths, 1951 anchors, 44 MB |
|
||||
| Silhouettes | four-colour badge | 4 | 0.10 s, 1 paths, 32 anchors, 36 MB |
|
||||
| Line Art | photo-like | 4 | 0.21 s, 127 paths, 12595 anchors, 36 MB |
|
||||
| Line Art | four-colour badge | 4 | 0.08 s, 1 paths, 465 anchors, 36 MB |
|
||||
| Technical Drawing | photo-like | 4 | 0.22 s, 931 paths, 33946 anchors, 36 MB |
|
||||
| Line Art | photo-like | 4 | 0.19 s, 127 paths, 12091 anchors, 36 MB |
|
||||
| Line Art | four-colour badge | 4 | 0.09 s, 1 paths, 419 anchors, 36 MB |
|
||||
| Technical Drawing | photo-like | 4 | 0.20 s, 931 paths, 23132 anchors, 36 MB |
|
||||
| Technical Drawing | four-colour badge | 4 | 0.08 s, 1 paths, 64 anchors, 36 MB |
|
||||
| Flat Logo | photo-like | 4 | refused after 0.10 s (not flat art), 40 MB |
|
||||
| Flat Logo | four-colour badge | 4 | 0.66 s, 5 paths, 69 anchors, 146 MB |
|
||||
| Default | photo-like | 16 | 1.08 s, 127 paths, 46199 anchors, 256 MB |
|
||||
| Default | four-colour badge | 16 | 0.46 s, 3 paths, 284 anchors, 240 MB |
|
||||
| High Fidelity Photo | photo-like | 16 | 3.48 s, 31131 paths, 1254672 anchors, 3954 MB |
|
||||
| High Fidelity Photo | four-colour badge | 16 | 1.07 s, 6 paths, 248 anchors, 576 MB |
|
||||
| Low Fidelity Photo | photo-like | 16 | 1.57 s, 2036 paths, 185091 anchors, 2050 MB |
|
||||
| Low Fidelity Photo | four-colour badge | 16 | 1.08 s, 6 paths, 188 anchors, 576 MB |
|
||||
| 3 Colors | photo-like | 16 | 1.27 s, 76 paths, 23365 anchors, 416 MB |
|
||||
| 3 Colors | four-colour badge | 16 | 0.80 s, 4 paths, 179 anchors, 336 MB |
|
||||
| 6 Colors | photo-like | 16 | 1.17 s, 470 paths, 63030 anchors, 673 MB |
|
||||
| 6 Colors | four-colour badge | 16 | 0.84 s, 5 paths, 118 anchors, 512 MB |
|
||||
| 16 Colors | photo-like | 16 | 1.30 s, 1973 paths, 149717 anchors, 1633 MB |
|
||||
| 16 Colors | four-colour badge | 16 | 0.84 s, 5 paths, 120 anchors, 504 MB |
|
||||
| Shades of Gray | photo-like | 16 | 1.32 s, 688 paths, 65052 anchors, 850 MB |
|
||||
| Shades of Gray | four-colour badge | 16 | 1.28 s, 9 paths, 2296 anchors, 800 MB |
|
||||
| Black and White Logo | photo-like | 16 | 1.04 s, 1128 paths, 77629 anchors, 144 MB |
|
||||
| Black and White Logo | four-colour badge | 16 | 0.45 s, 1 paths, 103 anchors, 128 MB |
|
||||
| Sketched Art | photo-like | 16 | 1.15 s, 15 paths, 17817 anchors, 144 MB |
|
||||
| Sketched Art | four-colour badge | 16 | 0.48 s, 2 paths, 478 anchors, 128 MB |
|
||||
| Silhouettes | photo-like | 16 | 1.40 s, 3 paths, 4891 anchors, 176 MB |
|
||||
| Silhouettes | four-colour badge | 16 | 0.55 s, 1 paths, 44 anchors, 144 MB |
|
||||
| Line Art | photo-like | 16 | 1.21 s, 713 paths, 64481 anchors, 144 MB |
|
||||
| Line Art | four-colour badge | 16 | 0.45 s, 1 paths, 786 anchors, 128 MB |
|
||||
| Technical Drawing | photo-like | 16 | 1.20 s, 4578 paths, 114292 anchors, 144 MB |
|
||||
| Technical Drawing | four-colour badge | 16 | 0.44 s, 1 paths, 103 anchors, 128 MB |
|
||||
| Flat Logo | four-colour badge | 4 | 0.65 s, 5 paths, 69 anchors, 146 MB |
|
||||
| Default | photo-like | 16 | 1.04 s, 127 paths, 43656 anchors, 256 MB |
|
||||
| Default | four-colour badge | 16 | 0.47 s, 3 paths, 250 anchors, 240 MB |
|
||||
| High Fidelity Photo | photo-like | 16 | 2.85 s, 31125 paths, 1175671 anchors, 3856 MB |
|
||||
| High Fidelity Photo | four-colour badge | 16 | 1.11 s, 6 paths, 274 anchors, 576 MB |
|
||||
| Low Fidelity Photo | photo-like | 16 | 1.49 s, 2036 paths, 181832 anchors, 1972 MB |
|
||||
| Low Fidelity Photo | four-colour badge | 16 | 1.07 s, 6 paths, 187 anchors, 576 MB |
|
||||
| 3 Colors | photo-like | 16 | 1.22 s, 76 paths, 22878 anchors, 416 MB |
|
||||
| 3 Colors | four-colour badge | 16 | 0.82 s, 4 paths, 190 anchors, 336 MB |
|
||||
| 6 Colors | photo-like | 16 | 1.13 s, 470 paths, 60539 anchors, 672 MB |
|
||||
| 6 Colors | four-colour badge | 16 | 0.82 s, 5 paths, 126 anchors, 500 MB |
|
||||
| 16 Colors | photo-like | 16 | 1.18 s, 1973 paths, 145173 anchors, 1625 MB |
|
||||
| 16 Colors | four-colour badge | 16 | 0.84 s, 5 paths, 128 anchors, 504 MB |
|
||||
| Shades of Gray | photo-like | 16 | 1.48 s, 686 paths, 53872 anchors, 852 MB |
|
||||
| Shades of Gray | four-colour badge | 16 | 1.40 s, 9 paths, 2216 anchors, 800 MB |
|
||||
| Black and White Logo | photo-like | 16 | 1.15 s, 1128 paths, 52547 anchors, 144 MB |
|
||||
| Black and White Logo | four-colour badge | 16 | 0.47 s, 1 paths, 95 anchors, 128 MB |
|
||||
| Sketched Art | photo-like | 16 | 1.16 s, 15 paths, 16298 anchors, 144 MB |
|
||||
| Sketched Art | four-colour badge | 16 | 0.47 s, 2 paths, 414 anchors, 128 MB |
|
||||
| Silhouettes | photo-like | 16 | 1.34 s, 3 paths, 4516 anchors, 176 MB |
|
||||
| Silhouettes | four-colour badge | 16 | 0.53 s, 1 paths, 40 anchors, 144 MB |
|
||||
| Line Art | photo-like | 16 | 1.04 s, 713 paths, 43982 anchors, 144 MB |
|
||||
| Line Art | four-colour badge | 16 | 0.45 s, 1 paths, 707 anchors, 128 MB |
|
||||
| Technical Drawing | photo-like | 16 | 1.21 s, 4578 paths, 84475 anchors, 144 MB |
|
||||
| Technical Drawing | four-colour badge | 16 | 0.46 s, 1 paths, 95 anchors, 128 MB |
|
||||
| Flat Logo | photo-like | 16 | refused after 0.11 s (not flat art), 40 MB |
|
||||
| Flat Logo | four-colour badge | 16 | 0.66 s, 5 paths, 67 anchors, 146 MB |
|
||||
| Flat Logo | four-colour badge | 16 | 0.69 s, 5 paths, 67 anchors, 146 MB |
|
||||
|
||||
## Revision history
|
||||
|
||||
| date | change |
|
||||
|:--|:--|
|
||||
| 2026-10-13 | Shared edges: outlines are cut at their junctions and each piece is fitted once, the same from either side |
|
||||
| 2026-10-13 | Photos: continuous-tone images are smoothed within their tones before quantising, and stacked layers go by lightness |
|
||||
| 2026-10-13 | The palette: made of the picture's own colours, noise-made colours merged, blend pixels settled, cuts where colours differ most, colours compared in Oklab |
|
||||
| 2026-10-13 | Sub-pixel edges and the outline fitter in Black and White, Grayscale and Color; the harness gains an aliased variant and an "outside px" column, so the baseline was measured again with it (same tracer, main at `baf9693f`) |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Localization parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (Czech: 128 strings added since the catalog was completed are translated, #1461; scores and estimates unchanged; Korean catalog added, complete under the tests; 12-language score 54% → 63%; Hungarian catalog added; Czech render-site localization follow-up; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (French: 423 strings added since the catalog was measured and 188 entries reworded, #1511; Hungarian: one message in the informal address, #1474; scores and estimates unchanged; Vietnamese catalog added, complete under the tests; 12-language score 63% → 71%; Czech: 128 strings added since the catalog was completed are translated, #1461; scores and estimates unchanged; Korean catalog added, complete under the tests; 12-language score 54% → 63%; Hungarian catalog added; Czech render-site localization follow-up; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x)
|
||||
|
||||
Per-language status of VectorCraft's interface. How the catalogs work and how to add a language:
|
||||
[`development.md` › Localisation](development.md#localisation). Part of
|
||||
@@ -34,10 +34,10 @@ Chinese by `imachus`). None is recorded as reviewed by a native speaker; the cat
|
||||
| Japanese | `ja` | 4,004 / 4,020 (99.6%) | all | CJK UI font as above; Japanese IME; vertical type partial; Japanese crop marks and composition settings | no | full | — (review) |
|
||||
| German | `de` | 3,952 / 4,020 (98%; tests hold menus, literals and messages complete) | all (602 of 604 message rows; landed 2026-10-10) | Latin | contributor-written, not reviewed | full | — (review) |
|
||||
| Korean | `ko` | 4,151 rows: menus, `tl!` literals and messages complete (tests); one plural form | all | Hangul from an installed system font on the desktop (Apple SD Gothic Neo, Malgun Gothic, Noto Sans CJK KR…); the web build has no Korean UI font yet (craft-fonts); IME path exists | no (AI-drafted) | full | — (review; web font) |
|
||||
| Vietnamese | `vi` | 0 | none | Latin with stacked diacritics (precomposed forms render) | no | none | 5–8 |
|
||||
| Vietnamese | `vi` | 4,225 rows: menus, `tl!` literals and messages complete (tests); one plural form; 3,548 plain + 13 `@plural` + 12 contextual + 652 `@msg` | all | Latin with Vietnamese diacritics (precomposed forms; glyph tests pass) | contributor is a Vietnamese speaker; AI-assisted draft, further native review welcome | full | — (review) |
|
||||
|
||||
**12-language score: ~63%** (equal weights: 100 + 85 + 100 + 0 + 0 + 100 + 68 + 0 + 100 + 98 + 100 + 0, over 12).
|
||||
**To `full` for all twelve: ~60–100 h**, most of it Arabic (right-to-left interface) and Hindi (interface
|
||||
**12-language score: ~71%** (equal weights: 100 + 85 + 100 + 0 + 0 + 100 + 68 + 0 + 100 + 98 + 100 + 100, over 12).
|
||||
**To `full` for all twelve: ~55–90 h**, most of it Arabic (right-to-left interface) and Hindi (interface
|
||||
shaping). Each new Latin-script catalog is ~4,000 rows; the existing ones were written in a few agent hours each
|
||||
plus review. Native-speaker review is human work and isn't in the hours.
|
||||
|
||||
@@ -50,9 +50,10 @@ plus review. Native-speaker review is human work and isn't in the hours.
|
||||
| Russian | `ru` | 4,009 / 4,020 | full |
|
||||
| Ukrainian | `uk` | 4,009 / 4,020 (one/few/many plurals) | full |
|
||||
| Hungarian | `hu` | 4,524 rows: menus, `tl!` literals and messages complete (tests); one plural form | full |
|
||||
| Polish | `pl` | 4,492 rows: menus, `tl!` literals and messages complete (tests); one/few/many plurals | full; native review pending |
|
||||
| Czech | `cs` | 4,411 catalog entries, including 636 `@msg` rows (measured locally 2026-10-11); menu, literal and message coverage enforced by tests | catalog complete; render-site gaps reduced; native review pending |
|
||||
|
||||
Thirteen non-English catalogs in all. Illustrator, as its public documentation lists, ships around two dozen
|
||||
Fifteen non-English catalogs in all. Illustrator, as its public documentation lists, ships around two dozen
|
||||
interface languages (including Polish, Dutch, Turkish, the Nordic languages, Hungarian, and Middle East
|
||||
editions with Arabic and Hebrew); it doesn't ship Hindi, Indonesian or Vietnamese.
|
||||
|
||||
@@ -79,8 +80,12 @@ font for the web build.
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-13 | minor | French: the 423 strings added since the catalog was last complete are translated, and 188 entries are reworded to the feature names French users know (#1511); Hungarian: one message uses the informal address like the rest of the interface (#1474); the entry counts in the tables are the earlier measurements; native review still pending; scores and estimates unchanged |
|
||||
| 2026-10-12 | minor | Vietnamese (`vi`) catalog added, complete under the tests (menus, literals, messages; one plural form); 12-language score 63% → 71%, hours 60–100 → 55–90 |
|
||||
| 2026-10-13 | minor | Czech: 128 more strings translated (tool names, PDF and Image Trace presets, pie graph options and others added since the catalog was completed; #1461); the entry count in the table is the 2026-10-11 measurement; native review still pending; scores and estimates unchanged |
|
||||
)
|
||||
| 2026-10-12 | minor | Korean (`ko`) catalog added, complete under the tests (menus, literals, messages); 12-language score 54% → 63%, hours 70–110 → 60–100 |
|
||||
| 2026-10-12 | minor | Polish (`pl`) catalog added, complete under the tests (menus, literals, messages; one/few/many plurals) |
|
||||
| 2026-10-12 | minor | Hungarian (`hu`) catalog added, complete under the tests |
|
||||
| 2026-10-11 | minor | Czech render-site follow-up: file-dialog filters, diagnostics, preset numbering, contributor tooltips/plurals, gradient grammar and contextual printer mark styles; estimates unchanged |
|
||||
| 2026-10-10 | major | First per-language table, measured from the catalog row counts against the complete French catalog |
|
||||
+7
-4
@@ -949,10 +949,11 @@ See [Revolve](revolve.md) for the scope and limitations.
|
||||
the other 3D types to a path or compound path, live like Revolve (the source stays editable; SVG/PDF exports and
|
||||
`effect.expandAppearance` use the same shaded faces). An object takes one 3D effect, Revolve included.
|
||||
|
||||
`threeD.extrude`: `depth` (points, 0..2000, default 50), `twist` (degrees, -360..360, 0), `taper` (1..100%, 100),
|
||||
`threeD.extrude`: `depth` (points, 0..2000, default 50), `twist` (degrees, 0..360, 0), `taper` (1..100%, 100),
|
||||
`cap` (true solid, false hollow), `bevel` (`none`, `classic`, `rounded`, `cove`, `step`, `ogee`), `bevelWidth`
|
||||
and `bevelHeight` (points, default 4), `bevelRepeat` (1..32), `bevelSpace` (0..90%), `bevelInside`,
|
||||
`bevelBothSides`. `threeD.inflate`: `depth` (points, default 0), `volume` (0..100%, default 50), `bothSides`.
|
||||
`bevelBothSides`. `threeD.inflate`: `depth` (points, default 0), `twist` (degrees, 0..360, 0), `taper` (1..100%, 100), `cap` (true
|
||||
solid, false open behind), `volume` (0..100%, default 50), `bothSides`.
|
||||
All three take `rotationX/Y/Z` (degrees, -180..180, default the Off-Axis Front position -21.88/-23.4/9.06),
|
||||
`perspective` and the lighting parameters as Revolve. See [three-d.md](three-d.md) for the Position presets,
|
||||
the scope and what differs from the public documentation.
|
||||
@@ -2114,7 +2115,8 @@ area). Each one has a query command:
|
||||
transparent around them).
|
||||
|
||||
What other apps copied goes into the clipboard with `clipboard.importSvg`, `clipboard.importPdf {dataBase64, page?,
|
||||
password?}` (the page's objects), `clipboard.importImage {dataBase64, mime?}` (an embedded image at 100% of its
|
||||
password?}` (the page's objects), `clipboard.importEps {dataBase64}` (an EPS or other PostScript picture's objects,
|
||||
as `document.open` reads one), `clipboard.importImage {dataBase64, mime?}` (an embedded image at 100% of its
|
||||
physical size) or `clipboard.importText {text}` (point text in the default type style), each centred on `center`
|
||||
(default: the first artboard); then any `edit.paste*` command pastes it. The desktop app does this itself: Copy and
|
||||
Cut publish the formats to the system clipboard, and a Paste outside text editing first reads what another app
|
||||
@@ -2191,7 +2193,8 @@ listed in `warnings`. Binary DXF and DWG files answer an error saying to save th
|
||||
|
||||
`links.list {show?: all|missing|modified|embedded, sort?: name|kind|status}` lists every image in the layers, top
|
||||
first, as the Links panel does: `{id, name, linked, status: ok|modified|missing|embedded, format, pixelWidth,
|
||||
pixelHeight, path?, found?, page?, preview?}`. `links.info {id?}` is the Link Info: `image.info`'s fields plus
|
||||
pixelHeight, path?, found?, page?, preview?, note?}` (`note`: the image is too large to decode and isn't drawn;
|
||||
`document.open` and `file.place` say the same in their `warnings`). `links.info {id?}` is the Link Info: `image.info`'s fields plus
|
||||
`status`, `format`, the file's `ppi` and the `effectivePpi`, `scale` (% of 100%), `rotation` (degrees,
|
||||
counter-clockwise), `placement` and, for a linked file, `fileName`, `location`, `fileSize`, `created`, `modified`.
|
||||
`links.goTo {id}` selects an image; `links.embed {ids?}` keeps linked files' pixels in the document (an image whose
|
||||
|
||||
+2
-1
@@ -1,6 +1,6 @@
|
||||
# Roadmap: milestones and what's next
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (Split Into Grid options, #1462; Transform Objects and Reflect's angle, #1448 and #1460; Pathfinder on a group, #1450; unknown parameter keys refused, #1442; grayscale TIFFs with alpha, #1466 in part; estimates unchanged; M9: Extrude & Bevel, Inflate and Rotate; `.ai` compound path paint, #1410, overprint on import, #1388, text-only PDF parts, #1387; Transform dialogs preview, #1412, and transforms of direct-selected anchors, #1408; strokes wider than their shape, #1396; estimates unchanged; `.ai` editing-data follow-ups, #1390, #1391, #1392; dialog keyboard focus, #1367 and #1395; symbol instance redraw cache, #1386; font rescan, #1376; estimates unchanged; gradient shapes import as paths, #1304; white constant alpha masks, #1286 in part; Live Corners widget click, #1239; custom ruler origin, #1212; M11: Artboard object clicks create a fitted artboard (#1227); estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; desktop print transport adopts craft-print, Windows 7 fallback retained, scores unchanged; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; M6: Simplify preview/counts and valid topology selection (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; M14: RPM runtime metadata (#1275); estimates unchanged; M5: squared-distance texture redraws (#1257); clean-machine/platform drag measurements still pending; estimates unchanged; M2: Curvature point display improved (#1236, in part); estimates unchanged; M7: full-name font matching (#1258); Windows font-service QA pending; estimates unchanged; M1: no-op anchor Align keeps Undo, Redo and saved state (#1180 follow-up); estimates unchanged; M14: dedicated Eyedropper cursor (#1269); estimates unchanged; M3: Eyedropper reads visible locked and outside-isolation sources (#1251); estimates unchanged; M2: Direct Selection segment picks and Delete (#1278); estimates unchanged; M3: adding paint to an unpainted zero-width stroke restores 1 pt (#1253); estimates unchanged; M12: graph designs, graph data, Graph Type and label options; M14: native macOS save Format menu, #866; M6: compound appearance and member fill-rule fixes, #1197; estimates unchanged; M9: PDF raster-effect cap warnings; G11: #893 fixed, #937 open; M7: accurate character controls after scaling, #1034; estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (Direct Selection anchor precedence, #1508; dialog slider rails, #1483; template layers in Outline view, #1486; type that does not fit its frame is reported, #1471; 3D on outlined type, #1481; print-size images, #1517; French catalog, #1511; estimates unchanged; Split Into Grid options, #1462; Transform Objects and Reflect's angle, #1448 and #1460; Pathfinder on a group, #1450; unknown parameter keys refused, #1442; grayscale TIFFs with alpha, #1466 in part; estimates unchanged; M9: Extrude & Bevel, Inflate and Rotate; `.ai` compound path paint, #1410, overprint on import, #1388, text-only PDF parts, #1387; Transform dialogs preview, #1412, and transforms of direct-selected anchors, #1408; strokes wider than their shape, #1396; estimates unchanged; `.ai` editing-data follow-ups, #1390, #1391, #1392; dialog keyboard focus, #1367 and #1395; symbol instance redraw cache, #1386; font rescan, #1376; estimates unchanged; gradient shapes import as paths, #1304; white constant alpha masks, #1286 in part; Live Corners widget click, #1239; custom ruler origin, #1212; M11: Artboard object clicks create a fitted artboard (#1227); estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; desktop print transport adopts craft-print, Windows 7 fallback retained, scores unchanged; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; M6: Simplify preview/counts and valid topology selection (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; M14: RPM runtime metadata (#1275); estimates unchanged; M5: squared-distance texture redraws (#1257); clean-machine/platform drag measurements still pending; estimates unchanged; M2: Curvature point display improved (#1236, in part); estimates unchanged; M7: full-name font matching (#1258); Windows font-service QA pending; estimates unchanged; M1: no-op anchor Align keeps Undo, Redo and saved state (#1180 follow-up); estimates unchanged; M14: dedicated Eyedropper cursor (#1269); estimates unchanged; M3: Eyedropper reads visible locked and outside-isolation sources (#1251); estimates unchanged; M2: Direct Selection segment picks and Delete (#1278); estimates unchanged; M3: adding paint to an unpainted zero-width stroke restores 1 pt (#1253); estimates unchanged; M12: graph designs, graph data, Graph Type and label options; M14: native macOS save Format menu, #866; M6: compound appearance and member fill-rule fixes, #1197; estimates unchanged; M9: PDF raster-effect cap warnings; G11: #893 fixed, #937 open; M7: accurate character controls after scaling, #1034; estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x)
|
||||
|
||||
Forward-looking plan. Where VectorCraft stands is in [`ROADMAP.md`](../ROADMAP.md) and
|
||||
[target-app-parity.md](target-app-parity.md); the ranked work list is [gaps.md](gaps.md). Hours are Opus 5.5
|
||||
@@ -80,6 +80,7 @@ After placed documents (File › Place of a `.vectorcraft` file, linked), M4.14
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-13 | minor | Direct Selection takes the selected object's anchor where anchors of overlapping objects coincide (#1508); commands and MCP `add_text` report area type that does not fit its frame (#1471), a tab stop in the Tabs panel drags from where the press landed (#1458); M9: 3D and Materials on outlined type and other shapes of many small curves (#1481), Twist 0° to 360° (#1524), twisted walls keep their faces (#1525); print-size images draw, export and crop (#1517), `vectorcraft-cli perf` reports load and memory on Windows (#1502); sliders in dialogs show their rail (#1483), a template layer stays in Preview in Outline view (#1486), French catalog complete again (#1511), a Hungarian message in the informal address (#1474); `graph.setData`'s CSV keeps each value exactly (#1485); estimates unchanged |
|
||||
| 2026-10-13 | minor | M6: Split Into Grid has its documented options (#1462), the Pathfinder panel works on a group selected alone (#1450); M2: Transform Objects in the Transform dialogs and Transform Each, Reflect across an angle (#1448, #1460); M4: grayscale TIFFs with an alpha channel or extra channels place (#1466 in part), strokes kept apart from a faded or masked fill on PDF and `.ai` import (#1434), SVG knockout groups without masks (#1435); M7: Fit Headline in a frame one line tall (#1444), frames beyond a billion points hold no text (#1451); M13: commands refuse a parameter key they don't take (#1442); M5: edits and Alt-drags of thousands of objects (#1429 in part); M14: a focused field follows its value (#1457), selected text in value fields is readable (#1465 in part), a window surface past 8192 px no longer closes the app (#1459 in part); estimates unchanged |
|
||||
| 2026-10-12 | minor | M9: Extrude & Bevel, Inflate and Rotate (initial 3D types, [three-d.md](three-d.md)) |
|
||||
| 2026-10-12 | minor | M4: a `.ai` compound path takes its last path's paint (#1410), PDF and `.ai` import keep overprint (#1388), a `.ai` whose PDF part draws only text opens from it (#1387); M2: the Transform dialogs preview and step with the arrow keys (#1412), the transform tools and commands act on direct-selected anchors (#1408), the bounding box hides during a corner drag (#1229); M5: a stroke wider than its closed shape paints solid on the canvas (#1396); estimates unchanged |
|
||||
|
||||
+17
-11
@@ -1,6 +1,6 @@
|
||||
# Parity with Adobe Illustrator
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (Image Trace: colour presets no longer shatter noisy flat art and photos trace into smooth tones, measured in `image-trace-quality.md`, score unchanged; Split Into Grid options, #1462; Transform Objects and Reflect across an angle, #1448 and #1460; Pathfinder panel on a group, #1450; unknown parameter keys refused, #1442; grayscale TIFFs with alpha, #1466 in part; scores and hours unchanged; Image Trace places edges to a fraction of a pixel in every mode, measured in `image-trace-quality.md`, score unchanged; 3D and Materials: Extrude & Bevel, Inflate and Rotate added, 3D ~8% → ~25% (estimated), menu stubs 25 → 22; Affinity text frames too short for their first line show it, #1423; Transform dialogs preview, #1412, and the transform tools and commands act on direct-selected anchors, #1408; `.ai` compound path paint, #1410; strokes wider than their shape, #1396; corner drags hide the bounding box, #1229; scores and hours unchanged; PDF and `.ai` import keep overprint, #1388, scores unchanged; `.ai` editing-data follow-ups: gradient stops, type of objects with an appearance, short area-type frames (#1390, #1391, #1392); dialog keyboard focus on every open (#1382, #1395); art flush with an artboard (#1375); font rescan (#1376); symbol instance redraw cache (#1386); scores and hours unchanged; grid tool options, #1377 in part, scores unchanged; `.ai` placed (linked) files read from the editing data, #1264, scores unchanged; gradient shapes import as paths, #1304; white constant alpha masks, #1286 in part; Live Corners widget click selects its corner, #1239; scores and hours unchanged; custom ruler origin, #1212; Artboard click-to-fit verified for objects, groups, visible bounds, undo and cancellation (#1227); scores and hours unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; Localization: Korean catalog added, 54% → 63%, full readiness unchanged at ~62%; desktop print transport adopts craft-print, Windows 7 fallback retained, scores unchanged; Image Trace: an image over 64 megapixels traces scaled down instead of being refused, scores unchanged; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; Path operations: Simplify preview/counts and component selection (#1272); scores unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; Platforms: RPM runtime capabilities and glibc floor (#1275); scores unchanged; Raster effects: squared-distance texture redraws (#1257); pixels unchanged; scores and hours unchanged; Drawing: Curvature point display improved (#1236, in part); scores unchanged; Type core: full-name font matching (#1258), scores and hours unchanged; Selection: no-op anchor Align preserves history and saved state (#1180 follow-up); scores unchanged; UI chrome: Eyedropper cursor and precise-setting regressions (#1269); scores unchanged; Colour: Eyedropper reads visible locked and outside-isolation sources (#1251); scores and hours unchanged; Selection: segment click/Delete preserves neighboring edges (#1278); scores unchanged; Colour: unpainted zero-width strokes regain 1 pt when painted (#1253); scores and hours unchanged; graphs: designs and markers, data, Graph Type and label options; UI chrome: native macOS save Format menu, #866, scores unchanged; compound paths inherit the backmost appearance and member fill-rule edits reach their owner, #1197; scores unchanged; Type core: Middle Eastern digit types done, Character panel menu › Digits; PDF export warns when the pixel cap lowers raster-effect resolution; Drawing tools: Live Corners round corners between curved sides, #1144; Type advanced: proportional vertical metrics (`vpal`) done, #966; Shape Builder: #893 fixed, merged regions of crossing lines no longer keep seams; scores unchanged while #937 is open; #1034: document-point character controls after object/group scaling; full readiness recomputed as the standard's additive weighted sum, ~62%; per-audience hours; essentials-user score ~75% added; ready-for-real-work re-examined: 50% → 55%, mainstream 65% added; earlier the same day: full re-measure against Illustrator 2026 30.x; merged the ROADMAP's parity estimate, honest assessment and "Shipped so far" into this file; SVG knockout groups of opaque art export without masks) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (late 2026-10-12 merges: type that does not fit its frame is reported, #1471; Tabs panel drags, #1458; template layers in Outline view, #1486; dialog slider rails, #1483; Direct Selection anchor precedence, #1508; print-size images, #1517; 3D on outlined type, #1481; French catalog, #1511; scores unchanged; Split Into Grid options, #1462; Transform Objects and Reflect across an angle, #1448 and #1460; Pathfinder panel on a group, #1450; unknown parameter keys refused, #1442; grayscale TIFFs with alpha, #1466 in part; Localization: Vietnamese catalog added, 63% → 71%, hours 60–100 → 55–90, full readiness unchanged at ~62%; scores and hours unchanged; Image Trace places edges to a fraction of a pixel in every mode, measured in `image-trace-quality.md`, score unchanged; 3D and Materials: Extrude & Bevel, Inflate and Rotate added, 3D ~8% → ~25% (estimated), menu stubs 25 → 22; Affinity text frames too short for their first line show it, #1423; Transform dialogs preview, #1412, and the transform tools and commands act on direct-selected anchors, #1408; `.ai` compound path paint, #1410; strokes wider than their shape, #1396; corner drags hide the bounding box, #1229; scores and hours unchanged; PDF and `.ai` import keep overprint, #1388, scores unchanged; `.ai` editing-data follow-ups: gradient stops, type of objects with an appearance, short area-type frames (#1390, #1391, #1392); dialog keyboard focus on every open (#1382, #1395); art flush with an artboard (#1375); font rescan (#1376); symbol instance redraw cache (#1386); scores and hours unchanged; grid tool options, #1377 in part, scores unchanged; `.ai` placed (linked) files read from the editing data, #1264, scores unchanged; gradient shapes import as paths, #1304; white constant alpha masks, #1286 in part; Live Corners widget click selects its corner, #1239; scores and hours unchanged; custom ruler origin, #1212; Artboard click-to-fit verified for objects, groups, visible bounds, undo and cancellation (#1227); scores and hours unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; Localization: Korean catalog added, 54% → 63%, full readiness unchanged at ~62%; desktop print transport adopts craft-print, Windows 7 fallback retained, scores unchanged; Image Trace: an image over 64 megapixels traces scaled down instead of being refused, scores unchanged; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; Path operations: Simplify preview/counts and component selection (#1272); scores unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; Platforms: RPM runtime capabilities and glibc floor (#1275); scores unchanged; Raster effects: squared-distance texture redraws (#1257); pixels unchanged; scores and hours unchanged; Drawing: Curvature point display improved (#1236, in part); scores unchanged; Type core: full-name font matching (#1258), scores and hours unchanged; Selection: no-op anchor Align preserves history and saved state (#1180 follow-up); scores unchanged; UI chrome: Eyedropper cursor and precise-setting regressions (#1269); scores unchanged; Colour: Eyedropper reads visible locked and outside-isolation sources (#1251); scores and hours unchanged; Selection: segment click/Delete preserves neighboring edges (#1278); scores unchanged; Colour: unpainted zero-width strokes regain 1 pt when painted (#1253); scores and hours unchanged; graphs: designs and markers, data, Graph Type and label options; UI chrome: native macOS save Format menu, #866, scores unchanged; compound paths inherit the backmost appearance and member fill-rule edits reach their owner, #1197; scores unchanged; Type core: Middle Eastern digit types done, Character panel menu › Digits; PDF export warns when the pixel cap lowers raster-effect resolution; Drawing tools: Live Corners round corners between curved sides, #1144; Type advanced: proportional vertical metrics (`vpal`) done, #966; Shape Builder: #893 fixed, merged regions of crossing lines no longer keep seams; scores unchanged while #937 is open; #1034: document-point character controls after object/group scaling; full readiness recomputed as the standard's additive weighted sum, ~62%; per-audience hours; essentials-user score ~75% added; ready-for-real-work re-examined: 50% → 55%, mainstream 65% added; earlier the same day: full re-measure against Illustrator 2026 30.x; merged the ROADMAP's parity estimate, honest assessment and "Shipped so far" into this file; SVG knockout groups of opaque art export without masks; Image Trace: colour presets no longer shatter noisy flat art and photos trace into smooth tones, Line truncated
|
||||
|
||||
The authoritative parity assessment. [`ROADMAP.md`](../ROADMAP.md) summarizes it; [`gaps.md`](gaps.md) is
|
||||
the work list made from it. Deep areas have their own checklists: [UI and interaction](ui-parity.md),
|
||||
@@ -114,7 +114,7 @@ review parallelize less and need humans.
|
||||
|
||||
| Audience | Ready % | Opus 5.5 agent hours to ~95% (one agent) | With 4–6 agents | Work that dominates |
|
||||
|---|---:|---:|---:|---|
|
||||
| **Full Illustrator** (ready for real work; decides the stage) | **~62%** (57–67%) | **~340–560 h** | ~95–160 h | the mainstream work below, plus 3D (50–85 h), raster effects (24–40 h), advanced type (20–28 h), generative AI (30–60 h), localization (60–100 h) and ecosystem |
|
||||
| **Full Illustrator** (ready for real work; decides the stage) | **~62%** (57–67%) | **~340–560 h** | ~95–160 h | the mainstream work below, plus 3D (50–85 h), raster effects (24–40 h), advanced type (20–28 h), generative AI (30–60 h), localization (55–90 h) and ecosystem |
|
||||
| **Mainstream illustrator** | **~65%** (60–70%) | **~230–380 h** | ~65–110 h | the interaction-fidelity pass (60–90 h), hardening and QA (30–50 h), stability (20–35 h), performance budgets (15–25 h), `.ai` exchange (10–20 h), the remaining depth of the everyday areas |
|
||||
| **Essentials user** | **~75%** (70–80%) | **~60–105 h** | ~25–45 h | launch and stability on Windows (20–35 h), in-app help and onboarding (8–15 h), basic QA (10–15 h), Shape Builder regions (4–8 h), UI polish |
|
||||
|
||||
@@ -132,7 +132,7 @@ Values are from [By dimension](#by-dimension).
|
||||
| Performance | 8 | 60% | 4.8 |
|
||||
| Platforms | 5 | 80% | 4.0 |
|
||||
| Hardware | 4 | 35% | 1.4 |
|
||||
| Localization | 4 | 63% | 2.5 |
|
||||
| Localization | 4 | 71% | 2.8 |
|
||||
| Ecosystem and plug-ins | 4 | 15% | 0.6 |
|
||||
| AI features | 3 | 0% | 0.0 |
|
||||
| **Ready for real work** | **100** | | **~62%** |
|
||||
@@ -237,7 +237,7 @@ what keeps it from beta, not from alpha.
|
||||
| UI/UX fidelity (look ~80%, interaction ~45%) | 55% | 70–110 | estimated | [ui-parity.md](ui-parity.md) |
|
||||
| File formats | 75% | 15–30 | estimated | [file-format-parity.md](file-format-parity.md) |
|
||||
| Hardware (GPU, pen, touch, displays) | 35% | 25–45 | estimated | [hardware-parity.md](hardware-parity.md) |
|
||||
| Localization (the 12 key languages, equally weighted) | 63% | 60–100 | measured from catalogs | [localization-parity.md](localization-parity.md) |
|
||||
| Localization (the 12 key languages, equally weighted) | 71% | 55–90 | measured from catalogs | [localization-parity.md](localization-parity.md) |
|
||||
| Performance | ~60% | 15–25 | estimated (unverified budgets) | [gaps.md](gaps.md) |
|
||||
| Stability | ~60% | 30–50 | estimated | [gaps.md](gaps.md) |
|
||||
| Platforms (macOS, Windows x64/arm64, Linux, FreeBSD, web) | 80% | 15–25 | estimated | [gaps.md](gaps.md) |
|
||||
@@ -395,7 +395,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
|
||||
- Panels undock (#495): a dock tab, a panel icon or a popped-out panel's title dragged out of the dock floats that panel inside the window, and the strip right of the dock's tabs floats the whole tabbed group. A floating group moves by its title bar, a tab dragged out of it floats on its own, and dropped on another group's title bar or tabs it stacks with that group; dropped on the dock, which lights up under the pointer, its panels go back where they live (the tabbed group or the icon column), as its × and the panel menus' Float Panel / Dock Panel do (`window.panel.float`, `window.panel.dock`). The Tools panel floats by its title bar and docks again on the window's left edge. Floating groups stack into sets: dropped on the upper or lower half of another group (a line shows where), a group joins it right above or below it, and the set's groups stack top to bottom, each showing one panel, moving together by the top group's title bar, whose × docks them all; a group dragged by its tab strip leaves the set (`window.panel.float {below, above}`). A double-click on a tab collapses a floating group to its tabs (`collapsed`). Sets dropped on the left edge of the icon column or of another dock column dock as dock columns (a multi-column dock, `window.panel.dock {column}`), whose groups stack, tab and tear out as a floating set's do, and whose top strip floats the column again. Floating panels, their sets and the Tools panel's position are kept with the preferences and in user workspaces, and clamped into a smaller window. Not done: panels in separate OS windows, docking a panel into the default tabbed group it doesn't belong to, reordering tabs by dragging, collapsing a set or dock column to icons, dock columns right of the default dock, resizing floating panels and the groups of a set.
|
||||
- The dock's double arrow collapses Properties, Layers and Libraries to icons at the top of the icon column, popping out like the other icon panels, and expands them again (`window.collapseDock`, kept with the preferences and in saved workspaces). The icon column's own arrow (expanding the icon panels into a full column) is not done.
|
||||
- Czech render-site labels: desktop/web file filters, diagnostics, suggested preset names and contributor tooltips/plurals now use catalog lookups; gradients have Czech adjective agreement and printer marks have a separate context (PDF/Print and Summary). Search, Home, toolbar/panel tooltips and Preferences already look up their labels; new variable keys fall back to English in other languages. Evidence: `i18n::tests::czech_render_site_follow_up`, `dialogs::tests_modal::printer_mark_labels_are_distinct_from_language_names`; see [localization-parity.md](localization-parity.md).
|
||||
- Localised UI: menus (in-window and native), panels, dialogs, toolbar and Preferences read from per-language catalogs (`crates/ui-egui/src/i18n`, see `docs/development.md`); Traditional Chinese (Taiwan), Simplified Chinese (mainland), Japanese, Korean, German, Spanish, French, Italian, Russian and Ukrainian ship complete, and Czech and Brazilian Portuguese cover every menu label (and more), with system-locale detection on macOS, Windows, Linux and the web (the browser's languages, or `?lang=`), a Language preference and, on macOS, a VectorCraft ▸ Language menu. Names are never translated (fonts and their styles, layers, artboards, saved presets, user libraries, custom workspaces, recent files, plug-ins).
|
||||
- Localised UI: menus (in-window and native), panels, dialogs, toolbar and Preferences read from per-language catalogs (`crates/ui-egui/src/i18n`, see `docs/development.md`); Traditional Chinese (Taiwan), Simplified Chinese (mainland), Japanese, Korean, Vietnamese, German, Spanish, French, Italian, Russian and Ukrainian ship complete, and Czech and Brazilian Portuguese cover every menu label (and more), with system-locale detection on macOS, Windows, Linux and the web (the browser's languages, or `?lang=`), a Language preference and, on macOS, a VectorCraft ▸ Language menu. Names are never translated (fonts and their styles, layers, artboards, saved presets, user libraries, custom workspaces, recent files, plug-ins).
|
||||
- Native macOS menu bar, vector tool cursors, a ⌘K command palette, middle-button panning with any tool. Native menus explicitly end tracking on command selection, outside window presses and Escape; menu updates wait until tracking ends, and a dismissing press never starts a canvas gesture (M14.8).
|
||||
- The macOS menu bar is built from the in-window menus (one model, `native_menu`) and laid out as a Mac app's: the VectorCraft menu holds About, Settings ▸ (each Preferences page, General… ⌘K), Language ▸, Appearance ▸ (UI brightness), Services, Hide VectorCraft ⌃⌘H (⌘H stays Hide Edges), Hide Others, Show All and Quit; Window gains Minimize ⌃⌘M, Zoom and Bring All to Front, and Help the system's search field. Its key equivalents go back to egui as key presses, so user shortcuts, focused text fields and the Type tool's input method behave as in the window, and nothing fires twice; enabled, check marks and labels update in place. `--in-window-menus` or `VECTORCRAFT_IN_WINDOW_MENUS=1` keeps the menus in the window; `ui.inspect` reports `nativeMenuBar`.
|
||||
- macOS: Finder opens every format File › Open reads with VectorCraft (double-click, Open With, a drop on the Dock icon, `open -a`), at launch or while it runs; the release and `cargo xtask bundle` apps share one `Info.plist` that declares those document types, owning only VectorCraft's own formats. The DMG opens on a designed Finder window, VectorCraft.app beside the `Applications` link over a background with the app icon (#493).
|
||||
@@ -436,9 +436,9 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
|
||||
Transform Again uses the Copy, Transform Patterns, Scale Strokes & Effects and Scale Corners choices of the transform it repeats; after a bounding-box handle drag on area type, it resizes the type area again and the type keeps its size (`object.transformAgain`). With anchors selected with Direct Selection, Move, Rotate, Scale, Reflect, Shear and Transform Again (the commands, their dialogs and the four tools' drags) transform only those anchors, their handles with them, about the centre of the box around them unless a point was clicked; a copy still copies the whole object (#1408; behaviour reference: [adjust path segments](https://helpx.adobe.com/illustrator/using/adjust-path-segments.html); the default reference point and the copy are our choices). The Move, Rotate, Scale, Reflect and Shear dialogs have a Preview checkbox, off when they open: while it is on the canvas follows the values, Cancel rolls it back and OK is still one undo step; Up/Down step the Rotate and Shear angles and the Scale percentages by 1, Shift by 10 (#1412). The five dialogs and Transform Each have Transform Objects beside Transform Patterns: off, the art stays and only its pattern tiles transform (`objects` on the transform commands), and Transform Again repeats the choice (#1448; behaviour reference: [scale objects](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/scale-objects.html); the checkboxes are always enabled and the Scale page's pair is applied to all of them, our choices). Reflect's axis is Horizontal, Vertical or an angle (#1460; [reflect objects along an axis](https://helpx.adobe.com/illustrator/desktop/manage-objects/arrange-objects/reflect-objects-along-an-axis.html)). The link that keeps Transform Each's two scales equal is not built.
|
||||
- **Live Corners:** drag a live rectangle's corner widgets (Selection or Direct Selection) to round all corners, with a radius readout; with Direct Selection, select corner anchors to show and round just those corners (#381). Each corner keeps its own radius and kind (Round, Inverted Round, Chamfer): Alt-click a widget to cycle the kind, double-click it for the Corners dialog, and the Properties and Transform radius fields set the selected corners (`object.setLiveShape {corners, radius, kind}`). Corners stay circular when the rectangle is scaled unevenly or along its own sides when rotated (the radius keeps its size, or scales by the mean scale with Scale Corners on); only a shear distorts them. On a rectangle that isn't square every corner is the same circle: a radius past half the shorter side stops there for all of them, the widgets sit on the corners as drawn and turn the edited corners red at that limit, CSS export writes the drawn radii, and rectangles saved stretched by older versions get circular corners when their corners are next set (#442). Live Corners work on any path's corners too, as on a rectangle's (#511): a star's, a live polygon's (which also shows them to the Selection tool, has a Corner Radius field in the Properties and Transform panels and keeps its radius as its sides change) and a pen path's, wherever two sides meet at an angle at an anchor that isn't smooth: curved sides too, trimmed to what the cut leaves of them (#1144). Direct Selection shows the widgets of every corner, or of the corners holding selected anchors; each corner is a circle tangent to both sides, drawn no larger than takes its cut halfway along its shorter side; a path keeps its uncut outline so its corners stay editable, and the same command, dialog, angle preference and View › Hide Corner Widget apply. With Direct Selection a click on a widget (no drag) selects its corner, as a click on the corner's anchor does, so the next drag on it rounds that corner alone; Shift-click toggles it (#1239). While a corner widget is dragged with the Selection tool the bounding box hides, so the rounding shows against the path alone (#1229).
|
||||
- **Pen:** Alt pressed in the middle of dragging out an anchor splits its handles and keeps the curve already shaped into it (only the outgoing handle follows the pointer); Space held while dragging out an anchor moves it, handles and all. A press on the first anchor of the path being drawn closes it whatever the drag then does: the drag shapes the closing curve (symmetric handles, Alt breaks them) and the release closes the path wherever it ends (#501). Clicking the last anchor of the path being drawn retracts its outgoing handle (convert cursor), and dragging from it pulls out a new one on its own. Auto Add/Delete: over a selected path, a click on a segment adds an anchor and a click on an anchor deletes it (add and delete cursors); Shift held, or Preferences › General › Disable Auto Add/Delete, starts a new path instead. Alt held over a handle end or an anchor of a selected path (the one being drawn too) works as the Anchor Point tool, with its convert cursor: dragging a handle moves it alone, clicking a smooth anchor makes it a corner, dragging an anchor pulls out new symmetric handles, dragging a segment reshapes it; drawing then goes on. Cmd (Ctrl) held drags with the selection tool chosen last, Direct Selection until one is (handles, anchors and segments of the path being drawn or another), and the Pen then goes on drawing its path while that stays selected; the pointer shows that tool's while Cmd is held. The same holds for agents (`pointer_gesture` with `mods.cmd`), as the borrowing now lives in the engine (#494).
|
||||
- **Direct Selection (#1278):** clicking a segment picks its connection, with hollow endpoint anchors and only that curve's direction handles. Delete leaves the neighboring curves and appearance intact, opens/splits contours in one undo step, and another Delete removes the remaining selected path. A whole-path drag and deliberate anchor selection keep their existing behavior; egui frames and native control/art checked, OS mouse/visual QA pending.
|
||||
- **Direct Selection (#1278):** clicking a segment picks its connection, with hollow endpoint anchors and only that curve's direction handles. Delete leaves the neighboring curves and appearance intact, opens/splits contours in one undo step, and another Delete removes the remaining selected path. A whole-path drag and deliberate anchor selection keep their existing behavior; egui frames and native control/art checked, OS mouse/visual QA pending. Where anchors of overlapping objects coincide, a click or drag takes the anchor of the selected object, also when another object lies over it (#1509, fixes #1508).
|
||||
- **Path editing (#494):** the handles shown are the handles that drag: Direct Selection drags those of a path selected as a whole (as the Pen leaves it), the Anchor Point tool and Pen+Alt no longer grab handles that Show handles when multiple anchors are selected hides, a handle shorter than the tolerance leaves its anchor to be picked, and the anchor tools pick within Selection & Anchor Display › Tolerance. A dragged segment keeps a smooth anchor at either end smooth. With Direct Selection, the Pen, the Curvature and the anchor tools, a path selected as a whole shows all its anchors selected, and the Control bar and the Properties panel offer Convert (corner, smooth) for them too; Remove and Cut wait for anchors direct-selected (#504). A handle drag gathers its snap targets once per drag (it took 21 ms per pointer move on a 20,000-path document, now 1 ms; `vectorcraft-cli perf` measures handle and segment drags on a 5,000-anchor path).
|
||||
- **Graphs:** Column, Stacked Column, Bar, Stacked Bar, Line, Area, Scatter, Pie and Radar graph tools with Graph Data and Graph Type. A series starts in greyscale; Group Selection's fill and stroke are kept separately on the graph and reapplied when its data, type or size changes, and through save, open and undo. Graph Type's Override Calculated Values (both `axisMin` and `axisMax`) uses exactly that value-axis range, split into the tick count (5 when automatic). Line graphs take Edge-to-Edge Lines (off by default: the points sit at the centres of their categories; graphs saved before the option keep their edge-to-edge lines), and radar graphs follow Mark Data Points and Connect Data Points. Blank cells (an empty CSV cell or `null`) are values left out, not zeros: columns, bars and stacked segments skip them, lines and scatter lines break around them and the value axis ignores them, while area, pie and radar draw them as 0; a number in straight quotes is a label, and the data window writes it back quoted. Graph types combine: Graph Type on series picked with Group Selection (or `series` indexes) draws them as their own type, with column, stacked column, line and area mixing on the vertical value axis and bar and stacked bar on the horizontal one; stacked columns take one slot of the cluster, lines draw in front of columns and areas behind. The value axis goes on the left, the right or both sides (column, stacked column, line, area and scatter graphs; bar graphs keep theirs along the bottom), and Separate Scales gives the series assigned to the right axis a scale of their own with its own tick values. Each value axis takes tick marks of no, short or full (across the plot) length and a count per division, and a prefix and suffix for its numbers; the category axis takes tick marks too (none by default), at or between its labels. Graph designs: Object › Graph › Design lists the designs with a preview of the selected one, and New Design (the selected art), Rename, Delete Design, Paste Design (a copy back to edit) and Select Unused, and Marker draws the data points and legend swatches of line, scatter and radar series with a design, scaled so its backmost object fills the default marker's square. Graph Data transposes rows and columns and switches a scatter graph's x and y, and a `|` in a category or series label starts a new line. Pie graphs take Legends in Wedges, Position (Even, Ratio sized by area, Stacked on one centre) and Sort (None, All, First). Graph Type takes column and cluster widths up to 1000% (overlapping past 100%) with First Row and First Column in Front (First Row orders each series' categories; across series the series order decides, so series stay one group each for Group Selection), draws filled lines, adds drop shadows behind columns, bars, lines and wedges, and puts the legend in rows across the top. Text formatted with the Character or Appearance controls, on the whole graph or on labels picked with Group Selection, is kept per kind of label (value axis numbers, category labels, legend labels) through the graph being drawn again. Object › Graph › Data opens a worksheet: cells picked with a click and edited above the sheet (Tab moves right, Enter down, the arrows between cells), Import Data from a tab-separated text file, Transpose, Switch x/y, Cell Style (decimals shown, column width), Revert and Apply. Column designs remain open.
|
||||
- **Graphs:** Column, Stacked Column, Bar, Stacked Bar, Line, Area, Scatter, Pie and Radar graph tools with Graph Data and Graph Type. A series starts in greyscale; Group Selection's fill and stroke are kept separately on the graph and reapplied when its data, type or size changes, and through save, open and undo. Graph Type's Override Calculated Values (both `axisMin` and `axisMax`) uses exactly that value-axis range, split into the tick count (5 when automatic). Line graphs take Edge-to-Edge Lines (off by default: the points sit at the centres of their categories; graphs saved before the option keep their edge-to-edge lines), and radar graphs follow Mark Data Points and Connect Data Points. Blank cells (an empty CSV cell or `null`) are values left out, not zeros: columns, bars and stacked segments skip them, lines and scatter lines break around them and the value axis ignores them, while area, pie and radar draw them as 0; a number in straight quotes is a label, and the data window writes it back quoted. Graph types combine: Graph Type on series picked with Group Selection (or `series` indexes) draws them as their own type, with column, stacked column, line and area mixing on the vertical value axis and bar and stacked bar on the horizontal one; stacked columns take one slot of the cluster, lines draw in front of columns and areas behind. The value axis goes on the left, the right or both sides (column, stacked column, line, area and scatter graphs; bar graphs keep theirs along the bottom), and Separate Scales gives the series assigned to the right axis a scale of their own with its own tick values. Each value axis takes tick marks of no, short or full (across the plot) length and a count per division, and a prefix and suffix for its numbers; the category axis takes tick marks too (none by default), at or between its labels. Graph designs: Object › Graph › Design lists the designs with a preview of the selected one, and New Design (the selected art), Rename, Delete Design, Paste Design (a copy back to edit) and Select Unused, and Marker draws the data points and legend swatches of line, scatter and radar series with a design, scaled so its backmost object fills the default marker's square. Graph Data transposes rows and columns and switches a scatter graph's x and y, and a `|` in a category or series label starts a new line. Pie graphs take Legends in Wedges, Position (Even, Ratio sized by area, Stacked on one centre) and Sort (None, All, First). Graph Type takes column and cluster widths up to 1000% (overlapping past 100%) with First Row and First Column in Front (First Row orders each series' categories; across series the series order decides, so series stay one group each for Group Selection), draws filled lines, adds drop shadows behind columns, bars, lines and wedges, and puts the legend in rows across the top. Text formatted with the Character or Appearance controls, on the whole graph or on labels picked with Group Selection, is kept per kind of label (value axis numbers, category labels, legend labels) through the graph being drawn again. Object › Graph › Data opens a worksheet: cells picked with a click and edited above the sheet (Tab moves right, Enter down, the arrows between cells), Import Data from a tab-separated text file, Transpose, Switch x/y, Cell Style (decimals shown, column width), Revert and Apply. Column designs remain open. `graph.setData` returns and takes the data as CSV with each value written exactly, not rounded to two decimals, so applying the returned CSV changes nothing (#1485).
|
||||
- **Other:** Eyedropper, Gradient annotator, Artboard (Alt-drag duplicates an artboard, and its art with Move/Copy Artwork with Artboard on; Copy, Cut and Paste take the artboard with its art, Paste right of the last artboard, Paste in Place where it was, into this or another document, #446; Object › Artboards › Rearrange All Artboards lays them out in a grid by row or by column, in one row or one column, left to right or right to left, from the menu, the Artboards panel or Properties' Rearrange All, #681; Scale Artwork with Artboard, in the Control bar and Properties beside Move Artwork with Artboard, resizes the artboard proportionally with the art fully inside it and its guides, strokes and corners per the Scale preferences, #602), Measure, Type, Hand, Zoom, Rotate View.
|
||||
- **Editing type:** double-clicking type with a selection tool switches to the Type tool with the caret where it was clicked.
|
||||
- **Temporary tools:** Cmd with any other tool drags with the selection tool used last (Selection, Direct Selection or Group Selection). Held Space pans and Cmd+Space zooms (Cmd+Alt+Space out) whatever the tool.
|
||||
@@ -451,20 +451,20 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
|
||||
Selected objects are deselected, the key object included, when a row drag moves them into a hidden layer or group, when `node.move` moves them into a hidden or locked layer or group, when Merge Selected moves them into a hidden or locked layer, when Collect in New Layer moves them into a new layer inside a hidden or locked layer, when Flatten Artwork moves them into a locked layer, and when applying a data set hides them.
|
||||
- **Drawing aids:** Smart Guides and snapping (moving, with the Selection, Group Selection or Direct Selection tool, and resizing by the bounding-box handles with the Selection or Free Transform tool, Shift and Alt too: the dragged corner or side lands on the other objects' anchors and centres ("anchor", "center") or lines up with their edges and centres (a line and "align"); with Smart Guides › Spacing Guides, a moved selection that lines up with nothing along an axis lands as far from its neighbour as two other objects of its row or column are apart, or halfway between its two neighbours, the equal gaps marked; with View › Snap to Grid on, a moved selection lands on the grid instead, by whichever of its edges or its centre is nearest a gridline on each axis, #740; the Rotate, Scale, Shear and Reflect tools drag from the anchor they grab and snap the pointer the same way, so a corner dragged onto another object's anchor scales exactly onto it, #482; anchors dragged with Direct Selection: the one pressed on lands on other anchors, its own path's that stay put among them, on path segments or in line with them, the others following, Shift keeps 45° steps, and with Smart Guides off Snap to Point pulls it onto anchors; the Artboard tool snaps artboards, and their bleed, to other artboards, bleeds and art, the grid or whole pixels while moving, copying and resizing them, and the Transform panel shows and edits the active artboard's X/Y/W/H; drawn points, #506: hovering shows where the press goes, the point lands on the other objects' anchors, centres and paths ("anchor", "center", "path") or lines up with their anchors, edges and centres ("align"), the Pen's on the path being drawn too, near a construction guide through the anchor before it on that guide (Preferences › Smart Guides › Construction Guides and Angles), and with Shift kept to its 45° step or a square's diagonal it slides along that way into line; the targets are gathered once per document state, from the art in the window, and the anchors, centres and lines near the pointer are found by bisection, so hovering never rescans the document), guides dragged out of the rulers (#451: they snap as moved guides do, Shift to the ruler's ticks; with the Artboard tool they are artboard guides, which run across their artboard only and move, copy and delete with it, `guide.add {artboard}`) that the Selection, Direct Selection and Group Selection tools then pick and drag (#414: the selected guides highlight and move together, Alt copies them, Shift snaps the dragged one to the ticks, otherwise it snaps to pixels, the grid or the edges, centres (so the sides' midpoints) and anchors of the art and the artboards, or with Smart Guides off Snap to Point pulls it onto anchors; Shift-clicked they join the selected art, and Shift-added art keeps them, so guides and art are selected together in either order, Delete then deleting both in one step; the arrow keys nudge them, Delete deletes them, dragging one back onto its ruler deletes it, Lock Guides and Hide Guides leave them alone; `guide.list`, `guide.select`, `guide.move` and `guide.remove` reach agents), a right-click on either ruler or the origin box to pick the document's units (the current one checked; `document.setUnits`, one undo step, #528), and Draw Normal / Behind / Inside modes.
|
||||
- **Artboard click-to-fit (#1227):** a plain click fits the topmost editable object or group to a new artboard, including its stroke like Fit to Selected Art, and makes the new artboard active. Art and its selection stay unchanged; creation is one undo step. Handles, modified clicks, drags, Escape and tool changes retain their meaning. Tested inside an artboard and on the pasteboard (`crates/engine/tests/artboard_click.rs`); the public [CS6 Printing Guide](https://helpx.adobe.com/content/dam/help/en/indesign/pdf/CS6_PrintGuide.pdf) documents the object-click gesture. This legacy gesture adds evidence without regrading the 2026 canvas controls.
|
||||
- **Views:** the status bar's artboard navigator (first, previous, next, last; `view.goToArtboard`) goes to an artboard and fits it, and Fit Artboard in Window and Actual Size show the navigator's artboard. The Artboards panel highlights that active artboard: clicking a row makes it active (the Artboard tool's too, while it is chosen) as does clicking an artboard with the Artboard tool, double-clicking its number also fits it in the window and double-clicking its name renames it. Duplicate Artboards, or a row dragged onto New Artboard, copies the artboard with its art (#446). Escape leaves Presentation Mode, F from it goes back to Normal Screen Mode, and the screen mode isn't saved with the preferences: the app always starts in Normal Screen Mode with its menus and panels (#472). View › Pixel Preview shows the art as the document pixels it rasterizes to, one per point and hard-edged when magnified, turning with a rotated view (it used to draw the smooth vectors, with only the pixel grid over them).
|
||||
- **Views:** the status bar's artboard navigator (first, previous, next, last; `view.goToArtboard`) goes to an artboard and fits it, and Fit Artboard in Window and Actual Size show the navigator's artboard. The Artboards panel highlights that active artboard: clicking a row makes it active (the Artboard tool's too, while it is chosen) as does clicking an artboard with the Artboard tool, double-clicking its number also fits it in the window and double-clicking its name renames it. Duplicate Artboards, or a row dragged onto New Artboard, copies the artboard with its art (#446). Escape leaves Presentation Mode, F from it goes back to Normal Screen Mode, and the screen mode isn't saved with the preferences: the app always starts in Normal Screen Mode with its menus and panels (#472). View › Pixel Preview shows the art as the document pixels it rasterizes to, one per point and hard-edged when magnified, turning with a rotated view (it used to draw the smooth vectors, with only the pixel grid over them). In Outline view a template layer keeps drawing in Preview, dimmed, so a picture placed to be traced stays visible under the outlines (#1505, fixes #1486).
|
||||
Align to Artboard uses the active artboard, the one the Artboards panel highlights (`object.align {artboard}`). A press with a selection tool inside an artboard makes that artboard active; with the Selection tool, a Shift-click on an empty spot inside an artboard makes it active and keeps the selection. One selected object is aligned to the active artboard too, whatever Align To says (a lone object is never the key object). When an action plays, its Align to Artboard, All on Active Artboard, Paste in Place, Paste in Front and Paste in Back steps that name no artboard use the artboard that is active then.
|
||||
Choosing the Artboard tool (Shift+O, its toolbar button, Edit Artboards in Properties or Document Setup, or the Artboards panel menu's Artboard Options…) starts it on the active artboard: the canvas outlines that artboard, and Delete, Cut, Copy, the Transform panel and Artboard Options act on it.
|
||||
- **Geometry and effects:**
|
||||
- Pathfinder (10 exact curve booleans; single-result shape operations retain source names and SVG IDs, #1293), Offset, Outline Stroke, Simplify, Clean Up, Split Into Grid, Divide Objects Below. With one group selected alone, the Pathfinder panel's buttons work on its members (among other selected objects a group still acts as one shape), and a greyed-out button says what it needs (#1450; behaviour reference: [combine objects using Pathfinder effects](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/combine-objects-using-pathfinder-effects.html); one plain path selected alone stays disabled, which the documentation doesn't settle). Split Into Grid takes the number, height or width, gutter and total of the rows and of the columns, Add Guides and Preview, and several selected paths become one grid painted as the topmost one (#1462, `object.path.splitIntoGrid`; behaviour reference: [divide or split objects](https://helpx.adobe.com/illustrator/desktop/manage-objects/edit-objects/divide-or-split-objects.html); a typed height or width keeps the gutter and changes the total, our choice). Object › Path › Remove Anchor Points (`path.removeAnchors`) removes the direct-selected anchors without opening their paths (the Delete key still deletes them with their segments): the neighbours keep their handle directions and their facing handles are refitted so one cubic follows the two old segments, and straight sides stay straight. The Delete Anchor Point tool uses the same refit. The Control bar, the contextual task bar, the Properties quick actions and the canvas context menu offer it when anchors are selected. With anchors selected, the Control bar and the Properties panel also convert them to corner or smooth (`path.convertAnchors`), connect end points (Join) and cut the path at them (`path.cutAtAnchors`, also on the task bar and the context menu): a closed path opens there and an open one becomes one path per piece, each cut leaving one of its two coincident anchors selected so a drag pulls the path apart.
|
||||
- Divide uses open lines, bent paths and curves as cutting edges (#1307), preserving the frontmost filled face's appearance and holes. Engine tests cover implicit closure of filled open contours and unrelated objects; native automation confirms independent piece movement, one Undo/Redo, canvas/window captures and an exact saved-document read by a separate CLI process. The shared planar map retains its 4,096-segment cap and filled-area fallback; other Pathfinder operations are unchanged.
|
||||
- Extrude & Bevel, Inflate and Rotate (Effect › 3D and Materials): live effects in the `three-d` crate with depth, twist, taper, solid or hollow caps, five original bevel shapes (width, height, repeat, spacing, inside, both sides), Inflate's volume and both sides, Rotate's turned fills and strokes, Position presets, the rotation and light gizmos, SVG/PDF vector output and Expand Appearance; one 3D effect per object; covered by geometry, effect, canvas-versus-export, command and dialog tests ([three-d.md](three-d.md)).
|
||||
- Extrude & Bevel, Inflate and Rotate (Effect › 3D and Materials): live effects in the `three-d` crate with depth, twist, taper, solid or hollow caps, five original bevel shapes (width, height, repeat, spacing, inside, both sides), Inflate's volume, both sides, twist, taper and cap, Rotate's turned fills and strokes, Position presets, the rotation and light gizmos, SVG/PDF vector output and Expand Appearance; one 3D effect per object; covered by geometry, effect, canvas-versus-export, command and dialog tests ([three-d.md](three-d.md)).
|
||||
- Initial live Revolve (#605, #846): a portable geometry and shading crate (`three-d`), editable path and compound profiles, compact options with artboard rotation and light gizmos, SVG/PDF vector output and Expand Appearance that can trim covered faces. Open ends are uncapped, shading is flat with one light, and intersecting surfaces' visibility is approximate ([revolve.md](revolve.md)).
|
||||
- Live effects with previewing dialogs: Distort & Transform, Path, Convert to Shape, 15 Warp styles, Round Corners, Scribble, Effect → Pathfinder (all 10 operations, live on groups; several loose objects are grouped first, in one undo step), Color Adjustments (Brightness/Contrast, Curves, Levels, Hue/Saturation, Shift to Color, Temperature/Tint, on vectors, live type and embedded images), and raster drop shadow, glows and feather. SVG and PDF export keep live effects (geometry baked; SVG raster effects as filters).
|
||||
- **Simplify feedback and component selection (#1272):** the existing tolerance dialog previews from the original interaction snapshot and shows original/new anchor counts. Preview off and Cancel restore the document and selection; OK is one Undo. A refit or Add Anchor Points clears component indexes only on paths whose geometry changes, keeping their objects selected whole and unchanged paths' components intact. Engine and real-egui frame regressions cover document/selection restoration and grouped paths.
|
||||
Public references: [Simplify benefits](https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/modify-paths/simplify-path-benefits.html) and [advanced options](https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/modify-paths/simplify-paths-advanced-options-overview.html).
|
||||
Nearly unchanged appearance is expected when reducing points. Percentage/auto-simplify controls, the corner-angle UI and Show Original are not implemented; the checklist is partial, without raising scores.
|
||||
Simplify and Add Anchor Points record no undo step when they change no path, and a path they leave as it is keeps its live shape.
|
||||
- **Type:** Character size, leading, baseline shift and horizontal-scale controls account for object/group transforms (#1034): exact document-point sizes, mixed-value display and keyboard increments, range edits, resettable horizontal stretch, undo/redo and native save/reopen (`tests_typescale`, Character panel tests). Point size is the transformed em-height axis, independent of glyph ink bounds; affine rotation, reflection and shear are retained. Group scaling still scales text; preserving font sizes is a separate enhancement. Behaviour reference: [Transform text](https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/transform-text.html) (public documentation). Text Wrap, Type on a Path effects (Rainbow/Skew/3D Ribbon/Stair Step/Gravity) and Options (Flip, Align to Path, Spacing; OK keeps the type where it is), type on a path's start, centre and end brackets (Selection and Direct Selection drag them to set where the type runs, slide it along its path and, across the path, flip it: #429), Character and Paragraph Styles (override-preserving redefine), paragraph attributes per paragraph (alignment, indents, spacing, hyphenation, hanging punctuation, tab stops, paragraph style, direction, mojikumi and leading model; the Paragraph, Tabs and Paragraph Styles panels act on the paragraphs the Type tool's selection or caret touches, Return continues the paragraph's style, threads and SVG keep them), Area Type Options (rows/columns/inset/first baseline, vertical Align Top/Center/Bottom/Justify per row/column, reflowing non-rectangular frames and text wrap), threaded text across any closed shapes, Fit Headline, the Every-line Composer for justified and ragged text (paragraph-wide breaks for an even rag, Single-line per object from the Paragraph panel menu or `text.setFormat {composer}`). Area type resizes like Illustrator's type areas: a bounding-box handle drag (Selection tool) or Area Type Options Width/Height size the frame and the text reflows at its size (threads too); Direct Selection drags a frame corner or edge (`text.reshapeArea`). Point type still scales. Auto Size: the frame height follows the text after every edit, in the same undo step (rectangular frames of horizontal type in one row; columns get the least height that holds them; a height set by hand or threading turns it off), and the "Auto Size New Area Type" preference gives it to new area type. Shrink Text to Fit (beyond Illustrator): overflowing area type scales its sizes, leading and baseline shifts down by the largest factor (in 0.1 pt steps of the first run, down to a minimum percentage) that fits, at layout time so every export sees it. `text.areaOptions` and `document.inspect` report overflow and the shrink factor. The Selection tool shows the type widget beside selected type's bounding box (hollow on point type, filled on area type); double-clicking it converts between them (Type › Convert To Area Type / Convert To Point Type), keeping the text and its styles in place. Variable fonts list every named instance as a style of their family (Figtree Light to Black and their italics, Bahnschrift, Segoe UI Variable), and the instance chosen is shaped, drawn, outlined and measured at its axis settings; PDF and SVG embed it as that instance. Variation axis sliders are not done. Beyond Illustrator: inline graphics (`text.insertInline`, Type ▸ Insert Inline Symbol) set a document symbol in a run of type as one character that flows, wraps and justifies like a glyph (InDesign-style inline objects, e.g. mana symbols in card rules text): sized to a multiple of the font size, centred on the cap height, opening up tall lines, following Redefine Symbol; a character of its run's size for Character Alignment and Top-to-Top leading, placed by the bidi reordering in right-to-left text (a neutral U+FFFC); drawn on the canvas, kept as live text plus a `<use>` in SVG and as vector art in PDF; Backspace removes it whole. Hebrew and Arabic are laid out right to left with the Unicode bidirectional algorithm (embedded Latin and numbers keep their direction, lines put in visual order after wrapping) in point, area and path type, with caret, selection and arrow keys in visual order; each paragraph's direction comes from its first strong character unless Paragraph Direction sets it (Paragraph panel with Show Indic Options, `text.setFormat {direction}`), and new type aligns to the start of its direction. SVG export outlines right-to-left text. Arabic typefaces: builds made with a craft-fonts checkout that holds its Arabic families (Noto Sans Arabic, the Arabic fallback, plus Cairo, Tajawal, Almarai, IBM Plex Sans Arabic, Noto Kufi Arabic, Amiri, Alexandria, Fustat, Reem Kufi, Aref Ruqaa and Gulzar once craft-fonts adds them, storytold/craft-fonts#11; releases get them when their craft-fonts pin moves) list them in the font menus on desktop; the web build fetches them from beside the wasm (Noto Sans Arabic before start, so Arabic never shows as boLine truncated
|
||||
- **Type:** Character size, leading, baseline shift and horizontal-scale controls account for object/group transforms (#1034): exact document-point sizes, mixed-value display and keyboard increments, range edits, resettable horizontal stretch, undo/redo and native save/reopen (`tests_typescale`, Character panel tests). Point size is the transformed em-height axis, independent of glyph ink bounds; affine rotation, reflection and shear are retained. Group scaling still scales text; preserving font sizes is a separate enhancement. Behaviour reference: [Transform text](https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/transform-text.html) (public documentation). Text Wrap, Type on a Path effects (Rainbow/Skew/3D Ribbon/Stair Step/Gravity) and Options (Flip, Align to Path, Spacing; OK keeps the type where it is), type on a path's start, centre and end brackets (Selection and Direct Selection drag them to set where the type runs, slide it along its path and, across the path, flip it: #429), Character and Paragraph Styles (override-preserving redefine), paragraph attributes per paragraph (alignment, indents, spacing, hyphenation, hanging punctuation, tab stops, paragraph style, direction, mojikumi and leading model; the Paragraph, Tabs and Paragraph Styles panels act on the paragraphs the Type tool's selection or caret touches, Return continues the paragraph's style, threads and SVG keep them), Area Type Options (rows/columns/inset/first baseline, vertical Align Top/Center/Bottom/Justify per row/column, reflowing non-rectangular frames and text wrap), threaded text across any closed shapes, Fit Headline, the Every-line Composer for justified and ragged text (paragraph-wide breaks for an even rag, Single-line per object from the Paragraph panel menu or `text.setFormat {composer}`). Area type resizes like Illustrator's type areas: a bounding-box handle drag (Selection tool) or Area Type Options Width/Height size the frame and the text reflows at its size (threads too); Direct Selection drags a frame corner or edge (`text.reshapeArea`). Point type still scales. Auto Size: the frame height follows the text after every edit, in the same undo step (rectangular frames of horizontal type in one row; columns get the least height that holds them; a height set by hand or threading turns it off), and the "Auto Size New Area Type" preference gives it to new area type. Shrink Text to Fit (beyond Illustrator): overflowing area type scales its sizes, leading and baseline shifts down by the largest factor (in 0.1 pt steps of the first run, down to a minimum percentage) that fits, at layout time so every export sees it. `text.areaOptions` and `document.inspect` report overflow and the shrink factor. The Selection tool shows the type widget beside selected type's bounding box (hollow on point type, filled on area type); double-clicking it converts between them (Type › Convert To Area Type / Convert To Point Type), keeping the text and its styles in place. Variable fonts list every named instance as a style of their family (Figtree Light to Black and their italics, Bahnschrift, Segoe UI Variable), and the instance chosen is shaped, drawn, outlined and measured at its axis settings; PDF and SVG embed it as that instance. Variation axis sliders are not done. Beyond Illustrator: inline graphics (`text.insertInline`, Type ▸ Insert Inline Symbol) set a document symbol in a run of type as one character that flows, wraps and justifies like a glyph (InDesign-style inline objects, e.g. mana symbols in card rules text): sized to a multiple of the font size, centred on the cap height, opening up tall lines, following Redefine Symbol; a character of its run's size for Character Alignment and Top-to-Top leading, placed by the bidi reordering in right-to-left text (a neutral U+FFFC); drawn on the canvas, kept as live text plus a `<use>` in SVG and as vector art in PDF; Backspace removes it whole. Hebrew and Arabic are laid out right to left with the Unicode bidirectional algorithm (embedded Latin and numbers keep their direction, lines put in visual order after wrapping) in point, area and path type, with caret, selection and arrow keys in visual order; each paragraph's direction comes from its first strong character unless Paragraph Direction sets it (Paragraph panel with Show Indic Options, `text.setFormat {direction}`), and new type aligns to the start of its direction. SVG export outlines right-to-left text. Arabic typefaces: builds made with a craft-fonts checkout that holds its Arabic families (Noto Sans Arabic, the Arabic fallback, plus Cairo, Tajawal, Almarai, IBM Plex Sans Arabic, Noto Kufi Arabic, Amiri, Alexandria, Fustat, Reem Kufi, Aref Ruqaa and Gulzar once craft-fonts adds them, storytold/craft-fonts#11; releases get them when their craft-fonts pin moves) list them in the font menus on desktop; the web build fetches them from beside the wasm (Noto Sans Arabic before start, so Arabic never shows as boLine truncated
|
||||
- **Interface languages, vertical type:** Preferences ▸ User Interface (or VectorCraft ▸ Language on macOS) picks Automatic (the system locale) or a registered language, persisted as `interfaceLanguage`. Translations come from per-language catalogs (`i18n/*.tsv`): Traditional and Simplified Chinese, Japanese, German (every `de-*` locale), Spanish (neutral international Spanish, for every `es-*` locale), French (every `fr-*` locale), Italian (every `it-*` locale) Russian (every `ru-*` locale) and Ukrainian (every `uk-*` locale) cover the whole interface, Czech and Brazilian Portuguese every menu label, Brazilian Portuguese the shell's `tl!` literals too (tested); German, Spanish, French, Italian, Japanese, Russian and Ukrainian also translate status-bar and error messages as they are shown (`@msg` templates, every message literal tested; the messages themselves stay English for agents); untranslated text stays English. Japanese and Chinese glyphs come from the craft-fonts build input (release builds) or the installed fonts, Czech, German, Spanish, French, Italian, Russian and Ukrainian ones from the bundled UI fonts. The web app follows the browser's languages (or `?lang=`). The Basic toolbar's Type button holds Vertical Type in its flyout. Vertical Type, Vertical Area Type and Vertical Type on a Path create vertical text (columns right to left, upright CJK glyphs and marks with `vert` alternates, Latin and longer numbers on their side, two- and three-digit numbers set as tate-chu-yoko in one em of the column (kept whole when justified), upright glyphs centred on the column (tracked or justified too), point type's anchor on the first column's centre line); kinsoku applies to horizontal and vertical Japanese (Paragraph panel › Kinsoku Set, `text.setFormat {kinsoku}`: Hard, the default, keeps closing marks, iteration marks, the prolonged sound mark and small kana off the line start and opening brackets off its end; Soft lets 々, ー and small kana start a line as JLREQ's level 3 rules do; None turns it off), and justified Japanese lines spread their room between characters (not inside Latin words); Paragraph panel › Mojikumi Set (shown with Preferences › Type › Show East Asian Options, `text.setFormat {mojikumi}`): None or Line-end Punctuation Half Width (new type's default, as in Illustrator; older documents and imported text keep None) shares one half-em space between consecutive punctuation, sets a closing mark ending a line half width and an opening bracket starting a line flush with its start (a paragraph's first line at its first-line indent, JIS X 4051's principle in JLREQ 3.1.5), and puts a quarter em between Japanese and Latin letters or digits; Paragraph panel menu › Top-to-Top / Bottom-to-Bottom Leading measures leading from em box top to top (area type's first line touches the frame) or from baseline to baseline (new type in a Japanese interface starts top to top, with em box centre character alignment, #432); Paragraph panel menu › Burasagari None / Regular / Force (`text.setFormat {burasagari: "none"|"standard"|"forced"}`) hangs an East Asian comma or full stop (、。,.、。) ending an area type line outside it, Regular (new type's default) when it doesn't fit and Force always (the rest of the line then fills the measure; closing brackets and Latin punctuation don't hang); Character panel menu › Character Alignment (with the East Asian options; `text.setFormat` or `text.setRangeStyle {charAlign}`) lines characters smaller than the largest on their line up on the Roman baseline, the em box top/right, centre or bottom/left, or the ICF (ideographic character face) top/right or bottom/left, from the font's BASE table or, without one, the average ink box of some ideographs and kana; Proportional Metrics (the same menu; `{proportionalMetrics}`) sets full-width glyphs on the font's proportional widths (OpenType `palt`) in horizontal type and upright glyphs on its proportional heights (`vpal`, shaped top to bottom; fonts without vertical metrics keep their cells) in vertical type, and Line-end Punctuation Half Width takes nothing more off the punctuation they re-spaced; Type ▸ Type Orientation switches existing text; caret, selection, hit testing and arrow keys follow the writing direction. Vertical text exports to PDF as real text and to SVG as outlines.
|
||||
- **Transparency:** opacity masks (clip/invert/disable/link), exported as SVG `<mask>` and PDF soft masks. The constant alpha soft mask that PDF and `.ai` export draw a CMYK page with transparency through is white, so applications that read every soft mask by its luminosity show the page too (#1359, part of #1286).
|
||||
Disable, Enable, Unlink and Link Opacity Mask and Opacity Mask Options (`transparency.setOpacityMask`) record no undo step when every mask they act on already has the flags they set.
|
||||
@@ -507,9 +507,11 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
|
||||
Use Precise Cursors restores the crosshair. The same glyph feeds OS bitmaps and painted
|
||||
cursors. DPI/hotspot and preference checks in `cursors.rs` and `tests_prefs.rs` cover #1269;
|
||||
actual OS display and Caps Lock remain unverified. Behaviour reference: [public tool-pointer documentation](https://helpx.adobe.com/illustrator/using/tools.html#change_tool_pointers).
|
||||
- **Performance:** 20k shapes + 1k texts render in 27 ms per full-retina frame (7.8 ms zoomed), 7× faster than the first version. The UI thread never blocks. The web build is 7.1 MB gzipped.
|
||||
- **Performance:** 20k shapes + 1k texts render in 27 ms per full-retina frame (7.8 ms zoomed), 7× faster than the first version. The UI thread never blocks. The web build is 7.1 MB gzipped. Print-size images (more than 512 MB decoded, up to 2 GB) draw in the canvas and in raster exports and can be trimmed with Crop Image; an image with a side past 65,535 px is shrunk to fit, a raster export says when an image could not be decoded, and Crop Image's trim leaves CMYK images alone (#1517). On Windows `vectorcraft-cli perf` reports the cores busy and, with `--deep`, its working set, and warns on a busy machine as it does on macOS and Linux (#1502).
|
||||
The Pathfinder panel, Offset Path, Clean Up, Divide Objects Below, Make and Release Blend, Envelope Distort's Make and Release, Repeat (Radial, Grid, Mirror and Release), Vector Halftone, New and Redefine Symbol, Delete Symbol, erasing with the Symbol Sprayer, Rasterize, Convert to Artboards, Flatten Transparency, Clear Guides, Shape Builder, Live Paint, Release Image Trace, Make and Release Opacity Mask, Delete with direct-selected anchor points, Remove Anchor Points, Cut Path, and the Shaper, Knife, Eraser, Path Eraser, Line Cut, Rectangle Cut, Mirror & Cut and Blob Brush tools add and remove objects in one pass over each layer or group they change, and Outline Stroke and Expand write each new object in the place of the object it replaces (#1429).
|
||||
Delete, Cut, Group, Ungroup, Make and Release Compound Path, Make Clipping Mask, Send to Current Layer, Duplicate, Copy in the Move, Rotate, Scale, Reflect and Shear dialogs, Transform Each with Copy and each move of an Alt-drag take time in proportion to the size of the document (#1429).
|
||||
An Alt-drag with the Selection, Direct Selection, Group Selection, Rotate, Scale, Reflect or Shear tool copies the selection on its first move, and the moves after it transform those copies; the previews of the Move, Rotate, Scale, Reflect and Shear dialogs with Copy and Preview on copy the selection once in the same way (#1429).
|
||||
The Layers panel's Delete Selection, Duplicate Selection, Collect in New Layer, Merge Selected, Flatten Artwork, Release to Layers (Sequence) and row drags also take time in proportion to the size of the document, and so do Cut Artboard and Duplicate Artboard with their art and each move of an Alt-drag of one artboard with the Artboard tool while Move/Copy Artwork with Artboard is on (#1429).
|
||||
- **Graphics processor:** the canvas is rasterized on the CPU and the GPU only composites it, so any GPU that can show the window will do. Preferences › Performance › Graphics Processor (`gpuPreference`) is Automatic by default: the power-saving GPU on Windows and macOS (the integrated one on hybrid-graphics laptops, where presenting from the discrete GPU made the window flicker, #306), and the GPU the desktop runs on under Linux, where KWin on an NVIDIA GPU refused frames from a Ryzen's integrated GPU and 0.5.0 crashed at startup (#502). Power Saving and High Performance pick a kind of GPU; `WGPU_POWER_PREF`, `WGPU_ADAPTER_NAME` and `WGPU_BACKEND` override them. The app picks among the adapters that can present to the window (native backends before OpenGL, hardware before software, and on Windows a GPU's DX12 adapter before its Vulkan one: Intel's Vulkan driver made the whole window flicker black, #545), and if the window fails on one while starting up it starts again without it instead of crashing; Help › About, `ui.inspect` and the log name the adapter in use. When the browser drops the page's WebGPU device or WebGL context, the web build writes recovery copies, starts again on new graphics with the same app and uploads its textures again; if it can't, it removes the stale canvas and says to reload, which offers the copies back. The desktop app writes the copies and says to restart (#369). On Windows and Linux the tools' cursors are OS cursors (bitmaps rasterized once per display scale), which the system moves itself: painted into the window, they trailed the pointer by the frames waiting to be shown, and the window now queues one frame instead of two (#444). macOS and the web still paint them (macOS sizes cursor bitmaps in points, eframe has no bitmap cursors on the web). Pens and touch drive the tools as the mouse does, and a tap pressed and lifted within one frame clicks where it tapped (#491); pen pressure reaches the Liquify tools' Use Pressure Pen on Windows, while winit 0.30 reports none on macOS or Linux and egui none on the web. On Linux, see `docs/development.md` for pens under Wayland.
|
||||
- **Tests and robustness:** ~2,840 automated tests: model-based property tests, a junk-parameter sweep over every command, import fuzzing (SVG, PDF, libraries), golden renders, and MCP end-to-end tests over stdio. Shipped code never panics (workspace lints and a rollback safety net; see `docs/development.md`).
|
||||
- **Raster-effect resolution in PDF:** when the 64 million pixel cap on an effect image renders an object's raster effects below the document's raster effects resolution, PDF export warns once per object with the requested and the lowest achieved ppi (`crates/engine/src/tests_pdfraster.rs`).
|
||||
@@ -525,7 +527,10 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-13 | minor | UI and tools: sliders in Save for Web, the raster export options, Preferences and the opacity popup show their rail, filled up to the handle (#1506, fixes #1483); in Outline view a template layer stays in Preview, dimmed (#1505, fixes #1486); Direct Selection takes the selected object's anchor where anchors of overlapping objects coincide (#1509, fixes #1508); a tab stop in the Tabs panel drags from where the press landed (#1487, fixes #1458); type: commands and MCP `add_text` report area type that does not fit its frame (#1500, fixes #1471); graphs: `graph.setData`'s CSV keeps each value exactly (#1485); scores and hours unchanged (estimated) |
|
||||
| 2026-10-13 | minor | 3D and Materials: outlined words and other shapes of many small curves take Rotate, Extrude & Bevel and Inflate (#1481), Twist runs 0° to 360° (#1524) and twisted walls keep their faces (#1525), all described in [three-d.md](three-d.md); rendering: print-size images draw, export and crop (#1517); `vectorcraft-cli perf` on Windows reports load and memory (#1502; no budget re-run); localization: French catalog complete again with 423 strings added and 188 reworded (#1511), a Hungarian message in the informal address (#1474); scores and hours unchanged (estimated) |
|
||||
| 2026-10-13 | minor | Image Trace, measured in `image-trace-quality.md`: the palette is made of the picture's own colours (#1504): 16 Colors on flat art 70 → 4.2 paths and 5.1 → 0.13 px off, paths in a colour the art lacks 5.9 → 0; photos are smoothed within their tones first and stacked layers go by lightness (#1516): High Fidelity Photo 3,653 → 121 paths with its colour error 2.30 → 1.75, 16 megapixels traced where 4 were refused. Score and hours unchanged (estimated) |
|
||||
| 2026-10-13 | minor | 3D and Materials: Inflate takes Twist, Taper and Cap ([three-d.md](three-d.md)); scores unchanged (3D ~25%) |
|
||||
| 2026-10-13 | minor | Automation: a command run through MCP, the control channel or the CLI refuses a parameter key it doesn't take, naming it and the nearest accepted key (#1496, fixes #1442; 598 of 737 engine commands checked, 168 params descriptions corrected); plug-ins: a numeric parameter's default stays inside its declared range (#1463); stability: a window surface larger than 8192 px is configured up to the graphics adapter's limit instead of closing the app (#1495, part of #1459; not reproduced on a mixed-scale setup); text frames, wrap shapes and type paths beyond a billion points hold no text instead of hanging the layout (#1451); scores and hours unchanged (estimated) |
|
||||
| 2026-10-13 | minor | File formats: PDF and `.ai` import keep a stroke opaque and unmasked when the fill under it was faded or masked by its group, and a `.ai` read as its PDF part keeps its RGB numbers (#1434); SVG export writes knockout groups of opaque art without masks, and `convert` takes `--decimals` (#1435); grayscale TIFFs with an alpha channel or other extra channels place, relink and open (#1489, part of #1466; colourising 1-bit and grayscale images is still open); score and hours unchanged (estimated) |
|
||||
| 2026-10-13 | minor | Path operations and transforms: Split Into Grid has its row and column sizes, gutters, totals, Add Guides and Preview (#1488, fixes #1462); the Pathfinder panel works on a group selected alone (#1493, for #1450; one plain path alone is not settled by the documentation); Transform Objects in the Transform dialogs and Transform Each, and Reflect across an angle (#1494, fixes #1448 and #1460); Fit Headline in a frame one line tall (#1445, fixes #1444); value fields: selected text is readable (#1490, part of #1465) and a focused field follows its value (#1497, fixes #1457); the many-object edits of #1430 and #1464 are under Performance already; scores and hours unchanged (estimated) |
|
||||
@@ -549,6 +554,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
|
||||
| 2026-10-12 | minor | Guides/rulers: custom document zero point (#1212) with preview, reset, relative position fields and native round-trip; global/artboard mode switch and video rulers remain; score 80% and 4–7 h unchanged (estimated) |
|
||||
| 2026-10-12 | minor | Artboard click-to-fit verified for objects, groups, visible bounds, undo and cancellation (#1227); scores and hours unchanged |
|
||||
| 2026-10-12 | minor | Pathfinder single-result booleans preserve source names and SVG IDs (#1293); paint-order, grouped/compound sources, unchanged geometry/appearance and Undo/Redo regression tests; scores and estimates unchanged |
|
||||
| 2026-10-12 | minor | Localization: Vietnamese (`vi`) catalog added, complete under the tests; Localization 63% → 71% (measured), hours 60–100 → 55–90; full readiness unchanged (~62%) |
|
||||
| 2026-10-12 | minor | Localization: Korean (`ko`) catalog added, complete under the tests; Localization 54% → 63% (measured), hours 70–110 → 60–100; full readiness unchanged (~62%) |
|
||||
| 2026-10-12 | minor | Desktop PDF spooler shared through craft-print; exact physical sizes, composed copies and Windows 7 fallback retained. Scores and remaining gaps unchanged. |
|
||||
| 2026-10-11 | minor | Image Trace: an image over 64 megapixels (up to 192) is traced scaled down to 64 megapixels with its paths at the image's own size, instead of being refused; score and hours unchanged (estimated) |
|
||||
|
||||
+4
-1
@@ -24,7 +24,7 @@ object is where the art was.
|
||||
| Option | What it does |
|
||||
|---|---|
|
||||
| Depth | 0–2,000 pt. At 0 the object is flat (the art turned like Rotate). |
|
||||
| Twist | −360° to 360°: the back face turned against the front, the walls following in slices. |
|
||||
| Twist | 0° to 360°: the back face turned against the front, the walls following in slices. |
|
||||
| Taper | 1–100 %: the back face's size against the front's. |
|
||||
| Cap | **Solid** closes the front and back; **Hollow** leaves them open, showing the insides of the walls. |
|
||||
| Bevel Shape | **None**, **Classic** (a straight chamfer), **Rounded** (convex quarter round), **Cove** (concave), **Step** (two steps) or **Ogee** (an S curve). Our own cross-sections. |
|
||||
@@ -44,6 +44,9 @@ middle.
|
||||
| Option | What it does |
|
||||
|---|---|
|
||||
| Depth | 0–2,000 pt of flat side between the front and the back. |
|
||||
| Twist | 0° to 360°: the back turned against the front (its puff too), the sides following in slices. |
|
||||
| Taper | 1–100 %: the back's size against the front's; a puffed back is lower in proportion. |
|
||||
| Cap | **Solid** closes the back; **Hollow** leaves it off, so the inflated front is a shell open behind, its inside showing. The public documentation lists Cap for every 3D type without saying what it means for Inflate; this is our reading. |
|
||||
| Volume | 0–100 %. The surface is a membrane under even pressure, held along the outline (and around holes): it rises vertically from the edge and rounds smoothly inside, without creases along the shape's middle. At 50 % a circle becomes a half sphere; 100 % puffs twice as high, 0 % is flat. |
|
||||
| Both sides | The back puffs out too; otherwise it is flat. |
|
||||
|
||||
|
||||
+4
-3
@@ -1,6 +1,6 @@
|
||||
# Type parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (Fit Headline in a frame one line tall, #1444; frames and paths beyond a billion points hold no text, #1451; scores unchanged; missing glyphs are looked for again after a font rescan, #1376; scores unchanged; Full-name font matching (#1258); Windows font-service QA pending; scores unchanged; Middle Eastern digit types: Character panel menu › Digits, `{digits}`; the font menu's script filter lists Thai fonts; Thai, Lao, Khmer and Myanmar lines break between words; the caret steps over whole clusters such as Thai consonants with their marks; Thai typed in a font without Thai falls back to a font made for Thai; earlier: Proportional Metrics in vertical type, `vpal`, #966) · **Target:** Adobe Illustrator 2026 (30.x)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (commands and MCP `add_text` report area type that does not fit its frame, #1471; Tabs panel stop drags, #1458; Fit Headline in a frame one line tall, #1444; frames and paths beyond a billion points hold no text, #1451; scores unchanged; missing glyphs are looked for again after a font rescan, #1376; scores unchanged; Full-name font matching (#1258); Windows font-service QA pending; scores unchanged; Middle Eastern digit types: Character panel menu › Digits, `{digits}`; the font menu's script filter lists Thai fonts; Thai, Lao, Khmer and Myanmar lines break between words; the caret steps over whole clusters such as Thai consonants with their marks; Thai typed in a font without Thai falls back to a font made for Thai; earlier: Proportional Metrics in vertical type, `vpal`, #966) · **Target:** Adobe Illustrator 2026 (30.x)
|
||||
|
||||
Type is Illustrator's largest single area by use (weight 13 of 105 across "Type core" and "Type advanced" in
|
||||
[target-app-parity.md](target-app-parity.md)). This checklist lists the features one by one. Status is from the
|
||||
@@ -19,7 +19,7 @@ against Illustrator's public documentation; **estimated** unless noted.
|
||||
| Feature | Status | Notes |
|
||||
|---|---|---|
|
||||
| Point, area and type-on-a-path type; vertical variants | done / partial | vertical point, area and path type have initial support |
|
||||
| Area Type Options (rows, columns, inset, first baseline, Auto Size, Shrink Text to Fit) | done | |
|
||||
| Area Type Options (rows, columns, inset, first baseline, Auto Size, Shrink Text to Fit) | done | the commands that make or restyle type say when it does not fit its frame; MCP `add_text` takes Auto Size and Shrink Text to Fit (#1471) |
|
||||
| Threaded text, Fit Headline, Text Wrap | done | Fit Headline sees wrapping in a frame one line tall (#1444) |
|
||||
| Type on a Path effects and options; brackets | done | #429 |
|
||||
| Character and Paragraph panels, styles with overrides | done | mixed-value display for differing paragraphs missing |
|
||||
@@ -34,7 +34,7 @@ against Illustrator's public documentation; **estimated** unless noted.
|
||||
| Every-line and Single-line composers | done | tuned by eye, never compared side by side |
|
||||
| Hyphenation… and Justification… options | missing | |
|
||||
| Optical Margin Alignment | missing | menu stub |
|
||||
| Tab stops and leaders; Tabs panel | partial | Tabs panel and stops with leaders in the model (`text.tabs.set`); leader drawing not verified against the docs |
|
||||
| Tab stops and leaders; Tabs panel | partial | Tabs panel and stops with leaders in the model (`text.tabs.set`); a stop drags from where the press landed (#1458); leader drawing not verified against the docs |
|
||||
| Hidden characters (Show Hidden Characters) | done | `type.hiddenCharacters` |
|
||||
| Spell check (Check Spelling, Auto Spell Check, custom dictionary) | missing | menu stubs; needs an openly licensed dictionary |
|
||||
| Smart Punctuation, Change Case, Find & Replace | done | |
|
||||
@@ -56,6 +56,7 @@ against Illustrator's public documentation; **estimated** unless noted.
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-13 | minor | The commands that make or restyle type, and MCP `add_text`, report area type that does not fit its frame, and `add_text` takes Auto Size and Shrink Text to Fit (#1500, fixes #1471); a tab stop in the Tabs panel drags from where the press landed, a stop just added included (#1487, fixes #1458); scores and hours unchanged |
|
||||
| 2026-10-13 | minor | Fit Headline no longer pushes the tracking to its ceiling and loses glyphs when the frame holds one line (#1445, fixes #1444); text frames, wrap shapes and type paths with coordinates beyond a billion points hold no text, and layout limits its search for room, instead of hanging (#1451); scores and hours unchanged |
|
||||
| 2026-10-12 | minor | A character no installed font had is looked for again after the fonts are rescanned, so installing a font fixes it without a restart (#1376, from #992); scores and hours unchanged |
|
||||
| 2026-10-12 | minor | Full-name font matching and Missing Fonts searches (#1258): synthetic static/variable font regressions; Windows font-service QA pending; scores and hours unchanged |
|
||||
|
||||
+6
-1
@@ -1,6 +1,6 @@
|
||||
# UI and interaction parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (Split Into Grid dialog, #1462; Transform Objects and Reflect's angle, #1448 and #1460; Pathfinder panel on a group, #1450; value fields: readable selection, #1465 in part, and a focused field follows its value, #1457; scores unchanged; Transform dialogs have a live Preview and step with the arrow keys, #1412; transform tools, dialogs and Move act on direct-selected anchors, #1408; the bounding box hides during a corner drag, #1229; scores unchanged; dialogs focus their first field on every open, #1367, and a first field that starts greyed out once it is enabled, #1395; scores unchanged; Rectangular Grid and Polar Grid click dialogs take the skew, frame, compound path and Fill Grid options, #1377 in part, score unchanged; Live Corners: a Direct Selection click on a widget selects its corner, #1239; custom ruler origin, #1212; Artboard tool clicks fit the topmost editable object in one undo step (#1227); scores unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; Simplify preview/counts and valid rewritten-path selection (#1272); parity remains partial; Texture-effect drag redraws optimized (#1257); idle Windows interaction still unverified; scores unchanged; Curvature point display improved (#1236, in part); scores unchanged; Direct Selection: no-op anchor Align preserves Undo, Redo and saved state (#1180 follow-up); Eyedropper cursor distinguished from precise crosshair (#1269); scores unchanged; Eyedropper can read visible locked and outside-isolation sources (#1251); scores unchanged; Direct Selection: independent segment picks and Delete (#1278); scores unchanged; Adding paint to an unpainted zero-width stroke restores the 1 pt default (#1253); scores unchanged; macOS save sheets offer a Format menu, #866; Attributes fill-rule controls edit the compound when a member is selected, #1197; a marquee needs to touch a path, not just lie inside its fill, #1103; earlier: Live Corners round corners between curved sides, #1144) · **Target:** Adobe Illustrator 2026 (30.x)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (dialog slider rails, #1483; template layers stay in Preview in Outline view, #1486; Direct Selection takes the selected object's anchor where anchors coincide, #1508; Tabs panel stop drags, #1458; Split Into Grid dialog, #1462; Transform Objects and Reflect's angle, #1448 and #1460; Pathfinder panel on a group, #1450; value fields: readable selection, #1465 in part, and a focused field follows its value, #1457; scores unchanged; Transform dialogs have a live Preview and step with the arrow keys, #1412; transform tools, dialogs and Move act on direct-selected anchors, #1408; the bounding box hides during a corner drag, #1229; scores unchanged; dialogs focus their first field on every open, #1367, and a first field that starts greyed out once it is enabled, #1395; scores unchanged; Rectangular Grid and Polar Grid click dialogs take the skew, frame, compound path and Fill Grid options, #1377 in part, score unchanged; Live Corners: a Direct Selection click on a widget selects its corner, #1239; custom ruler origin, #1212; Artboard tool clicks fit the topmost editable object in one undo step (#1227); scores unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; Simplify preview/counts and valid rewritten-path selection (#1272); parity remains partial; Texture-effect drag redraws optimized (#1257); idle Windows interaction still unverified; scores unchanged; Curvature point display improved (#1236, in part); scores unchanged; Direct Selection: no-op anchor Align preserves Undo, Redo and saved state (#1180 follow-up); Eyedropper cursor distinguished from precise crosshair (#1269); scores unchanged; Eyedropper can read visible locked and outside-isolation sources (#1251); scores unchanged; Direct Selection: independent segment picks and Delete (#1278); scores unchanged; Adding paint to an unpainted zero-width stroke restores the 1 pt default (#1253); scores unchanged; macOS save sheets offer a Format menu, #866; Attributes fill-rule controls edit the compound when a member is selected, #1197; a marquee needs to touch a path, not just lie inside its fill, #1103; earlier: Live Corners round corners between curved sides, #1144) · **Target:** Adobe Illustrator 2026 (30.x)
|
||||
|
||||
How VectorCraft's tools, handles, snapping and panels behave next to Illustrator's. A vector illustrator's
|
||||
trust is in the details: where a handle lands, what Shift and Alt do mid-drag, what a smart guide snaps to,
|
||||
@@ -76,6 +76,7 @@ tools, never checked side by side) isn't captured row by row.
|
||||
| Corners between curved sides (#1144), Relative rounding | partial | Relative rounding missing |
|
||||
| Isolation mode by double-click (groups and single objects), breadcrumbs, Esc to exit | done | Single paths, compound paths, images, meshes and placed files isolate too ([helpx: Isolate objects](https://helpx.adobe.com/africa/illustrator/desktop/manage-objects/select-objects/isolate-objects.html)) |
|
||||
| "Magnetic" moves of selected anchors with a modifier (#955 request) | missing | not an Illustrator feature as documented; under discussion |
|
||||
| Coinciding anchors of overlapping objects: the selected object's anchor is taken, also under another object | done | #1508 |
|
||||
|
||||
## Smart guides and snapping
|
||||
|
||||
@@ -128,6 +129,7 @@ tools, never checked side by side) isn't captured row by row.
|
||||
| Scrubby labels (beyond Illustrator) | done | #400 |
|
||||
| Dialogs open with the first field focused and selected; Enter confirms | done | on every open, not only the first (#1367); a first field that starts greyed out takes the focus once enabled (#1395) |
|
||||
| Value fields: selected text readable in every brightness; a focused field follows its value when a slider or preview changes it | partial | #1490 (part of #1465: the wheel over a hovered, unfocused field still scrolls the panel), #1457 |
|
||||
| Sliders in dialogs and popups (Save for Web, raster export options, Preferences, the opacity popup) show their rail, filled up to the handle | done | #1483 |
|
||||
| Rectangular Grid and Polar Grid click dialogs: size, divider counts with a skew slider each, Use Outside Rectangle As Frame / Create Compound Path From Ellipses, Fill Grid; kept as the tool's options, used by drags too | partial | #1377. The options are the documented ones; how far a skew percentage moves the dividers and which way the radial skew turns are our own choice (the public documentation gives neither), and the reference point locator and a double-click on the tool are missing |
|
||||
|
||||
## Modifier keys and temporary tools
|
||||
@@ -180,6 +182,8 @@ Layers panel.
|
||||
| DXF export/import preserves intended curve closure | done | #1299; R13–2018 closed SPLINE bit set/read; an open curve ending at its start stays open; knots and control points unchanged; native CIRCLE/ARC export and CNC preset remain pending |
|
||||
| macOS save sheet: choose the format from a menu | done | #866; `apps/vectorcraft/src/mac_save_format.rs`; current suffix selects the initial format, changing it updates the filename; native SVG save and cancel after changing to PDF checked |
|
||||
| Preferences: every option acts | partial | ~16 stored but unused (#394) |
|
||||
| Tabs panel: a stop drags from where the press landed, a stop just added included | done | #1458 |
|
||||
| Outline view keeps template layers in Preview, dimmed | done | #1486; a picture placed to be traced stays visible under the outlines |
|
||||
|
||||
## Look and feel
|
||||
|
||||
@@ -192,6 +196,7 @@ re-measure of the panels added since.
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-13 | minor | Sliders in Save for Web, the raster export options, Preferences and the opacity popup show their rail (#1506, fixes #1483); a template layer stays in Preview, dimmed, in Outline view (#1505, fixes #1486); Direct Selection takes the selected object's anchor where anchors of overlapping objects coincide (#1509, fixes #1508); a tab stop in the Tabs panel drags from where the press landed (#1487, fixes #1458); scores unchanged (estimated) |
|
||||
| 2026-10-13 | minor | Split Into Grid has its row and column sizes, gutters, totals, Add Guides and Preview (#1488, fixes #1462); Transform Objects in the Transform dialogs and Transform Each, and Reflect across an angle (#1494, fixes #1448 and #1460); the Pathfinder panel works on a group selected alone and its greyed-out buttons say what they need (#1493, for #1450); selected text in value fields is white or black on the highlight (#1490, part of #1465); a focused field follows its value when a slider or preview changes it (#1497, fixes #1457); an Alt-drag copies once per drag (#1464); scores unchanged (estimated) |
|
||||
| 2026-10-12 | minor | Transform dialogs (Move, Rotate, Scale, Reflect, Shear) preview on the canvas and step their angles and percentages with the arrow keys (#1428, fixes #1412); the transform tools, dialogs and Move act on anchors selected with Direct Selection (#1427, fixes #1408); the bounding box hides during a corner drag with the Selection tool (#1402, fixes #1229); a picture pastes when blank text sits beside it on the clipboard (#1404); scores unchanged (estimated) |
|
||||
| 2026-10-12 | minor | Dialogs opened from a menu take the keyboard focus every time they open (#1367); a first field that starts greyed out takes it once enabled (#1395); art flush with an artboard within a rounding error moves, copies and scales with it (#1375, from #1295); scores and hours unchanged |
|
||||
|
||||
Reference in new issue
Block a user