Compare commits

...
Author SHA1 Message Date
bflatasticandClaude Opus 5.5 30ba774e6f Part of gap 2: Effect › Texture (Craquelure, Grain, Mosaic Tiles, Patchwork, Stained Glass, Texturizer)
Six raster effects on the pixel-filter pipeline
(vectorcraft_effects::pixel::texture), under a new Effect › Texture
submenu between Sharpen and Video in the Raster Effects section:

- Craquelure (crackSpacing pt 2..100, default 15; crackDepth 0..10, 6;
  crackBrightness 0..10, 9): the object on relief plaster that cracks into
  plates about Crack Spacing apart (a Voronoi network over jittered points)
  and along the quarter, half and three-quarter contours of its softened
  tones. Depth sets how dark and wide the cracks are and how high the
  plates' bevels and the tones stand (lit from the top left); Brightness
  how brightly the surface is lit.
- Grain (intensity 0..100, 40; contrast 0..100, 50 = unchanged; grainType
  Regular, Soft, Sprinkles, Clumped, Contrasty, Enlarged, Stippled,
  Horizontal, Vertical, Speckle; default Regular): coloured 1-point grains,
  smooth ones, clumps, 2-point grains, streaks along rows or columns, dark
  specks; Sprinkles and Stippled use the background colour, white.
- Mosaic Tiles (tileSize pt 2..100, 12; groutWidth 1..15, 3; lightenGrout
  0..10, 9): irregular tiles bevelled at their edges, with sunken grout half
  a point wide per step, lightened as Lighten Grout says.
- Patchwork (squareSize pt 0..10, 4; relief 0..25, 8): squares filled with
  the colour around their centres, raised to heights that follow the
  highlights and shadows, a little more or less at random.
- Stained Glass (cellSize pt 2..50, 10; borderThickness 1..20, 4;
  lightIntensity 0..10, 3): single-coloured panes leaded in the foreground
  colour (black, half a point per step), lit from behind at the object's
  centre and fading out towards its corners (PixelSpace gains `extent`).
- Texturizer (texture Brick, Burlap, Canvas, Sandstone, default Canvas;
  scaling % 50..200, 100; relief 0..50, 4; lightDirection Bottom, Bottom
  Left, Left, Top Left, Top, Top Right, Right, Bottom Right, default Top;
  invert): the object on a surface in relief. The four surfaces are height
  fields generated in code (bricks in stretcher bond, plain weaves coarse
  and fine, octaves of smooth noise); no image files. Load Texture is left
  out.

Every pattern lies in document space around the object's centre, so the
result is the same at any zoom or raster resolution, moves with the object
and is identical on every redraw (hash noise of cell indices); slopes are
taken in document units. Work per pixel is bounded (a 5 x 5 cell search,
four or five height samples, box blurs); parameters are clamped to their
ranges and finite-checked. The Pixelate helpers (Place, the cell search,
the softened colours) are shared, Mezzotint uses the shared smooth noise,
and the effect dialogs' dropdowns translate their labels in an "effect"
context where the plain word means something else (Soft, Horizontal and
Vertical are type settings in Japanese and Chinese).

Catalogue rows with parameter docs, outsets and reaches, menu entries,
dropdowns (Grain Type, Texture, Light Direction), translations, tests
(parameters and clamping, cracks and contours, ten distinct grains, tiles
and grout, squares in relief, leaded panes and the centre light, the
textures lit and inverted, the same at twice the resolution and when
moved, canvas, multithreaded, PDF and SVG, the command sweep with junk
parameters, the dialogs and the menu) and ROADMAP.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 19:31:47 +07:00
BFlat 32a688d68b Merge pull request #953 from crqch/fix-contributor-links
Fix contributor links
2026-10-10 18:50:11 +07:00
BFlat 5d07d646a9 Merge pull request #950 from seldoms/i18n/chinese-translations
i18n: wrap hardcoded UI strings for Chinese localization
2026-10-10 18:40:44 +07:00
bflatasticandClaude Opus 5.5 22443d8711 Keep CJK UI fallback fonts to ui_fonts
The app already finds an installed font for CJK (and other) characters
the UI fonts lack, the first time a frame paints one (ui_fonts): reading
a system CJK font at every startup duplicated that, cost a font file's
read and memory for interfaces that never show CJK, and broke its test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 18:36:15 +07:00
Michał Kosior crqch a0cc4a4b94 Fix contributor links 2026-10-10 13:35:36 +02:00
seldoms f0349fb2f8 i18n: wrap hardcoded UI strings for Chinese localization
- Wrap remaining hardcoded UI labels and buttons with tr()/tl!() calls
- Add missing Simplified and Traditional Chinese translations
- Add entries to other language catalogs for consistency
2026-10-10 18:27:56 +07:00
BFlat d428de6caa Merge pull request #952 from storytold/fix/949-artboard-label-width
Fix #949: artboard names fit their artboard when zoomed out
2026-10-10 18:25:02 +07:00
bflatasticandClaude Opus 5.5 6a13088840 Fix #949: artboard names fit their artboard when zoomed out
Artboard names are drawn at a fixed screen size above each artboard, so
zoomed out, where artboards shrink on screen, the names ran past them
and over their neighbours. A name is now no wider than its artboard on
screen, ending in an ellipsis when longer, and an artboard narrower
than 24 px shows none.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 18:24:50 +07:00
BFlat 18c9d27c8d Merge pull request #947 from IHAGI-c/fix/graph-blank-values
Graph Data: blank cells are left out, not drawn as zero; quoted numbers are labels
2026-10-10 18:16:26 +07:00
BFlat 3b32ea9626 Merge pull request #946 from rschuerer/fix/861-draw-non-native-art
Part of #861: draw non-native art in .ai editing data from its PDF
2026-10-10 18:13:52 +07:00
bflatasticandClaude Opus 5.5 42b198e3cb Graph data: constants after the imports
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 17:59:47 +07:00
IHAGI-c 35c4a428ff Graph Data: blank cells are left out, not drawn as zero; quoted numbers are labels
A graph's data can now hold blank cells: an empty CSV cell, a null in
`rows` (any cell that isn't a finite number), the rest of a short row,
or a row that has only a label.

- Columns, bars and stacked segments leave a blank cell out instead of
  drawing a zero-height mark.
- Line and scatter graphs break their lines around it and draw no
  marker there; the value axis ignores it.
- Area, pie and radar graphs draw it as 0, which is what it adds to
  them.

The help pages don't describe blank cells, so this is our own choice:
a cell with no value has nothing to draw, which also matches how a
series with a missing year reads in a report.

In the file a blank cell keeps a 0 in `rows` and is listed in a new
`blanks` field ([category, series] pairs, written only when there are
any). A version that doesn't know blanks ignores the field and reads
the zeros, so files still open there, and files without blanks are
unchanged.

A number in straight quotes is a label, as the help page on graph
labels says (a year as a category name, for one). The CSV reader used
to strip the quotes first and read it as a value. Label columns are now
decided for the whole column: once any row starts with a label, every
row's first cell is its label, so a row with an empty label no longer
shifts its values one place left. The data window writes a header row,
labels that are empty, read as numbers or have spaces around them in
quotes, and blanks as empty cells, so they come back as they were.
graph.setData {} returns blanks in `rows` as null, so the rows it
returns can be edited and sent back. A label-only row keeps its place
with blank values; a row with neither a value nor a label is dropped,
and a first row that is a label followed by empty cells (`Year,,`) is
still read as a header with no series names.

The data grid is built in full for drawing and for the data window, so
it now stops at 10,000 categories of 256 series, which keeps a file
with one very long row from allocating without bound.

Behaviour (quoted labels): Add graph labels and data sets:
https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/visualize-data/add-graph-labels-and-data-sets.html
2026-10-10 17:59:45 +07:00
bflatasticandClaude Opus 5.5 1446b4f5dc Keep the doc comment of unreadable on unreadable
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 17:58:55 +07:00
Reinhard SchuererandClaude Fable 5.1 79dd751d3b Draw non-native art in .ai editing data from its PDF
A /ForeignObject (non-native art: the content of a placed PDF, which
Illustrator shows but doesn't edit) was left out with a note. The
letterhead of #861 keeps its fan of swooshes that way: a radial gradient
in a 3-point PDF, scaled 271 times and clipped by the swoosh paths, so
the page lost its main graphic.

The reader now decodes the object's ASCII85 /Data, has the PDF importer
read the PDF, and fits its art to the object's box as a group named
"Non-native art": /Bounds measured from /Origin with y down, then
/RTransform, which lands exactly on the clipping paths around both
objects of that file; the PDF's content is kept in its own y-up
coordinates, so it is flipped into the box. Ids are the document's and
the PDF's swatches stay unlinked; its images join the document by
content key. A PDF that can't be read is left out with a note, as
before.

The EPS crate now uses the PDF crate (same layer; allowed in the layer
check like render and metafile).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ZwZjADUZP4VzvrFGpiZch
2026-10-10 17:58:55 +07:00
BFlat 7d6be31711 Merge pull request #948 from storytold/feat/brush-options-scatter-art-pattern
Part of gap 5: Scatter, Art and Pattern Brush Options
2026-10-10 17:57:25 +07:00
bflatasticandClaude Opus 5.5 2bc6c66c94 Part of gap 5: Scatter, Art and Pattern Brush Options
Double-clicking a Scatter, Art or Pattern brush in the Brushes panel (or the
panel menu's Brush Options...) now opens its options dialog, as Calligraphic
and Bristle brushes already did. OK runs brush.options and every stroke
painted with the brush redraws.

- Scatter Brush Options: Name; Size (10-1000 %), Spacing (10-10000 %),
  Scatter (-1000-1000 %) and Rotation (-180-180 deg), each Fixed (the first
  value), Random (between the two values) or Pressure (the first value at
  the lightest pen pressure, the second at the heaviest); Rotation relative
  to Page or Path. Scatter brushes now keep these modes (`modes`); brushes
  saved before keep their look (Random where the two values differ).
- Art Brush Options: Name; Width (1-1000 %); Scale Proportionately, Stretch
  to Fit Stroke Length or Stretch Between Guides with the guides' Start and
  End (% of the art length); the four Directions; Flip Along and Across.
- Pattern Brush Options: Name; Scale (1-10000 %), Spacing (0-10000 %); the
  five tiles (Outer Corner, Side, Inner Corner, Start, End) drawn with their
  art or None; Flip Along and Across; Stretch to fit, Add space to fit or
  Approximate path.
- All three: Colorization (None, Tints, Tints and Shades, Hue Shift with its
  Key Color picked from the art's colours, the most used one by default) and
  a live preview of a stroke painted with the edited brush.

Brush definitions from files and commands are now kept within these ranges
(Brush::sanitize, also for Calligraphic and Bristle values); unknown modes,
directions, fits or colorization methods are refused. The brush.options and
ui.brushOptions docs list the new params and fields. Tests: the brush model
(modes, pressure, sanitizing, art colours), engine params reaching the
brush and strokes redrawing, junk params in command_sweep, and headless
dialog tests for each dialog.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 17:48:05 +07:00
BFlat 5b198f3c00 Merge pull request #941 from IHAGI-c/fix/graph-line-options
Graph Type: Edge-to-Edge Lines for line graphs; radar follows Mark and Connect Data Points
2026-10-10 17:32:01 +07:00
bflatasticandClaude Opus 5.5 471c47f96a Graph Type: Edge-to-Edge Lines start off, as the reference app's do
New line graphs put their points at the centres of their categories, the
option's default in the reference app; graphs saved before the option
existed were drawn edge to edge and keep it (a field default of its own).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 17:31:33 +07:00
IHAGI-c bb778a50aa Graph Type: Edge-to-Edge Lines for line graphs; radar follows Mark and Connect Data Points
Line graphs take the Edge-to-Edge Lines option (`edgeToEdge` on
graph.setType). On, the lines run across the whole plot, which is how
they always drew and stays the default, so existing graphs and files
look the same. Off, the points sit at the centres of their categories,
like the columns of a column graph, so a line graph lines up with a
column graph of the same size. The page describes only what the option
does when on; the off position is its opposite.

Radar graphs drew their ring and markers whatever Mark Data Points and
Connect Data Points said. They now follow both, like line and scatter
graphs.

Behaviour: Format columns, bars, and lines, Graph Type options for
line, scatter and radar graphs (Mark Data Points, Edge-to-Edge Lines,
Connect Data Points):
https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/visualize-data/format-columns-bars-and-lines.html
2026-10-10 17:31:33 +07:00
BFlat 7241726e56 Merge pull request #940 from IHAGI-c/fix/graph-axis-override
Graph Type: Override Calculated Values uses the exact value-axis range
2026-10-10 17:23:59 +07:00
BFlat 3bdaa45607 Merge pull request #945 from storytold/docs/readme-status-1010
Docs: README status for 2026-10-10
2026-10-10 17:22:12 +07:00
bflatasticandClaude Opus 5.5 b4c89f7301 Docs: README status for 2026-10-10 (PSD and PSB open and place)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 17:21:50 +07:00
BFlat 20c1a1621e Merge pull request #944 from storytold/fix/938-corner-widgets-multi
Fix #938: Live Corner widgets on every selected path
2026-10-10 17:20:05 +07:00
bflatasticandClaude Opus 5.5 d57134a308 Fix #938: Live Corner widgets on every selected path
The corner widgets only showed when the selection was exactly one path.
Now every selected path that has them shows its widgets: live rectangles
and polygons with the Selection tool, any path with Direct Selection,
each with its own corners (all of a path selected whole, the
Direct-Selected ones otherwise). Dragging any widget rounds every shown
corner of every path in one step, Alt-click cycles their kind, and the
red limit outline covers them all; a double-click opens Corners for the
path it was on. Each widget is drawn in its own layer's colour.

object.setLiveShape takes items: [{id, corners?}], several objects each
with its own corners, which the drag sends.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 17:19:52 +07:00
BFlat 9449c92136 Merge pull request #943 from storytold/feat/926-library-groups
Fix #926: the Libraries panel's search, groups and library export/import
2026-10-10 17:18:20 +07:00
bflatasticandClaude Opus 5.5 d62ecfe33a Fix #926: the Libraries panel's search, groups and library export/import
Search: a search field at the top of the Libraries panel filters the
current library's colours, character and paragraph styles and graphics
by name, ignoring case; groups without a match are hidden, groups with
one open, and a search nothing matches says so ("No results").

Groups: items can be put in user-named groups, kept in the library file
(`groups: [{name, items: [{kind, item}]}]`, left out when there are
none, so files written before groups load as they are). New commands:
library.createGroup {library?, name?, items?}, library.renameGroup
{library?, group, name}, library.deleteGroup {library?, group} (its
items stay, ungrouped) and library.moveItem {library?, kind, item,
group?}. An item is in one group at most; deleting an item takes it out
of its group. The panel shows each group as a collapsible section with
its item count after the ungrouped items (rename and delete on its
context menu), each item's context menu has Move to Group (No Group,
the groups, New Group...), and a New Group button sits in the footer.
library.get lists the groups, library.list counts them. The generic
command dialog takes fixed params in `__params` for this.

Export / import: library.export {library?, path?} writes a library
(colours, styles, graphics with art and thumbnails, groups) as a
.vclibrary file, the JSON the library folder keeps (without a path:
{data}); library.import {path | data | dataBase64, name?} adds one as a
new library, made current, numbered on a name clash ("Brand (2)").
The panel menu has Import Library... and Export Library..., through the
app's file dialogs (the web downloads, and picks through File > Open,
which now lists Libraries files). Imported and folder files are made
safe: at most 10,000 items of a kind and 1,000 groups, names clipped and
made unique, sizes finite, thumbnails kept only when their PNG header
says at most 1024 px a side, group entries naming missing items or
unknown kinds dropped; a file over 512 MB or that isn't a library is an
error. Paths go through the engine's file helpers (automation roots).
The importer is fuzzed (import_fuzz: garbage and mutated libraries).

Also fixed: the third graphic added to a library got the second's id
(`graphic-2` twice).

Docs: the commands' params docs, docs/mcp.md, ROADMAP.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 17:17:01 +07:00
BFlat c738400ebb Merge pull request #942 from storytold/feat/video-effects
Part of gap 2: Effect › Video (De-Interlace, NTSC Colors)
2026-10-10 17:06:09 +07:00
bflatasticandClaude Opus 5.5 c71af3079c Part of gap 2: Effect › Video (De-Interlace, NTSC Colors)
Adds the Video group to the raster effects, after Sharpen:

- De-Interlace…: Eliminate Odd or Even Fields, Create New Fields by
  Duplication or Interpolation. The fields are the rows of the document's
  raster grid (Document Raster Effects Settings › Resolution, carried to
  the filters as PixelSpace::line) counted from the top of the page, so
  the result is the same at any zoom and moves with the page. A line
  taken out copies the line above (the one below at the top), or takes
  the average of the two.
- NTSC Colors (no options, applied at once): colours a composite
  television signal can't carry, with luma plus chroma past 110 % of white
  or luma minus chroma below -20 % in YIQ, have their chroma cut to fit,
  keeping their luma. Saturated yellow, cyan and pure red lose some
  saturation; legal colours stay as they are.

Catalogue rows with parameter docs, dialog dropdowns, menu, translations,
tests (effects, canvas, command sweep junk, menu) and ROADMAP.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 17:05:56 +07:00
BFlat 7fb2c47006 Merge pull request #939 from storytold/fix/811-identify-unnamed-glyphs
Fix #811: unnamed glyphs of an installed font stay in their type
2026-10-10 16:46:54 +07:00
bflatasticandClaude Opus 5.5 a61aa93c1c Fix #811: unnamed glyphs of an installed font stay in their type
In a PDF (or the PDF part of an .ai file), a glyph of a subset font that
no ToUnicode map, CID table or character map of the font program names
was drawn as outlines, so one text object came in split: "RADA" as the
type "ADA" plus the R's outline.

Such a glyph is now looked for in the installed face of the font's name:
the first character (of those single-byte fonts encode: ASCII, Latin-1,
Latin Extended-A, punctuation, the euro and trade mark signs, fi and fl)
whose glyph draws the same outline, point for point within 2/1000 em,
names it. A subset carries the font's own outlines, so an installed copy
of the same font finds every glyph. Other versions of the font, empty
glyphs and glyphs that are no character still keep their outlines, with
the warning as before. Results are cached per glyph.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 16:46:39 +07:00
BFlat 552945f4e7 Merge pull request #936 from storytold/fix/927-direct-marquee-type
Part of #927: Direct Selection marquees take the type they touch
2026-10-10 16:19:59 +07:00
bflatasticandClaude Opus 5.5 7bf002af2b Part of #927: Direct Selection marquees take the type they touch
A Direct Selection (or Group Selection) marquee only gathered the
anchors of paths, so type, images, symbols and other objects without
anchors to pick were never selected by it. They are now selected whole
when the marquee touches them, as with the Selection tool, next to the
path anchors it holds; locked and hidden objects stay out.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 16:19:44 +07:00
BFlat 3da4080b5e Merge pull request #935 from storytold/fix/918-place-psd
Fix #918: PSD and PSB files open and place as their merged image
2026-10-10 16:09:58 +07:00
bflatasticandClaude Opus 5.5 0eb4ce5e98 Fix #918: PSD and PSB files open and place as their merged image
File › Place and File › Open read PSD and PSB files: the merged image
every such document carries after its layers, which is what a linked PSD
shows. A placed PSD links like any image, so the Links panel updates and
relinks it, and it is sized by its ResolutionInfo resource.

The reader is written from the public file format specification. It
reads Bitmap, Grayscale, Duotone, Indexed, RGB, CMYK (through the colour
settings' CMYK) and Lab, at 1, 8, 16 and 32 bits per channel (32-bit
values are linear), raw, PackBits or zipped with or without prediction.
A document with transparency stores its merged image on white with the
alpha in the first extra channel. It comes back with straight colours,
also for 16- and 32-bit files, which say so in their Lr16/Lr32 block.
Multichannel files are refused with a message. The file is untrusted:
every read is bounds-checked and the image's size is checked against
the decode budget before anything is allocated.

PSB is a read-only format of its own, so exports never write a PSD under
a .psb name. The desktop packaging still doesn't associate either
extension with VectorCraft (UNASSOCIATED_EXTS): they belong to raster
editors. Layers (import options) are left for later.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 16:09:41 +07:00
BFlat be51213f0d Merge pull request #929 from haranishi/fix/eps-gradient-stop-cap
EPS import: bound the stitched-gradient builder
2026-10-10 16:06:43 +07:00
BFlat fe2d2ca173 Merge pull request #933 from storytold/fix/921-properties-gradient
Fix #921: the Properties panel's Gradient section with Type and Draw
2026-10-10 15:51:05 +07:00
bflatasticandClaude Opus 5.5 b3ed7df2a4 Fix #921: the Properties panel's Gradient section with Type and Draw
With the Gradient tool active, the Properties panel shows a Gradient
section: the type buttons (Linear, Radial, Freeform) and, for a
freeform gradient, Draw: Points or Lines as radio buttons. The Gradient
panel and the Properties panel share the type row and the draw mode
change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 15:50:23 +07:00
BFlat 93fb184939 Merge pull request #932 from storytold/fix/924-tab-leader-typing
Fix #924: the Tabs panel's Leader and Align On keep what is typed
2026-10-10 15:50:20 +07:00
bflatasticandClaude Opus 5.5 2ef32bb1a2 Fix #924: the Tabs panel's Leader and Align On keep what is typed
Both fields read their text from the tab stop again every frame, so each
keystroke was lost before Enter or a click away could apply it: the
field seemed to clear itself while typing. They now use the panels' text
field, which keeps the text being typed until it is committed. The
leader keeps spaces (". " for spaced dots), up to eight characters.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 15:49:49 +07:00
BFlat 5ccbbcca3f Merge pull request #931 from storytold/fix/925-links-on-focus
Fix #925: linked files changed in another app update when the window comes back
2026-10-10 15:49:46 +07:00
IHAGI-c 0044143880 Graph Type: Override Calculated Values uses the exact value-axis range
With both axisMin and axisMax set, the value axis now runs exactly from
one to the other, split into the tick count (5 when automatic). Before,
the override went through the same rounding as the calculated axis, so
asking for 0 to 12000 drew 0 to 12500 and the columns came out shorter
than the data. With only one bound set the axis is still calculated
around the data, as before.

Ticks are now counted from the minimum instead of added up, so the last
tick lands on the maximum (an offset range like 1e10..1e10+1 lost it to
rounding) and a step too small for the range can't loop forever. An
override too wide for f64 used to give a NaN axis and an internal error
in graph.setType ("min > max, or either was NaN"); it now falls back to
the calculated axis.

graph.setType {} also reports axisMin and axisMax.

Behaviour: Change graph value axes, Tick Values > Override Calculated
Values (minimum, maximum and divisions):
https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/visualize-data/change-graph-value-axes.html
2026-10-10 17:49:19 +09:00
bflatasticandClaude Opus 5.5 11beb8fff1 Fix #925: linked files changed in another app update when the window comes back
Preferences › File Handling › Update Links only acted when a document
opened, so a linked image edited in another app while VectorCraft stayed
open kept its old pixels until Update Link in the Links panel, even with
Automatically chosen.

When the window comes back to the front, the active document's links
are now checked. With Automatically, changed files are read again; with
Ask When Modified, they are offered for update, once per change of a
file (keyed by path, size and modification time, since two saves can
share a time); with Manually, the Links panel shows them as modified.
Nothing is asked while a dialog is open.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 15:49:08 +07:00
BFlat 04eb5e52c8 Merge pull request #930 from storytold/feat/pixelate-effects
Part of gap 2: Effect › Pixelate (Color Halftone, Crystallize, Mezzotint, Pointillize)
2026-10-10 15:47:10 +07:00
bflatasticandClaude Opus 5.5 9bad0a24ca Part of gap 2: Effect › Pixelate (Color Halftone, Crystallize, Mezzotint, Pointillize)
Four Photoshop-style raster effects on the pixel-filter pipeline
(vectorcraft_effects::pixel::pixelate), under a new Effect › Pixelate
submenu between Blur and Sharpen in the Raster Effects section:

- Color Halftone (maxRadius 4..127, default 8; channel1..channel4 screen
  angles -360..360, defaults 108/162/90/45): each colour channel is
  screened at its angle in square cells sized so a dot of Max. Radius
  fills one; each cell's dot covers the share of the cell that is the
  channel's coverage-weighted mean there (white stays white, black
  black). RGB documents screen R, G, B (channels 1 to 3); CMYK documents
  screen C, M, Y, K inks (channels 1 to 4), also when they are drawn ink
  plane by ink plane (the K plane uses channel 4).
- Crystallize (cellSize 3..300, default 10): every pixel takes the
  colour at the nearest of one jittered point per cell (exact Voronoi
  over a 5 x 5 neighbourhood, pruned by distance).
- Mezzotint (type: Fine/Medium/Grainy/Coarse Dots, Short/Medium/Long
  Lines, Short/Medium/Long Strokes; default Fine Dots; a dropdown in the
  dialog): each channel is turned fully on or off against its own random
  threshold pattern, so the object becomes pure colours.
- Pointillize (cellSize 3..300, default 5): one dot of random size per
  cell at a jittered point, coloured by the softened object there and as
  opaque as the object at its centre, stacked in random order over a
  white canvas where the object is.

Lengths are in points (Scale Strokes & Effects scales them) and every
pattern lies in document space around the object's centre, so the
result is the same at any zoom or raster resolution, moves with the
object, and is identical on every redraw (hash noise of the pattern's
cell indices, no state). Work per pixel is bounded whatever the
parameters, which are clamped to their ranges and finite-checked; the
halftone's cell grid is capped by the raster's size and a screen finer
than 3 pixels leaves the image as it is (its average). PixelSpace gains
`channels` so the renderer tells the filters what the channels hold.

Catalogue rows with parameter docs, outsets and reaches for cropping,
menu entries, dialog labels (Max. Radius, Channel 1-4, Cell Size), the
Mezzotint Type dropdown, translations, and tests: parameters and
clamping, tone and coverage, CMYK inks, polygon crystals, pure-colour
mezzotints with ten distinct patterns, dots on a white canvas, the same
result at twice the resolution and when moved, the canvas, PDF and SVG
through the engine, the command sweep with junk parameters, the dialogs
and the menu.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 15:42:24 +07:00
BFlat 2d6014583a Merge pull request #923 from callmeBalloch/fix/artboard-artwork-movement
Fix artwork movement when editing artboard X/Y coordinates
2026-10-10 15:29:47 +07:00
Ikki NishiharaandClaude Sonnet 5.5 de3f53b24d EPS: bound the stitched-gradient builder
Cap the stops taken from a stitching function (1024), bound linear_points by
depth and by a shared visit count, and stop eval from copying the whole
Functions array on every call.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-10 17:29:45 +09:00
bflatasticandClaude Opus 5.5 f3f5e38bb6 ROADMAP: the #922 note joins the Last updated list
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 15:06:59 +07:00
Rick 934b65b42a M11: honor Move Artwork in artboard coordinate edits 2026-10-10 09:49:24 +02:00
BFlat e1599a16aa Merge pull request #799 from vbasky/feat/graph-series-palette
Colour graph series through Group Selection
2026-10-10 14:37:23 +07:00
bflatasticandClaude Opus 5.5 f21f8a89ce Graph series: find series art in one pass and ignore indices past the series
Painting looked up each selected object's graph by walking up through
parent_of, a search of the whole document per level, so a fill or stroke
change on a large selection grew with the square of the document. The
series art of every graph is now mapped in one pass, and documents with
no graph skip the lookup.

A series index comes from the file: one past the graph's series grew the
graph's series paints to that length. Such a group is no longer taken
for a series.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 14:33:41 +07:00
Vikram Bhaskaran 5c9ffc69e8 Colour graph series through Group Selection
Group Selection's fill and stroke on a graph series are kept on the graph,
separately per series, and reapplied when its data, type or size changes,
and through save, open and undo.
2026-10-10 14:33:40 +07:00
BFlat dd61dfa146 Merge pull request #920 from storytold/feat/885-transform-effect-dialog
Part of #885: the Transform effect's dialog takes Transform Each's controls
2026-10-10 14:32:55 +07:00
bflatasticandClaude Opus 5.5 20e5a746bd Part of #885: the Transform effect's dialog takes Transform Each's controls
Effect › Distort & Transform › Transform… was a plain list of fields.
It now has Transform Each's layout: Scale and Move with sliders, Rotate
with an angle dial, then Copies, Reflect X and Y, the reference point and
Random, kept with the effect so editing it from the Appearance panel
shows them again.

- The effect scales, rotates and reflects about the chosen point of the
  9-point grid (`reference`, the centre by default).
- Random takes a share of each value, drawn from the object's id: each
  object varies its own way and keeps its result on every redraw; a
  group's members move together.
- Copies stop before one reaches past 1e9 pt, and the scale and move are
  capped, so large values can't overflow.
- Transform Each and the effect share the sections and options code; the
  angle dial is a new widget.
- A plain number field now takes the focus when it is a dialog's first
  field, so both dialogs open with Scale › Horizontal ready for typing.

Transform Objects, Transform Patterns and Scale Strokes & Effects are not
in the effect yet: the geometry effects reshape paths only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 14:32:10 +07:00
BFlat da73a46137 Merge pull request #919 from storytold/feat/832-automation-roots
Fix #832: --automation-read-root and --automation-write-root confine the MCP server's files
2026-10-10 14:28:11 +07:00
bflatasticandClaude Opus 5.5 9f875050ef Fix #832: --automation-read-root and --automation-write-root confine the MCP server's files
vectorcraft-cli mcp and vectorcraft --control take the flags PhotoCraft
takes. While roots are in force, every path a command reads (open, place,
relink, SVG image links, library and profile loads, plug-ins, preset
imports, font searches) must lie inside the read root and every path it
writes (save, export, package, Export for Screens and for Web, print to
file, captures, library saves) inside the write root. Paths are made
absolute, links are followed (a missing file's nearest folder's), and the
result is compared by whole components, ignoring case on Windows; `..`
below a missing folder, dangling links and device names are refused. A
root left out grants none of its access; without the flags nothing
changes.

The checks live in one place, vectorcraft_engine::file_access, consulted
by the engine's file helpers and the app's file services. The headless
server confines its whole process; the app confines control requests,
the frames carrying input they inject and the background saves they
start, leaving the person at the keyboard and the app's own folders
alone. The screenshot tool now has the backend write its file.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 14:27:24 +07:00
BFlat ab69087d68 Merge pull request #915 from Messhias/feat/394-display-print-size
Part of #394: Display Print Size at 100% Zoom drives Actual Size
2026-10-10 14:26:31 +07:00
bflatasticandClaude Opus 5.5 77e2a30c6d Part of #394: Actual Size shows print size whatever the display scaling
Display Print Size at 100% Zoom divided an assumed 96 pixels per inch by
the display's scale factor, so on a scaled display (150%, Retina) a
document inch came out a fraction of an inch. Nothing measures the
screen's real density, so an inch is now 96 screen points, the reference
density display scaling is set against, at any scale factor. The unused
screen_ppi and pixels_per_point fields go.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 14:25:50 +07:00
Fabio William Conceição a7faeb4299 Part of #394: Display Print Size at 100% Zoom drives Actual Size. 2026-10-10 14:25:44 +07:00
BFlat 90818cae40 Merge pull request #910 from Messhias/feat/394-font-names-in-english
Part of #394: Show Font Names in English labels the font menus
2026-10-10 14:25:15 +07:00
Fabio William Conceição 89812275d7 Part of #394: Show Font Names in English labels the font menus. 2026-10-10 14:10:31 +07:00
SawizzleandClaude Opus 5.5 e15ef47094 docs: register the MCP server from an installed release (#856)
* docs: register the MCP server from an installed release

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs: drop the third-party link; the Windows path is the default install folder

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs: drop the third-party link; the Windows path is the default install folder

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs: drop the third-party link; the Windows path is the default install folder

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 14:08:20 +07:00
BFlat 3ab2af0744 Merge pull request #917 from storytold/fix/909-profile-preview-scale
Fix #909: a Width tool profile's preview fits the Profile menu
2026-10-10 13:53:54 +07:00
bflatasticandClaude Opus 5.5 8d2a3f48a1 Fix #909: a Width tool profile's preview fits the Profile menu
The Profile menu's preview drew a stroke's width profile assuming its
widths stay within the stroke weight. A profile made with the Width tool
can be many times wider, so its silhouette spilled over the Stroke panel.
It is now scaled to the preview by its widest point and clipped to it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 13:52:41 +07:00
BFlat 71003386ac Merge pull request #916 from storytold/fix/900-segmented-field
Fix #900: spinner fields read as one box in three parts
2026-10-10 13:32:13 +07:00
BFlat bd439727dd Merge pull request #914 from storytold/feat/812-tool-options
Part of #812: polygon properties, Flare Tool Options, more tool double-clicks
2026-10-10 13:23:32 +07:00
BFlat 809c4266a4 Merge pull request #913 from storytold/fix/901-shift-click-target
Fix #901: Shift-click a target circle in the Layers panel to add or remove it
2026-10-10 13:15:31 +07:00
BFlat 994e821f53 Merge pull request #912 from storytold/fix/903-reselect-after-deselect
Fix #903: Reselect repeats a Same command after Deselect
2026-10-10 13:15:17 +07:00
bflatasticandClaude Opus 5.5 2543da7cf0 Fix #900: spinner fields read as one box in three parts
A number field with a spinner and a preset list (stroke weight, font
size, leading…) was three boxes side by side: the field came out a
point taller than the spinner and the chevron, and each joint had two
borders. The field is now exactly as tall as the other controls of a
row, with its text centred, and the three parts share their borders.
num_field draws its box with the panel text fields' code instead of a
copy of it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 13:13:41 +07:00
bflatasticandClaude Opus 5.5 990d869897 Part of #812: more tools open their options on a double-click
Double-clicking a tool's button (or tool.options {tool}) now also opens:
- Artboard tool: Artboard Options of the active artboard (the Artboard
  tool's while it is in use, else the window's);
- the graph tools: Graph Type for the selected graph (an error without
  one), the dialog Object > Graph > Type opens;
- the Symbolism tools: Symbolism Tools Options with the brush the eight
  tools share (Diameter, Intensity, Symbol Set Density);
- Magic Wand: the Magic Wand panel.

The Object > Graph dialogs' opener is shared with the toolbar. The new
labels are translated in the eight complete catalogs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 13:00:21 +07:00
bflatasticandClaude Opus 5.5 ae27d899b8 Part of #812: Flare Tool Options last and open from the toolbar
The Flare tool keeps its options (Center: Diameter, Opacity, Brightness;
Halo: Growth, Fuzziness; Rays: on/off, Number, Longest, Fuzziness; Rings:
on/off, Path, Number, Largest, Direction) as persistent tool options,
saved with the preferences. The next flare dragged out uses them (the drag
still sets the centre and diameter, and Up/Down the ray count), a click
with the tool opens them filled in, and double-clicking the tool (or
tool.options {tool: "flare"}) opens them to edit: OK keeps them and draws
nothing. Before, the click dialog was the generic parameter form with
fixed defaults and the drag always used the command's defaults.

The new Flare Tool Options dialog lays the four groups out in two
columns; Rays and Rings have their checkboxes, and every value is kept
within its range. Its labels are translated in the eight complete
catalogs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 13:00:21 +07:00
bflatasticandClaude Opus 5.5 7c0ce8a382 Part of #812: polygon properties in the Properties and Transform panels
A selected live polygon now shows Illustrator's Polygon Properties in the
Transform panel and the Properties panel's Transform section: side count,
angle, corner type and radius, radius, side length and Make Sides Equal
(enabled once an uneven scale has made its sides differ). Before, only the
side count and corner radius were there.

object.setLiveShape takes polygonAngle (degrees counterclockwise),
polygonRadius and sideLength (points, centre to vertex / side) and
makeSidesEqual, which drops the uneven scale and shear and keeps the
polygon's centre, angle and mean radius. Each call is one undo step; a
radius or length that isn't a number above 0 is an error. A star stays a
plain path, as in Illustrator.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 13:00:21 +07:00
BFlat 5305b80308 Merge pull request #899 from vbasky/credit/vbasky-name
Credit vbasky by real and display name
2026-10-10 12:56:25 +07:00
bflatasticandClaude Opus 5.5 980cc4fe44 Fix #901: Shift-click a target circle in the Layers panel to add or remove it
Clicking an object's target circle in the Layers panel always replaced
the selection, so several objects couldn't be picked there. Shift-click
now adds the object (a layer: its art) to the selection, or takes it out
when it is selected already, as in Illustrator; nothing stays targeted
then. layer.target takes add: true for it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 12:55:38 +07:00
bflatasticandClaude Opus 5.5 6917984329 Fix #903: Reselect repeats a Same command after Deselect
Select › Reselect repeated the last selection command, but a Select Same
command needs a selected object to compare with, so after Deselect it
failed with "nothing selected". Reselect now remembers the objects that
were selected when the command ran and, with nothing selected, takes the
reference from those still in the document.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 12:55:37 +07:00
BFlat ba2fc64d89 Merge pull request #906 from storytold/feat/852-pressure-brushes
Part of #852: Brush Options for Calligraphic and Bristle brushes, pen pressure
2026-10-10 12:38:10 +07:00
bflatasticandClaude Opus 5.5 5cf9233d26 Part of #852: Brush Options for Calligraphic and Bristle brushes, pen pressure
Double-clicking a brush in the Brushes panel (or the panel menu's Brush
Options…) opens its options. Calligraphic Brush Options: Angle,
Roundness and Size, each Fixed, Random or Pressure with its Variation,
over live previews of the nib and of a stroke. Bristle Brush Options:
Shape and the bristle sliders. OK runs brush.options; strokes painted
with the brush update.

The Paintbrush records the pen pressure along a stroke (path.freehand
takes [x, y, pressure] points), and a Calligraphic brush with Pressure
variation changes its nib along the stroke, from the value less the
variation at the lightest pressure to the value plus it at the heaviest.
Brushes saved before keep their Random variation. Pen pressure doesn't
reach the app on macOS yet (winit 0.30 reports none there).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 12:15:18 +07:00
Vikram Bhaskaran c81f120ece Credit vbasky by real and display name in the About window 2026-10-10 16:03:48 +11:00
BFlat db23e80ec4 Merge pull request #897 from storytold/fix/886-shift-anchor-guides
Fix #886: Smart Guides keep working on a Shift-constrained anchor drag
2026-10-10 12:00:28 +07:00
BFlat da010c33a1 Merge pull request #896 from storytold/fix/888-smooth-wheel-zoom
Fix #888: wheel notches zoom in a glide with Zoom with Mouse Wheel
2026-10-10 12:00:22 +07:00
bflatasticandClaude Opus 5.5 e47d542680 Fix #886: Smart Guides keep working on a Shift-constrained anchor drag
Dragging anchors with Direct Selection snapped to points, paths and
Smart Guides, but with Shift held the move kept its 45° angle and
snapped to nothing. The grabbed anchor now slides along the constrained
line onto the anchors, paths and alignment lines that cross it, as the
drawing tools' Shift does (counted from General › Constrain Angle).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 11:49:56 +07:00
bflatasticandClaude Opus 5.5 d8759492df Fix #888: wheel notches zoom in a glide with Zoom with Mouse Wheel
With General › Zoom with Mouse Wheel, each wheel notch's zoom was
applied on the frame it arrived, so the view jumped a step per notch
while scrolling glides over a few frames. A mouse wheel's notches now
glide in (what is left shrinks by e every 50 ms), ending at the same
zoom; a trackpad's fine steps still apply at once.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 11:49:54 +07:00
BFlat 6ba5e0585e Merge pull request #895 from storytold/fix/867-macos-dialog-thread
Fix #867: macOS file dialogs no longer run inside the window's event handler
2026-10-10 11:49:20 +07:00
BFlat 4c1e16d791 Merge pull request #894 from storytold/fix/890-artboard-edge-art
Fix #890: artboard art includes art flush with its right or bottom edge
2026-10-10 11:49:13 +07:00
bflatasticandClaude Opus 5.5 dd8f144770 Fix #867: macOS file dialogs no longer run inside the window's event handler
On macOS the Open and Save panels ran modally on the UI thread, inside
the window's event handler. Resizing the panel delivered window events
to that handler again, and the app crashed when the mouse was released.
They now open from a thread of their own, as on Linux (#592): rfd runs
the panel on the main thread from the run loop, outside the handler, and
the app picks the answer up when it comes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 11:30:33 +07:00
bflatasticandClaude Opus 5.5 6c131431ed Fix #890: artboard art includes art flush with its right or bottom edge
The art that goes with an artboard (Duplicate Artboard and Copy/Cut with
art, the Artboard tool's Move/Copy Artwork with Artboard, and moving an
artboard by its X and Y) was found with a half-open containment test, so
an object whose right or bottom edge lay exactly on the artboard's was
left behind. Its edges now count as inside.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 11:30:32 +07:00
BFlat 8ae59bf6b7 Merge pull request #892 from storytold/fix/787-precise-export
Fix #787: raster exports keep translucent edges over opaque art opaque
2026-10-10 11:29:36 +07:00
BFlat b47f611e18 Merge pull request #891 from storytold/fix/798-curvature-any-path
Fix #798: the Curvature tool edits any selected path
2026-10-10 11:29:31 +07:00
BFlat 02d02f660f Merge pull request #889 from storytold/pr/roadmap-batch-2026-10-10
Docs: ROADMAP for the 2026-10-10 fixes; Other Library lists color books
2026-10-10 11:12:50 +07:00
144 changed files with 12602 additions and 1026 deletions

No files matched your search

Generated
+2
View File
@@ -4499,6 +4499,7 @@ dependencies = [
"vectorcraft-effects",
"vectorcraft-geom",
"vectorcraft-metafile",
"vectorcraft-pdf",
"vectorcraft-render",
"vectorcraft-testkit",
"vectorcraft-text",
@@ -4741,6 +4742,7 @@ dependencies = [
"serde",
"serde_json",
"toml",
"vectorcraft-brush",
"vectorcraft-cad",
"vectorcraft-color",
"vectorcraft-doc",
+8 -2
View File
@@ -186,6 +186,12 @@ Register the MCP server with Claude Code:
claude mcp add vectorcraft -- /path/to/vectorcraft-cli mcp
```
To keep an agent to one project's files, give it folders to read and write (the same flags as PhotoCraft):
```sh
claude mcp add vectorcraft -- /path/to/vectorcraft-cli mcp --automation-read-root /work/project --automation-write-root /work/project
```
The details are in [`docs/mcp.md`](docs/mcp.md) and [`docs/control-protocol.md`](docs/control-protocol.md).
For the experimental, unsupported 64-bit Windows 7 build, see [Windows 7 instructions](docs/windows7.md).
@@ -195,10 +201,10 @@ For the experimental, unsupported 64-bit Windows 7 build, see [Windows 7 instruc
VectorCraft is under active development. [**ROADMAP.md**](ROADMAP.md) covers what ships today, the
milestones, and honest time-to-parity estimates.
**Where we are (2026-10-09):** roughly 69–75% of Illustrator's features exist and work, and about 40–55% of
**Where we are (2026-10-10):** roughly 69–75% of Illustrator's features exist and work, and about 40–55% of
"a power user can't tell the difference". Everyday vector illustration is close to usable: drawing and path tools,
Pathfinder and Shape Builder, paint, gradients, appearance and transparency, type with styles, threading and Hebrew/Arabic bidirectional layout, and
files (SVG, PDF and PDF-compatible `.ai` with PDF/X, EPS, DXF, EMF/WMF, raster formats and PSD, Print, Package).
files (SVG, PDF and PDF-compatible `.ai` with PDF/X, EPS, DXF, EMF/WMF, raster formats, PSD out and PSD/PSB in as their merged image, Print, Package).
Affinity documents (`.af` from Affinity 3, `.afdesign`, `.afpub` and, by their content, `.afphoto` from Affinity 1 and 2) open
and place natively: layers, groups, artboards and pages, curves and shapes, fills, gradients and strokes, clipping
and masks, text and images, with what didn't come in (effects, adjustments, brushes, master pages…) listed in the
+35 -27
View File
@@ -4,7 +4,7 @@ VectorCraft is a clean-room, open-source, pure-Rust reimplementation of the Adob
This file tracks **how far we are and what's left**. Time estimates are wall-clock hours of continuous Claude Opus 5.5 agent work (including builds and the CI gate), given both for **one agent** and for **4–6 parallel agents** on disjoint crates. They are counted from the remaining work (see [Parity estimate](#parity-estimate)), calibrated against measured throughput, and updated as work lands.
_Last updated: 2026-10-10 (after placed documents (File › Place of a `.vectorcraft` file, linked), M4.14–M4.98, M8.1–M8.20, M14.4–M14.7, the MCP prompts, resource templates, completions and logging, the 2026-10-07 issue fixes, the Layers panel rework and the 2026-10-08 community contributions: Pen and shape-tool modifiers, path editing, Layers, Artboards, Swatches/Brushes and dialogs, Japanese composition and PDF text, Hebrew/Arabic type, interface languages (Spanish among them), the font menu, saved selections, MCP and EPS, the missing-font export warning (#319), open paths in the Shape Builder (#374), PDF/`.ai` import keeping strokes live and hidden and nested layers (#372), per-corner Live Corners (#381), SVG type opening at the size it draws at (#396), the Wayland clipboard with copied-file paste (#398), ruler guides that can be selected, moved and deleted (#414), two batches of Preferences options that were stored but unused (#394), skeleton reads with `document.find` for large documents over MCP (#416) and anchors dragged with Direct Selection snapping to points, segments and smart guides, clipped SVG layers opening as clipping layers (#420), PDF/`.ai` layers read on pages of tens of millions of operators (#419), Document Color Mode putting RGB greys on the black plate on request and converting pattern tiles (#421) and movable dialogs with Offset Path's Joins dropdown (#423) and numeric fields scrubbed by dragging their label (#400), stroke weight presets in the stroke unit (#426), the Appearance panel's Opacity popup (#430), type on a path brackets, flip and Type on a Path Options that keep the type in place (#429), Layers rows dragged with a ghost and dropped on the trash or New Layer (#435), tool flyouts opening on a right or corner press (#436), tool flyouts torn off into floating toolbars (#437), the Shape Builder's erase mode deleting edges too (#439), installed fonts registered outside the font folders and fonts installed while the app runs listed in the font menus (#443), alike circular corners on rectangles that aren't square (#442), tool cursors as OS cursors so the pointer no longer trails over the canvas (#444), artboards copied and pasted with their art (#446), guides dragged out of the rulers snapping to the art's side midpoints and the artboards, artboard guides (#451), the toolbar's double arrow and Window › Toolbars › Double Column switching between one and two columns in any window height (#455), the Smart Guides display options and Snapping Tolerance (#394), Font Size and Leading combo boxes with preset lists in the Character and Properties panels and the Control bar (#458), screenshots pasted from the clipboard on macOS, Windows and the web (#457), the Properties panel's Fill and Stroke matching the Control bar's, Illustrator's Window menu panel keys (Shift+F7 for Align…) and Alt-wheel zoom whatever the wheel preference (Discord feedback), the point/area type widget (Discord feedback), groups in legacy Illustrator .ai files (Discord feedback), a rotating desktop log file (#465), image controls in the Control bar and Properties panel: Image Trace with its presets, Mask, Crop Image, Embed/Unembed, and an Image Trace object's preset, Expand and Release (Discord feedback), the Pen working as the Anchor Point tool with Alt held over a selected path's handles and anchors, and anchor buttons that convert, connect and cut paths at the selected anchors (Discord feedback), Japanese hanging punctuation (burasagari, #463), Escape leaving Presentation Mode with no screen mode saved across restarts, documents dropped on the desktop app opening as tabs of their own and a note that an `.ai` file's art off its artboards isn't in the part read (#472), Add/Delete Anchor Point tool icons drawn from the Pen tool's (#479), EPS import running more of PostScript Level 3 (patterns, Type 3 fonts, mesh shadings, masked images, VM and resource probes), and a preview that stands in saying why (Discord feedback, #466, #469, #474), chords with modifiers recorded in Edit › Keyboard Shortcuts (#487), Type Object Selection by Path Only (#394), Anti-aliased Artwork (#394), Show Pixel Grid (#394), pen and touch taps pressed and lifted within one frame clicking where they tapped and the Linux/Wayland pen-tablet workaround (#491), bounding-box resizes landing on the other objects' anchors and centres with labels, and the Rotate, Scale, Shear and Reflect tools snapping their drags (#482), Shift-drag marquees that toggle the objects or anchors they reach and a Lasso whose Alt subtraction keeps the rest of the selection (#483), the mouse wheel stepping the focused numeric field, the dialogs' and preferences' drag values made such fields (#485), Show The Home Screen When No Documents Are Open (#394), Numbers Without Units Are Points (#394), Auto-Collapse Iconic Panels (#394), the Contextual Task Bar moved by its handle and its More Options menu pinning and resetting its position, a designed macOS DMG windoLine truncated
_Last updated: 2026-10-10 (after placed documents (File › Place of a `.vectorcraft` file, linked), M4.14–M4.98, M8.1–M8.20, M14.4–M14.7, the MCP prompts, resource templates, completions and logging, the 2026-10-07 issue fixes, the Layers panel rework and the 2026-10-08 community contributions: Pen and shape-tool modifiers, path editing, Layers, Artboards, Swatches/Brushes and dialogs, Japanese composition and PDF text, Hebrew/Arabic type, interface languages (Spanish among them), the font menu, saved selections, MCP and EPS, the missing-font export warning (#319), open paths in the Shape Builder (#374), PDF/`.ai` import keeping strokes live and hidden and nested layers (#372), per-corner Live Corners (#381), SVG type opening at the size it draws at (#396), the Wayland clipboard with copied-file paste (#398), ruler guides that can be selected, moved and deleted (#414), two batches of Preferences options that were stored but unused (#394), skeleton reads with `document.find` for large documents over MCP (#416) and anchors dragged with Direct Selection snapping to points, segments and smart guides, clipped SVG layers opening as clipping layers (#420), PDF/`.ai` layers read on pages of tens of millions of operators (#419), Document Color Mode putting RGB greys on the black plate on request and converting pattern tiles (#421) and movable dialogs with Offset Path's Joins dropdown (#423) and numeric fields scrubbed by dragging their label (#400), stroke weight presets in the stroke unit (#426), the Appearance panel's Opacity popup (#430), type on a path brackets, flip and Type on a Path Options that keep the type in place (#429), Layers rows dragged with a ghost and dropped on the trash or New Layer (#435), tool flyouts opening on a right or corner press (#436), tool flyouts torn off into floating toolbars (#437), the Shape Builder's erase mode deleting edges too (#439), installed fonts registered outside the font folders and fonts installed while the app runs listed in the font menus (#443), alike circular corners on rectangles that aren't square (#442), tool cursors as OS cursors so the pointer no longer trails over the canvas (#444), artboards copied and pasted with their art (#446), guides dragged out of the rulers snapping to the art's side midpoints and the artboards, artboard guides (#451), the toolbar's double arrow and Window › Toolbars › Double Column switching between one and two columns in any window height (#455), the Smart Guides display options and Snapping Tolerance (#394), Font Size and Leading combo boxes with preset lists in the Character and Properties panels and the Control bar (#458), screenshots pasted from the clipboard on macOS, Windows and the web (#457), the Properties panel's Fill and Stroke matching the Control bar's, Illustrator's Window menu panel keys (Shift+F7 for Align…) and Alt-wheel zoom whatever the wheel preference (Discord feedback), the point/area type widget (Discord feedback), groups in legacy Illustrator .ai files (Discord feedback), a rotating desktop log file (#465), image controls in the Control bar and Properties panel: Image Trace with its presets, Mask, Crop Image, Embed/Unembed, and an Image Trace object's preset, Expand and Release (Discord feedback), the Pen working as the Anchor Point tool with Alt held over a selected path's handles and anchors, and anchor buttons that convert, connect and cut paths at the selected anchors (Discord feedback), Japanese hanging punctuation (burasagari, #463), Escape leaving Presentation Mode with no screen mode saved across restarts, documents dropped on the desktop app opening as tabs of their own and a note that an `.ai` file's art off its artboards isn't in the part read (#472), Add/Delete Anchor Point tool icons drawn from the Pen tool's (#479), EPS import running more of PostScript Level 3 (patterns, Type 3 fonts, mesh shadings, masked images, VM and resource probes), and a preview that stands in saying why (Discord feedback, #466, #469, #474), chords with modifiers recorded in Edit › Keyboard Shortcuts (#487), Type Object Selection by Path Only (#394), Anti-aliased Artwork (#394), Show Pixel Grid (#394), pen and touch taps pressed and lifted within one frame clicking where they tapped and the Linux/Wayland pen-tablet workaround (#491), bounding-box resizes landing on the other objects' anchors and centres with labels, and the Rotate, Scale, Shear and Reflect tools snapping their drags (#482), Shift-drag marquees that toggle the objects or anchors they reach and a Lasso whose Alt subtraction keeps the rest of the selection (#483), the mouse wheel stepping the focused numeric field, the dialogs' and preferences' drag values made such fields (#485), Show The Home Screen When No Documents Are Open (#394), Numbers Without Units Are Points (#394), Auto-Collapse Iconic Panels (#394), the Contextual Task Bar moved by its handle and its More Options menu pinning and resetting its position, a designed macOS DMG windoLine truncated
## Where we are
@@ -35,15 +35,15 @@ since 2026-10-01.
|---|---|---|
| **Breadth:** menus, tools and panels exist | ~90% | 13 menu items still stubbed; every tool implemented except Touch Type; 51 panel modules |
| **Depth:** each feature behaves like Illustrator | ~69–75% | Strong: paint, appearance and colour (M3), Pathfinder and booleans, selection, drawing, files (M4). Weak: advanced type (~45%; inline symbols in text are beyond parity and don't raise it), brushes and symbols (in progress), raster effects (~20%) |
| **Large missing subsystems** | 0–20% | 3D & Materials (0%), Photoshop-style raster effects and the Effect Gallery (~4 of ~56 filters), SVG Filters, CJK composition (vertical type with kinsoku, tate-chu-yoko and the font's vertical metrics; ruby, mojikumi and proportional vertical metrics open), Variables (data merge), scripting |
| **Large missing subsystems** | 0–20% | 3D & Materials (0%), Photoshop-style raster effects and the Effect Gallery (~16 of ~56 filters), SVG Filters, CJK composition (vertical type with kinsoku, tate-chu-yoko and the font's vertical metrics; ruby, mojikumi and proportional vertical metrics open), Variables (data merge), scripting |
| **Interaction fidelity:** modifiers, cursors, small behaviours | ~30–40% | The dedicated pass hasn't started, and there has been no side-by-side session with Illustrator yet. A power user notices this first |
| **Look & feel** | ~75–80% | Measured against Illustrator 2026 screenshots (2026-10-02); the panels added since haven't been re-measured |
| **File interop** | ~85% | SVG/SVGZ, PDF and PDF-compatible `.ai`, EPS, DXF, EMF/WMF, raster formats, PSD export, Place and Links, Package, Print, clipboard flavours. Affinity documents (Affinity 1–3) open natively as editable art, checked against 51 pinned public files (33 current `.af`), with selected saved-thumbnail comparisons and every-board native/SVG/PDF/PSD checks for current files; Affinity export is missing and effects, adjustments and brushes don't import. Affinity isn't an Illustrator feature, so this doesn't raise the parity score. The editing copy inside an Illustrator EPS or `.ai` is read for its structure: layers, groups, names, hidden objects and type, colours, gradients, transparency, images and every artboard (see Out of scope for what it doesn't read yet); DWG has no open spec |
| **File interop** | ~85% | SVG/SVGZ, PDF and PDF-compatible `.ai`, EPS, DXF, EMF/WMF, raster formats, PSD export and PSD/PSB placement (merged image), Place and Links, Package, Print, clipboard flavours. Affinity documents (Affinity 1–3) open natively as editable art, checked against 51 pinned public files (33 current `.af`), with selected saved-thumbnail comparisons and every-board native/SVG/PDF/PSD checks for current files; Affinity export is missing and effects, adjustments and brushes don't import. Affinity isn't an Illustrator feature, so this doesn't raise the parity score. The editing copy inside an Illustrator EPS or `.ai` is read for its structure: layers, groups, names, hidden objects and type, colours, gradients, transparency, images and every artboard (see Out of scope for what it doesn't read yet); DWG has no open spec |
| **Bundled content:** brush, symbol, style and swatch libraries | ~30% | Ours are original and generated in code, and far fewer than Illustrator ships; brush and symbol libraries are still missing |
| **Performance** | unverified | Multithreaded, off-thread rendering and caches are in place. The last budget run (2026-10-01, loaded machine) measured 290 ms for a 50k-path fit against a 16 ms budget. Re-run `vectorcraft-cli perf` on an idle machine |
| **Robustness** | good, new | ~2,840 tests, property tests, no panics in shipped code (lints, the `guard` safety net, import fuzzing), Data Recovery. Missing: a corpus of real-world files, Windows/Linux/browser QA |
| **Platforms and 1.0 polish** | ~60% | The macOS app and the web build work, and Finder opens every readable format with the macOS app (#295, #354); on macOS the menus are in the system menu bar, laid out as a Mac app's, with the in-window menus as a fallback (`--in-window-menus`; verified on Windows and by cross-compilation, not yet by hand on a Mac); hybrid-graphics laptops render on the power-saving GPU by default on Windows and macOS (#306), and Windows starts on Direct3D 12, falling back to OpenGL, and loads no Vulkan driver unless `WGPU_BACKEND` asks for it (#545: Vulkan flickered black on an Intel GPU, DX12 and OpenGL didn't; #806: a faulty Vulkan driver crashed the app before its window appeared; verified by unit tests and the reporter's runs with `WGPU_BACKEND`), and Linux on the GPU the desktop runs on, since a Wayland compositor may refuse frames from the other one; a window that fails on its GPU while starting up starts again on the next one instead of crashing (#502; the dual-GPU Wayland case is verified by unit tests and cross-compilation, not on such hardware); web drops land in the document they were dropped on (#359); desktop drops carry no pointer position (winit 0.30), so a dropped document opens as a tab of its own and a picture or text is placed in the middle of the view, while the web places anything dropped on the canvas and opens what is dropped off it, as Illustrator does (#472); a web page that loses its WebGPU device or WebGL context restarts its graphics with the same documents (#369); on Linux/Wayland the clipboard reads the compositor's own (data-control) and copied image files paste, but files can't be dropped on the window under Wayland until eframe moves to winit 0.31 (#398; the Wayland side is verified by compilation only); on Linux the Paste menu checks the system clipboard on a background thread, so a clipboard owner that never answers no longer freezes the window, while Windows and macOS keep their quick in-line check (#510; the X11 hang verified by the contributor, here by headless tests); on KDE Plasma 6.3+ under Wayland, pens and pen displays reach the app only under XWayland (`WAYLAND_DISPLAY=`) or with KWin's `KWIN_WAYLAND_EMULATE_TABLET=1`, since winit 0.30 has no Wayland tablet input (#491, not verified on hardware); Windows/Linux packaging and accessibility are pending; an experimental, unsupported Windows 7 x64 OpenGL build exists (community-contributed, not verified by maintainers) |
| **Agent automation** | beyond Illustrator | Every command, gesture and dialog is drivable over MCP, the CLI and the control channel |
| **Agent automation** | beyond Illustrator | Every command, gesture and dialog is drivable over MCP, the CLI and the control channel; what an agent reads and writes on disk can be confined to chosen folders (`--automation-read-root` / `--automation-write-root`, as in PhotoCraft, #832) |
| **Scrubby numeric fields** | beyond Illustrator | As in Photoshop, dragging a numeric field's label (`W:`, `Opacity:`, the Character panel's icons) left or right steps its value live; one undo step per drag, Escape cancels (#400) |
### Where we're lacking (in priority order)
@@ -53,12 +53,15 @@ Ordered by how much each gap blocks someone from switching. Sizes are one-agent
1. **Interaction fidelity:** go tool by tool and panel by panel against `plan/illustrator/05-tools.md`,
`06-panels.md` and `09-shortcuts.md`, covering modifier keys, cursors, the Properties panel per context and
isolation mode. The Shaper’s rough-square recognition and shape orientation steps are corrected; Shaper Groups now retain editable originals for region merges/punches and separate fill/stroke edits; their construction-mode widget and gesture refinements remain. 60–90 h.
2. **Photoshop-style raster effects and the Effect Gallery:** about 51 filters (Artistic, Brush Strokes, Distort,
Pixelate, Sketch, Stylize, Texture, Video) and the Effect Gallery. The pixel-filter pipeline is in place
2. **Photoshop-style raster effects and the Effect Gallery:** about 39 filters (Artistic, Brush Strokes, Distort,
Sketch, Stylize) and the Effect Gallery. The pixel-filter pipeline is in place
(`vectorcraft_effects::pixel`: the object rendered offscreen, filtered in stack order with Gaussian Blur and
Feather, cached while panning, images in PDF/SVG/Expand Appearance) with Blur › Radial Blur and Smart Blur
(Normal mode only), Sharpen › Unsharp Mask and Stylize › Glowing Edges; each new filter is now one function, a catalogue row, menu and
translations. Parallelizes well across agents. 26–40 h.
(Normal mode only), Pixelate › Color Halftone, Crystallize, Mezzotint and Pointillize, Sharpen › Unsharp Mask,
Stylize › Glowing Edges, Texture › Craquelure, Grain, Mosaic Tiles, Patchwork, Stained Glass and Texturizer (its
Brick, Burlap, Canvas and Sandstone surfaces made in code; Load Texture is missing) and Video › De-Interlace and
NTSC Colors; each new filter is now one function, a catalogue row, menu and translations. Parallelizes
well across agents. 20–33 h.
3. **3D and Materials:** Extrude & Bevel, Revolve, Inflate and Rotate with lighting and materials, using a software
renderer in its own crate (layering allows it below L6), with output in SVG/PDF as rasters or projected vectors.
The largest single gap. 50–80 h.
@@ -67,9 +70,12 @@ Ordered by how much each gap blocks someone from switching. Sizes are one-agent
composer choice is done), tab leaders, a
spell-check dictionary (open licence), Touch Type and Snap to Glyph. Vertical point, area and path type have
initial support. 34–47 h across type core and advanced.
5. **Brushes, symbols and libraries:** brush options depth; original brush, symbol and graphic-style libraries
generated in code (never Adobe's); dynamic symbols; Start Global Edit; brushes driven by pen tilt, bearing and
barrel rotation (6D art pens, #372), which needs tablet input beyond pressure on every platform. 20–32 h.
5. **Brushes, symbols and libraries:** brush options depth (Brush Options exist for every brush type, Calligraphic
and Scatter with pen pressure; still missing are the Art brush's Overlap, auto-generated Pattern brush corners,
new tile art picked in Pattern Brush Options and the Key Color eyedropper); original brush, symbol and
graphic-style libraries generated in code (never Adobe's); dynamic symbols; Start Global Edit; brushes driven by
pen tilt, bearing and barrel rotation (6D art pens, #372), which needs tablet input beyond pressure on every
platform. 17–28 h.
6. **Automation:** Variables (data merge), a scripting surface over the command registry, batch. 8–12 h.
7. **Views and windows:** multiple windows and arrange, Consolidate All Windows, video
rulers. 15–25 h.
@@ -92,11 +98,12 @@ When a task lands, update this section, the parity table and "Shipped so far" in
grade by behaviour against `plan/illustrator/`, not by whether a menu item exists.
## Shipped so far
- **Architecture:** 19+ crates with enforced layering (`cargo xtask layers`). Every action is a command (~400 engine + ~50 UI). Undo is unlimited via structural sharing. `command.batch` runs several commands as one transaction.
- **Automation:**
- Actions panel (record/playback, persisted), generic parameter dialogs for every "…" command.
- JSON-lines control channel with real egui pointer and keyboard injection.
- MCP server with 25 tools (drawing, text, effects, Pathfinder, transforms, graphs, text wrap, export, screenshots, any command), attached to the running app or headless. Tool descriptions and replies agree with the engine: format lists come from its format table, `save_file` says it keeps the document's own format, `inspect_document` reports the paint type's characters show, and selection exports report the encoder's warnings.
- MCP server with 25 tools (drawing, text, effects, Pathfinder, transforms, graphs, text wrap, export, screenshots, any command), attached to the running app or headless. Tool descriptions and replies agree with the engine: format lists come from its format table, `save_file` says it keeps the document's own format, `inspect_document` reports the paint type's characters show, and selection exports report the encoder's warnings. `--automation-read-root` / `--automation-write-root` (on `vectorcraft-cli mcp` and on `vectorcraft --control`) confine the files commands read and write to chosen folders, links followed, with the flags PhotoCraft takes (#832).
- MCP protocol: five prompts (`prompts/list` / `prompts/get`: poster, icon set, recolour, trace-and-style, export set), `completion/complete` for prompt arguments and template variables read from the live catalogues (formats, effect ids, command ids, trace presets, swatch and object ids), four resource templates (`vectorcraft://object/{id}`, `//command/{id}`, `//effect/{id}`, `//swatch/{name}`) so an agent reads one thing instead of the whole document, and `logging/setLevel` with `notifications/message`. `window.panel` ids are case-insensitive (display labels work too), `vectorcraft://object/{id}` reads through `document.node {summary: true}` (the same reply bytes from one lookup instead of summarizing the whole document: a leaf read on a 2,000-object document drops from ~20 ms to ~0.06 ms), large containers slice with `document.node {depth?, childLimit?}` (truncated levels report `childCount`), `document.inspect` and the `inspect_document` tool slice the same way so a depth-0 read is the layer skeleton (~9.7 KB instead of ~2 MB on a 2,000-object document), `document.find` locates nodes by name/kind/text with ancestor paths, and the CLI sweeps the protocol surface over stdio against the compiled binary. `docs/mcp.md` has a Protocol section covering the capability table and what is deliberately absent (subscriptions, progress, cancellation, sampling, elicitation, Streamable HTTP — all of which need the transport to interleave reads).
- Headless CLI (`vectorcraft-cli run`, `convert`, `info`, `bench`, `perf`, `mcp`). A `run` step, like a `command_batch` step over MCP, can use an earlier step's result (`"$1.id"`, #785).
- Actions panel that records and plays back commands.
@@ -114,7 +121,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
- A canvas context menu: right-click selects the object under the pointer and lists what applies to the selection (Undo/Redo, clipboard, group, isolation, join, Remove Anchor Points when anchors are selected, masks, compound paths, guides, Transform, Arrange, Select, Export Selection), or the view commands on empty canvas.
- Save / Don't Save / Cancel before closing or quitting with unsaved documents (tabs, Close, Close All, Quit, window close).
- Four brightness themes, persistent preferences.
- Preferences that act (#394, in part): Show Tool Tips, Double Click To Isolate, Zoom with Mouse Wheel (the wheel zooms about the pointer, Shift-wheel scrolls up and down, Cmd/Ctrl-wheel sideways; Alt/Option-wheel and a trackpad pinch zoom about the pointer either way), Use Precise Cursors; Selection & Anchor Display › Tolerance (objects, anchors and handles), Object Selection by Path Only, Command Click to Select Objects Behind (cycling down the stack), Zoom to Selection, anchor Size, Highlight anchors on mouse over; Type › Number of Recent Fonts (up to 15); Guides & Grid › guide and grid Color and Style (lines or dots) and Grids In Back. Then Snap to Point (its distance, for moves, drawn points and reference points), Move Locked and Hidden Artwork with Artboard, Enable Rubber Band for the Pen and Curvature tools, Handles style, Show handles when multiple anchors are selected, Hide Corner Widget for angles greater than; General › Transform Pattern Tiles (the transforms' and dialogs' Transform Patterns) and Select Same Tint %; Type › Size/Leading, Tracking and Baseline Shift increments (`type.step`, Cmd+Shift+. and Cmd+Shift+, for the size, the Type tool's Alt+arrows), Enable Missing Glyph Protection and Fill New Type Objects With Placeholder Text. Then Smart Guides › Color, Alignment Guides, Anchor/Path Labels, Measurement Labels, Transform Tools, Object Highlighting (with Smart Guides on) and Snapping Tolerance (4 px by default, for every smart guide snap), then Construction Guides and their Angles (#506: a drawn point near a line at one of those angles through the point it leaves from lands on it). Then Type › Type Object Selection by Path Only (type is picked on the baseline of any of its lines, area type's frame or the path of type on a path, no longer anywhere in its bounds; the Type tools and the Eyedropper still take a click among the characters). Then General › Anti-aliased Artwork (off, the canvas draws the art with hard edges). Then Guides & Grid › Show Pixel Grid (Above 600% Zoom), a line at every document pixel over the art in Pixel Preview, turning with a rotated view as the document grid now does too. Then General › Show The Home Screen When No Documents Are Open (off, closing the last document leaves an empty window; the Home button still opens the screen). Then Units › Numbers Without Units Are Points (on by default: a number typed with no unit into a field in picas is read in points, a typed unit still wins; dimmed unless a unit is Picas). Then User Interface › Auto-Collapse Iconic Panels (off by default: on, a click away from a panel popped out of the icon column puts it away). Then Performance › Animated Zoom (on, with GPU Performance, which it needs and which dims it when off: the Zoom tool dragged sideways zooms about the press, right in and left out, and held still zooms on, Alt out; off, a drag zooms to the area dragged across, now drawn as a marquee), User Interface › Large Tabs (taller document tabs) and Scale Cursor Proportional to UI (off, the tool cursors keep their size whatever the UI Scaling), and File Handling › Display Bitmaps as Anti-aliased Images in Pixel Preview (off by default: Pixel Preview draws images with each pixel taking its nearest image pixel). Type › Auto Size New Area Type works too (new area type gets Auto Size). They reach agents too (`prefs.set`, then `pointer_gesture` or `press_key`; `ui.wheel` on the control channel). Then Smart Guides › Spacing Guides (moves space the selection evenly with its row or column, see Drawing aids). Then Hyphenation › Exceptions (comma-separated words that never break, or break only at their hyphens; `prefs.set` and restore push them into the hyphenator, #394). About 19 stored options still do nothing yet (the rest of #394: Show Font Names in English, Highlight Alternate Glyphs, Display Print Size at 100% Zoom, Identify Objects By, East Asian Type units, Open Documents As Tabs, Real-time Drawing and Editing, GPU Performance beyond Animated Zoom, hyphenation language, scratch disks, the legacy clipboard, Appearance of Black, Touch Workspace).
- Preferences that act (#394, in part): Show Tool Tips, Double Click To Isolate, Zoom with Mouse Wheel (the wheel zooms about the pointer, Shift-wheel scrolls up and down, Cmd/Ctrl-wheel sideways; Alt/Option-wheel and a trackpad pinch zoom about the pointer either way), Use Precise Cursors; Selection & Anchor Display › Tolerance (objects, anchors and handles), Object Selection by Path Only, Command Click to Select Objects Behind (cycling down the stack), Zoom to Selection, anchor Size, Highlight anchors on mouse over; Type › Number of Recent Fonts (up to 15); Guides & Grid › guide and grid Color and Style (lines or dots) and Grids In Back. Then Snap to Point (its distance, for moves, drawn points and reference points), Move Locked and Hidden Artwork with Artboard, Enable Rubber Band for the Pen and Curvature tools, Handles style, Show handles when multiple anchors are selected, Hide Corner Widget for angles greater than; General › Transform Pattern Tiles (the transforms' and dialogs' Transform Patterns) and Select Same Tint %; Type › Size/Leading, Tracking and Baseline Shift increments (`type.step`, Cmd+Shift+. and Cmd+Shift+, for the size, the Type tool's Alt+arrows), Enable Missing Glyph Protection and Fill New Type Objects With Placeholder Text. Then Smart Guides › Color, Alignment Guides, Anchor/Path Labels, Measurement Labels, Transform Tools, Object Highlighting (with Smart Guides on) and Snapping Tolerance (4 px by default, for every smart guide snap), then Construction Guides and their Angles (#506: a drawn point near a line at one of those angles through the point it leaves from lands on it). Then Type › Type Object Selection by Path Only (type is picked on the baseline of any of its lines, area type's frame or the path of type on a path, no longer anywhere in its bounds; the Type tools and the Eyedropper still take a click among the characters). Then General › Anti-aliased Artwork (off, the canvas draws the art with hard edges). Then Guides & Grid › Show Pixel Grid (Above 600% Zoom), a line at every document pixel over the art in Pixel Preview, turning with a rotated view as the document grid now does too. Then General › Show The Home Screen When No Documents Are Open (off, closing the last document leaves an empty window; the Home button still opens the screen). Then Units › Numbers Without Units Are Points (on by default: a number typed with no unit into a field in picas is read in points, a typed unit still wins; dimmed unless a unit is Picas). Then User Interface › Auto-Collapse Iconic Panels (off by default: on, a click away from a panel popped out of the icon column puts it away). Then Performance › Animated Zoom (on, with GPU Performance, which it needs and which dims it when off: the Zoom tool dragged sideways zooms about the press, right in and left out, and held still zooms on, Alt out; off, a drag zooms to the area dragged across, now drawn as a marquee), User Interface › Large Tabs (taller document tabs) and Scale Cursor Proportional to UI (off, the tool cursors keep their size whatever the UI Scaling), and File Handling › Display Bitmaps as Anti-aliased Images in Pixel Preview (off by default: Pixel Preview draws images with each pixel taking its nearest image pixel). Type › Auto Size New Area Type works too (new area type gets Auto Size). They reach agents too (`prefs.set`, then `pointer_gesture` or `press_key`; `ui.wheel` on the control channel). Then Smart Guides › Spacing Guides (moves space the selection evenly with its row or column, see Drawing aids). Then Hyphenation › Exceptions (comma-separated words that never break, or break only at their hyphens; `prefs.set` and restore push them into the hyphenator, #394). Then Type › Show Font Names in English (off, font menus label a family by its native-language name when the font has one; documents still use the English family, #394). Then General › Display Print Size at 100% Zoom (off by default: View › Actual Size is one document point per screen point; on, one document inch fills 96 screen points, an inch at the reference density display scaling is set against; `view.actualSize` / `prefs.set`, #394). About 17 stored options still do nothing yet (the rest of #394: Highlight Alternate Glyphs, Identify Objects By, East Asian Type units, Open Documents As Tabs, Real-time Drawing and Editing, GPU Performance beyond Animated Zoom, hyphenation language, scratch disks, the legacy clipboard, Appearance of Black, Touch Workspace).
- Dialogs move by dragging their heading (the band down from the top edge, as a title bar), stay on screen and keep where they were moved for the session; the form dialogs' choices are dropdowns (Offset Path's Joins, Average's and Shear's and Reflect's axis), and Offset Path lists Offset, Joins, Miter Limit and previews (#423).
- Dialogs open with their first field focused and its value selected: type a value and press Enter (Move, Rotate, Scale…). Number fields in panels, the Control bar and dialogs (preferences too) step with Up/Down, or with the mouse wheel over the focused field (#485: a notch a step; the panel under it then stays put, and an unfocused field leaves the wheel to scrolling) (Shift: ten, Ctrl/Cmd: a tenth of their unit), applied at once.
- Scrubby labels (#400, beyond Illustrator, as in Photoshop): dragging a numeric field's label left or right (the ↔ cursor shows over it) steps the value one unit every 2 points (Shift: ten, Ctrl/Cmd: a tenth; counts stay whole), in the field's unit and within its limits, applied live as the art follows; the whole drag is one undo step and Escape puts the value back. A click on the label focuses the field; the text box itself never scrubs, and a field with no label doesn't. Text labels before a field (Transform, Properties, Control bar, Stroke, Transparency, form rows in dialogs) and the Character and Paragraph panels' icons act as labels. Preferences › General › Scrub Numeric Fields by Dragging (on by default) turns it off.
@@ -129,18 +136,18 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
Clear deletes individual selected compound-path members without deleting their siblings, and explicit object ids work without a selection; Cut still removes the same objects it copies (#786).
- **Saved selections:** Select ▸ Save Selection… asks for a name and keeps the selection in the document (saved with the file, undoable, up to 25); each one is listed at the bottom of the Select menu and a click selects its objects again; Edit Selection… lists them to rename or delete, applied together on OK. A file's saved selections are capped and checked against its objects when it opens. Not yet: Update Selection and the Layers panel's saved-selection menu.
- **Drawing:** Pen, Curvature, anchor tools, Pencil, Paintbrush, Blob Brush, Smooth, Path Eraser, Join. Direction handles dragged with Direct Selection or the Anchor Point tool snap to smart guides, and Shift keeps them at 45° steps from their anchor. The Pen continues any open path from the end clicked, and while drawing, a click on an end of another open path joins the two into one (#776). The Curvature tool edits any selected path, whatever drew it, keeping its shape except where edited (#798): a click on a point makes it current and a drag moves it, re-curving only its two segments; Alt-click or double-click toggles it smooth or corner and Delete removes it; a click on a segment adds a point without changing the shape; with an end of an open path current, clicks go on from it and its other end closes it (`path.curvatureEdit`). Every point a drawing tool places snaps to Smart Guides through one snapper (#506): the Pen's and Curvature tool's anchors, the Pencil's ends, the shape tools' start and dragged corner or radius (Rectangle, Rounded Rectangle, Ellipse, Polygon, Star, Line Segment, Arc, Spiral, the grids), the Type tool's click and frame, a new artboard's corners.
- **Shapes:** all shape tools (including Flare) and the line, arc, spiral and grid tools. Space held while dragging out a shape, line, arc, spiral or grid moves it at its current size. While a grid is dragged out, Up/Down change its rows (concentric dividers) and Left/Right its columns (radial dividers). An ellipse's Pie Start and End Angle and Invert Pie (Transform and Properties panels' Ellipse Properties, `object.setLiveShape {pieStart, pieEnd, invertPie}`) cut it as a pie (#812; the on-canvas pie widget is still to come).
- **Shapes:** all shape tools (including Flare) and the line, arc, spiral and grid tools. Space held while dragging out a shape, line, arc, spiral or grid moves it at its current size. While a grid is dragged out, Up/Down change its rows (concentric dividers) and Left/Right its columns (radial dividers). An ellipse's Pie Start and End Angle and Invert Pie (Transform and Properties panels' Ellipse Properties, `object.setLiveShape {pieStart, pieEnd, invertPie}`) cut it as a pie (#812; the on-canvas pie widget is still to come). A live polygon's Polygon Properties there: side count, angle, corner type and radius, radius, side length and Make Sides Equal once an uneven scale has made its sides differ (`object.setLiveShape {sides, polygonAngle, polygonRadius, sideLength, makeSidesEqual}`, part of #812); a star is a plain path, as in Illustrator. The Flare Tool Options last as the tool's options: the next flare dragged out uses them, a click's dialog shows them and double-clicking the tool opens them (part of #812).
- **Cutting:** Eraser, Scissors, Knife, Mirror & Cut, Line Cut and Rectangle Cut (real geometry, compound paths keep their holes).
- **Transform:** Rotate, Reflect, Scale, Shear (click or Alt-click snaps the reference point to anchors and centres; Alt-click opens the tool's dialog, and a drag with Alt down at the release, from the press or pressed mid-drag, transforms a copy), Reshape, Free Transform (free and perspective distort and shear, switched by the keys held during a handle drag).
- **Live Corners:** drag a live rectangle's corner widgets (Selection or Direct Selection) to round all corners, with a radius readout; with Direct Selection, select corner anchors to show and round just those corners (#381). Each corner keeps its own radius and kind (Round, Inverted Round, Chamfer): Alt-click a widget to cycle the kind, double-click it for the Corners dialog, and the Properties and Transform radius fields set the selected corners (`object.setLiveShape {corners, radius, kind}`). Corners stay circular when the rectangle is scaled unevenly or along its own sides when rotated (the radius keeps its size, or scales by the mean scale with Scale Corners on); only a shear distorts them. On a rectangle that isn't square every corner is the same circle: a radius past half the shorter side stops there for all of them, the widgets sit on the corners as drawn and turn the edited corners red at that limit, CSS export writes the drawn radii, and rectangles saved stretched by older versions get circular corners when their corners are next set (#442). Live Corners work on any path's corners too, as on a rectangle's (#511): a star's, a live polygon's (which also shows them to the Selection tool, has a Corner Radius field in the Properties and Transform panels and keeps its radius as its sides change) and a pen path's, wherever two straight sides meet at an anchor. Direct Selection shows the widgets of every corner, or of the corners holding selected anchors; each corner is a circle tangent to both sides, drawn no larger than takes its cut halfway along its shorter side; a path keeps its uncut outline so its corners stay editable, and the same command, dialog, angle preference and View › Hide Corner Widget apply.
- **Pen:** Alt pressed in the middle of dragging out an anchor splits its handles and keeps the curve already shaped into it (only the outgoing handle follows the pointer); Space held while dragging out an anchor moves it, handles and all. A press on the first anchor of the path being drawn closes it whatever the drag then does: the drag shapes the closing curve (symmetric handles, Alt breaks them) and the release closes the path wherever it ends (#501). Clicking the last anchor of the path being drawn retracts its outgoing handle (convert cursor), and dragging from it pulls out a new one on its own. Auto Add/Delete: over a selected path, a click on a segment adds an anchor and a click on an anchor deletes it (add and delete cursors); Shift held, or Preferences › General › Disable Auto Add/Delete, starts a new path instead. Alt held over a handle end or an anchor of a selected path (the one being drawn too) works as the Anchor Point tool, with its convert cursor: dragging a handle moves it alone, clicking a smooth anchor makes it a corner, dragging an anchor pulls out new symmetric handles, dragging a segment reshapes it; drawing then goes on. Cmd (Ctrl) held drags with the selection tool chosen last, Direct Selection until one is (handles, anchors and segments of the path being drawn or another), and the Pen then goes on drawing its path while that stays selected; the pointer shows that tool's while Cmd is held. The same holds for agents (`pointer_gesture` with `mods.cmd`), as the borrowing now lives in the engine (#494).
- **Path editing (#494):** the handles shown are the handles that drag: Direct Selection drags those of a path selected as a whole (as the Pen leaves it), the Anchor Point tool and Pen+Alt no longer grab handles that Show handles when multiple anchors are selected hides, a handle shorter than the tolerance leaves its anchor to be picked, and the anchor tools pick within Selection & Anchor Display › Tolerance. A dragged segment keeps a smooth anchor at either end smooth. With Direct Selection, the Pen, the Curvature and the anchor tools, a path selected as a whole shows all its anchors selected, and the Control bar and the Properties panel offer Convert (corner, smooth) for them too; Remove and Cut wait for anchors direct-selected (#504). A handle drag gathers its snap targets once per drag (it took 21 ms per pointer move on a 20,000-path document, now 1 ms; `vectorcraft-cli perf` measures handle and segment drags on a 5,000-anchor path).
- **Graphs:** Column, Stacked Column, Bar, Stacked Bar, Line, Area, Scatter, Pie and Radar graph tools with Graph Data and Graph Type.
- **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 Design, Column and Marker artwork remain open.
- **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.
- **Panel drag and drop:** art dragged off the canvas onto the Symbols panel becomes a symbol, onto the Swatches panel a pattern swatch and onto the Brushes panel an Art brush; a symbol dragged onto the canvas places an instance where it is dropped, and a brush dragged onto a path is applied to it.
- **Tool double-clicks:** double-clicking a tool's button opens its options: Hand fits the artboard in the window, Zoom shows 100 %, Rotate, Scale, Reflect and Shear open their Object › Transform dialogs, the Selection, Direct Selection and Group Selection tools the Move dialog (Enter opens these seven tools' dialogs too), Gradient, Eyedropper, Blend and the Liquify tools their panels or options, and the Pencil, Paintbrush, Smooth, Blob Brush and Eraser tools their Tool Options (Fidelity, fill, the tolerances, the brush size).
- **Tool double-clicks:** double-clicking a tool's button opens its options: Hand fits the artboard in the window, Zoom shows 100 %, Rotate, Scale, Reflect and Shear open their Object › Transform dialogs, the Selection, Direct Selection and Group Selection tools the Move dialog (Enter opens these seven tools' dialogs too), Gradient, Eyedropper, Blend and the Liquify tools their panels or options, the Pencil, Paintbrush, Smooth, Blob Brush and Eraser tools their Tool Options (Fidelity, fill, the tolerances, the brush size), and (#812) the Flare tool its Flare Tool Options, the Artboard tool the active artboard's Artboard Options, the graph tools Graph Type for the selected graph, the Symbolism tools the Symbolism Tools Options (Diameter, Intensity, Symbol Set Density) and the Magic Wand tool its panel.
- **Layers panel:** every layer, sublayer, group and object is a clickable row at any depth: a click highlights it and makes its layer current (new art goes there), Shift-click a range, Ctrl/Cmd-click toggles. Sublayers are no longer treated as objects: clicks, marquees, Select All and the Select menu take the art inside them, and each sublayer selects in its own colour. Selection squares on rows with selected art (click selects a row's art, Shift adds or removes; dragging the square moves the selection to another row, Alt copies), the eye and lock on every row with drag-down-the-column (one undo step), Ctrl/Cmd-click an eye for per-layer Outline, Alt-click an eye or lock to hide or lock the other layers (again: show or unlock them all), triangles with Alt to open or close everything inside, drag and drop rows above, below or into layers and groups with a drop line (Alt copies) and a dimmed copy of the row under the pointer (dropped on the trash rows are deleted, on New Layer duplicated, as in Illustrator), inline rename, Delete key on highlighted rows. Buttons and the full panel menu: New Layer/Sublayer (Alt: options, Ctrl: on top), Duplicate, Delete, Options for Selection, clipping mask, isolation, Locate Object, Merge Selected, Flatten Artwork, Collect in New Layer, Release to Layers (Sequence and Build), Reverse Order, Template, Hide/Outline/Lock Others and Show/Preview/Unlock All, Paste Remembers Layers, Panel Options (row size, thumbnails per kind, Show Layers Only). Layer Options: name, colour (presets, each with a chip of its colour, or custom), Template, Lock, Show, Print, Preview (outline drawing and clicking) and Dim Images to %. Every operation is a command (`layer.move`, `layer.highlight`, `layer.merge`…) with validation against cycles and invalid nesting. A document opens with only its top-level layers open; the rows left open are saved in the native file (nothing is written for the default) and reopen that way. Only the rows scrolled into view are laid out, from a row list made once per change: a layer of 5,000 groups of 10 paths, all open, draws the panel in 0.4 ms a frame instead of about 29 s (#638).
- **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; 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.
- **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).
@@ -157,6 +164,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
- **Colour:** a Color Picker (colour field, H/S/B/R/G/B channel slider, HSB/RGB/Lab/CMYK fields, hex, web-only snapping, out-of-gamut correction); the Color panel follows each colour's own model and Alt-click paints the other proxy; proxies look through groups and show "?" for mixed paint; Invert, Complement, Apply Last Color (`,`) and Apply Last Gradient (`.`) keep the colour model; every paint command feeds the recent colours. The Color Guide has 21 harmony rules, Steps and Variation options and Limit to Library. Adjust Color Balance (its Global mode shifts tints) and Saturate preview live; Edit Colors and Recolor also reach meshes, images and patterns. Recolor Artwork has colour reduction, five recolour methods, presets, Limit to Library, a colour-wheel Edit tab and Edit Color Group; a local Color Themes panel saves five-colour themes. Fill/Stroke chips open swatch and mixer popovers (the Properties panel has the Control bar's Fill and Stroke chips, Stroke link and weight spinner, with nothing selected too), panels have Illustrator's Window menu keys (Align Shift+F7, Transform Shift+F8, Pathfinder Cmd+Shift+F9, Layers F7, Character Cmd+T…; every panel can be given one in Edit › Keyboard Shortcuts), and Eyedropper Options pick up and apply chosen attributes (Alt and Shift+Alt, image pixels). An ICC CMYK profile converts straight into the RGB working space (not through sRGB, so a wider space such as Wide Gamut RGB keeps colours sRGB can't hold) with the colour settings' black-point compensation, on the canvas and in RGB exports alike. Fills and strokes can overprint (Attributes panel, Overprint Black options), and CMYK documents blend in inks on screen, in PDF and when flattening. Document Color Mode converts the art, symbols, pattern tiles and swatches through the colour settings: Gray stays Gray, RGB greys separate into four-colour greys and a rich black as Illustrator does, or go on the black plate only with `grays: "black"` (also for `document.open {colorMode}`).
- **Gradients:** lossless, validated gradient params; gradients follow rotate, reflect, shear, non-uniform scale and distortions; the Gradient tool edits the fill or stroke of the active proxy, type included; an interactive on-canvas annotator (move, length/angle, rotate, add/move/delete/duplicate stops, midpoints, a stop popover, Delete and arrow keys). The Gradient panel has the Fill/Stroke proxy, a gradient-swatch dropdown with Save to Swatches, midpoint locations, Alt-drag to copy or swap stops, swatch drops on the ramp and a stop eyedropper; copied appearances and graphic styles place gradients on each target's own bounds; gradient handles snap and honour Constrain Angle. Gradient stops stay linked to global and spot swatches. Freeform gradients (points and lines) are edited on the canvas; radial gradients have an extent ellipse, an aspect handle and an off-centre focal point (SVG `fx`/`fy`, PDF two-point radials). A stroke's gradient runs within, along or across it. Object ▸ Expand turns gradients into N objects or a gradient mesh.
- **Strokes:** one stroke-geometry module shared by the canvas, SVG, PDF and Outline Stroke; dotted lines (zero-length dashes draw dots or squares); arrowheads with hollow outlines, tip-on-end or extend-past-end alignment and one opacity for line and head. SVG and PDF export arrowheads, width profiles, brushes and aligned strokes exactly as the canvas draws them; Outline Stroke and the live Outline Stroke effect outline every stroke item as drawn (dashes, heads, profiles, alignment); dashes can be fitted to corners and path ends; width profiles carry through dashes, and their corners take the join. The Stroke panel edits type's character strokes, follows the stroke units (its weight presets are round values in that unit: `0.25 mm`, `0.0625 in`, `1 px`, #426; fields show four decimals in mm, cm and inches, three in points), shows mixed values blank and opens from the Control bar. A width profile library, thirty more arrowheads, and Width Point Edit with multi-select and discontinuous points. Visual bounds and clicks cover the whole painted stroke. Scale Strokes & Effects, Scale Corners and Use Preview Bounds work everywhere, and new art inherits stroke options, a graphic style or the whole last appearance.
- **Brushes (part of #852):** double-clicking a brush in the Brushes panel (or the panel menu's Brush Options…) opens Calligraphic Brush Options (Name; Angle, Roundness and Size, each Fixed, Random or Pressure with its Variation; live previews of the nib and of a light–heavy–light stroke) or Bristle Brush Options (Shape and the bristle sliders); OK edits the brush and every stroke painted with it. The Paintbrush records the pen pressure along each stroke (kept with the path, saved, and joined when a stroke continues a path); a Calligraphic value on Pressure goes from value − variation at the lightest pressure to value + variation at the heaviest, on the canvas and in SVG, PDF and the other exports. Brushes saved before keep their look. Scatter Brush Options (Size, Spacing, Scatter and Rotation, each Fixed, Random between two values or Pressure from the first at the lightest pressure to the second at the heaviest; Rotation relative to Page or Path), Art Brush Options (Width, Scale Proportionately, Stretch to Fit Stroke Length or Stretch Between Guides with the guides' start and end, the four Directions, Flip Along and Across) and Pattern Brush Options (Scale, Spacing, the five tiles shown, Flip Along and Across, Stretch to fit, Add space to fit or Approximate path) each have Colorization (None, Tints, Tints and Shades, Hue Shift with its Key Color picked from the art's colours) and a live stroke preview; values from files and commands are clamped to their ranges. Still to come: the Art brush's Overlap, auto-generated Pattern brush corners, new tile art picked in Pattern Brush Options, Stylus Wheel, Tilt, Bearing and Rotation, and pen pressure on macOS.
- **Appearance:** the Appearance panel's selected row drives every paint, stroke, gradient and transparency edit (`item` on the commands); effects apply to one fill or stroke; per-item Opacity popups, whose blend-mode list and opacity slider stay open inside them (#430; the Control bar's Stroke popover too); Mixed Appearances, Layers target dots and basic-appearance rules. Effects can be reordered, moved between items and copied by dragging; Show All Hidden Attributes; clicking an effect edits it in place; the object thumbnail drags onto art; effects render on type, images, symbols, blends, envelopes, meshes and repeats, and per-item raster effects export. Groups, layers and type have their own fills, strokes and effects (Contents and Characters rows); the Layers panel's target circles target layers, groups and objects; Expand Appearance turns fills, strokes, effects and brushes into objects.
- **Masks and clipping:** opacity-mask controls work while the mask is being edited; transparency commands take ids and work without a selection; clipping sets clip by compound paths, even-odd paths, text and groups on screen and in raster export, matching SVG/PDF. Alt-click shows only the mask; SVG masks keep Clip and Invert; the transparency grid is per-document state; a clipping path's own fill and stroke paint behind and over the clipped art; layers can be clipping masks.
- **Transparency:** three-state Knockout Group (on, neutral, off), Opacity & Mask Define Knockout Shape, Page Isolated Blending and Page Knockout Group, on screen and in SVG/PDF. Blend modes match the standard formulas on screen, in SVG and in PDF, and non-isolated groups blend with the art below. Flatten Transparency with presets, a dialog and a Flattener Preview panel.
@@ -167,7 +175,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
- **Files (M4):**
- `.vectorcraft` (lossless JSON, compressed, atomic saves, save down to v1), Save As with a format chooser, Save a Copy, Revert, templates, Data Recovery after a crash (browser tabs hold their copies with Web Locks too, so a background tab's copies are never taken for a crash's, and a tab repairs copies another removed when it resumes: #367), background save and export. The desktop app writes its warnings and errors (graphics adapter, a lost graphics device, panics the guard recovered from) to standard error and to `logs/vectorcraft.log` next to the preferences, the two previous runs kept and each file capped at 16 MiB, so a launch without a terminal still leaves something to attach to a bug report (#465).
- SVG/SVGZ in and out with SVG Options, Preserve Editing, symbols, filters, rich text and physical units (SVG type under a transform or `viewBox` scale opens at the size it draws at, the scale moved into its size, leading, baseline shift and strokes, a stretch, skew or rotation kept as its transform: #396; a top-level group with a clip path, such as a clipped Inkscape layer, opens as a layer with a clipping mask, its name kept: #420; hidden objects written hidden on Save and `convert`, and `data-*` attributes kept as the objects' own data: #522; top-level groups open as layers named from `data-name`, else the id, also when one more group (a transform on all the art, a `<switch>`) wraps them, hidden ones hidden, and a file whose art sits loose under a root `<svg>` with an id (or `data-name`), as one-layer exports write it, opens with its one layer named from it; nested groups stay groups, except the sublayers VectorCraft writes, marked `data-vc-layer`, and Inkscape sublayers, which open as sublayers in their layer's colour: #372); PDF and PDF-compatible `.ai` in and out (layers, with hidden ones kept hidden with their art and nested ones as sublayers, on pages of up to 64 million operators, art whose layer can't be told going to a hidden, non-printing layer rather than showing or printing: #419; strokes kept live, a fill and a stroke of one outline as one object; a type object's stroke written as each glyph's outline stroked back on the type, so it stays one editable object; lines wrapped in a frame (each wrapped line ending in a space, one left edge, leading and font) back as one area type object, left aligned or justified with its paragraph spacing, where laying it out breaks the lines where the file does; glyphs set apart by gaps wider than a space (a barcode's digit groups) keeping their places; layers stacked as the pages mark them even when one first paints on a later page, art ending a layer's marked content or a clip closing in a later layer kept in its own layer, and layers with no art kept empty: #508; art reaching past a plain PDF's page clipped to its crop box, as PDF viewers show it, each such object in a clip group of its own (a PDF-compatible `.ai` opens whole: #675); masks, editable text with type set along a curve coming back as type on a path, each artboard's art on its own page only, an `.ai`'s white page under its layers left out rather than coming in as art, spot colours, presets, security, marks and bleed, ICC output intents, raster effects, subset fonts, PDF/X, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3). CMYK images keep their ink amounts from PDF import (and from placed CMYK TIFFs) to PDF export: a CMYK JPEG passes through unchanged, others stay CMYK (DeviceCMYK, or ICC-based CMYK in PDF/X-3/X-4) unless the Output settings convert them; the canvas shows them through the working CMYK profile (#320). A masked CMYK image (`/SMask`, `/Mask` or a colour key) keeps its inks too, its alpha coming in as a greyscale opacity mask.
- EPS and DXF in and out, EMF/WMF in and out; PNG (with PNG-8), JPEG, WebP, GIF, TIFF, BMP, Targa and layered PSD export. EPS import runs a PostScript Level 3 interpreter written from the PostScript Language Reference: resource categories and `resourceforall`, local and global VM (`currentglobal`/`setglobal`/`gcheck`), `clipsave`/`cliprestore`, subarrays and substrings that share storage, the standard dictionaries in `systemdict`, `dictstack`, `internaldict`, executable filters and strings, `load` by any key, `//name` immediate names, `token`, `execform`, user paths, `cshow`, `rootfont`, `cachestatus`, colour rendering and halftone queries, file `status` and data read with `flushfile`, and `pdfmark` marks dropped (Discord feedback, #466, #469, #474). Masked images (`ImageType 3` in its three interleavings) and reusable streams are read, and an image's several data sources are read in turn, as the reference defines (procedures that read one file get their own rows: such images came in banded, #498). `restore` puts dictionaries back as they were at the `save`, so programs that count their saves in a dictionary read to their end (part of #505). Tiling patterns come in as pattern swatches, uncoloured ones too; Type 3 fonts' glyphs as their outlines, spaced by `ashow`/`widthshow`/`xshow`/`kshow`; mesh and function-based shadings (types 1, 4–7) as gradient meshes; sampled functions (type 0) and Flate/LZW predictors are read. It is tested with programs written from the specification in the ways common generators use these features; Inkscape/cairo EPS files open as vectors. When a file's PostScript still can't be read, it opens as its TIFF preview, its Windows metafile preview (as vectors) or its EPSI preview, and the warning (in the status bar, `document.open`/`app.open`/MCP `open_file` replies and `vectorcraft-cli info`) names the error, the operator that raised it and the procedures it ran in. Legacy PostScript `.ai` files (Illustrator 3–8, written with the prolog that defines their operators) keep their groups and nested groups (`u` … `U`), between and inside clipping groups; EPS and EMF/WMF import nest clips (and groups) at most 128 deep, so a hostile file can't exhaust the stack. DXF TEXT justified Fit or Aligned spans its two points (stretched, or scaled as a whole). PDF export writes type as real, searchable text in embedded subset fonts by default, as Illustrator's PDF presets do (Convert Text to Outlines is an option; printer's marks stay shapes), so text-heavy PDFs stay small (#733). DXF export writes each fill as its outlines followed by its hatch, so laser and cutter software, which skips hatches, gets filled art and type too (#546).
- EPS and DXF in and out, EMF/WMF in and out; PNG (with PNG-8), JPEG, WebP, GIF, TIFF, BMP, Targa and layered PSD export; PSD and PSB files open and place, linked or embedded, as their merged image in every colour mode and depth (#918). EPS import runs a PostScript Level 3 interpreter written from the PostScript Language Reference: resource categories and `resourceforall`, local and global VM (`currentglobal`/`setglobal`/`gcheck`), `clipsave`/`cliprestore`, subarrays and substrings that share storage, the standard dictionaries in `systemdict`, `dictstack`, `internaldict`, executable filters and strings, `load` by any key, `//name` immediate names, `token`, `execform`, user paths, `cshow`, `rootfont`, `cachestatus`, colour rendering and halftone queries, file `status` and data read with `flushfile`, and `pdfmark` marks dropped (Discord feedback, #466, #469, #474). Masked images (`ImageType 3` in its three interleavings) and reusable streams are read, and an image's several data sources are read in turn, as the reference defines (procedures that read one file get their own rows: such images came in banded, #498). `restore` puts dictionaries back as they were at the `save`, so programs that count their saves in a dictionary read to their end (part of #505). Tiling patterns come in as pattern swatches, uncoloured ones too; Type 3 fonts' glyphs as their outlines, spaced by `ashow`/`widthshow`/`xshow`/`kshow`; mesh and function-based shadings (types 1, 4–7) as gradient meshes; sampled functions (type 0) and Flate/LZW predictors are read. It is tested with programs written from the specification in the ways common generators use these features; Inkscape/cairo EPS files open as vectors. When a file's PostScript still can't be read, it opens as its TIFF preview, its Windows metafile preview (as vectors) or its EPSI preview, and the warning (in the status bar, `document.open`/`app.open`/MCP `open_file` replies and `vectorcraft-cli info`) names the error, the operator that raised it and the procedures it ran in. Legacy PostScript `.ai` files (Illustrator 3–8, written with the prolog that defines their operators) keep their groups and nested groups (`u` … `U`), between and inside clipping groups; EPS and EMF/WMF import nest clips (and groups) at most 128 deep, so a hostile file can't exhaust the stack. DXF TEXT justified Fit or Aligned spans its two points (stretched, or scaled as a whole). PDF export writes type as real, searchable text in embedded subset fonts by default, as Illustrator's PDF presets do (Convert Text to Outlines is an option; printer's marks stay shapes), so text-heavy PDFs stay small (#733). DXF export writes each fill as its outlines followed by its hatch, so laser and cutter software, which skips hatches, gets filled art and type too (#546).
- Affinity documents in (AF.1–AF.4): `.af` (Affinity 3) and `.afdesign`/`.afphoto`/`.afpub` (Affinity 1 and 2, container versions 8–12) open and place as editable art: layers and sublayers, groups (pass-through or isolated), artboards (with their names and backgrounds) and Publisher pages side by side, curves with live corners, parametric shapes (rectangles with corner radii, ellipses, polygons, stars, pies, triangles, trapezoids), compound shapes, solid/linear/elliptical gradient fills and strokes (weight, dashes, alignment, scale-with-object), clipping by nesting, vector and pixel masks, artistic and frame text with fonts, sizes, tracking, leading and alignment, placed images (their original JPEG/PNG) and pixel layers (8/16-bit RGB, CMYK), opacity, visibility, locking and blend modes, at the document's resolution in points. Effects, adjustments, brushes and pressure profiles, transparency gradients, master pages, symbol instances (imported as their cached picture), special shapes (cloud, heart, cog…, imported as ellipses) and Affinity-only blend modes are named in the import warning. A file whose native data can't be read opens as its embedded preview with the reason, and that preview can't be placed. Save never writes back over the Affinity file and there is no Affinity export. AF.4 adds Affinity 3 artboard recognition and actual-outline clipping, source-backed JPEG/PNG raster tiles, Unicode text-run recovery, stored fallback/exact installed fonts, OpenType feature flags and compound-geometry limits. 51 public CC0/MIT/Apache-2.0 files (33 current `.af`), pinned and sha256-verified (`cargo xtask corpus --affinity`), test native retention and every-board native/SVG/PDF/PSD output for current files; selected files compare their saved thumbnails. No Affinity reopen or independent paired PSD oracle was available; [source audit and complex-font gaps](docs/affinity-validation.md); [scope, provenance and limits](crates/affinity/README.md).
- Placed documents: File › Place of a `.vectorcraft` file links to it by default (`file.place`'s `link`): one locked object showing an artboard of the document (or its art's bounds), read the way placing it without Link reads it, its symbols, patterns and images kept apart from the document's; without Link (or from bytes, not a file) it is an editable copy as before. It stays vectors in every output (SVG, PDF, EPS, EMF, DXF), and so do placed documents inside it, read from their own files up to 8 levels deep (documents that place each other stop there). On screen they draw from bitmaps cached per file and zoom step, shared by every copy and made in the background (a placeholder until ready), so a sheet of them redraws in about a millisecond; exports and prints draw them exactly. Like linked images, a save keeps only a preview of each (JPEG when opaque) unless Include Linked Files; output, and zooming past the preview, read the file again, falling back to the preview with a warning when it is gone or changed. Saves for older versions write them as plain art. Links check, update, relink, Update Links on open, Link Info (the artboard's size), Placement Options, Break Link (also Embed, Object › Expand: the editable copy placing without Link gives, its resources joining the document), Edit Original (the document in a new tab) and the Links panel cover them; saving a document updates the open documents that place it. The native format (v4) keeps data of 8 KB or more after the JSON instead of base64 in it.
- Place and the Links panel, Package (placed `.vectorcraft` documents packaged with their own linked files and fonts, relinked to the copies: #777), File Info, Print with print presets, PostScript output and print tiling, slices and Save for Web, Export for Screens and Asset Export, CSS Properties, PNG/PDF/SVG/text clipboard flavours; files copied in a file manager paste as art (SVG, PDF, EMF/WMF, bitmaps) on every desktop platform, and Linux/Wayland reads the compositor's clipboard (#398); on Linux the Paste menu checks the clipboard off the UI thread, so an X11 owner that never answers can't freeze the window (#510); screenshots and other copied pictures paste as embedded images at the view centre everywhere, the web included (#457).
@@ -190,8 +198,8 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
| M8 | Transform & distort (Puppet Warp, Liquify tools, Envelopes, Blends, Perspective Grid) | ✅ done (M8.1–M8.20 on 2026-10-06, fidelity pass): Liquify Tool Options with one shared brush, pen pressure, hold-to-apply, a safe scope and incremental strokes; tool options that last; Puppet Warp rotation, multi-select, Control bar and a rest-shape session that follows Undo; Width tool on compound paths; Envelope dialogs with preview, Reset with Warp/Mesh, full Envelope Options, type/images/symbols/appearance/gradients/patterns distorted on canvas and in every export, warps that keep their frame, Edit Contents, mesh handles and Control bar; Blend Options, anchor-targeted Blend tool, editable spine, keys picked on the canvas, faithful interpolation and knockout; Perspective Grid with Define Grid, presets, lock/snap/rulers, widgets, the 1–4 plane switch, movable planes, Perspective Selection scale/copy/perpendicular/Transform Again, and type and symbols truly projected with Edit Text. Left: Anti-Alias and Preserve Shape output, warp-envelope point editing, brush interpolation in blends, Free Transform/Scale/Reflect along a rotated box | 2–4 |
| M9 | Live effects (+ 3D & Materials) | 🟡 2D effects done incl. Effect → Pathfinder and Color Adjustments; SVG Filters, Document Raster Effects Settings, 3D pending | 86–135 |
| M10 | Brushes, symbols, patterns, Repeat | 🟡 pattern swatches (5 tile types, Pattern Options, editing mode, SVG `<pattern>`/PDF export) and live Repeat (radial/grid/mirror) done; brushes/symbols in progress | 23–37 |
| M11 | Artboards & views (artboard panel/tool, Trim View, middle-button pan, print tiling and the status bar's artboard navigator done; multiple windows, presentation polish) | 🟡 | 15–25 |
| M12 | Advanced color & art (CMYK/ICC, separations, Gradient Mesh, Live Paint, Image Trace, Graphs) | 🟡 Gradient Mesh, Live Paint, Image Trace (13 presets, 18 ms/1k² image; the Flat Logo mode places edges to a fraction of a pixel, restores sharp corners, snaps lines and circles, and refuses gradients), Recolor Artwork, colour management (ICC, soft proofing, separations preview), Graphs (all 9 tools, Graph Data/Type, regenerate in place) done; graph Design/Column/Marker designs pending | 6–10 |
| M11 | Artboards & views (artboard panel/tool with Move Artwork for X/Y fields and Artboard Options, Trim View, middle-button pan, print tiling and the status bar's artboard navigator done; multiple windows, presentation polish) | 🟡 | 15–25 |
| M12 | Advanced color & art (CMYK/ICC, separations, Gradient Mesh, Live Paint, Image Trace, Graphs) | 🟡 Gradient Mesh, Live Paint, Image Trace (13 presets, 18 ms/1k² image; the Flat Logo mode places edges to a fraction of a pixel, restores sharp corners, snaps lines and circles, and refuses gradients), Recolor Artwork, colour management (ICC, soft proofing, separations preview), Graphs (all 9 tools, Graph Data/Type, group-selected series colours persist through regenerate/save/open) done; graph Design/Column/Marker designs pending | 6–10 |
| M13 | Automation (Actions ✅ record/playback, persisted; variables, scripting, batch) | 🟡 | 18–27 |
| M14 | 1.0 polish (preferences, shortcut editor, workspaces, accessibility, packaging for all OSes) | 🟡 Preferences, shortcut editor (chords with Shift and a punctuation key fire as typed), workspaces, a custom title bar on Windows/Linux with a Home button, content-sized dialogs, menus that scroll when longer than the window and macOS native menu dismissal done (M14.8); an experimental, unsupported Windows 7 x64 portable OpenGL build (not verified by maintainers); accessibility and broader Windows/Linux packaging/QA pending | 18–28 |
| — | Interaction fidelity pass (every tool's modifiers, Properties panel per context, isolation, nuance) | ⬜ | 60–90 |
@@ -216,24 +224,24 @@ about 1–1.5 agent-hours each, so the other rows (estimated on the older scale)
| Drawing tools | 7 | 85% | rough-square Shaper recognition and orientation steps corrected; editable Shaper Groups landed; remaining: Shaper construction widget/gesture refinements, pencil and the remaining pen modifier nuances, the freehand Tool Options' Keep selected, Alt to the Smooth tool, Blob Brush Merge Only with Selection and brush angle/roundness, Touch Type, Live Corners on corners between curved sides (and Relative rounding) | 8–13 |
| Path operations, Pathfinder, Shape Builder, Live Paint | 5 | 86% | Live Paint gap options, Shape Builder gap detection, options (Tool Options dialog, clicking a stroke in merge mode) and Shift-drag marquee | 3–6 |
| Colour, swatches, gradients, patterns, mesh, recolor | 7 | 96% | freeform and mixed spot/process gradients in SVG/PDF (exported as stops or process colours), pattern fills in Expand | 2–4 |
| Strokes, brushes, width profiles | 5 | 88% | brush options depth, brush libraries (generated in code), brushes driven by pen tilt, bearing and barrel rotation (6D art pens) | 12–20 |
| Strokes, brushes, width profiles | 5 | 90% | the Art brush's Overlap, auto-generated Pattern brush corners and new tile art in Pattern Brush Options (Brush Options for every brush type done), brush libraries (generated in code), pen pressure on macOS (the windowing layer reports none there), brushes driven by pen tilt, bearing and barrel rotation (6D art pens) | 8–15 |
| Appearance, transparency, graphic styles, masks | 5 | 98% | raster effect reach in preview bounds (flattener presets apply in PDF, EPS and print) | 1–2 |
| Live vector effects | 5 | 85% | Outline Object, Pathfinder Hard/Soft Mix and Trap, SVG Filters | 6–10 |
| Raster effects (Effect Gallery, Document Raster Effects Settings) | 4 | 25% | ~51 Photoshop-style filters (Artistic, Brush Strokes, Distort, Pixelate, Sketch, Stylize, Texture, Video), Smart Blur's Edge Only / Overlay Edge modes and the Effect Gallery; Document Raster Effects Settings, raster effects in PDF, the pixel-filter pipeline, Radial Blur, Smart Blur, Unsharp Mask and Glowing Edges are done | 26–40 |
| Live vector effects | 5 | 85% | Outline Object, Pathfinder Hard/Soft Mix and Trap, SVG Filters, the Transform effect's Transform Objects/Patterns and Scale Strokes & Effects options | 6–10 |
| Raster effects (Effect Gallery, Document Raster Effects Settings) | 4 | 32% | ~39 Photoshop-style filters (Artistic, Brush Strokes, Distort, Sketch, Stylize), Smart Blur's Edge Only / Overlay Edge modes, Texturizer's Load Texture and the Effect Gallery; Document Raster Effects Settings, raster effects in PDF, the pixel-filter pipeline, Radial Blur, Smart Blur, Unsharp Mask, Glowing Edges, Pixelate (Color Halftone, Crystallize, Mezzotint, Pointillize), Texture (Craquelure, Grain, Mosaic Tiles, Patchwork, Stained Glass, Texturizer) and Video (De-Interlace, NTSC Colors) are done | 20–33 |
| 3D and Materials | 4 | 0% | Extrude & Bevel, Revolve, Inflate, Rotate, lighting, materials (software renderer) | 50–80 |
| Type core | 9 | 80% | Middle Eastern features beyond bidi layout and Paragraph Direction (Character Direction, digit types, kashidas, the Middle Eastern composers, editable bidirectional SVG text, a split caret where directions meet); Hyphenation… and Justification… options (Every-line Composer for justified and ragged text, per object, is done; it is tuned by eye, not yet compared side by side with Illustrator; mixed type sizes in a paragraph still break single-line), Optical Margin Alignment, paragraph spacing limit for vertically justified area type, hidden characters, paragraph attributes in the Type tool's copy and paste (the characters keep their styles, the paragraphs take the destination's), mixed-value display in the Paragraph panel for a selection across differing paragraphs, Transform Again after a type-area resize (it scales the type), partial selection of type-area anchors by marquee | 14–19 |
| Type advanced | 4 | 50% | CJK composition for vertical type (ruby, mojikumi beyond Line-end Punctuation Half Width — the other sets (and their paragraph-start bracket and indent rules), custom kinsoku sets (beyond Hard, Soft and None) and the Mojikumi Settings dialog —, proportional vertical metrics, manual tate-chu-yoko), variable font axis sliders (named instances are styles), tab leaders, spell check (open dictionary), Touch Type, Retype; vertical point/area/path type and Type Orientation have initial support (beyond parity, not scored: inline symbols in text) | 20–28 |
| Symbols, blends, envelopes, Repeat, perspective | 5 | 88% | symbol libraries (original), dynamic symbols, envelope Anti-Alias/Preserve Shape output and warp point editing, brush interpolation in blends (M8 fidelity pass done) | 5–8 |
| Image Trace, graphs, image tools | 3 | 72% | graph Design/Column/Marker, Create Object Mosaic, per-feature stroke/fill choice and varying widths in Image Trace's Create Strokes (Vector Halftone and the Image Trace View modes, Palette and Create Strokes are done) | 6–10 |
| Layers, artboards, document setup | 5 | 82% | Layers panel: a confirmation before deleting layers that hold art, Collect for Export, row context menus, the Layer Options dialog for several rows with mixed values, Paste Remembers Layers for sublayers, drag-scrolling a long panel while dragging rows; artboard preset polish (Rearrange All Artboards, Document Setup, New Document and the rest of the Layers panel are done) | 4–7 |
| Image Trace, graphs, image tools | 3 | 72% | graph Design/Column/Marker artwork (series colours set with Group Selection are done), Create Object Mosaic, per-feature stroke/fill choice and varying widths in Image Trace's Create Strokes (Vector Halftone and the Image Trace View modes, Palette and Create Strokes are done) | 6–10 |
| Layers, artboards, document setup | 5 | 82% | Layers panel: a confirmation before deleting layers that hold art, Collect for Export, row context menus, the Layer Options dialog for several rows with mixed values, Paste Remembers Layers for sublayers, drag-scrolling a long panel while dragging rows; artboard preset polish (Move Artwork for artboard X/Y edits, Rearrange All Artboards, Document Setup, New Document and the rest of the Layers panel are done) | 4–7 |
| View & navigation | 3 | 70% | New View/Edit Views, multiple windows/arrange, Snap to Pixel/Glyph (print tiling is done) | 9–14 |
| Guides, grids, smart guides, snapping, rulers | 3 | 85% | global/video rulers, Spacing Guides' distance readouts and spacing for resized or drawn art, Smart Guides' angle and distance readouts while drawing, Alt swapping a ruler guide's orientation, ruler guides selected together with art (marquee, Shift-click), ruler guides as Layers rows with their own lock and hide (#451: Lock Guides locks them all; Make Guides paths are Layers rows already) (dragging existing guides is done, #414; new guides snapping to side midpoints and artboards, and artboard guides, #451; the drawing tools snapping, with Construction Guides, #506; Spacing Guides for moves, #394; moves snapping to the grid, #740; ruler guides pulling drawn points with Smart Guides on, and "center" and "anchor" labels while hovering with the Selection tool, #812) | 2–4 |
| File formats | 6 | 85% | Affinity export, and the rest of Affinity import (effects, adjustments, brushes, master pages, live symbol instances), DWG (no open spec: DXF instead), PSD placement as layers, EPS checked against more real files users send (Inkscape files open as vectors; other generators' constructs are tested with programs written from the PostScript Language Reference only; CID-keyed CJK type and `CCITTFaxDecode` images not read), legacy `.ai` files saved without the prolog that defines their operators (their own operator set isn't read yet), the rest of Illustrator's editing data (appearances and live effects, symbols, pattern fills, brushes, placed files: #637), the remaining fidelity polish; EPS, DXF, EMF/WMF, TIFF, BMP, Targa, PSD export, SVGZ, PDF security, PDF/X, PDF layers, presets and Affinity import are done | 6–10 (existing scope; Affinity export unestimated) |
| File formats | 6 | 85% | Affinity export, and the rest of Affinity import (effects, adjustments, brushes, master pages, live symbol instances), DWG (no open spec: DXF instead), PSD placement as layers (Photoshop Import Options; the merged image is done), EPS checked against more real files users send (Inkscape files open as vectors; other generators' constructs are tested with programs written from the PostScript Language Reference only; CID-keyed CJK type and `CCITTFaxDecode` images not read), legacy `.ai` files saved without the prolog that defines their operators (their own operator set isn't read yet), the rest of Illustrator's editing data (appearances and live effects, symbols, pattern fills, brushes, placed files: #637), the remaining fidelity polish; EPS, DXF, EMF/WMF, TIFF, BMP, Targa, PSD export, SVGZ, PDF security, PDF/X, PDF layers, presets and Affinity import are done | 6–10 (existing scope; Affinity export unestimated) |
| Export for Screens, Asset Export, slices, Save for Web | 3 | 85% | polish only (all four are done) | 2–4 |
| Print, colour management, separations, flattener | 3 | 80% | print preview fidelity (flattener presets in PDF and print, overprint in PDF and composite print, Print dialog, presets, PostScript, marks, separations, Print Tiling, print overprint/flattener/bitmap options and printer profile are done) | 1–2 |
| Automation | 3 | 60% | Variables (data merge), scripting surface, batch | 8–12 |
| UI chrome (panels, contextual Properties, workspaces, prefs) | 6 | 78% | macOS native menu dismissal fixed (M14.8); remaining: Variables, SVG Interactivity, Properties per context, Consolidate All Windows, floating panels outside the app window and docked into any group, context menus beyond the canvas (Links, Asset Export, CSS Properties, Attributes and the canvas context menu are done), about 19 Preferences options stored but not yet acted on (#394: Show Font Names in English, Highlight Alternate Glyphs, Display Print Size, Open Documents As Tabs, Real-time Drawing, hyphenation language, Appearance of Black, devices…) | 14–22 |
| Libraries, Links, Package | 2 | 80% | the Libraries panel is local only (no cloud by design: libraries are files on this machine) and has no shared libraries, linked graphics or gradients and patterns as items yet; its graphics, colours and text styles are done (#745); Links and Package are done; placed VectorCraft documents are done (linked by default, vectors in every output, previews, Break Link, Expand, Edit Original, refresh on save) | 2–3 |
| UI chrome (panels, contextual Properties, workspaces, prefs) | 6 | 78% | macOS native menu dismissal fixed (M14.8); remaining: Variables, SVG Interactivity, Properties per context, Consolidate All Windows, floating panels outside the app window and docked into any group, context menus beyond the canvas (Links, Asset Export, CSS Properties, Attributes and the canvas context menu are done), about 17 Preferences options stored but not yet acted on (#394: Highlight Alternate Glyphs, Open Documents As Tabs, Real-time Drawing, hyphenation language, Appearance of Black, devices…) | 14–22 |
| Libraries, Links, Package | 2 | 82% | the Libraries panel is local only (no cloud by design: libraries are files on this machine) and has no shared libraries, linked graphics or gradients and patterns as items yet; its graphics, colours and text styles (#745) and its search, groups and library export/import as `.vclibrary` files (#926) are done; Links and Package are done; placed VectorCraft documents are done (linked by default, vectors in every output, previews, Break Link, Expand, Edit Original, refresh on save) | 2–3 |
| **Feature parity** | **103** | **~75%** | | **~210–330** |
| Interaction-fidelity pass (side by side with Illustrator: every tool modifier, cursor, dialog, Properties context) | | | | 60–90 |
| Hardening (big-file corpus, fuzzing, cross-platform and browser QA, accessibility, packaging) | | | | 50–80 |
+34 -4
View File
@@ -1,7 +1,7 @@
//! `vectorcraft-cli`: VectorCraft from the command line.
//!
//! ```text
//! vectorcraft-cli mcp [--connect 127.0.0.1:7979 | --headless]
//! vectorcraft-cli mcp [--connect 127.0.0.1:7979 | --headless] [--automation-read-root DIR] [--automation-write-root DIR]
//! vectorcraft-cli run [--in FILE] [--cmd id [--params '{json}']]... [--export out.svg]... [--scale 2]
//! vectorcraft-cli commands
//! vectorcraft-cli convert IN OUT [--scale 2] [--artboard 0 | --range 1-3,5] [--outline-text] [--replace-lossy]
@@ -14,6 +14,7 @@
#![deny(unsafe_code)]
use std::io::Write;
use std::path::Path;
use std::process::ExitCode;
/// `println!` / `print!` that end the program quietly when stdout is closed
@@ -50,6 +51,7 @@ mod prefs_dir;
mod system_fonts;
use serde_json::{Value, json};
use vectorcraft_engine::file_access::{self, AutomationRoots};
use vectorcraft_mcp::{Backend, DEFAULT_ADDR, Headless, Remote, Server};
/// stdout went away. A reader that stopped early (a closed pipe) ends the program quietly, as
@@ -66,9 +68,13 @@ const USAGE: &str = "\
vectorcraft-cli — VectorCraft automation
USAGE:
vectorcraft-cli mcp [--connect ADDR | --headless]
vectorcraft-cli mcp [--connect ADDR | --headless] [--automation-read-root DIR] [--automation-write-root DIR]
Run the MCP server on stdio. Default: connect to a running app at 127.0.0.1:7979
(vectorcraft --control 7979), falling back to a headless in-process session.
--automation-read-root / --automation-write-root confine the files the agent's commands read
(open, place, relink, library loads…) and write (save, export, package, library saves…) to
DIR, links followed; a root left out grants none of its access. They imply --headless: a
running app enforces its own (vectorcraft --control PORT --automation-read-root DIR …).
vectorcraft-cli run [--in FILE] [--cmd ID [--params JSON]]... [--export FILE]... [--scale N]
Headless batch: open FILE (any readable format) or start a new document, run commands in
@@ -145,18 +151,42 @@ fn main() -> ExitCode {
fn mcp(args: &[String]) -> Result<(), String> {
let mut connect: Option<String> = None;
let mut headless = false;
let (mut read_root, mut write_root): (Option<String>, Option<String>) = (None, None);
let mut it = args.iter();
while let Some(a) = it.next() {
match a.as_str() {
// The roots also come as `--flag=DIR`, as the other Craft apps take them.
let (flag, inline) = match a.split_once('=') {
Some((f, v)) if f.starts_with("--automation-") => (f, Some(v.to_string())),
_ => (a.as_str(), None),
};
match flag {
"--connect" => connect = Some(it.next().cloned().ok_or("--connect needs an address")?),
"--headless" => headless = true,
"--automation-read-root" | "--automation-write-root" => {
let dir = inline.or_else(|| it.next().cloned()).ok_or_else(|| format!("{flag} needs a folder"))?;
let slot = if flag == "--automation-read-root" { &mut read_root } else { &mut write_root };
if slot.replace(dir).is_some() {
return Err(format!("{flag} is given twice: it takes one folder"));
}
}
other => return Err(format!("unknown mcp option `{other}`")),
}
}
if headless && connect.is_some() {
return Err("use either --connect or --headless".into());
}
let backend: Box<dyn Backend> = if headless {
let roots = AutomationRoots::new(read_root.as_deref().map(Path::new), write_root.as_deref().map(Path::new))?;
if roots.is_some() && connect.is_some() {
return Err("--automation-read-root and --automation-write-root confine the headless server; a running app enforces its own roots: start it with `vectorcraft --control <port> --automation-read-root <dir> --automation-write-root <dir>`"
.into());
}
let backend: Box<dyn Backend> = if let Some(roots) = roots {
// Every thread of this process, not only the server's: nothing reaches past the roots.
file_access::confine_process(roots.clone());
let show = |root: Option<&Path>| root.map_or_else(|| "none".to_string(), |r| r.display().to_string());
eprintln!("vectorcraft-cli: files confined: read root {}, write root {}", show(roots.read_root()), show(roots.write_root()));
Box::new(Headless::with_document().with_automation_roots(Some(roots)))
} else if headless {
Box::new(Headless::with_document())
} else if let Some(addr) = connect {
// Explicit address: fail loudly if the app isn't there.
+52
View File
@@ -83,6 +83,58 @@ fn mcp_headless_over_stdio() {
assert_eq!(replies[3]["result"]["content"][0]["type"], "image");
}
/// `mcp --automation-read-root` / `--automation-write-root` (#832): the flags are checked before the
/// server starts, and the process then reads and writes only inside its roots.
#[test]
fn mcp_automation_roots() {
let base = tmp("roots");
for d in ["in", "out", "outside"] {
std::fs::create_dir_all(base.join(d)).unwrap();
}
let svg = r#"<svg xmlns="http://www.w3.org/2000/svg" width="40" height="30"><rect width="20" height="10"/></svg>"#;
std::fs::write(base.join("in/a.svg"), svg).unwrap();
std::fs::write(base.join("outside/b.svg"), svg).unwrap();
let dir = |d: &str| base.join(d).to_string_lossy().into_owned();
let fails = |args: &[&str], why: &str| {
let out = Command::new(BIN).args(args).stdin(Stdio::null()).output().unwrap();
let err = String::from_utf8_lossy(&out.stderr);
assert!(!out.status.success() && err.contains(why), "{args:?}: {err}");
};
fails(&["mcp", "--automation-read-root", &dir("missing")], "cannot open the automation read root");
fails(&["mcp", "--automation-read-root", &dir("in"), "--automation-read-root", &dir("out")], "given twice");
fails(&["mcp", "--automation-write-root"], "needs a folder");
fails(&["mcp", "--connect", "127.0.0.1:1", &format!("--automation-write-root={}", dir("out"))], "a running app enforces its own roots");
let mut child = Command::new(BIN)
.args(["mcp", "--automation-read-root", &dir("in"), &format!("--automation-write-root={}", dir("out"))])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.unwrap();
let call = |id: u64, name: &str, args: Value| json!({"jsonrpc":"2.0","id":id,"method":"tools/call","params":{"name":name,"arguments":args}});
let file = |d: &str, f: &str| base.join(d).join(f).to_string_lossy().into_owned();
{
let mut stdin = child.stdin.take().unwrap();
for m in [
call(1, "open_file", json!({"path": file("in", "a.svg")})),
call(2, "open_file", json!({"path": file("outside", "b.svg")})),
call(3, "export", json!({"path": file("out", "a.png")})),
call(4, "export", json!({"path": file("in", "a.png")})),
call(5, "save_file", json!({"path": file("outside", "a.vectorcraft")})),
] {
writeln!(stdin, "{m}").unwrap();
}
}
let out = child.wait_with_output().unwrap();
assert!(out.status.success());
let replies: Vec<Value> = String::from_utf8(out.stdout).unwrap().lines().map(|l| serde_json::from_str(l).unwrap()).collect();
let errors: Vec<bool> = replies.iter().map(|r| r["result"]["isError"] == true).collect();
assert_eq!(errors, [false, true, false, true, true], "{replies:?}");
assert!(replies[1]["result"]["content"][0]["text"].as_str().unwrap().contains("outside the read root"));
assert!(base.join("out/a.png").is_file() && !base.join("in/a.png").exists() && !base.join("outside/a.vectorcraft").exists());
}
/// End-to-end protocol sweep over stdio against the compiled binary. The in-process
/// tests drive `Server::handle_line` directly, so the binary's own wiring (argument
/// parsing, logger install, stdout pollution, notification order) is untested: this
+2
View File
@@ -161,6 +161,7 @@ pub fn display_gpus() -> Vec<DisplayGpu> {
}
/// Record that the GPU `pci` drives a display, merging it with its other displays.
#[cfg(any(windows, target_os = "linux", test))]
fn add_display_gpu(gpus: &mut Vec<DisplayGpu>, pci: (u32, u32), primary: bool) {
match gpus.iter_mut().find(|g| g.pci == pci) {
Some(gpu) => gpu.primary |= primary,
@@ -197,6 +198,7 @@ fn windows_display_gpus() -> Vec<DisplayGpu> {
/// The PCI `(vendor, device)` of a Windows device id such as
/// `PCI\VEN_10DE&DEV_2204&SUBSYS_40421458&REV_A1`, any case; `None` for anything else.
#[cfg(any(windows, test))]
fn pci_ids(device_id: &str) -> Option<(u32, u32)> {
let field = |key: &str| {
device_id.split(['\\', '&']).find_map(|part| {
+85 -37
View File
@@ -1,13 +1,17 @@
//! VectorCraft desktop app.
//!
//! Usage: `vectorcraft [--control <port>] [--in-window-menus] [files…]`
//! Usage: `vectorcraft [--control <port> [--automation-read-root <dir>] [--automation-write-root <dir>]]
//! [--in-window-menus] [files…]`
//!
//! `--in-window-menus` (or `VECTORCRAFT_IN_WINDOW_MENUS=1`) keeps the menus inside the window on
//! macOS instead of the macOS menu bar (`mac_menu`).
//!
//! `--control <port>` (or `VECTORCRAFT_CONTROL_PORT`) starts a localhost JSON-lines control server:
//! `{"id":1,"method":"ui.inspect","params":{}}` → `{"id":1,"ok":true,"result":…}`.
//! See `vectorcraft_ui_egui::control` for the methods.
//! See `vectorcraft_ui_egui::control` for the methods. `--automation-read-root` and
//! `--automation-write-root` (or `VECTORCRAFT_AUTOMATION_READ_ROOT` / `_WRITE_ROOT`) confine the
//! files its requests read and write (`vectorcraft_engine::file_access`); the person at the
//! keyboard isn't confined.
#![cfg_attr(all(target_os = "windows", not(debug_assertions)), windows_subsystem = "windows")]
#[cfg(all(feature = "windows7", any(feature = "wgpu", feature = "accessibility")))]
@@ -36,6 +40,7 @@ mod window;
use vectorcraft_engine::Session;
use vectorcraft_engine::cmd::fileio;
use vectorcraft_engine::file_access::{self, AutomationRoots};
use vectorcraft_ui_egui::graphics::GraphicsLoss;
use vectorcraft_ui_egui::picks::PickRequest;
use vectorcraft_ui_egui::{ClipboardProbeFactory, FilePick, Services, VectorcraftApp};
@@ -51,7 +56,39 @@ struct App {
}
impl eframe::App for App {
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
// Each frame runs unconfined: it is the person at the keyboard's. The app confines what the
// control channel asks for, and the frames carrying input it injected, to the automation roots
// (`VectorcraftApp::with_automation_roots`), which are in force elsewhere in the process.
fn logic(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) {
file_access::unconfined(|| self.logic_frame(ctx, frame));
}
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) {
#[cfg(target_os = "macos")]
if self.app.services.native_menu.is_some() {
mac_menu::raw_input_hook(raw);
}
file_access::unconfined(|| self.app.raw_input_hook(raw));
}
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
file_access::unconfined(|| self.app.ui(ui));
#[cfg(target_os = "macos")]
if self.app.take_ime_discard() {
discard_marked_text();
}
}
#[cfg(not(feature = "windows7"))]
fn on_exit(&mut self) {
file_access::unconfined(|| save_prefs(&self.app));
end_before_teardown(self.frames);
}
#[cfg(feature = "windows7")]
fn on_exit(&mut self, _gl: Option<&eframe::glow::Context>) {
file_access::unconfined(|| save_prefs(&self.app));
}
}
impl App {
fn logic_frame(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
self.frames = ctx.cumulative_frame_nr();
if let Some(why) = self.graphics_loss.take() {
// eframe can't give a window a new device: the user saves and starts again.
@@ -76,29 +113,6 @@ impl eframe::App for App {
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
}
}
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) {
#[cfg(target_os = "macos")]
if self.app.services.native_menu.is_some() {
mac_menu::raw_input_hook(raw);
}
self.app.raw_input_hook(raw);
}
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
self.app.ui(ui);
#[cfg(target_os = "macos")]
if self.app.take_ime_discard() {
discard_marked_text();
}
}
#[cfg(not(feature = "windows7"))]
fn on_exit(&mut self) {
save_prefs(&self.app);
end_before_teardown(self.frames);
}
#[cfg(feature = "windows7")]
fn on_exit(&mut self, _gl: Option<&eframe::glow::Context>) {
save_prefs(&self.app);
}
}
/// On macOS with accessibility, end the process once the app has saved what it keeps on quitting,
@@ -274,9 +288,12 @@ fn write_file(path: &str, bytes: &[u8]) -> Result<(), String> {
fileio::write_atomic(std::path::Path::new(path), bytes).map_err(|e| e.to_string())
}
/// Linux: show the dialog for `request` over `parent` on a thread of its own, answering on the
/// receiver. Shown on the UI thread, nothing would answer the compositor meanwhile, which then
/// offers to kill the window as not responding (#592).
/// Linux and macOS: show the dialog for `request` over `parent` on a thread of its own, answering
/// on the receiver. Shown on the UI thread, nothing would answer the compositor meanwhile on Linux,
/// which then offers to kill the window as not responding (#592); on macOS the panel ran modally
/// inside the window's event handler, and resizing it delivered window events to that handler
/// again, which crashed the app (#867). From another thread, rfd runs the panel on the main thread
/// from the run loop, outside the handler.
fn start_pick(request: PickRequest, parent: &Parent) -> Option<std::sync::mpsc::Receiver<Vec<String>>> {
let dialog = file_dialog(&request, parent);
let (tx, rx) = std::sync::mpsc::channel();
@@ -296,9 +313,9 @@ fn services(parent: Parent) -> Services {
pick_open: Some(Box::new(move |pick: &FilePick| pick_now(PickRequest::Open(pick.clone()), &p1).into_iter().next())),
pick_open_multi: Some(Box::new(move || pick_now(PickRequest::OpenMany, &p2))),
pick_save: Some(Box::new(move |pick: &FilePick| pick_now(PickRequest::Save(pick.clone()), &p3).into_iter().next())),
// Windows and macOS dialogs run the window's events while they are open; Linux's don't.
start_pick: cfg!(all(unix, not(target_os = "macos")))
.then(|| Box::new(move |request: PickRequest| start_pick(request, &p5)) as vectorcraft_ui_egui::picks::StartPick),
// Windows dialogs run the window's events while they are open; Linux's don't, and macOS
// ones re-enter the window's event handler (#867).
start_pick: cfg!(unix).then(|| Box::new(move |request: PickRequest| start_pick(request, &p5)) as vectorcraft_ui_egui::picks::StartPick),
read: Some(Box::new(|p: &str| std::fs::read(p).map_err(|e| e.to_string()))),
write: Some(Box::new(write_file)),
// Background Save and Export write from a worker thread.
@@ -386,12 +403,19 @@ fn main() -> std::process::ExitCode {
// VectorCraft's own Fonts folder, which text.addFontFiles copies fonts into.
prefs_dir::install_fonts();
let mut control_port: Option<u16> = std::env::var("VECTORCRAFT_CONTROL_PORT").ok().and_then(|p| p.parse().ok());
let root_env = |name: &str| std::env::var_os(name).filter(|v| !v.is_empty()).map(std::path::PathBuf::from);
let mut read_root = root_env("VECTORCRAFT_AUTOMATION_READ_ROOT");
let mut write_root = root_env("VECTORCRAFT_AUTOMATION_WRITE_ROOT");
let mut files = Vec::new();
let mut in_window_menus = std::env::var_os("VECTORCRAFT_IN_WINDOW_MENUS").is_some_and(|v| !v.is_empty() && v != "0");
let mut args = std::env::args().skip(1);
while let Some(a) = args.next() {
match a.as_str() {
"--control" => control_port = args.next().and_then(|p| p.parse().ok()),
"--automation-read-root" => read_root = args.next().map(std::path::PathBuf::from),
"--automation-write-root" => write_root = args.next().map(std::path::PathBuf::from),
_ if a.starts_with("--automation-read-root=") => read_root = a.split_once('=').map(|(_, d)| d.into()),
_ if a.starts_with("--automation-write-root=") => write_root = a.split_once('=').map(|(_, d)| d.into()),
"--in-window-menus" => in_window_menus = true,
"--version" => {
println!("vectorcraft {}", env!("CARGO_PKG_VERSION"));
@@ -414,6 +438,23 @@ fn main() -> std::process::ExitCode {
None => logger.no_file(),
}
}
// The roots confine the control channel: a bad one keeps the app from starting rather than
// leaving agents unconfined. Without a control port there is nothing to confine.
let roots = if control_port.is_none() {
if read_root.is_some() || write_root.is_some() {
log::warn!("--automation-read-root and --automation-write-root confine the control channel: ignored without --control");
}
None
} else {
match AutomationRoots::new(read_root.as_deref(), write_root.as_deref()) {
Ok(roots) => roots,
Err(e) => {
log::error!("{e}");
eprintln!("vectorcraft: {e}");
return std::process::ExitCode::FAILURE;
}
}
};
let saved = read_prefs();
let saved_window = saved.as_ref().and_then(|ui| ui.window);
#[cfg(feature = "wgpu")]
@@ -529,7 +570,13 @@ fn main() -> std::process::ExitCode {
app.custom_titlebar = CUSTOM_TITLEBAR;
if let Some(port) = control_port {
let rx = control_server::start(port, cc.egui_ctx.clone());
app = app.with_control(rx);
app = app.with_control(rx).with_automation_roots(roots.clone());
if let Some(roots) = &roots {
// Everywhere the app doesn't say whose work it is (worker threads), the
// roots hold: unconfined is only what the person at the keyboard does.
file_access::confine_process(roots.clone());
log::info!("control channel confined to the automation roots: read {:?}, write {:?}", roots.read_root(), roots.write_root());
}
}
#[cfg(target_os = "macos")]
{
@@ -541,7 +588,7 @@ fn main() -> std::process::ExitCode {
}
#[cfg(not(target_os = "macos"))]
let _ = in_window_menus;
open_files(&mut app, files);
file_access::unconfined(|| open_files(&mut app, files));
Ok(Box::new(App { app, graphics_loss, graphics_lost: false, frames: 0 }))
}),
)
@@ -574,13 +621,14 @@ mod tests {
.collect()
}
/// Finder offers the app for every file File › Open reads (#295, #354), takes over no other
/// app's files, and hands them to the app rather than to AppKit's document machinery.
/// Finder offers the app for every file File › Open reads (#295, #354) but Photoshop documents
/// (`UNASSOCIATED_EXTS`), takes over no other app's files, and hands them to the app rather than
/// to AppKit's document machinery.
#[test]
fn the_macos_bundle_opens_every_readable_format() {
let plist = include_str!("../../../packaging/macos/Info.plist.in");
let declared = plist_extensions(plist);
for e in fileio::OPEN_EXTS {
for e in fileio::OPEN_EXTS.iter().filter(|e| !fileio::UNASSOCIATED_EXTS.contains(e)) {
assert!(declared.contains(e), "Info.plist.in doesn't declare .{e}");
}
for e in &declared {
+13
View File
@@ -26,6 +26,9 @@ sync_github_name = false
real_name = "Riccardo Balloch"
sync_github_name = false
[people.crqch]
sync_github_name = true
[people.mo0kid]
sync_github_name = true
@@ -45,3 +48,13 @@ sync_github_name = false
real_name = "Ahmed Moussa"
display_name = "Ahmed Moussa"
sync_github_name = true
[people.vbasky]
real_name = "Vikram Bhaskaran"
display_name = "Vikram Bhaskaran"
sync_github_name = false
[people.Messhias]
real_name = "Fabio William Conceição"
display_name = "Messhias"
sync_github_name = false
+87 -30
View File
@@ -4,13 +4,21 @@
//! in the directions normal to the path. For nib axes `u`, `v` with semi-axes `a`, `b` the
//! support point in direction `d` is `(a²(u·d)u + b²(v·d)v) / √(a²(u·d)² + b²(v·d)²)`, so the
//! stroke's local width across direction `n` is `2·√(a²(u·n)² + b²(v·n)²)`.
//!
//! A Calligraphic brush with Pressure variation changes its nib along the stroke with the pen
//! pressure recorded on it (`StrokeLayer::pressure`): the sweep is sampled densely and each
//! sample takes the nib for the pressure there.
use vectorcraft_color::Paint;
use vectorcraft_doc::Node;
use vectorcraft_doc::{Node, PressureProfile, StrokeLayer};
use vectorcraft_geom::{BezPath, PathData, Point, SubPath, Vec2};
use crate::track::{Rng, Track, normal, seed_of, tracks};
use crate::{Bristle, BristleShape, Calligraphic, filled, tolerance};
use crate::{Bristle, BristleShape, Calligraphic, Variation, filled, tolerance};
/// The most pieces a varying nib cuts one segment of a subpath into, and about how many samples
/// it takes along a whole subpath.
const MAX_STEPS: f64 = 1024.0;
/// An elliptical nib.
#[derive(Clone, Copy, Debug)]
@@ -43,39 +51,53 @@ fn rot(base: Vec2, angle: f64) -> Vec2 {
base * angle.cos() + normal(base) * angle.sin()
}
/// Samples (point, tangent) along a track, with extra tangents fanned in at corners so the
/// outer edge of the sweep follows the nib around the corner.
fn samples(t: &Track) -> Vec<(Point, Vec2)> {
/// Samples (point, tangent, fraction of the track's length) along a track, with extra tangents
/// fanned in at corners so the outer edge of the sweep follows the nib around the corner. `step`
/// (for a nib that varies along the track): segments are cut into pieces at most that long.
fn samples(t: &Track, step: Option<f64>) -> Vec<(Point, Vec2, f64)> {
let mut out = vec![];
let n = t.segments();
let len = t.len().max(1e-12);
for i in 0..=n {
let p = t.verts[i];
let f = t.cum[i] / len;
if t.corner[i] {
let (a, b) = t.in_out(i);
let turn = a.cross(b).atan2(a.dot(b));
let steps = (turn.abs() / 10f64.to_radians()).ceil().max(1.0) as usize;
for k in 0..=steps {
out.push((p, rot(a, turn * k as f64 / steps as f64)));
out.push((p, rot(a, turn * k as f64 / steps as f64), f));
}
} else {
out.push((p, t.tangents[i]));
out.push((p, t.tangents[i], f));
}
if let Some(step) = step
&& i < n
{
let seg = t.cum[i + 1] - t.cum[i];
let pieces = (seg / step).ceil().clamp(1.0, MAX_STEPS) as usize;
for k in 1..pieces {
let u = k as f64 / pieces as f64;
out.push((p.lerp(t.verts[i + 1], u), t.dirs[i], (t.cum[i] + seg * u) / len));
}
}
}
out
}
/// The swept outline of `nib` along `t` as closed subpaths.
pub(crate) fn sweep(t: &Track, nib: &Nib) -> Vec<SubPath> {
let s = samples(t);
let left: Vec<Point> = s.iter().map(|(p, tg)| *p + nib.support(normal(*tg))).collect();
let right: Vec<Point> = s.iter().map(|(p, tg)| *p + nib.support(-normal(*tg))).collect();
/// The swept outline along `t` of the nib `nib(f)` (`f`: the fraction of the track's length) as
/// closed subpaths. `step`: how often a varying nib is sampled (None: a constant nib).
pub(crate) fn sweep(t: &Track, nib: &dyn Fn(f64) -> Nib, step: Option<f64>) -> Vec<SubPath> {
let s = samples(t, step);
let left: Vec<Point> = s.iter().map(|(p, tg, f)| *p + nib(*f).support(normal(*tg))).collect();
let right: Vec<Point> = s.iter().map(|(p, tg, f)| *p + nib(*f).support(-normal(*tg))).collect();
if t.closed {
let mut r = right;
r.reverse();
return vec![SubPath::polyline(&left, true), SubPath::polyline(&r, true)];
}
let cap = |p: Point, tg: Vec2, from: f64, to: f64, out: &mut Vec<Point>| {
let steps = 16;
let cap = |(p, tg, f): (Point, Vec2, f64), from: f64, to: f64, out: &mut Vec<Point>| {
let (steps, nib) = (16, nib(f));
for k in 1..steps {
let ang = from + (to - from) * k as f64 / steps as f64;
out.push(p + nib.support(rot(tg, ang)));
@@ -83,31 +105,66 @@ pub(crate) fn sweep(t: &Track, nib: &Nib) -> Vec<SubPath> {
};
let half = std::f64::consts::FRAC_PI_2;
let mut poly = left.clone();
let (Some(&(pe, te)), Some(&(ps, ts))) = (s.last(), s.first()) else { return Vec::new() };
cap(pe, te, half, -half, &mut poly);
let (Some(&end), Some(&start)) = (s.last(), s.first()) else { return Vec::new() };
cap(end, half, -half, &mut poly);
poly.extend(right.iter().rev());
cap(ps, ts, -half, -3.0 * half, &mut poly);
cap(start, -half, -3.0 * half, &mut poly);
vec![SubPath::polyline(&poly, true)]
}
fn randomized(c: &Calligraphic, rng: &mut Rng) -> (f64, f64, f64) {
let [va, vr, vs] = c.variation;
let angle = c.angle + rng.range(-va, va);
let round = (c.roundness + rng.range(-vr, vr)).clamp(1.0, 100.0);
let size = (c.size + rng.range(-vs, vs)).max(0.05);
(angle, round, size)
/// The stroke's angle, roundness and size: Random ones drawn for this stroke (from `rng`), the
/// others the brush's values.
fn randomized(c: &Calligraphic, rng: &mut Rng) -> [f64; 3] {
let mut out = [c.angle, c.roundness, c.size];
for ((v, var), mode) in out.iter_mut().zip(c.variation).zip(c.modes()) {
// Every value draws, so a stroke's random values stay the same whatever the other modes.
let r = rng.range(-var, var);
if mode == Variation::Random {
*v += r;
}
}
let [angle, round, size] = out;
[angle, round.clamp(1.0, 100.0), size.max(0.05)]
}
pub(crate) fn calligraphic(c: &Calligraphic, bp: &BezPath, weight: f64, paint: &Paint, name: &str) -> Vec<Node> {
/// The nib's angle, roundness and size at pen pressure `p` (0..1): each Pressure value goes from
/// the brush's value less its variation (lightest) to the value plus the variation (heaviest),
/// kept within its range; the others are `base`'s.
fn pressured(c: &Calligraphic, base: [f64; 3], p: f64) -> [f64; 3] {
let values = [c.angle, c.roundness, c.size];
let mut out = base;
for (i, (v, mode)) in out.iter_mut().zip(c.modes()).enumerate() {
let (Some(&value), Some(&var), Some(&(lo, hi))) = (values.get(i), c.variation.get(i), Calligraphic::RANGES.get(i)) else { continue };
if mode == Variation::Pressure {
let x = value + var * (2.0 * p.clamp(0.0, 1.0) - 1.0);
*v = if x.is_finite() { x.clamp(lo, hi) } else { value.clamp(lo, hi) };
}
}
let [angle, round, size] = out;
[angle, round.clamp(1.0, 100.0), size.max(0.05)]
}
pub(crate) fn calligraphic(c: &Calligraphic, bp: &BezPath, weight: f64, stroke: &StrokeLayer, name: &str) -> Vec<Node> {
let mut rng = Rng::new(seed_of(bp, name));
let (angle, round, size) = randomized(c, &mut rng);
let size = size * weight;
let nib = Nib::new(angle, round / 100.0, size);
let subs: Vec<SubPath> = tracks(bp, tolerance(size)).iter().flat_map(|t| sweep(t, &nib)).collect();
let base = randomized(c, &mut rng);
let subs: Vec<SubPath> = if c.uses_pressure() {
let pressure = stroke.pressure.as_ref();
let nib = |f: f64| {
let [angle, round, size] = pressured(c, base, pressure.map_or(PressureProfile::MID, |p| p.at(f)));
Nib::new(angle, round / 100.0, size * weight)
};
// Flattened for the largest nib the pressure gives.
let size = pressured(c, base, 0.0)[2].max(pressured(c, base, 1.0)[2]) * weight;
tracks(bp, tolerance(size)).iter().flat_map(|t| sweep(t, &nib, Some((t.len() / MAX_STEPS).max(0.25)))).collect()
} else {
let [angle, round, size] = base;
let nib = Nib::new(angle, round / 100.0, size * weight);
tracks(bp, tolerance(size * weight)).iter().flat_map(|t| sweep(t, &|_| nib, None)).collect()
};
if subs.is_empty() {
return vec![];
}
vec![filled(PathData::new(subs), paint)]
vec![filled(PathData::new(subs), &stroke.paint)]
}
/// Offset a track sideways by `d` (a polyline following it).
@@ -165,7 +222,7 @@ pub(crate) fn bristle(b: &Bristle, bp: &BezPath, weight: f64, paint: &Paint, nam
let Some(sp) = offset_track(&t, off, t0.min(l * 0.3), t1.min(l * 0.3)) else { continue };
let w = thick * (0.6 + 0.4 * edge) * rng.range(0.8, 1.2);
let nib = Nib::new(0.0, 1.0, w);
let subs: Vec<SubPath> = tracks(&sp, tolerance(w)).iter().flat_map(|tt| sweep(tt, &nib)).collect();
let subs: Vec<SubPath> = tracks(&sp, tolerance(w)).iter().flat_map(|tt| sweep(tt, &|_| nib, None)).collect();
if subs.is_empty() {
continue;
}
+28
View File
@@ -115,3 +115,31 @@ pub fn instance_art(art: &Node, inst: &Node) -> Node {
}
a
}
/// The solid colours of the fills and strokes under `nodes`, most used first (then in the order
/// they first appear): the colours Brush Options offers as Hue Shift's key colour, the first being
/// its default.
pub fn art_colors<'a>(nodes: impl IntoIterator<Item = &'a Node>) -> Vec<Color> {
fn walk(n: &Node, seen: &mut std::collections::HashMap<String, (usize, usize, Color)>) {
for it in &n.appearance.items {
let (visible, paint) = match it {
AppearanceItem::Fill(f) => (f.visible, &f.paint),
AppearanceItem::Stroke(s) => (s.visible, &s.paint),
};
if let (true, Some(c)) = (visible, paint.color()) {
let order = seen.len();
seen.entry(c.to_hex()).or_insert((0, order, c)).0 += 1;
}
}
for c in n.children().into_iter().flatten() {
walk(c, seen);
}
}
let mut seen = std::collections::HashMap::new();
for n in nodes {
walk(n, &mut seen);
}
let mut v: Vec<(usize, usize, Color)> = seen.into_values().collect();
v.sort_by(|a, b| b.0.cmp(&a.0).then(a.1.cmp(&b.1)));
v.into_iter().map(|(.., c)| c).collect()
}
+3 -1
View File
@@ -132,7 +132,7 @@ fn diamond_tile() -> Node {
}
fn calli(name: &str, angle: f64, roundness: f64, size: f64) -> Brush {
Brush { name: name.into(), kind: BrushKind::Calligraphic(Calligraphic { angle, roundness, size, variation: [0.0; 3] }) }
Brush { name: name.into(), kind: BrushKind::Calligraphic(Calligraphic { angle, roundness, size, ..Default::default() }) }
}
/// The default library.
@@ -172,6 +172,7 @@ pub fn defaults() -> &'static [Brush] {
rotation: (-180.0, 180.0),
rotation_relative_to_path: false,
colorization: Colorization::Tints,
modes: None,
}),
},
Brush {
@@ -184,6 +185,7 @@ pub fn defaults() -> &'static [Brush] {
rotation: (-35.0, 35.0),
rotation_relative_to_path: true,
colorization: Colorization::None,
modes: None,
}),
},
Brush {
+226 -13
View File
@@ -23,7 +23,7 @@ use vectorcraft_color::Paint;
use vectorcraft_doc::{Appearance, AppearanceItem, Document, FillLayer, Node, NodeId, NodeKind, StrokeLayer};
use vectorcraft_geom::{BezPath, Rect};
pub use colorize::{colorize, instance_art, stain, tint_node};
pub use colorize::{art_colors, colorize, instance_art, stain, tint_node};
pub use defaults::defaults;
pub use serde_json;
@@ -71,6 +71,15 @@ impl BrushKind {
BrushKind::Bristle(_) => "bristle",
}
}
/// How the brush's art takes the stroke colour (Calligraphic and Bristle brushes paint with it).
pub fn colorization(&self) -> Colorization {
match self {
BrushKind::Art(a) => a.colorization,
BrushKind::Scatter(s) => s.colorization,
BrushKind::Pattern(p) => p.colorization,
BrushKind::Calligraphic(_) | BrushKind::Bristle(_) => Colorization::None,
}
}
}
/// How brush art takes the stroke colour.
@@ -88,6 +97,29 @@ pub enum Colorization {
HueShift { key: vectorcraft_color::Color },
}
impl Colorization {
pub const IDS: [&'static str; 4] = ["none", "tints", "tintsAndShades", "hueShift"];
/// The name brush definitions use (`method`).
pub fn id(&self) -> &'static str {
match self {
Colorization::None => "none",
Colorization::Tints => "tints",
Colorization::TintsAndShades => "tintsAndShades",
Colorization::HueShift { .. } => "hueShift",
}
}
/// The method named `id` (`key`: the key colour of Hue Shift).
pub fn parse(id: &str, key: vectorcraft_color::Color) -> Option<Self> {
Some(match id {
"none" => Colorization::None,
"tints" => Colorization::Tints,
"tintsAndShades" => Colorization::TintsAndShades,
"hueShift" => Colorization::HueShift { key },
_ => return None,
})
}
}
/// Calligraphic brush: an elliptical nib swept along the path. Sizes are at 1 pt stroke weight
/// (the stroke weight scales the brush).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
@@ -99,13 +131,69 @@ pub struct Calligraphic {
pub roundness: f64,
/// Nib diameter in points.
pub size: f64,
/// Random variation per stroke: angle (°), roundness (%), size (pt).
/// How far angle (°), roundness (%) and size (pt) vary each way, as [`Self::modes`] say.
pub variation: [f64; 3],
/// How angle, roundness and size vary. `None` (brushes saved before there were modes): Random
/// where the variation is non-zero, else Fixed — see [`Calligraphic::modes`].
#[serde(skip_serializing_if = "Option::is_none")]
pub modes: Option<[Variation; 3]>,
}
impl Default for Calligraphic {
fn default() -> Self {
Self { angle: 0.0, roundness: 100.0, size: 3.0, variation: [0.0; 3] }
Self { angle: 0.0, roundness: 100.0, size: 3.0, variation: [0.0; 3], modes: None }
}
}
impl Calligraphic {
/// The ranges of angle (°), roundness (%) and size (pt), and the most each varies.
pub const RANGES: [(f64, f64); 3] = [(-180.0, 180.0), (0.0, 100.0), (0.0, 1296.0)];
pub const MAX_VARIATION: [f64; 3] = [180.0, 100.0, 1296.0];
/// How angle, roundness and size vary.
pub fn modes(&self) -> [Variation; 3] {
self.modes.unwrap_or_else(|| self.variation.map(|v| if v != 0.0 { Variation::Random } else { Variation::Fixed }))
}
/// Does the pen's pressure change the nib?
pub fn uses_pressure(&self) -> bool {
self.modes().contains(&Variation::Pressure)
}
}
/// How a Calligraphic brush's angle, roundness or size varies (Brush Options). Saved by name: new
/// modes are appended, never renamed.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum Variation {
/// Always the value.
#[default]
Fixed,
/// A random value within the variation either side, new for each stroke.
Random,
/// From the value less the variation at the lightest pen pressure to the value plus the
/// variation at the heaviest.
Pressure,
}
impl Variation {
pub const ALL: [Variation; 3] = [Variation::Fixed, Variation::Random, Variation::Pressure];
/// The name brush definitions use.
pub fn id(self) -> &'static str {
match self {
Variation::Fixed => "fixed",
Variation::Random => "random",
Variation::Pressure => "pressure",
}
}
pub fn label(self) -> &'static str {
match self {
Variation::Fixed => "Fixed",
Variation::Random => "Random",
Variation::Pressure => "Pressure",
}
}
pub fn parse(id: &str) -> Option<Self> {
Self::ALL.into_iter().find(|v| v.id() == id)
}
}
@@ -125,6 +213,12 @@ pub struct Scatter {
/// Rotation relative to the path direction (else to the page).
pub rotation_relative_to_path: bool,
pub colorization: Colorization,
/// How size, spacing, scatter and rotation vary between their two values: Fixed takes the
/// first, Random any value between them, Pressure the first at the lightest pen pressure and
/// the second at the heaviest. `None` (brushes saved before there were modes): Random where
/// the two differ, else Fixed — see [`Scatter::modes`].
#[serde(skip_serializing_if = "Option::is_none")]
pub modes: Option<[Variation; 4]>,
}
impl Default for Scatter {
@@ -137,10 +231,25 @@ impl Default for Scatter {
rotation: (0.0, 0.0),
rotation_relative_to_path: false,
colorization: Colorization::None,
modes: None,
}
}
}
impl Scatter {
/// The ranges of size (%), spacing (%), scatter (%) and rotation (°).
pub const RANGES: [(f64, f64); 4] = [(1.0, 10000.0), (1.0, 10000.0), (-1000.0, 1000.0), (-180.0, 180.0)];
/// Size, spacing, scatter and rotation, each as its (first, second) values.
pub fn values(&self) -> [(f64, f64); 4] {
[self.size, self.spacing, self.scatter, self.rotation]
}
/// How size, spacing, scatter and rotation vary.
pub fn modes(&self) -> [Variation; 4] {
self.modes.unwrap_or_else(|| self.values().map(|(a, b)| if a != b { Variation::Random } else { Variation::Fixed }))
}
}
/// Direction the art runs in the brush definition.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
@@ -180,6 +289,11 @@ pub struct ArtBrush {
pub colorization: Colorization,
}
impl ArtBrush {
/// The range of the width (%).
pub const WIDTH_RANGE: (f64, f64) = (1.0, 1000.0);
}
impl Default for ArtBrush {
fn default() -> Self {
Self {
@@ -230,6 +344,12 @@ pub struct PatternBrush {
pub colorization: Colorization,
}
impl PatternBrush {
/// The ranges of the scale and of the spacing (%).
pub const SCALE_RANGE: (f64, f64) = (1.0, 10000.0);
pub const SPACING_RANGE: (f64, f64) = (0.0, 10000.0);
}
impl Default for PatternBrush {
fn default() -> Self {
Self {
@@ -284,12 +404,104 @@ pub struct Bristle {
pub stiffness: f64,
}
impl Bristle {
/// The ranges of the size (pt), the bristle length (%) and the other percentages.
pub const SIZE_RANGE: (f64, f64) = (0.1, 1296.0);
pub const LENGTH_RANGE: (f64, f64) = (25.0, 300.0);
pub const PERCENT_RANGE: (f64, f64) = (1.0, 100.0);
}
impl Default for Bristle {
fn default() -> Self {
Self { shape: BristleShape::RoundPoint, size: 6.0, length: 100.0, density: 50.0, thickness: 40.0, opacity: 75.0, stiffness: 50.0 }
}
}
// ---------- validation ----------
/// `v` within `(lo, hi)`; `fallback` (kept within too) when it isn't a number.
fn within(v: f64, (lo, hi): (f64, f64), fallback: f64) -> f64 {
if v.is_finite() { v.clamp(lo, hi) } else { fallback.clamp(lo, hi) }
}
fn within_pair((a, b): (f64, f64), range: (f64, f64), fallback: f64) -> (f64, f64) {
(within(a, range, fallback), within(b, range, fallback))
}
impl Colorization {
/// A Hue Shift key colour that isn't a colour (components out of reach) becomes black.
fn sanitize(&mut self) {
if let Colorization::HueShift { key } = self
&& !key.to_rgb().iter().all(|c| c.is_finite())
{
*key = vectorcraft_color::Color::BLACK;
}
}
}
impl Brush {
/// The art the brush is made of: a scatter or art brush's art, a pattern brush's tiles.
pub fn art(&self) -> Vec<&Node> {
match &self.kind {
BrushKind::Scatter(s) => vec![&s.art],
BrushKind::Art(a) => vec![&a.art],
BrushKind::Pattern(p) => {
std::iter::once(&p.side).chain([&p.outer_corner, &p.inner_corner, &p.start, &p.end].into_iter().flatten()).collect()
}
BrushKind::Calligraphic(_) | BrushKind::Bristle(_) => vec![],
}
}
/// Keep every value of the definition within its range (definitions come from files and
/// commands, so they are untrusted): numbers are clamped, non-numbers take the default.
pub fn sanitize(&mut self) {
match &mut self.kind {
BrushKind::Calligraphic(c) => {
let d = Calligraphic::default();
let [angle, roundness, size] = Calligraphic::RANGES;
c.angle = within(c.angle, angle, d.angle);
c.roundness = within(c.roundness, roundness, d.roundness);
c.size = within(c.size, size, d.size);
for (v, max) in c.variation.iter_mut().zip(Calligraphic::MAX_VARIATION) {
*v = within(*v, (0.0, max), 0.0);
}
}
BrushKind::Scatter(s) => {
let d = Scatter::default();
let [size, spacing, scatter, rotation] = Scatter::RANGES;
s.size = within_pair(s.size, size, d.size.0);
s.spacing = within_pair(s.spacing, spacing, d.spacing.0);
s.scatter = within_pair(s.scatter, scatter, 0.0);
s.rotation = within_pair(s.rotation, rotation, 0.0);
s.colorization.sanitize();
}
BrushKind::Art(a) => {
a.width = within(a.width, ArtBrush::WIDTH_RANGE, 100.0);
if let ArtScale::BetweenGuides { start, end } = &mut a.scale {
*start = within(*start, (0.0, 1.0), 0.0);
*end = within(*end, (0.0, 1.0), 1.0);
}
a.colorization.sanitize();
}
BrushKind::Pattern(p) => {
p.scale = within(p.scale, PatternBrush::SCALE_RANGE, 100.0);
p.spacing = within(p.spacing, PatternBrush::SPACING_RANGE, 0.0);
p.colorization.sanitize();
}
BrushKind::Bristle(b) => {
let d = Bristle::default();
b.size = within(b.size, Bristle::SIZE_RANGE, d.size);
b.length = within(b.length, Bristle::LENGTH_RANGE, d.length);
for (v, fallback) in
[(&mut b.density, d.density), (&mut b.thickness, d.thickness), (&mut b.opacity, d.opacity), (&mut b.stiffness, d.stiffness)]
{
*v = within(*v, Bristle::PERCENT_RANGE, fallback);
}
}
}
}
}
// ---------- library ----------
/// The brush library of `doc`: its stored brushes, or the defaults when it has none.
@@ -300,9 +512,15 @@ pub fn library(doc: &Document) -> Vec<Brush> {
}
}
/// Parse a stored library value (malformed entries are skipped).
/// Parse a stored library value (malformed entries are skipped, values kept within their
/// ranges — see [`Brush::sanitize`]).
pub fn parse_library(v: &serde_json::Value) -> Vec<Brush> {
v.as_array().map(|a| a.iter().filter_map(|b| serde_json::from_value(b.clone()).ok()).collect()).unwrap_or_default()
let parse = |b: &serde_json::Value| {
let mut b: Brush = serde_json::from_value(b.clone()).ok()?;
b.sanitize();
Some(b)
};
v.as_array().map(|a| a.iter().filter_map(parse).collect()).unwrap_or_default()
}
/// Store `lib` as the document's brush library.
@@ -345,18 +563,13 @@ fn tolerance(scale: f64) -> f64 {
pub fn stroke_pieces(brush: &Brush, bp: &BezPath, stroke: &StrokeLayer) -> Vec<Node> {
let w = if stroke.width > 0.0 { stroke.width } else { 1.0 };
let mut out = match &brush.kind {
BrushKind::Calligraphic(c) => calli::calligraphic(c, bp, w, &stroke.paint, &brush.name),
BrushKind::Calligraphic(c) => calli::calligraphic(c, bp, w, stroke, &brush.name),
BrushKind::Bristle(b) => calli::bristle(b, bp, w, &stroke.paint, &brush.name),
BrushKind::Art(a) => warp::art(a, bp, w),
BrushKind::Scatter(s) => warp::scatter(s, bp, w, &brush.name),
BrushKind::Scatter(s) => warp::scatter(s, bp, w, stroke.pressure.as_ref(), &brush.name),
BrushKind::Pattern(p) => warp::pattern(p, bp, w),
};
let colorization = match &brush.kind {
BrushKind::Art(a) => a.colorization,
BrushKind::Scatter(s) => s.colorization,
BrushKind::Pattern(p) => p.colorization,
_ => Colorization::None,
};
let colorization = brush.kind.colorization();
for n in &mut out {
colorize(n, colorization, &stroke.paint);
}
+188 -1
View File
@@ -16,7 +16,7 @@ fn stroke(w: f64) -> StrokeLayer {
}
fn calli(angle: f64, roundness: f64, size: f64) -> Brush {
Brush { name: "c".into(), kind: BrushKind::Calligraphic(Calligraphic { angle, roundness, size, variation: [0.0; 3] }) }
Brush { name: "c".into(), kind: BrushKind::Calligraphic(Calligraphic { angle, roundness, size, ..Default::default() }) }
}
fn bounds(nodes: &[Node]) -> Rect {
@@ -334,3 +334,190 @@ fn expand_keeps_fill_and_adds_pieces() {
let plain = Node::path(NodeId(9), shapes::rectangle(Rect::new(0.0, 0.0, 1.0, 1.0)), Appearance::default_art());
assert!(expand(&d, &plain).is_none());
}
/// A Calligraphic brush (round nib) whose size follows the pen pressure by `var` either way.
fn pressure_calli(size: f64, var: f64) -> Brush {
let c = Calligraphic {
size,
variation: [0.0, 0.0, var],
modes: Some([Variation::Fixed, Variation::Fixed, Variation::Pressure]),
..Default::default()
};
Brush { name: "p".into(), kind: BrushKind::Calligraphic(c) }
}
/// The stroke's half-width (largest |y|) among outline points with x in `x0..x1`.
fn half_width(nodes: &[Node], x0: f64, x1: f64) -> f64 {
let mut pts = vec![];
for n in nodes {
all_points(n, &mut pts);
}
pts.iter().filter(|p| p.x >= x0 && p.x <= x1).map(|p| p.y.abs()).fold(0.0, f64::max)
}
fn pressed(points: Vec<(f64, f64)>) -> StrokeLayer {
StrokeLayer { pressure: Some(vectorcraft_doc::PressureProfile { points }), ..stroke(1.0) }
}
/// #852: with Size on Pressure the nib goes from size − variation at the lightest pressure to
/// size + variation at the heaviest, along the stroke where the pen pressed.
#[test]
fn pressure_maps_to_size_along_the_stroke() {
let b = pressure_calli(10.0, 4.0);
let light_to_heavy = pressed(vec![(0.0, 0.0), (1.0, 1.0)]);
let out = stroke_pieces(&b, &line(0.0, 0.0, 100.0, 0.0), &light_to_heavy);
// Lightest: 6 pt (half 3); heaviest: 14 pt (half 7); in between the pressure there.
assert!(near(half_width(&out, 0.5, 1.5), 3.0, 0.1), "{}", half_width(&out, 0.5, 1.5));
assert!(near(half_width(&out, 98.5, 99.5), 7.0, 0.1), "{}", half_width(&out, 98.5, 99.5));
assert!(near(half_width(&out, 49.5, 50.5), 5.0, 0.1), "{}", half_width(&out, 49.5, 50.5));
// The stroke weight scales it.
let heavy = StrokeLayer { width: 2.0, ..light_to_heavy };
let out = stroke_pieces(&b, &line(0.0, 0.0, 100.0, 0.0), &heavy);
assert!(near(half_width(&out, 98.5, 99.5), 14.0, 0.2));
// No pressure recorded (a mouse, or a path the brush was applied to): the size itself.
let out = stroke_pieces(&b, &line(0.0, 0.0, 100.0, 0.0), &stroke(1.0));
assert!(near(bounds(&out).height(), 10.0, 0.05), "{:?}", bounds(&out));
// Kept within the valid range: 2 ± 4 never goes below nothing.
let out = stroke_pieces(&pressure_calli(2.0, 4.0), &line(0.0, 0.0, 100.0, 0.0), &pressed(vec![(0.0, 0.0), (1.0, 0.0)]));
assert!(!out.is_empty() && bounds(&out).height() < 0.2, "{:?}", bounds(&out));
}
/// Angle and roundness follow the pressure the same way, clamped to their ranges.
#[test]
fn pressure_varies_angle_and_roundness() {
let c = |modes| Calligraphic { angle: 0.0, roundness: 60.0, size: 10.0, variation: [90.0, 60.0, 0.0], modes: Some(modes) };
let horizontal = line(0.0, 0.0, 100.0, 0.0);
let draw = |c: Calligraphic, p: f64| {
let b = Brush { name: "a".into(), kind: BrushKind::Calligraphic(c) };
bounds(&stroke_pieces(&b, &horizontal, &pressed(vec![(0.0, p), (1.0, p)]))).height()
};
use Variation::{Fixed, Pressure};
// Angle 0 ± 90: heavy pressure stands the flat nib up across the stroke.
assert!(near(draw(c([Pressure, Fixed, Fixed]), 0.5), 6.0, 0.05));
assert!(near(draw(c([Pressure, Fixed, Fixed]), 1.0), 10.0, 0.05));
// Roundness 60 ± 60, kept within 1..100: light is a hairline, heavy is round.
let vertical = Calligraphic { angle: 90.0, ..c([Fixed, Pressure, Fixed]) };
let wide = |p| {
let b = Brush { name: "r".into(), kind: BrushKind::Calligraphic(vertical.clone()) };
bounds(&stroke_pieces(&b, &line(0.0, 0.0, 0.0, 100.0), &pressed(vec![(0.0, p), (1.0, p)]))).width()
};
assert!(near(wide(0.0), 0.1, 0.05), "{}", wide(0.0));
assert!(near(wide(1.0), 10.0, 0.05), "{}", wide(1.0));
}
/// Brushes saved before the variation modes keep their look: a variation means Random, none
/// means Fixed, and they are written back without modes.
#[test]
fn brushes_without_modes_vary_randomly_as_before() {
let old: Brush = serde_json::from_value(serde_json::json!({"name": "x", "type": "calligraphic", "size": 8, "variation": [0, 0, 3]})).unwrap();
let BrushKind::Calligraphic(c) = &old.kind else { panic!() };
assert_eq!(c.modes, None);
assert_eq!(c.modes(), [Variation::Fixed, Variation::Fixed, Variation::Random]);
assert!(!c.uses_pressure());
assert!(serde_json::to_value(&old).unwrap().get("modes").is_none(), "written back as it was");
// The same strokes as the brush with its modes spelled out.
let explicit = Brush {
kind: BrushKind::Calligraphic(Calligraphic { modes: Some([Variation::Fixed, Variation::Fixed, Variation::Random]), ..c.clone() }),
..old.clone()
};
let bp = preview_path(120.0, 24.0);
assert_eq!(stroke_pieces(&old, &bp, &stroke(1.0)), stroke_pieces(&explicit, &bp, &stroke(1.0)));
// Fixed ignores the variation; Random still draws a different size per stroke.
let fixed = Brush { kind: BrushKind::Calligraphic(Calligraphic { modes: Some([Variation::Fixed; 3]), ..c.clone() }), ..old.clone() };
assert!(near(bounds(&stroke_pieces(&fixed, &line(0.0, 0.0, 100.0, 0.0), &stroke(1.0))).height(), 8.0, 0.05));
// Recorded pressure changes nothing for a brush that doesn't use it.
assert_eq!(stroke_pieces(&old, &bp, &stroke(1.0)), stroke_pieces(&old, &bp, &pressed(vec![(0.0, 0.0), (1.0, 1.0)])));
// Modes are saved by name.
assert_eq!(serde_json::to_value(Variation::ALL).unwrap(), serde_json::json!(["fixed", "random", "pressure"]));
assert!(Variation::ALL.iter().all(|v| Variation::parse(v.id()) == Some(*v)));
}
/// Junk definitions and pressure never panic and never draw non-finite art.
#[test]
fn junk_pressure_brushes_draw_finite_art() {
let bp = preview_path(120.0, 24.0);
for (size, var) in [(1e308, 1e308), (-1e308, 1e308), (0.0, -1e308), (f64::MAX, f64::MAX)] {
for profile in [vec![], vec![(0.0, 2.0), (0.0, -1.0), (5.0, 0.5)], vec![(1.0, 1.0), (0.0, 0.0)]] {
let out = stroke_pieces(&pressure_calli(size, var), &bp, &pressed(profile));
let mut pts = vec![];
for n in &out {
all_points(n, &mut pts);
}
assert!(pts.iter().all(|p| p.x.is_finite() && p.y.is_finite()), "{size} {var}");
}
}
}
/// A scatter brush of 10 × 4 pt rectangles whose size goes from `lo` % to `hi` % as `mode` says.
fn sized_scatter(lo: f64, hi: f64, mode: Variation) -> Brush {
let s = scatter_brush(100.0);
let BrushKind::Scatter(sc) = s.kind else { panic!("not a scatter brush") };
let modes = [mode, Variation::Fixed, Variation::Fixed, Variation::Fixed];
Brush { kind: BrushKind::Scatter(Scatter { size: (lo, hi), modes: Some(modes), ..sc }), ..s }
}
/// Scatter Brush Options: Fixed takes the first value, Pressure goes from the first at the
/// lightest pen pressure to the second at the heaviest; brushes saved before modes keep their look.
#[test]
fn scatter_values_follow_their_modes() {
let bp = line(0.0, 0.0, 400.0, 0.0);
let heights =
|b: &Brush, st: &StrokeLayer| -> Vec<f64> { stroke_pieces(b, &bp, st).iter().map(|n| n.geometric_bounds().unwrap().height()).collect() };
// Fixed: every copy at 50 %, whatever the second value.
assert!(heights(&sized_scatter(50.0, 300.0, Variation::Fixed), &stroke(1.0)).iter().all(|h| near(*h, 2.0, 1e-6)));
// Pressure: light at the start (50 %: 2 pt tall), heavy at the end (300 %: 12 pt).
let ramp = pressed(vec![(0.0, 0.0), (1.0, 1.0)]);
let h = heights(&sized_scatter(50.0, 300.0, Variation::Pressure), &ramp);
let (first, last) = (h[0], h[h.len() - 1]);
assert!(near(first, 2.0, 0.1) && last > 10.0, "{h:?}");
assert!(h.windows(2).all(|w| w[1] >= w[0] - 1e-9), "grows with the pressure: {h:?}");
// Without recorded pressure, half way between the two.
assert!(heights(&sized_scatter(50.0, 300.0, Variation::Pressure), &stroke(1.0)).iter().all(|h| near(*h, 7.0, 1e-6)));
// No modes saved: Random where the values differ, as before; Fixed and Random draw the same.
let old = sized_scatter(50.0, 300.0, Variation::Random);
let BrushKind::Scatter(sc) = &old.kind else { panic!("not a scatter brush") };
assert_eq!(Scatter { modes: None, ..sc.clone() }.modes(), [Variation::Random, Variation::Fixed, Variation::Fixed, Variation::Fixed]);
let unsaved = Brush { kind: BrushKind::Scatter(Scatter { modes: None, ..sc.clone() }), ..old.clone() };
assert_eq!(stroke_pieces(&old, &bp, &stroke(1.0)), stroke_pieces(&unsaved, &bp, &stroke(1.0)));
assert!(serde_json::to_value(&unsaved).unwrap().get("modes").is_none(), "not saved when unset");
}
/// Definitions from files and commands are kept within their ranges.
#[test]
fn sanitize_keeps_values_within_their_ranges() {
let mut v = serde_json::to_value(sized_scatter(1e308, -1e308, Variation::Random)).unwrap();
v["rotation"] = serde_json::json!([720.0, -1e300]);
v["scatter"] = serde_json::json!([5000.0, 0.0]);
let lib = parse_library(&serde_json::json!([
v,
{"name": "a", "type": "art", "art": rect_art(10.0, 4.0), "width": 1e308, "scale": {"mode": "betweenGuides", "start": -3.0, "end": 9.0}},
{"name": "p", "type": "pattern", "side": rect_art(10.0, 4.0), "scale": 0.0, "spacing": -5.0},
{"name": "c", "type": "calligraphic", "angle": 400.0, "roundness": -1.0, "size": 1e9, "variation": [1e9, -1.0, 2.0]},
{"name": "b", "type": "bristle", "size": 0.0, "length": 1000.0, "density": 0.0},
]));
let [s, a, p, c, b] = lib.as_slice() else { panic!("{lib:?}") };
let BrushKind::Scatter(s) = &s.kind else { panic!() };
assert_eq!((s.size, s.rotation, s.scatter), ((10000.0, 1.0), (180.0, -180.0), (1000.0, 0.0)));
let BrushKind::Art(a) = &a.kind else { panic!() };
assert_eq!((a.width, a.scale), (1000.0, ArtScale::BetweenGuides { start: 0.0, end: 1.0 }));
let BrushKind::Pattern(p) = &p.kind else { panic!() };
assert_eq!((p.scale, p.spacing), (1.0, 0.0));
let BrushKind::Calligraphic(c) = &c.kind else { panic!() };
assert_eq!((c.angle, c.roundness, c.size, c.variation), (180.0, 0.0, 1296.0, [180.0, 0.0, 2.0]));
let BrushKind::Bristle(b) = &b.kind else { panic!() };
assert_eq!((b.size, b.length, b.density), (0.1, 300.0, 1.0));
}
/// Hue Shift's key colour defaults to the art's most used colour.
#[test]
fn art_colors_list_the_most_used_first() {
let red = Color::rgb(1.0, 0.0, 0.0);
let blue = Color::rgb(0.0, 0.0, 1.0);
let path =
|c: Color| Node::path(NodeId(0), shapes::rectangle(Rect::new(0.0, 0.0, 1.0, 1.0)), Appearance::basic(Paint::solid(c), Paint::None, 0.0));
let g = Node::group(NodeId(0), vec![Arc::new(path(red)), Arc::new(path(blue)), Arc::new(path(blue))]);
assert_eq!(art_colors([&g]), vec![blue, red]);
let b = defaults().iter().find(|b| b.name == "Chain").unwrap();
assert_eq!(b.art().len(), 3, "the side and both corner tiles");
assert!(calli(0.0, 100.0, 3.0).art().is_empty());
}
+25 -7
View File
@@ -6,11 +6,11 @@
use std::sync::Arc;
use vectorcraft_doc::{Node, NodeKind};
use vectorcraft_doc::{Node, NodeKind, PressureProfile};
use vectorcraft_geom::{Affine, BezPath, PathData, PathEl, Point, Rect, SubPath};
use crate::track::{Rng, Track, seed_of, tracks};
use crate::{ArtBrush, ArtScale, Direction, PatternBrush, PatternFit, Scatter, tolerance};
use crate::{ArtBrush, ArtScale, Direction, PatternBrush, PatternFit, Scatter, Variation, tolerance};
/// Longest mapped segment (points in document space).
const MAX_SEG: f64 = 1.5;
@@ -188,13 +188,28 @@ pub(crate) fn art(a: &ArtBrush, bp: &BezPath, weight: f64) -> Vec<Node> {
out
}
pub(crate) fn scatter(sc: &Scatter, bp: &BezPath, weight: f64, name: &str) -> Vec<Node> {
/// One of a scatter brush's values for the next copy: Fixed takes the first of `(a, b)`, Random a
/// value between them, Pressure the first at pen pressure 0 and the second at 1. Every value
/// draws, so a copy's random values stay the same whatever the other modes.
fn vary(rng: &mut Rng, (a, b): (f64, f64), mode: Variation, pressure: f64) -> f64 {
let r = rng.range(a, b);
match mode {
Variation::Fixed => a,
Variation::Random => r,
Variation::Pressure => a + (b - a) * pressure.clamp(0.0, 1.0),
}
}
pub(crate) fn scatter(sc: &Scatter, bp: &BezPath, weight: f64, pressure: Option<&PressureProfile>, name: &str) -> Vec<Node> {
let Some(b) = sc.art.geometric_bounds() else { return vec![] };
let (aw, ah) = (b.width().max(1e-3), b.height().max(1e-3));
let mut rng = Rng::new(seed_of(bp, name));
let [size_mode, spacing_mode, scatter_mode, rotation_mode] = sc.modes();
let mut out = vec![];
for t in tracks(bp, 0.05) {
let l = t.len();
// The pen pressure at arc length `at` (half way without a recorded one).
let pressure_at = |at: f64| pressure.map_or(PressureProfile::MID, |p| p.at(if l > 1e-9 { at / l } else { 0.0 }));
let mut s: Option<f64> = None;
let mut guard = 0;
loop {
@@ -202,8 +217,10 @@ pub(crate) fn scatter(sc: &Scatter, bp: &BezPath, weight: f64, name: &str) -> Ve
if guard > 20000 {
break;
}
let size = rng.range(sc.size.0, sc.size.1).max(1.0) / 100.0 * weight;
let spacing = rng.range(sc.spacing.0, sc.spacing.1).max(1.0) / 100.0;
// Size and spacing place the next copy, so they take the pressure at the last one.
let p = pressure_at(s.unwrap_or(0.0));
let size = vary(&mut rng, sc.size, size_mode, p).max(1.0) / 100.0 * weight;
let spacing = vary(&mut rng, sc.spacing, spacing_mode, p).max(1.0) / 100.0;
let step = aw * size * spacing;
let pos = match s {
None => step / 2.0,
@@ -213,8 +230,9 @@ pub(crate) fn scatter(sc: &Scatter, bp: &BezPath, weight: f64, name: &str) -> Ve
break;
}
s = Some(pos);
let off = rng.range(sc.scatter.0, sc.scatter.1) / 100.0 * ah * size;
let rot = rng.range(sc.rotation.0, sc.rotation.1).to_radians();
let p = pressure_at(pos);
let off = vary(&mut rng, sc.scatter, scatter_mode, p) / 100.0 * ah * size;
let rot = vary(&mut rng, sc.rotation, rotation_mode, p).to_radians();
let (p, tg) = t.frame(pos);
let place = p + crate::track::normal(tg) * off;
// Page rotation is counter-clockwise on screen (y-down), like the calligraphic angle.
+190
View File
@@ -293,6 +293,129 @@ impl WidthProfile {
}
}
/// Pen pressure recorded along a stroke (the Paintbrush with a graphics tablet): (position 0..1
/// along the path, pressure 0..1) samples in order, positions measured as a [`WidthProfile`]'s
/// are. Calligraphic brushes with Pressure variation read it; everything else ignores it.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct PressureProfile {
pub points: Vec<(f64, f64)>,
}
impl PressureProfile {
/// The pressure of a stroke drawn without one (a mouse, or a path the brush was applied to):
/// half way, so a Pressure brush draws its own value there.
pub const MID: f64 = 0.5;
/// The most samples a stroke keeps (after simplifying them).
pub const MAX_POINTS: usize = 512;
/// How far (in pressure) simplifying may move the recorded curve.
const TOLERANCE: f64 = 0.01;
/// The pressure at `t` (0..1), linear between samples; [`Self::MID`] without any.
pub fn at(&self, t: f64) -> f64 {
let p = &self.points;
let (Some(first), Some(last)) = (p.first(), p.last()) else { return Self::MID };
let v = if t <= first.0 {
first.1
} else {
p.windows(2)
.find_map(|w| match w {
[a, b] if t <= b.0 => Some(a.1 + (b.1 - a.1) * ((t - a.0) / (b.0 - a.0).max(1e-9)).clamp(0.0, 1.0)),
_ => None,
})
.unwrap_or(last.1)
};
if v.is_finite() { v.clamp(0.0, 1.0) } else { Self::MID }
}
/// A profile from (position, pressure) samples in order along the stroke: values are clamped
/// into 0..1 (non-finite ones dropped), the curve is simplified to within 1 % and kept to
/// [`Self::MAX_POINTS`]. `None` without a sample.
pub fn from_samples(samples: impl IntoIterator<Item = (f64, f64)>) -> Option<Self> {
let pts: Vec<(f64, f64)> =
samples.into_iter().filter(|(t, p)| t.is_finite() && p.is_finite()).map(|(t, p)| (t.clamp(0.0, 1.0), p.clamp(0.0, 1.0))).collect();
if pts.is_empty() {
return None;
}
// Simplifying is quadratic at worst: a huge stroke is thinned evenly first.
let mut pts = thin(pts, 16 * Self::MAX_POINTS);
// Positions only ever go forward.
let mut prev = 0.0;
for p in &mut pts {
p.0 = p.0.max(prev);
prev = p.0;
}
let keep = simplify_1d(&pts, Self::TOLERANCE);
let pts = thin(pts.iter().zip(keep).filter(|(_, k)| *k).map(|(p, _)| *p).collect(), Self::MAX_POINTS);
let round = |v: f64| (v * 1e4).round() / 1e4;
Some(Self { points: pts.into_iter().map(|(t, p)| (round(t), round(p))).collect() })
}
/// The pressure of a path `old_len` long (`old`; None: drawn without pressure) continued at its
/// end by a stroke `added_len` long (`added`). None when neither has pressure.
pub fn extended(old: Option<&Self>, old_len: f64, added: Option<&Self>, added_len: f64) -> Option<Self> {
if old.is_none() && added.is_none() {
return None;
}
let (old_len, added_len) = (old_len.max(0.0), added_len.max(0.0));
let total = old_len + added_len;
if !total.is_finite() || total <= 1e-9 {
return old.or(added).cloned();
}
let flat = Self { points: vec![(0.0, Self::MID), (1.0, Self::MID)] };
let (old, added) = (old.unwrap_or(&flat), added.unwrap_or(&flat));
let o = old_len / total;
let old_pts = old.points.iter().map(|&(t, p)| (t * o, p));
Self::from_samples(old_pts.chain(added.points.iter().map(|&(u, p)| (o + u * (1.0 - o), p))))
}
/// The pressure of the path reversed.
pub fn reversed(&self) -> Self {
Self { points: self.points.iter().rev().map(|&(t, p)| (1.0 - t, p)).collect() }
}
/// A light–heavy–light stroke, for previews of pressure-sensitive brushes.
pub fn sample() -> Self {
Self { points: vec![(0.0, 0.0), (0.5, 1.0), (1.0, 0.0)] }
}
}
/// At most `max` of `pts`, evenly spread (the ends kept).
fn thin(pts: Vec<(f64, f64)>, max: usize) -> Vec<(f64, f64)> {
let n = pts.len();
if n <= max || max < 2 {
return pts;
}
(0..max).filter_map(|i| pts.get(i * (n - 1) / (max - 1)).copied()).collect()
}
/// Douglas–Peucker over a function sampled at `pts`: which samples to keep so the polyline through
/// them stays within `tol` (vertically) of every sample. The ends are always kept.
fn simplify_1d(pts: &[(f64, f64)], tol: f64) -> Vec<bool> {
let mut keep = vec![false; pts.len()];
let Some(last) = pts.len().checked_sub(1) else { return keep };
for i in [0, last] {
if let Some(k) = keep.get_mut(i) {
*k = true;
}
}
let mut stack = vec![(0, last)];
while let Some((i, j)) = stack.pop() {
let (Some(&a), Some(&b)) = (pts.get(i), pts.get(j)) else { continue };
let line = |t: f64| if b.0 - a.0 > 1e-12 { a.1 + (b.1 - a.1) * (t - a.0) / (b.0 - a.0) } else { (a.1 + b.1) / 2.0 };
let far = (i + 1..j).filter_map(|k| pts.get(k).map(|q| (k, (q.1 - line(q.0)).abs()))).max_by(|x, y| x.1.total_cmp(&y.1));
if let Some((k, d)) = far
&& d > tol
{
if let Some(f) = keep.get_mut(k) {
*f = true;
}
stack.push((i, k));
stack.push((k, j));
}
}
keep
}
/// A width profile saved to the Profile list under a name (Add to Profiles), kept with the
/// preferences.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
@@ -386,6 +509,11 @@ pub struct StrokeLayer {
/// How a gradient paint maps onto the stroke (within, along or across it).
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
pub gradient_mode: StrokeGradientMode,
/// The pen pressure the stroke was drawn with (Paintbrush with a tablet): pressure-sensitive
/// brushes vary with it. It belongs to this path's shape, so appearances copied to other
/// art leave it behind ([`Appearance::without_pressure`]).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pressure: Option<PressureProfile>,
}
/// How a gradient on a stroke is laid out (the Gradient panel's Stroke buttons). Saved by variant
@@ -456,6 +584,7 @@ impl StrokeLayer {
arrow_align: ArrowAlign::Extend,
overprint: false,
gradient_mode: StrokeGradientMode::Within,
pressure: None,
}
}
/// Weight of the start (`end == false`) or end arrowhead: stroke weight × its scale, at least
@@ -640,6 +769,15 @@ impl Appearance {
pub fn stroke_mut(&mut self) -> Option<&mut StrokeLayer> {
self.items.iter_mut().rev().find_map(|i| if let AppearanceItem::Stroke(s) = i { Some(s) } else { None })
}
/// This appearance for other art: the pen pressure its strokes were drawn with stays with them.
pub fn without_pressure(mut self) -> Self {
for it in &mut self.items {
if let AppearanceItem::Stroke(s) = it {
s.pressure = None;
}
}
self
}
pub fn fill_paint(&self) -> Paint {
self.fill().map(|f| f.paint.clone()).unwrap_or(Paint::None)
}
@@ -1103,6 +1241,58 @@ mod tests {
assert!(matches!(a.items[0], AppearanceItem::Fill(_)));
}
/// #852: pen pressure is kept simplified along the stroke and read back by position.
#[test]
fn pressure_profile_samples_simplify_and_interpolate() {
// A steady ramp keeps only its ends; a bump keeps its peak.
let ramp = PressureProfile::from_samples((0..=100).map(|i| (i as f64 / 100.0, i as f64 / 100.0))).unwrap();
assert_eq!(ramp.points, vec![(0.0, 0.0), (1.0, 1.0)]);
assert!((ramp.at(0.25) - 0.25).abs() < 1e-9);
let bump = PressureProfile::from_samples([(0.0, 0.2), (0.3, 0.2), (0.5, 0.9), (0.7, 0.2), (1.0, 0.2)]).unwrap();
assert!(bump.points.contains(&(0.5, 0.9)) && (bump.at(0.4) - 0.55).abs() < 1e-9, "{bump:?}");
// Junk is clamped or dropped; positions never go back; nothing at all is no profile.
let junk = PressureProfile::from_samples([(f64::NAN, 0.5), (0.5, 7.0), (0.2, -1.0), (2.0, f64::INFINITY)]).unwrap();
assert_eq!(junk.points, vec![(0.5, 1.0), (0.5, 0.0)]);
assert!(PressureProfile::from_samples([(f64::NAN, 0.5)]).is_none());
assert_eq!(PressureProfile::default().at(0.3), PressureProfile::MID);
// A long noisy stroke stays bounded.
let noisy = PressureProfile::from_samples((0..1_000_000).map(|i| (i as f64 / 1e6, (i % 2) as f64))).unwrap();
assert!(noisy.points.len() <= PressureProfile::MAX_POINTS);
// Out-of-order points (a hand-edited file) still read within 0..1.
let odd = PressureProfile { points: vec![(1.0, 5.0), (0.0, -3.0)] };
assert!((0.0..=1.0).contains(&odd.at(0.5)));
}
#[test]
fn pressure_profile_extends_and_reverses() {
let old = PressureProfile { points: vec![(0.0, 0.0), (1.0, 1.0)] };
let added = PressureProfile { points: vec![(0.0, 1.0), (1.0, 0.0)] };
// A 30 pt path continued by 10 pt: its ramp fills the first three quarters.
let joined = PressureProfile::extended(Some(&old), 30.0, Some(&added), 10.0).unwrap();
assert!((joined.at(0.75) - 1.0).abs() < 1e-9 && (joined.at(0.375) - 0.5).abs() < 1e-9 && joined.at(1.0) == 0.0, "{joined:?}");
// A side without pressure is half way.
let half = PressureProfile::extended(None, 10.0, Some(&added), 10.0).unwrap();
assert_eq!(half.at(0.25), PressureProfile::MID);
assert!(PressureProfile::extended(None, 10.0, None, 10.0).is_none());
assert_eq!(PressureProfile::extended(Some(&old), 0.0, None, f64::NAN), Some(old.clone()));
assert_eq!(old.reversed().points, vec![(0.0, 1.0), (1.0, 0.0)]);
}
/// Strokes saved before pen pressure load as they were, and are written without it.
#[test]
fn strokes_without_pressure_round_trip_unchanged() {
let json = serde_json::to_value(StrokeLayer::new(Paint::None, 2.0)).unwrap();
assert!(json.get("pressure").is_none());
let st: StrokeLayer = serde_json::from_value(json).unwrap();
assert_eq!(st.pressure, None);
let pressed = StrokeLayer { pressure: Some(PressureProfile::sample()), ..st };
let back: StrokeLayer = serde_json::from_value(serde_json::to_value(&pressed).unwrap()).unwrap();
assert_eq!(back, pressed);
let mut ap = Appearance::basic(Paint::None, Paint::None, 1.0);
ap.stroke_mut().unwrap().pressure = Some(PressureProfile::sample());
assert_eq!(ap.without_pressure().stroke().unwrap().pressure, None);
}
#[test]
fn profile_interp() {
let p = WidthProfile::lens();
+64 -1
View File
@@ -1,8 +1,15 @@
//! Graphs (Illustrator's graph tools): a group whose art is generated from a [`GraphSpec`].
use std::collections::HashSet;
use serde::{Deserialize, Serialize};
use vectorcraft_geom::Rect;
/// Most categories (rows) and series (columns) a graph's data grid holds: the grid is built in full for drawing and
/// for the data window, so a file can't make it allocate without bound.
pub const MAX_GRAPH_CATEGORIES: usize = 10_000;
pub const MAX_GRAPH_SERIES: usize = 256;
/// The nine graph types, in the Graph tool group's order.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
@@ -64,18 +71,39 @@ impl GraphKind {
}
}
/// The fill and stroke captured from one series' generated marks. An absent paint keeps whatever
/// the generator draws for that part (greyscale for a series fill, the part's own stroke otherwise).
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct SeriesPaint {
#[serde(skip_serializing_if = "Option::is_none")]
pub fill: Option<vectorcraft_color::Paint>,
#[serde(skip_serializing_if = "Option::is_none")]
pub stroke: Option<vectorcraft_color::Paint>,
/// Stroke weight in points, kept with [`Self::stroke`].
#[serde(skip_serializing_if = "Option::is_none")]
pub stroke_width: Option<f64>,
}
/// Graph data and options. `rows` are categories (one per row of the Graph Data window), each
/// holding one value per series; `series` are the legend labels (the first row of the window).
/// Scatter graphs read each series as (y, x) column pairs, like Illustrator.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct GraphSpec {
/// Paint taken from the series' marks (Group Selection). One entry per series index; missing
/// entries keep the generator's own fill and stroke.
pub series_paints: Vec<SeriesPaint>,
pub kind: GraphKind,
/// The plot rectangle (the area the graph tool dragged), document points.
pub rect: Rect,
pub series: Vec<String>,
pub categories: Vec<String>,
pub rows: Vec<Vec<f64>>,
/// Blank cells as `[category, series]`: their `rows` entry is a 0 placeholder, so a file still reads (as zeros)
/// in a version that doesn't know blanks. Cells past the end of a short row are blank too.
#[serde(skip_serializing_if = "Vec::is_empty")]
pub blanks: Vec<[usize; 2]>,
/// Column/bar width as a % of the available category width (90).
pub column_width: f64,
/// Cluster width as a % (80).
@@ -86,22 +114,56 @@ pub struct GraphSpec {
pub ticks: usize,
pub axis_min: Option<f64>,
pub axis_max: Option<f64>,
/// Line graphs: mark data points, connect them, fill (area-like) the lines.
/// Line, scatter and radar graphs: mark data points, connect them.
pub mark_points: bool,
pub connect_points: bool,
/// Line graphs: run the lines from edge to edge of the plot; off (the default for new graphs), the points sit
/// at the centres of their categories, like the columns of a column graph. Graphs saved before the option
/// existed were drawn edge to edge and keep it.
#[serde(default = "edge_to_edge_before_the_option")]
pub edge_to_edge: bool,
/// Bounds of the art when it was last generated (detects moves/scales of the graph group).
#[serde(skip_serializing_if = "Option::is_none")]
pub placed: Option<Rect>,
}
/// Edge-to-Edge Lines for a graph saved before the option existed: on, as it was drawn then.
fn edge_to_edge_before_the_option() -> bool {
true
}
impl GraphSpec {
/// The data with blank cells as `None`, every row as long as the longest (at most [`MAX_GRAPH_CATEGORIES`] rows
/// of [`MAX_GRAPH_SERIES`] cells).
pub fn cells(&self) -> Vec<Vec<Option<f64>>> {
let width = self.rows.iter().map(Vec::len).max().unwrap_or(0).min(MAX_GRAPH_SERIES);
let blank: HashSet<[usize; 2]> = self.blanks.iter().copied().collect();
self.rows
.iter()
.take(MAX_GRAPH_CATEGORIES)
.enumerate()
.map(|(c, r)| (0..width).map(|s| r.get(s).copied().filter(|_| !blank.contains(&[c, s]))).collect())
.collect()
}
/// Store `cells`, a blank (`None`) as a 0 placeholder listed in `blanks`.
pub fn set_cells(&mut self, cells: Vec<Vec<Option<f64>>>) {
self.blanks =
cells.iter().enumerate().flat_map(|(c, r)| r.iter().enumerate().filter(|(_, v)| v.is_none()).map(move |(s, _)| [c, s])).collect();
self.rows = cells.into_iter().map(|r| r.into_iter().map(|v| v.unwrap_or(0.0)).collect()).collect();
}
}
impl Default for GraphSpec {
fn default() -> Self {
Self {
series_paints: vec![],
kind: GraphKind::Column,
rect: Rect::new(0.0, 0.0, 200.0, 150.0),
series: vec![],
categories: vec![],
rows: vec![vec![1.0]],
blanks: vec![],
column_width: 90.0,
cluster_width: 80.0,
legend: true,
@@ -110,6 +172,7 @@ impl Default for GraphSpec {
axis_max: None,
mark_points: true,
connect_points: true,
edge_to_edge: false,
placed: None,
}
}
+13 -5
View File
@@ -60,12 +60,12 @@ pub(crate) mod skip {
pub use appearance::StrokeGradientMode;
pub use appearance::{
Appearance, AppearanceItem, ArrowAlign, Arrowhead, Dash, Effect, FillLayer, LineCap, LineJoin, ProfilePreset, SavedProfile, StrokeAlign,
StrokeLayer, WidthProfile,
Appearance, AppearanceItem, ArrowAlign, Arrowhead, Dash, Effect, FillLayer, LineCap, LineJoin, PressureProfile, ProfilePreset, SavedProfile,
StrokeAlign, StrokeLayer, WidthProfile,
};
pub use assets::ExportAsset;
pub use corners::LiveCorners;
pub use graph::{GraphKind, GraphSpec};
pub use graph::{GraphKind, GraphSpec, MAX_GRAPH_CATEGORIES, MAX_GRAPH_SERIES, SeriesPaint};
pub use hit::{Hit, HitKind};
pub use links::{LinkInfo, PlacementOptions};
pub use live::{BlendOrientation, BlendSpacing, BlendSpec, EnvelopeKind, GradientMesh, MeshPoint};
@@ -960,8 +960,12 @@ impl Document {
if c.is_layer() {
collect(c, rect, all, out);
} else if let Some(b) = c.geometric_bounds()
&& rect.contains(Point::new(b.x0, b.y0))
&& rect.contains(Point::new(b.x1, b.y1))
// Inside it, edges included: art flush with the right or bottom edge is on it too
// (`Rect::contains` leaves those out, #890).
&& b.x0 >= rect.x0
&& b.y0 >= rect.y0
&& b.x1 <= rect.x1
&& b.y1 <= rect.y1
{
out.push(c.id);
}
@@ -1174,6 +1178,10 @@ mod tests {
let wide = Rect::new(-1.0, -1.0, 40.0, 15.0);
assert_eq!(d.art_on_artboard(Rect::new(-1.0, -1.0, 15.0, 15.0), false), vec![a]);
assert_eq!(d.art_on_artboard(Rect::new(-1.0, -1.0, 25.0, 15.0), false), vec![a], "b only half inside");
// Flush with the artboard's edges counts as inside: the right and bottom ones too (#890).
for board in [Rect::new(0.0, 0.0, 10.0, 10.0), Rect::new(-5.0, 0.0, 10.0, 15.0), Rect::new(0.0, -5.0, 15.0, 10.0)] {
assert_eq!(d.art_on_artboard(board, false), vec![a], "{board:?}");
}
d.node_mut(a).unwrap().locked = true;
assert_eq!(d.art_on_artboard(wide, false), vec![b]);
// Move Locked and Hidden Artwork with Artboard (#394): hidden art stays too, unless on.
+89 -1
View File
@@ -174,6 +174,61 @@ impl LiveShape {
spread(radii, *sides as usize);
spread(kinds, *sides as usize);
}
/// A polygon's radius in the document, centre to vertex: its own radius by the mean scale of
/// `xf` (exact while its sides are equal). None for other shapes.
pub fn polygon_radius(&self) -> Option<f64> {
let LiveShape::Polygon { radius, xf, .. } = self else { return None };
Some(radius * xf.determinant().abs().sqrt())
}
/// A polygon's angle: the counterclockwise degrees in [0, 360) its first vertex is turned by
/// from straight up (the Rotate field's sense). None for other shapes.
pub fn polygon_angle(&self) -> Option<f64> {
let LiveShape::Polygon { .. } = self else { return None };
// No −0° or 360° from rounding: a polygon drawn upright reads 0°.
let a = (-self.angle_deg()).rem_euclid(360.0);
Some(if a > 360.0 - 1e-9 { 0.0 } else { a + 0.0 })
}
/// Whether a polygon's sides are all as long: `xf` scales it evenly and doesn't shear it. True
/// for other shapes.
pub fn polygon_sides_equal(&self) -> bool {
let LiveShape::Polygon { xf, .. } = self else { return true };
let [a, b, c, d, _, _] = xf.as_coeffs();
let (sx, sy) = (a.hypot(b), c.hypot(d));
(sx - sy).abs() <= 1e-9 * sx.max(sy) && (a * c + b * d).abs() <= 1e-9 * sx * sy
}
/// Give a polygon the document radius `r` (centre to vertex, see [`LiveShape::polygon_radius`]).
pub fn set_polygon_radius(&mut self, r: f64) {
let LiveShape::Polygon { radius, xf, .. } = self else { return };
let k = xf.determinant().abs().sqrt();
if k > 1e-12 && r.is_finite() {
*radius = r.max(0.0) / k;
}
}
/// The document radius that makes a polygon's sides `side` long (with its sides equal).
pub fn polygon_radius_for_side(&self, side: f64) -> Option<f64> {
let LiveShape::Polygon { sides, .. } = self else { return None };
Some(side / (2.0 * (std::f64::consts::PI / f64::from((*sides).max(3))).sin()))
}
/// Turn a polygon about its centre to `angle` (degrees, as [`LiveShape::polygon_angle`]).
pub fn set_polygon_angle(&mut self, angle: f64) {
let Some(now) = self.polygon_angle() else { return };
if let LiveShape::Polygon { xf, .. } = self
&& angle.is_finite()
{
let centre = *xf * Point::ZERO;
*xf = Affine::rotate_about((now - angle).to_radians(), centre) * *xf;
}
}
/// Make a polygon's sides equal (Make Sides Equal): drop the uneven scale and the shear from
/// `xf`, keeping its centre, angle, reflection and [`LiveShape::polygon_radius`].
pub fn make_sides_equal(&mut self) {
let Some(r) = self.polygon_radius() else { return };
let LiveShape::Polygon { radius, xf, .. } = self else { return };
let [a, b, _, _, e, f] = xf.as_coeffs();
let flip = if xf.determinant() < 0.0 { Affine::FLIP_Y } else { Affine::IDENTITY };
*xf = Affine::translate((e, f)) * Affine::rotate(b.atan2(a)) * flip;
*radius = r;
}
/// Apply `a` to the shape. True when the shape is no longer `a` applied to the old path, so
/// the caller must regenerate the path with [`LiveShape::to_path`].
///
@@ -253,7 +308,7 @@ impl LiveShape {
radii.iter_mut().for_each(|r| *r /= k);
true
}
/// Rotation angle of the live shape in degrees (shown in the Properties panel).
/// Rotation angle of the live shape in degrees, clockwise on the page (y points down).
pub fn angle_deg(&self) -> f64 {
match self {
LiveShape::Rectangle { xf, .. } | LiveShape::Ellipse { xf, .. } | LiveShape::Polygon { xf, .. } => {
@@ -473,6 +528,10 @@ pub struct Node {
/// Graph object: the group's children are generated from this spec (Object → Graph).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub graph: Option<Box<crate::graph::GraphSpec>>,
/// Index of the graph series this generated group belongs to. Axes, the legend and other
/// children leave it unset, so a series named "Legend" or "Axes" is not mistaken for them.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub series_index: Option<u32>,
/// Editable Shaper composition; the original art is retained in its source child.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub shaper: Option<Box<crate::shaper::ShaperSpec>>,
@@ -540,6 +599,7 @@ impl Node {
trace: None,
wrap: None,
graph: None,
series_index: None,
shaper: None,
kind,
graphic_style: None,
@@ -1357,6 +1417,34 @@ mod tests {
assert_eq!(path.label(), "Path");
}
#[test]
fn a_polygon_reports_and_takes_its_radius_angle_and_equal_sides() {
let close = |a: f64, b: f64| (a - b).abs() < 1e-9;
// A hexagon of radius 10 at (50, 50), drawn 2× and turned 30° clockwise on the page.
let xf = Affine::translate((50.0, 50.0)) * Affine::rotate(30f64.to_radians()) * Affine::scale(2.0);
let mut p = LiveShape::Polygon { radius: 10.0, sides: 6, xf, radii: vec![], kinds: vec![] };
assert!(close(p.polygon_radius().unwrap(), 20.0) && close(p.polygon_angle().unwrap(), 330.0) && p.polygon_sides_equal());
p.set_polygon_angle(45.0);
assert!(close(p.polygon_angle().unwrap(), 45.0));
let centre = p.to_path().bounds().unwrap().center();
assert!(close(centre.x, 50.0) && close(centre.y, 50.0), "turned about its centre: {centre:?}");
p.set_polygon_radius(30.0);
assert!(close(p.polygon_radius().unwrap(), 30.0));
// A hexagon's side is as long as its radius.
assert!(close(p.polygon_radius_for_side(12.0).unwrap(), 12.0));
// Stretched, its sides differ; Make Sides Equal keeps its centre, angle and mean radius.
p.transform(Affine::scale_non_uniform(2.0, 1.0));
assert!(!p.polygon_sides_equal());
let (r, a) = (p.polygon_radius().unwrap(), p.polygon_angle().unwrap());
p.make_sides_equal();
assert!(p.polygon_sides_equal() && close(p.polygon_radius().unwrap(), r) && close(p.polygon_angle().unwrap(), a));
let LiveShape::Polygon { xf, .. } = &p else { panic!("polygon") };
assert_eq!(*xf * Point::ZERO, Point::new(100.0, 50.0));
// Other shapes have none of it.
let e = LiveShape::Ellipse { w: 1.0, h: 1.0, pie: (0.0, 360.0), xf: Affine::IDENTITY };
assert_eq!((e.polygon_radius(), e.polygon_angle()), (None, None));
}
/// A node holding a live rectangle (`w` × `h`, all radii `r`, placed by `xf`) and its path.
fn live_rect(w: f64, h: f64, r: f64, xf: Affine) -> Node {
let live = LiveShape::Rectangle { w, h, radii: [r; 4], kinds: Default::default(), xf };
+14
View File
@@ -128,6 +128,20 @@ impl Selection {
}
self.target = Some(id);
}
/// Shift-clicking `id`'s target circle: it (a layer: its art) joins the selection, or leaves it
/// when it is all selected already. Several objects are then selected, none targeted.
pub fn toggle_target(&mut self, doc: &Document, id: NodeId) {
let ids = match doc.node(id) {
Some(n) if n.is_layer() => n.layer_art(true),
_ => vec![id],
};
if ids.iter().all(|i| self.contains(*i)) {
ids.iter().for_each(|i| self.remove(*i));
} else {
ids.into_iter().for_each(|i| self.add(i));
}
self.target = None;
}
/// What appearance and transparency edits act on: the targeted object, group or layer, else
/// the selected objects.
pub fn subjects(&self) -> &[NodeId] {
+28 -11
View File
@@ -102,25 +102,42 @@ pub fn roughen(path: &PathData, b: Rect, p: &Value) -> PathData {
PathData::new(subs)
}
/// Transform effect (scale, move, rotate, reflect about the centre, plus copies).
pub fn transform(path: &PathData, b: Rect, p: &Value) -> PathData {
let sx = num(p, "scaleH", 100.0) / 100.0;
let sy = num(p, "scaleV", 100.0) / 100.0;
let (rx, ry) = (if flag(p, "reflectX", false) { -1.0 } else { 1.0 }, if flag(p, "reflectY", false) { -1.0 } else { 1.0 });
let c = b.center().to_vec2();
// Reflect X mirrors across the horizontal axis (flips x coordinates in Illustrator's dialog).
let m = Affine::translate(Vec2::new(num(p, "moveH", 0.0), num(p, "moveV", 0.0)) + c)
* Affine::rotate(-num(p, "rotate", 0.0).to_radians())
* Affine::scale_non_uniform(sx * rx, sy * ry)
* Affine::translate(-c);
/// Largest scale (%) and move (pt) the Transform effect takes.
const MAX_SCALE: f64 = 100_000.0;
const MAX_MOVE: f64 = 1.0e7;
/// The Transform effect's copies stop before one reaches further than this (pt) from the origin.
const MAX_REACH: f64 = 1.0e9;
/// Transform effect: scale, rotate and reflect about reference point `reference` of the bounds
/// (the 9-point grid, 4 = centre), then move; `copies` more times, each from the last. With
/// `random`, each scale goes a random share of the way from 100 % to its value and each move and
/// the angle a share of theirs, drawn from `seed` (the object's), so an object keeps its result
/// on every redraw while each object varies its own way.
pub fn transform(path: &PathData, b: Rect, p: &Value, seed: u64) -> PathData {
let random = flag(p, "random", false);
// The share (0..1) of value `k` applied.
let share = |k: u64| if random { (noise(seed, k, 0, 0) + 1.0) / 2.0 } else { 1.0 };
let scale = |key: &str, k: u64| 1.0 + (num(p, key, 100.0).clamp(-MAX_SCALE, MAX_SCALE) / 100.0 - 1.0) * share(k);
let moved = |key: &str, k: u64| num(p, key, 0.0).clamp(-MAX_MOVE, MAX_MOVE) * share(k);
let reflect = |key: &str| if flag(p, key, false) { -1.0 } else { 1.0 };
let o = vectorcraft_geom::reference_point(b, num(p, "reference", 4.0).clamp(0.0, 8.0) as usize).to_vec2();
// Reflect X flips the x coordinates (left to right), Reflect Y the y coordinates.
let m = Affine::translate(Vec2::new(moved("moveH", 2), moved("moveV", 3)) + o)
* Affine::rotate(-(num(p, "rotate", 0.0) * share(4)).to_radians())
* Affine::scale_non_uniform(scale("scaleH", 0) * reflect("reflectX"), scale("scaleV", 1) * reflect("reflectY"))
* Affine::translate(-o);
let copies = num(p, "copies", 0.0).clamp(0.0, 1000.0) as usize;
if copies == 0 {
return path.transformed(m);
}
let within = |r: Rect| [r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.abs() <= MAX_REACH);
let mut out = path.clone();
let mut cur = path.clone();
for _ in 0..copies {
cur = cur.transformed(m);
if !cur.bounds().is_some_and(within) {
break;
}
out.subpaths.extend(cur.subpaths.iter().cloned());
}
out
+11
View File
@@ -319,6 +319,17 @@ mod tests {
assert_eq!(div.len(), 3);
}
#[test]
fn a_random_transform_on_a_group_moves_its_members_together() {
let mut g = group_with("distort.transform");
g.appearance.effects[0].params = serde_json::json!({"moveH": 100, "moveV": 100, "random": true});
let art = evaluate_container(&g).unwrap();
let corner = |i: usize| art.children().and_then(|c| c.get(i)).and_then(|n| n.geometric_bounds()).map(|b| (b.x0, b.y0)).unwrap();
let (a, b) = (corner(0), corner(1));
assert!(a.0 > 0.0 && a.0 < 100.0, "moved by a share of the value: {a:?}");
assert!((b.0 - a.0 - 5.0).abs() < 1e-9 && (b.1 - a.1).abs() < 1e-9, "{a:?} {b:?}");
}
#[test]
fn hidden_effect_or_plain_group_is_untouched() {
let mut g = group_with("pathfinder.add");
+104 -6
View File
@@ -6,7 +6,8 @@
//! - **Geometry effects** (Distort & Transform, Path, Convert to Shape, Round Corners, Scribble,
//! Warp) rewrite a path: [`apply_geometry`] evaluates them in stack order.
//! - **Raster effects** (Drop Shadow, Inner/Outer Glow, Feather, Gaussian Blur, and the
//! Photoshop-style filters of [`pixel`]: Radial Blur, Smart Blur, Unsharp Mask and Glowing Edges) are described by
//! Photoshop-style filters of [`pixel`]: Radial Blur, Smart Blur, Color Halftone, Crystallize, Mezzotint, Pointillize,
//! Unsharp Mask and Glowing Edges) are described by
//! [`raster_effects`] and painted by the renderer; [`outset`] says how far they reach beyond
//! the geometry.
//! - **Stroke geometry** ([`stroke`]): arrowheads, dash patterns and width profiles, shared by the
@@ -27,7 +28,8 @@
//! renderer and the exporters.
//!
//! Everything is deterministic: "random" effects (Roughen, Tweak, Scribble) use a seeded hash
//! noise (`seed` parameter, default 0).
//! noise (`seed` parameter, default 0); the random Pixelate filters hash their pattern's cells,
//! anchored at the object's centre.
#![forbid(unsafe_code)]
mod adjust;
@@ -126,6 +128,12 @@ fn lengths_of(id: &str) -> Lengths {
"stylize.dropShadow" => always(&["x", "y", "blur"]),
"stylize.innerGlow" | "stylize.outerGlow" => always(&["blur"]),
"stylize.glowingEdges" => always(&["edgeWidth", "smoothness"]),
"pixelate.colorHalftone" => always(&["maxRadius"]),
"pixelate.crystallize" | "pixelate.pointillize" => always(&["cellSize"]),
"texture.craquelure" => always(&["crackSpacing"]),
"texture.mosaicTiles" => always(&["tileSize"]),
"texture.patchwork" => always(&["squareSize"]),
"texture.stainedGlass" => always(&["cellSize"]),
_ => Lengths::default(),
}
}
@@ -139,6 +147,9 @@ const STYLIZE: &[&str] = &["Effect", "Stylize"];
const WARP: &[&str] = &["Effect", "Warp"];
const BLUR: &[&str] = &["Effect", "Blur"];
const SHARPEN: &[&str] = &["Effect", "Sharpen"];
const PIXELATE: &[&str] = &["Effect", "Pixelate"];
const TEXTURE: &[&str] = &["Effect", "Texture"];
const VIDEO: &[&str] = &["Effect", "Video"];
const PATHFINDER: &[&str] = &["Effect", "Pathfinder"];
const PLUGINS: &[&str] = &["Effect", "Plug-ins"];
const ADJUST: &[&str] = &["Effect", "Color Adjustments"];
@@ -207,8 +218,8 @@ pub fn effect_catalog() -> Vec<EffectInfo> {
"distort.transform",
"Transform…",
DT,
"{scaleH: % (100), scaleV: % (100), moveH: pt (0), moveV: pt (0), rotate: deg (0), copies: int (0), reflectX: bool, reflectY: bool}",
json!({"scaleH": 100.0, "scaleV": 100.0, "moveH": 0.0, "moveV": 0.0, "rotate": 0.0, "copies": 0, "reflectX": false, "reflectY": false}),
"{scaleH: % (100), scaleV: % (100), moveH: pt (0; right = +), moveV: pt (0; down = +), rotate: deg (0; counter-clockwise), copies: 0..1000 (0; each copy transforms the last again), reflectX: bool (false; flips left to right), reflectY: bool (false; flips top to bottom), reference: 0..8 (4; the point of the bounds' 9-point grid it scales, rotates and reflects about: 0 top left, 4 centre, 8 bottom right), random: bool (false; each scale goes a random share of the way from 100 % to its value, each move and the angle a random share of theirs, differently for each object and the same on every redraw)}",
json!({"scaleH": 100.0, "scaleV": 100.0, "moveH": 0.0, "moveV": 0.0, "rotate": 0.0, "copies": 0, "reflectX": false, "reflectY": false, "reference": 4, "random": false}),
),
g(
"distort.tweak",
@@ -298,6 +309,90 @@ pub fn effect_catalog() -> Vec<EffectInfo> {
"{edgeWidth: pt 1..14 (2), edgeBrightness: 0..20 (6), smoothness: pt 1..15 (5)} finds alpha-weighted Sobel edges and draws bright coloured outlines on black",
json!({"edgeWidth": 2.0, "edgeBrightness": 6.0, "smoothness": 5.0}),
),
r(
"pixelate.colorHalftone",
"Color Halftone…",
PIXELATE,
"{maxRadius: pt 4..127 (8; the radius of a dot at full strength, which fills its square cell), channel1: screen angle deg -360..360 (108), channel2: deg (162), channel3: deg (90), channel4: deg (45)} screens each colour channel (red, green, blue: channels 1 to 3; in CMYK documents cyan, magenta, yellow, black: 1 to 4) at its angle into dots whose area follows the channel's mean over their cell",
json!({"maxRadius": 8.0, "channel1": 108.0, "channel2": 162.0, "channel3": 90.0, "channel4": 45.0}),
),
r(
"pixelate.crystallize",
"Crystallize…",
PIXELATE,
"{cellSize: pt 3..300 (10)} redraws the object as polygon crystals of solid colour around random points about cellSize apart",
json!({"cellSize": 10.0}),
),
r(
"pixelate.mezzotint",
"Mezzotint…",
PIXELATE,
"{type: \"fineDots\"|\"mediumDots\"|\"grainyDots\"|\"coarseDots\"|\"shortLines\"|\"mediumLines\"|\"longLines\"|\"shortStrokes\"|\"mediumStrokes\"|\"longStrokes\" (\"fineDots\")} turns each colour channel fully on or off against a random pattern of dots, lines or strokes: fully saturated colours",
json!({"type": "fineDots"}),
),
r(
"pixelate.pointillize",
"Pointillize…",
PIXELATE,
"{cellSize: pt 3..300 (5)} redraws the object as randomly placed dots of its colours on a white canvas",
json!({"cellSize": 5.0}),
),
r(
"texture.craquelure",
"Craquelure…",
TEXTURE,
"{crackSpacing: pt 2..100 (15; how far apart the cracks are), crackDepth: 0..10 (6; how deep and wide the cracks are, and how high the plates and the tones stand), crackBrightness: 0..10 (9; how brightly the plaster is lit)} paints the object on relief plaster cracked into plates and along the contours of its tones",
json!({"crackSpacing": 15.0, "crackDepth": 6.0, "crackBrightness": 9.0}),
),
r(
"texture.grain",
"Grain…",
TEXTURE,
"{intensity: 0..100 (40), contrast: 0..100 (50; the image's contrast, 50 leaves it as it is), grainType: \"regular\"|\"soft\"|\"sprinkles\"|\"clumped\"|\"contrasty\"|\"enlarged\"|\"stippled\"|\"horizontal\"|\"vertical\"|\"speckle\" (\"regular\")} adds grain in 1 pt grains (2 pt Enlarged, clumps Clumped, streaks Horizontal and Vertical); Sprinkles and Stippled use the background colour, white",
json!({"intensity": 40.0, "contrast": 50.0, "grainType": "regular"}),
),
r(
"texture.mosaicTiles",
"Mosaic Tiles…",
TEXTURE,
"{tileSize: pt 2..100 (12), groutWidth: 1..15 (3; the grout is half a point wide per step), lightenGrout: 0..10 (9; how light the grout is)} lays the object in irregular tiles bevelled at their edges, with sunken grout between them",
json!({"tileSize": 12.0, "groutWidth": 3.0, "lightenGrout": 9.0}),
),
r(
"texture.patchwork",
"Patchwork…",
TEXTURE,
"{squareSize: pt 0..10 (4; 0 draws 1 pt squares), relief: 0..25 (8)} redraws the object in squares of the colour around their centres, raised to heights that follow its highlights and shadows, a little more or less at random, lit from the top left",
json!({"squareSize": 4.0, "relief": 8.0}),
),
r(
"texture.stainedGlass",
"Stained Glass…",
TEXTURE,
"{cellSize: pt 2..50 (10), borderThickness: 1..20 (4; the lead between the panes, half a point wide per step, in the foreground colour: black), lightIntensity: 0..10 (3; a light behind the object's centre, fading out towards its corners)} redraws the object as single-coloured panes around random points about cellSize apart",
json!({"cellSize": 10.0, "borderThickness": 4.0, "lightIntensity": 3.0}),
),
r(
"texture.texturizer",
"Texturizer…",
TEXTURE,
"{texture: \"brick\"|\"burlap\"|\"canvas\"|\"sandstone\" (\"canvas\"; surfaces made in code), scaling: % 50..200 (100), relief: 0..50 (4), lightDirection: \"bottom\"|\"bottomLeft\"|\"left\"|\"topLeft\"|\"top\"|\"topRight\"|\"right\"|\"bottomRight\" (\"top\"), invert: bool (false; turns the surface's heights over)} paints the object on a surface in relief",
json!({"texture": "canvas", "scaling": 100.0, "relief": 4.0, "lightDirection": "top", "invert": false}),
),
r(
"video.deinterlace",
"De-Interlace…",
VIDEO,
"{eliminate: \"odd\"|\"even\" (\"odd\"; which field lines are taken out), create: \"duplication\"|\"interpolation\" (\"duplication\"; a line taken out becomes a copy of the line above, or the average of the lines above and below)} the field lines are the rows of the document's raster grid (Document Raster Effects Settings › Resolution), numbered from 1 at the top of the page",
json!({"eliminate": "odd", "create": "duplication"}),
),
r(
"video.ntscColors",
"NTSC Colors",
VIDEO,
"{} makes colours a television signal can't carry (luma plus chroma past 110 % of white, or luma minus chroma below −20 %) less saturated, keeping their brightness",
json!({}),
),
];
v.extend([
g(
@@ -489,6 +584,9 @@ pub struct GeomContext<'a> {
pub stroke: Option<&'a StrokeLayer>,
/// The object's fill rule (inside and outside alignment).
pub rule: FillRule,
/// The seed of the Transform effect's Random: the object's id, so each object varies its own
/// way and keeps its result on every redraw.
pub seed: u64,
}
impl<'a> GeomContext<'a> {
@@ -498,7 +596,7 @@ impl<'a> GeomContext<'a> {
NodeKind::Path { rule, .. } | NodeKind::Compound { rule, .. } => *rule,
_ => FillRule::NonZero,
};
Self { stroke: n.appearance.stroke().filter(|s| !s.paint.is_none() && s.width > 0.0), rule }
Self { stroke: n.appearance.stroke().filter(|s| !s.paint.is_none() && s.width > 0.0), rule, seed: n.id.0 }
}
/// The context of the effects on `item`, one of the same object's appearance items: a
@@ -551,7 +649,7 @@ pub(crate) fn apply_one(id: &str, p: &Value, path: &PathData, b: Rect, ctx: &Geo
"distort.freeDistort" => distort::free_distort(path, b, p),
"distort.puckerBloat" => distort::pucker_bloat(path, b, num(p, "amount", 0.0)),
"distort.roughen" => distort::roughen(path, b, p),
"distort.transform" => distort::transform(path, b, p),
"distort.transform" => distort::transform(path, b, p, ctx.seed),
"distort.tweak" => distort::tweak(path, b, p),
"distort.twist" => distort::twist(path, b, num(p, "angle", 10.0)),
"distort.zigZag" => distort::zig_zag(path, b, p),
+177 -5
View File
@@ -1,5 +1,7 @@
//! Photoshop-style raster effects (Effect › Blur › Radial Blur and Smart Blur, Sharpen › Unsharp
//! Mask, and Stylize › Glowing Edges): filters over premultiplied RGBA8 pixels.
//! Photoshop-style raster effects (Effect › Blur › Radial Blur and Smart Blur, Pixelate › Color
//! Halftone, Crystallize, Mezzotint and Pointillize, Sharpen › Unsharp Mask, Stylize › Glowing
//! Edges, Texture › Craquelure, Grain, Mosaic Tiles, Patchwork, Stained Glass and Texturizer, and
//! Video › De-Interlace and NTSC Colors): filters over premultiplied RGBA8 pixels.
//!
//! - Every distance is in document units (points) and becomes pixels through the raster's
//! [`PixelSpace::px`], so an effect looks the same at any zoom and at any Document Raster Effects
@@ -10,15 +12,38 @@
mod blur;
mod edges;
mod pixelate;
mod sharpen;
mod texture;
mod video;
use serde_json::Value;
use vectorcraft_geom::{Affine, Point, Rect};
use crate::util::{num, text};
use crate::util::{flag, num, text};
pub use pixelate::{MEZZOTINT_TYPES, Mezzotint};
pub use texture::{GRAIN_TYPES, Grain, LIGHT_DIRECTIONS, Light, TEXTURES, Texture};
/// The Photoshop-style effect ids (all raster effects, see [`crate::is_raster`]).
pub const PIXEL_EFFECTS: [&str; 4] = ["blur.radial", "blur.smart", "sharpen.unsharpMask", "stylize.glowingEdges"];
pub const PIXEL_EFFECTS: [&str; 16] = [
"blur.radial",
"blur.smart",
"pixelate.colorHalftone",
"pixelate.crystallize",
"pixelate.mezzotint",
"pixelate.pointillize",
"sharpen.unsharpMask",
"stylize.glowingEdges",
"texture.craquelure",
"texture.grain",
"texture.mosaicTiles",
"texture.patchwork",
"texture.stainedGlass",
"texture.texturizer",
"video.deinterlace",
"video.ntscColors",
];
/// A Photoshop-style raster effect with its parameters read and clamped to their ranges. Lengths
/// are in document units.
@@ -36,6 +61,54 @@ pub enum PixelFx {
UnsharpMask { amount: f64, radius: f64, threshold: f64 },
/// Stylize › Glowing Edges: bright coloured Sobel outlines on black.
GlowingEdges { width: f64, brightness: f64, smoothness: f64 },
/// Pixelate › Color Halftone: each colour channel screened at its angle (`angles`° of channels
/// 1 to 4) into dots of up to `max_radius`, whose area follows the channel's strength.
ColorHalftone { max_radius: f64, angles: [f64; 4] },
/// Pixelate › Crystallize: polygons of solid colour around random points about `cell` apart.
Crystallize { cell: f64 },
/// Pixelate › Mezzotint: every colour channel fully on or off against a random pattern.
Mezzotint { kind: Mezzotint },
/// Pixelate › Pointillize: random dots about `cell` across of the object's colours on a white
/// canvas.
Pointillize { cell: f64 },
/// Texture › Craquelure: cracked relief plaster, plates about `spacing` apart, cracks as deep
/// as `depth` (0..10), lit as brightly as `brightness` (0..10).
Craquelure { spacing: f64, depth: f64, brightness: f64 },
/// Texture › Grain: noise of `kind` as strong as `intensity` (0..100), the image's contrast
/// set by `contrast` (0..100, 50 leaves it).
Grain { intensity: f64, contrast: f64, kind: Grain },
/// Texture › Mosaic Tiles: irregular tiles about `tile` across with sunken grout `grout` / 2
/// wide (1..15), lightened by `lighten` (0..10).
MosaicTiles { tile: f64, grout: f64, lighten: f64 },
/// Texture › Patchwork: squares `square` across of the colour around their centres, raised by
/// up to `relief` (0..25).
Patchwork { square: f64, relief: f64 },
/// Texture › Stained Glass: panes about `cell` across leaded `border` / 2 wide (1..20) in
/// black, lit at the centre by `light` (0..10).
StainedGlass { cell: f64, border: f64, light: f64 },
/// Texture › Texturizer: a `texture` surface scaled by `scaling` (0.5..2), in relief as strong
/// as `relief` (0..50), lit from `light`, its heights turned over when `invert`.
Texturizer { texture: Texture, scaling: f64, relief: f64, light: Light, invert: bool },
/// Video › De-Interlace: the odd (or `even`) field lines made again from the others, by
/// duplication or, with `interpolate`, by averaging.
DeInterlace { even: bool, interpolate: bool },
/// Video › NTSC Colors: colours a television signal can't carry made less saturated.
NtscColors,
}
/// What a raster's colour channels hold, for the filters that treat them apart (Color Halftone
/// screens each channel, Mezzotint thresholds each).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum Channels {
/// Red, green and blue light.
#[default]
Rgb,
/// Screen colours of a CMYK document: treated as its cyan, magenta, yellow and black inks.
Cmyk,
/// The complemented C, M and Y inks (a CMYK document drawn ink plane by ink plane).
CmyPlane,
/// The complemented K ink, as a grey.
KPlane,
}
/// Where a raster's pixels lie in the document.
@@ -46,8 +119,17 @@ pub struct PixelSpace {
pub to_doc: Affine,
/// The size of a pixel in document units.
pub px: f64,
/// The object's centre (document coordinates): where Radial Blur blurs around.
/// The object's centre (document coordinates): where Radial Blur blurs around, and where the
/// Pixelate filters' patterns are anchored (so they move with the object).
pub center: Point,
/// What the colour channels hold.
pub channels: Channels,
/// The height of a line of the document's raster grid (document units: 72 / Document Raster
/// Effects Settings › Resolution), the fields De-Interlace works on.
pub line: f64,
/// How far the corners of the object's bounds lie from its centre (document units): Stained
/// Glass's light fades out over it.
pub extent: f64,
}
/// Effect `id`'s parameters `p` (defaults merged in) as a [`PixelFx`]; `None` for other effects.
@@ -77,6 +159,49 @@ pub(crate) fn parse(id: &str, p: &Value) -> Option<PixelFx> {
brightness: num(p, "edgeBrightness", 6.0).clamp(0.0, 20.0),
smoothness: num(p, "smoothness", 5.0).clamp(1.0, 15.0),
},
"pixelate.colorHalftone" => PixelFx::ColorHalftone {
max_radius: num(p, "maxRadius", 8.0).clamp(4.0, 127.0),
angles: [("channel1", 108.0), ("channel2", 162.0), ("channel3", 90.0), ("channel4", 45.0)]
.map(|(k, d)| num(p, k, d).clamp(-360.0, 360.0)),
},
"pixelate.crystallize" => PixelFx::Crystallize { cell: num(p, "cellSize", 10.0).clamp(3.0, 300.0) },
"pixelate.mezzotint" => PixelFx::Mezzotint { kind: Mezzotint::parse(text(p, "type", "fineDots")) },
"pixelate.pointillize" => PixelFx::Pointillize { cell: num(p, "cellSize", 5.0).clamp(3.0, 300.0) },
"texture.craquelure" => PixelFx::Craquelure {
spacing: num(p, "crackSpacing", 15.0).clamp(2.0, 100.0),
depth: num(p, "crackDepth", 6.0).clamp(0.0, 10.0),
brightness: num(p, "crackBrightness", 9.0).clamp(0.0, 10.0),
},
"texture.grain" => PixelFx::Grain {
intensity: num(p, "intensity", 40.0).clamp(0.0, 100.0),
contrast: num(p, "contrast", 50.0).clamp(0.0, 100.0),
kind: Grain::parse(text(p, "grainType", "regular")),
},
"texture.mosaicTiles" => PixelFx::MosaicTiles {
tile: num(p, "tileSize", 12.0).clamp(2.0, 100.0),
grout: num(p, "groutWidth", 3.0).clamp(1.0, 15.0),
lighten: num(p, "lightenGrout", 9.0).clamp(0.0, 10.0),
},
"texture.patchwork" => {
PixelFx::Patchwork { square: num(p, "squareSize", 4.0).clamp(0.0, 10.0), relief: num(p, "relief", 8.0).clamp(0.0, 25.0) }
}
"texture.stainedGlass" => PixelFx::StainedGlass {
cell: num(p, "cellSize", 10.0).clamp(2.0, 50.0),
border: num(p, "borderThickness", 4.0).clamp(1.0, 20.0),
light: num(p, "lightIntensity", 3.0).clamp(0.0, 10.0),
},
"texture.texturizer" => PixelFx::Texturizer {
texture: Texture::parse(text(p, "texture", "canvas")),
scaling: num(p, "scaling", 100.0).clamp(50.0, 200.0) / 100.0,
relief: num(p, "relief", 4.0).clamp(0.0, 50.0),
light: Light::parse(text(p, "lightDirection", "top")),
invert: flag(p, "invert", false),
},
"video.deinterlace" => PixelFx::DeInterlace {
even: text(p, "eliminate", "odd").eq_ignore_ascii_case("even"),
interpolate: text(p, "create", "duplication").eq_ignore_ascii_case("interpolation"),
},
"video.ntscColors" => PixelFx::NtscColors,
_ => return None,
})
}
@@ -94,6 +219,18 @@ impl PixelFx {
PixelFx::SmartBlur { radius, .. } => radius,
PixelFx::UnsharpMask { .. } => 0.0,
PixelFx::GlowingEdges { width, smoothness, .. } => width.max(smoothness * 3.0),
PixelFx::ColorHalftone { .. }
| PixelFx::Mezzotint { .. }
| PixelFx::Craquelure { .. }
| PixelFx::Grain { .. }
| PixelFx::MosaicTiles { .. }
| PixelFx::Texturizer { .. }
| PixelFx::DeInterlace { .. }
| PixelFx::NtscColors => 0.0,
// A crystal's, dot's or pane's point inside the object reaches out by up to its cell.
PixelFx::Crystallize { cell } | PixelFx::Pointillize { cell } | PixelFx::StainedGlass { cell, .. } => 1.5 * cell,
// A square whose centre is on the object is drawn whole.
PixelFx::Patchwork { square, .. } => square.max(1.0),
}
}
@@ -105,6 +242,21 @@ impl PixelFx {
PixelFx::SmartBlur { radius, .. } => Some(radius),
PixelFx::UnsharpMask { radius, .. } => Some(3.0 * radius),
PixelFx::GlowingEdges { width, smoothness, .. } => Some(width.max(smoothness * 3.0)),
// The cells around the pixel's, and the cells around theirs.
PixelFx::ColorHalftone { max_radius, .. } => Some(3.0 * std::f64::consts::SQRT_2 * max_radius),
PixelFx::Crystallize { cell } => Some(1.5 * cell),
PixelFx::Mezzotint { .. } => Some(0.0),
// The dots' points, and the softened colour around them.
PixelFx::Pointillize { cell } => Some(2.5 * cell),
// The softened tones around the pixel.
PixelFx::Craquelure { spacing, .. } => Some(3.0 * texture::CONTOUR_SOFTNESS * spacing),
PixelFx::Grain { .. } | PixelFx::MosaicTiles { .. } | PixelFx::Texturizer { .. } => Some(0.0),
// The square's or pane's point, and the softened colour around it.
PixelFx::Patchwork { square, .. } => Some(2.0 * square.max(1.0)),
PixelFx::StainedGlass { cell, .. } => Some(2.5 * cell),
// The lines above and below.
PixelFx::DeInterlace { .. } => Some(video::MAX_LINE),
PixelFx::NtscColors => Some(0.0),
}
}
@@ -118,10 +270,30 @@ impl PixelFx {
PixelFx::SmartBlur { radius, threshold, samples } => blur::smart(px, w, h, to_px(radius), threshold, samples),
PixelFx::UnsharpMask { amount, radius, threshold } => sharpen::unsharp(px, w, h, amount, to_px(radius), threshold),
PixelFx::GlowingEdges { width, brightness, smoothness } => edges::glow(px, w, h, to_px(width), brightness, to_px(smoothness)),
PixelFx::ColorHalftone { max_radius, angles } => pixelate::color_halftone(px, w, h, space, max_radius, angles),
PixelFx::Crystallize { cell } => pixelate::crystallize(px, w, h, space, cell),
PixelFx::Mezzotint { kind } => pixelate::mezzotint(px, w, h, space, kind),
PixelFx::Pointillize { cell } => pixelate::pointillize(px, w, h, space, cell),
PixelFx::Craquelure { spacing, depth, brightness } => texture::craquelure(px, w, h, space, spacing, depth, brightness),
PixelFx::Grain { intensity, contrast, kind } => texture::grain(px, w, h, space, intensity, contrast, kind),
PixelFx::MosaicTiles { tile, grout, lighten } => texture::mosaic_tiles(px, w, h, space, tile, grout, lighten),
PixelFx::Patchwork { square, relief } => texture::patchwork(px, w, h, space, square, relief),
PixelFx::StainedGlass { cell, border, light } => texture::stained_glass(px, w, h, space, cell, border, light),
PixelFx::Texturizer { texture, scaling, relief, light, invert } => {
texture::texturizer(px, w, h, space, texture, scaling, relief, light, invert)
}
PixelFx::DeInterlace { even, interpolate } => video::deinterlace(px, w, h, space, even, interpolate),
PixelFx::NtscColors => video::ntsc(px),
}
}
}
/// The entry of `all` whose value in `table` (the same order) is `value`, any case; `default`
/// otherwise.
fn pick<T: Copy>(table: &[(&str, &str)], all: &[T], value: &str, default: T) -> T {
table.iter().zip(all).find(|((_, v), _)| v.eq_ignore_ascii_case(value)).map_or(default, |(_, t)| *t)
}
/// `data` as `w` × `h` RGBA pixels, `None` when it isn't that size (or is empty).
fn pixels(data: &mut [u8], w: usize, h: usize) -> Option<&mut [[u8; 4]]> {
let (px, rest) = data.as_chunks_mut::<4>();
+548
View File
@@ -0,0 +1,548 @@
//! Pixelate › Color Halftone, Crystallize, Mezzotint and Pointillize.
//!
//! Their patterns (screens, crystals, grains and dots) lie in document space around the object's
//! centre, so they keep their size at any zoom or resolution and move with the object. The random
//! ones hash the pattern's cell indices: every redraw gives the same result.
use std::sync::OnceLock;
use vectorcraft_geom::{Affine, Point};
use super::{Channels, PixelSpace, Px16, gaussian, pick, to16};
use crate::util::noise;
/// Mezzotint's patterns, in its Type menu's order: (label, parameter value).
pub const MEZZOTINT_TYPES: [(&str, &str); 10] = [
("Fine Dots", "fineDots"),
("Medium Dots", "mediumDots"),
("Grainy Dots", "grainyDots"),
("Coarse Dots", "coarseDots"),
("Short Lines", "shortLines"),
("Medium Lines", "mediumLines"),
("Long Lines", "longLines"),
("Short Strokes", "shortStrokes"),
("Medium Strokes", "mediumStrokes"),
("Long Strokes", "longStrokes"),
];
/// A Mezzotint pattern ([`MEZZOTINT_TYPES`]): random dots, or horizontal lines or strokes.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Mezzotint {
/// Dots `size` points across (smooth blobs when `smooth`), plus fine grain when `grainy`.
Dots { size: u8, smooth: bool, grainy: bool },
/// Horizontal streaks about `length` points long and `width` points thick.
Lines { length: u8, width: u8 },
}
/// The patterns of [`MEZZOTINT_TYPES`], in the same order.
const MEZZOTINT_KINDS: [Mezzotint; 10] = {
const fn dots(size: u8, smooth: bool, grainy: bool) -> Mezzotint {
Mezzotint::Dots { size, smooth, grainy }
}
const fn lines(length: u8, width: u8) -> Mezzotint {
Mezzotint::Lines { length, width }
}
[
dots(1, false, false),
dots(2, true, false),
dots(3, true, true),
dots(4, true, false),
lines(4, 1),
lines(8, 1),
lines(16, 1),
lines(6, 2),
lines(12, 2),
lines(24, 2),
]
};
impl Mezzotint {
/// Fine Dots, the first type.
pub const FINE_DOTS: Mezzotint = Mezzotint::Dots { size: 1, smooth: false, grainy: false };
/// The pattern of Type `value` (a [`MEZZOTINT_TYPES`] value, any case); Fine Dots otherwise.
pub fn parse(value: &str) -> Self {
pick(&MEZZOTINT_TYPES, &MEZZOTINT_KINDS, value, Self::FINE_DOTS)
}
}
/// Seeds of the filters' noise ("PIXELATE" and a filter number).
const SEED: u64 = 0x5049_5845_4C41_5445;
/// A random number in [0, 1) for cell (`i`, `j`) of pattern `salt`; `k` picks one of several.
pub(super) fn rand(salt: u64, i: i64, j: i64, k: u64) -> f64 {
0.5 * (noise(SEED ^ salt, i as u64, j as u64, k) + 1.0)
}
/// The cell holding coordinate `v` (in cells); `None` far beyond any raster (or not finite).
pub(super) fn cell(v: f64) -> Option<i64> {
let f = v.floor();
(f.abs() < 1e15).then_some(f as i64)
}
/// Where a raster's pixels lie relative to the object's centre, in document units.
pub(super) struct Place {
/// Pixel coordinates → document coordinates relative to the centre.
pub(super) to_rel: Affine,
/// The other way.
pub(super) to_px: Affine,
/// A pixel's size in document units.
pub(super) px: f64,
w: usize,
h: usize,
}
impl Place {
pub(super) fn new(space: &PixelSpace, w: usize, h: usize) -> Option<Self> {
let to_rel = Affine::translate(-space.center.to_vec2()) * space.to_doc;
let to_px = to_rel.inverse();
(to_rel.is_finite() && to_px.is_finite() && space.px.is_finite() && space.px > 0.0).then_some(Self { to_rel, to_px, px: space.px, w, h })
}
/// The centre of pixel (`x`, `y`).
pub(super) fn at(&self, x: usize, y: usize) -> Point {
self.to_rel * Point::new(x as f64 + 0.5, y as f64 + 0.5)
}
/// The index of the pixel holding point `p`; `None` outside the raster.
pub(super) fn index(&self, p: Point) -> Option<usize> {
let q = self.to_px * p;
(q.x >= 0.0 && q.y >= 0.0 && q.x < self.w as f64 && q.y < self.h as f64).then(|| q.y as usize * self.w + q.x as usize)
}
/// Run `f(x, y, pixel)` on every pixel.
pub(super) fn each(&self, px: &mut [[u8; 4]], mut f: impl FnMut(usize, usize, &mut [u8; 4])) {
for (y, row) in px.chunks_exact_mut(self.w.max(1)).enumerate().take(self.h) {
for (x, p) in row.iter_mut().enumerate() {
f(x, y, p);
}
}
}
}
/// The cells around a pixel's cell, in the order they can hold its nearest random point: the
/// cell itself, its 8 neighbours, then the ring around them.
pub(super) const AROUND: [(i64, i64); 25] = [
(0, 0),
(-1, 0),
(1, 0),
(0, -1),
(0, 1),
(-1, -1),
(1, -1),
(-1, 1),
(1, 1),
(-2, -2),
(-1, -2),
(0, -2),
(1, -2),
(2, -2),
(-2, -1),
(2, -1),
(-2, 0),
(2, 0),
(-2, 1),
(2, 1),
(-2, 2),
(-1, 2),
(0, 2),
(1, 2),
(2, 2),
];
/// What the cells [`AROUND`] one cell hold, made when first asked for and kept while consecutive
/// pixels stay in that cell.
pub(super) struct Around<T> {
cell: Option<(i64, i64)>,
items: [Option<T>; 25],
}
impl<T: Copy> Around<T> {
pub(super) fn new() -> Self {
Self { cell: None, items: [None; 25] }
}
/// Item `k` of [`AROUND`] cell (`i`, `j`): `make(cell i', j')`.
pub(super) fn get(&mut self, (i, j): (i64, i64), k: usize, make: impl FnOnce(i64, i64) -> T) -> Option<T> {
if self.cell != Some((i, j)) {
self.cell = Some((i, j));
self.items = [None; 25];
}
let (di, dj) = AROUND.get(k)?;
let slot = self.items.get_mut(k)?;
Some(*slot.get_or_insert_with(|| make(i.wrapping_add(*di), j.wrapping_add(*dj))))
}
}
/// The random point of cell (`i`, `j`) of pattern `salt`, in cells.
pub(super) fn point_in(salt: u64, i: i64, j: i64) -> Point {
Point::new(i as f64 + rand(salt, i, j, 0), j as f64 + rand(salt, i, j, 1))
}
/// How far cell `c` lies from coordinate `t` (in cells) along one axis.
pub(super) fn gap(c: i64, t: f64) -> f64 {
(c as f64 - t).max(t - c as f64 - 1.0).max(0.0)
}
/// Smooth random values in [0, 1) of pattern `salt` at whole coordinates, blended between them.
pub(super) fn value_noise(salt: u64, x: f64, y: f64) -> f64 {
let (Some(i), Some(j)) = (cell(x), cell(y)) else { return 0.5 };
let smooth = |t: f64| t * t * (3.0 - 2.0 * t);
let (fx, fy) = (smooth(x - i as f64), smooth(y - j as f64));
let at = |di: i64, dj: i64| rand(salt, i.wrapping_add(di), j.wrapping_add(dj), 0);
let top = at(0, 0) + (at(1, 0) - at(0, 0)) * fx;
let bottom = at(0, 1) + (at(1, 1) - at(0, 1)) * fx;
top + (bottom - top) * fy
}
/// Crystallize: every pixel takes the colour at the nearest of one random point per `cell` ×
/// `cell` square (a Voronoi diagram over a jittered grid): polygon crystals of solid colour.
pub(super) fn crystallize(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, cell_size: f64) {
let Some(place) = Place::new(space, w, h) else { return };
if !(cell_size.is_finite() && cell_size > 0.0) {
return;
}
let src = px.to_vec();
let mut around = Around::new();
place.each(px, |x, y, out| {
let q = place.at(x, y);
let (u, v) = (q.x / cell_size, q.y / cell_size);
let (Some(i), Some(j)) = (cell(u), cell(v)) else { return };
let mut best = (f64::INFINITY, None);
for (k, (di, dj)) in AROUND.iter().enumerate() {
// A cell farther than the best point so far can't hold a nearer one.
let (ci, cj) = (i.wrapping_add(*di), j.wrapping_add(*dj));
if gap(ci, u).hypot(gap(cj, v)) >= best.0 {
continue;
}
if let Some(p) = around.get((i, j), k, |a, b| point_in(1, a, b)) {
let d = (p.x - u).hypot(p.y - v);
if d < best.0 {
best = (d, Some(p));
}
}
}
if let Some(p) = best.1 {
let at = Point::new(p.x * cell_size, p.y * cell_size);
*out = place.index(at).and_then(|i| src.get(i)).copied().unwrap_or([0; 4]);
}
});
}
/// The object's colours softened by a Gaussian of `sigma` pixels, with its coverage as it is: what
/// the cell filters (Pointillize, Patchwork, Stained Glass) fill their cells with.
pub(super) struct Soft {
colour: Vec<Px16>,
alpha: Vec<u8>,
}
impl Soft {
pub(super) fn new(px: &[[u8; 4]], w: usize, sigma: f64) -> Self {
let mut colour = to16(px);
gaussian(&mut colour, w, sigma);
Self { colour, alpha: px.iter().map(|p| p[3]).collect() }
}
/// The colour around pixel `k` (premultiplied, 0..1), as opaque as the object is at `k`; clear
/// outside the raster.
pub(super) fn at(&self, k: Option<usize>) -> [f32; 4] {
let a = k.and_then(|k| self.alpha.get(k)).map_or(0.0, |a| f32::from(*a) / 255.0);
match k.and_then(|k| self.colour.get(k)) {
Some(p) if p[3] > 0 => {
let s = a / f32::from(p[3]);
[f32::from(p[0]) * s, f32::from(p[1]) * s, f32::from(p[2]) * s, a]
}
_ => [0.0; 4],
}
}
}
/// One of Pointillize's dots: where (in cells), its radius (document units), its stacking order
/// and its colour (premultiplied, 0..1).
#[derive(Clone, Copy)]
struct Dot {
at: Point,
radius: f64,
z: f64,
colour: [f32; 4],
}
/// The smallest dot radius and the spread above it, in cells.
const DOT_RADIUS: (f64, f64) = (0.45, 0.25);
/// Pointillize: a dot at one random point per `cell` × `cell` square, of random size around the
/// cell's, coloured by the (softened) object there, over a white canvas where the object is.
pub(super) fn pointillize(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, cell_size: f64) {
let Some(place) = Place::new(space, w, h) else { return };
if !(cell_size.is_finite() && cell_size > 0.0) {
return;
}
// Each dot takes the colour around its centre, not of a single pixel there, and is as opaque
// as the object at its centre (whole dots on the object, none beside it).
let soft = Soft::new(px, w, cell_size / place.px / 4.0);
let dot = |i: i64, j: i64| {
let at = point_in(2, i, j);
let colour = soft.at(place.index(Point::new(at.x * cell_size, at.y * cell_size)));
Dot { at, radius: (DOT_RADIUS.0 + DOT_RADIUS.1 * rand(2, i, j, 2)) * cell_size, z: rand(2, i, j, 3), colour }
};
let mut around = Around::new();
place.each(px, |x, y, out| {
let q = place.at(x, y);
let (u, v) = (q.x / cell_size, q.y / cell_size);
let (Some(i), Some(j)) = (cell(u), cell(v)) else { return };
// The dots covering the pixel (only the 3 × 3 cells around it reach it), bottom first.
let mut hits: [(f64, f32, [f32; 4]); 9] = [(0.0, 0.0, [0.0; 4]); 9];
let mut n = 0;
for k in 0..9 {
let Some(d) = around.get((i, j), k, dot) else { continue };
let dist = (d.at.x - u).hypot(d.at.y - v) * cell_size;
let cover = ((d.radius - dist) / place.px + 0.5).clamp(0.0, 1.0) as f32;
if cover > 0.0
&& d.colour[3] > 0.0
&& let Some(slot) = hits.get_mut(n)
{
*slot = (d.z, cover, d.colour);
n += 1;
}
}
let hits = hits.get_mut(..n).unwrap_or_default();
hits.sort_by(|a, b| a.0.total_cmp(&b.0));
let alpha = f32::from(out[3]) / 255.0;
let mut acc = [alpha; 4];
for (_, cover, c) in hits.iter() {
let keep = 1.0 - c[3] * cover;
for (a, v) in acc.iter_mut().zip(c) {
*a = v * cover + *a * keep;
}
}
let a = (acc[3].clamp(0.0, 1.0) * 255.0).round();
let v = |c: f32| (c.clamp(0.0, 1.0) * 255.0).round().min(a) as u8;
*out = [v(acc[0]), v(acc[1]), v(acc[2]), a as u8];
});
}
/// A pixel's colour as the channels the Pixelate filters treat apart (straight, 0..1; see
/// [`Channels`]), and how many of the four there are.
fn split(p: [u8; 4], channels: Channels) -> ([f32; 4], usize) {
let a = f32::from(p[3]);
let [r, g, b] = [p[0], p[1], p[2]].map(|v| if a > 0.0 { (f32::from(v) / a).min(1.0) } else { 0.0 });
match channels {
Channels::Rgb => ([r, g, b, 0.0], 3),
Channels::Cmyk => {
// A light black generation, as separations print: black ink takes over in the
// shadows, coloured inks build the midtones.
let k = (1.0 - r.max(g).max(b)).powi(2);
let ink = |v: f32| if k < 1.0 { ((1.0 - v - k) / (1.0 - k)).clamp(0.0, 1.0) } else { 0.0 };
([ink(r), ink(g), ink(b), k], 4)
}
Channels::CmyPlane => ([1.0 - r, 1.0 - g, 1.0 - b, 0.0], 3),
Channels::KPlane => ([1.0 - r, 0.0, 0.0, 0.0], 1),
}
}
/// The pixel of channel values `v` ([`split`]'s) at coverage `alpha` (premultiplied).
fn join(v: [f32; 4], channels: Channels, alpha: u8) -> [u8; 4] {
let [a, b, c, d] = v.map(|x| x.clamp(0.0, 1.0));
let rgb = match channels {
Channels::Rgb => [a, b, c],
Channels::Cmyk => [(1.0 - a) * (1.0 - d), (1.0 - b) * (1.0 - d), (1.0 - c) * (1.0 - d)],
Channels::CmyPlane => [1.0 - a, 1.0 - b, 1.0 - c],
Channels::KPlane => [1.0 - a; 3],
};
let al = f32::from(alpha);
let [r, g, b] = rgb.map(|x| (x * al).round().clamp(0.0, al) as u8);
[r, g, b, alpha]
}
/// Which of Color Halftone's four screen angles channel `i` of `channels` uses: the K plane's
/// only channel is the fourth (black) one.
fn angle_of(channels: Channels, i: usize) -> usize {
if channels == Channels::KPlane { 3 } else { i }
}
/// The share of a `1` × `1` square that a circle of radius `rho` around its centre covers.
fn circle_in_square(rho: f64) -> f64 {
let half = 0.5;
if rho <= half {
std::f64::consts::PI * rho * rho
} else if rho < std::f64::consts::FRAC_1_SQRT_2 {
// The circle less the four caps beyond the square's sides.
let cap = rho * rho * (half / rho).acos() - half * (rho * rho - half * half).sqrt();
std::f64::consts::PI * rho * rho - 4.0 * cap
} else {
1.0
}
}
/// The radius (in cells) of the dot that covers share `v` of its cell.
fn dot_radius(v: f64) -> f64 {
const STEPS: usize = 256;
static TABLE: OnceLock<[f64; STEPS + 1]> = OnceLock::new();
let table = TABLE.get_or_init(|| {
std::array::from_fn(|k| {
let target = k as f64 / STEPS as f64;
let (mut lo, mut hi) = (0.0, std::f64::consts::FRAC_1_SQRT_2);
for _ in 0..48 {
let mid = 0.5 * (lo + hi);
if circle_in_square(mid) < target {
lo = mid;
} else {
hi = mid;
}
}
hi
})
});
let x = if v.is_finite() { v.clamp(0.0, 1.0) * STEPS as f64 } else { 0.0 };
let k = (x.floor() as usize).min(STEPS - 1);
let (a, b) = (table.get(k).copied().unwrap_or(0.0), table.get(k + 1).copied().unwrap_or(0.0));
a + (b - a) * (x - k as f64)
}
/// One channel's halftone screen: square cells at an angle, each with the dot of its mean value.
struct Screen {
/// Document coordinates relative to the centre → cells.
to_cells: Affine,
/// The first cell's indices and the grid's size in cells.
origin: (i64, i64),
size: (usize, usize),
/// Per cell: the channel's sum (× coverage) and the coverage's (then the dot's radius in
/// document units, in `sums[k][0]`).
sums: Vec<[f32; 2]>,
}
impl Screen {
/// The screen at `angle`° with cells `side` wide over the raster `place` covers; `None` when
/// it would have more than `limit` cells.
fn new(place: &Place, angle: f64, side: f64, limit: usize) -> Option<Self> {
let to_cells = Affine::scale(1.0 / side) * Affine::rotate(angle.to_radians());
let corners = [(0.0, 0.0), (place.w as f64, 0.0), (0.0, place.h as f64), (place.w as f64, place.h as f64)];
let pts = corners.map(|(x, y)| to_cells * (place.to_rel * Point::new(x, y)));
let (lo, hi) = pts.iter().fold(((f64::INFINITY, f64::INFINITY), (f64::NEG_INFINITY, f64::NEG_INFINITY)), |(lo, hi), p| {
((lo.0.min(p.x), lo.1.min(p.y)), (hi.0.max(p.x), hi.1.max(p.y)))
});
let (i0, j0, i1, j1) = (cell(lo.0)?.checked_sub(1)?, cell(lo.1)?.checked_sub(1)?, cell(hi.0)?.checked_add(1)?, cell(hi.1)?.checked_add(1)?);
let gw = usize::try_from(i1.checked_sub(i0)?.checked_add(1)?).ok()?;
let gh = usize::try_from(j1.checked_sub(j0)?.checked_add(1)?).ok()?;
(gw.checked_mul(gh)? <= limit).then(|| Self { to_cells, origin: (i0, j0), size: (gw, gh), sums: vec![[0.0; 2]; gw * gh] })
}
/// The index of cell (`i`, `j`) in the grid.
fn slot(&self, i: i64, j: i64) -> Option<usize> {
let x = usize::try_from(i.checked_sub(self.origin.0)?).ok().filter(|x| *x < self.size.0)?;
let y = usize::try_from(j.checked_sub(self.origin.1)?).ok().filter(|y| *y < self.size.1)?;
Some(y * self.size.0 + x)
}
}
/// Color Halftone: each channel screened at its angle in square cells sized so that a dot of
/// `max_radius` fills one; each cell's dot covers the share of it that is the channel's mean
/// there (white stays white, black black). Coverage stays as it is.
pub(super) fn color_halftone(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, max_radius: f64, angles: [f64; 4]) {
let Some(place) = Place::new(space, w, h) else { return };
let side = max_radius * std::f64::consts::SQRT_2;
// Cells under 3 pixels can't show their dots: on average a screen is the image itself.
if !(side.is_finite() && side / place.px >= 3.0) {
return;
}
let channels = space.channels;
let n = split([0; 4], channels).1;
let limit = w.saturating_mul(h).saturating_add(4096);
let mut screens = Vec::with_capacity(n);
for i in 0..n {
let angle = angles.get(angle_of(channels, i)).copied().unwrap_or(0.0);
let Some(s) = Screen::new(&place, angle, side, limit) else { return };
screens.push(s);
}
// Each cell's mean of each channel, weighted by coverage.
place.each(px, |x, y, p| {
if p[3] == 0 {
return;
}
let (v, _) = split(*p, channels);
let a = f32::from(p[3]) / 255.0;
let q = place.at(x, y);
for (s, v) in screens.iter_mut().zip(v) {
let c = s.to_cells * q;
if let (Some(i), Some(j)) = (cell(c.x), cell(c.y))
&& let Some(sum) = s.slot(i, j).and_then(|k| s.sums.get_mut(k))
{
sum[0] += v * a;
sum[1] += a;
}
}
});
for s in &mut screens {
for sum in &mut s.sums {
let mean = if sum[1] > 1e-6 { f64::from(sum[0] / sum[1]) } else { 0.0 };
// Full strength fills the cell, corners too; none leaves no speck.
let radius = match mean {
m if m >= 254.5 / 255.0 => f64::INFINITY,
m if m <= 0.5 / 255.0 => 0.0,
m => dot_radius(m) * side,
};
sum[0] = radius as f32;
}
}
place.each(px, |x, y, p| {
if p[3] == 0 {
return;
}
let q = place.at(x, y);
let mut v = [0.0f32; 4];
for (s, out) in screens.iter().zip(v.iter_mut()) {
let c = s.to_cells * q;
let (Some(i), Some(j)) = (cell(c.x), cell(c.y)) else { continue };
// A dot reaches past its cell into the neighbours on the pixel's side only.
let (si, sj) = (if c.x - (i as f64) < 0.5 { -1 } else { 1 }, if c.y - (j as f64) < 0.5 { -1 } else { 1 });
for (di, dj) in [(0, 0), (si, 0), (0, sj), (si, sj)] {
let (ci, cj) = (i.wrapping_add(di), j.wrapping_add(dj));
let radius = s.slot(ci, cj).and_then(|k| s.sums.get(k)).map_or(0.0, |sum| f64::from(sum[0]));
if radius <= 0.0 {
continue;
}
let dist = (c.x - (ci as f64 + 0.5)).hypot(c.y - (cj as f64 + 0.5)) * side;
*out = out.max(((radius - dist) / place.px + 0.5).clamp(0.0, 1.0) as f32);
}
}
*p = join(v, channels, p[3]);
});
}
/// Mezzotint: every channel turned fully on or off where it is above or below a random threshold
/// pattern (each channel its own), so the image becomes dots or streaks of pure colours.
pub(super) fn mezzotint(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, kind: Mezzotint) {
let Some(place) = Place::new(space, w, h) else { return };
let channels = space.channels;
place.each(px, |x, y, p| {
if p[3] == 0 {
return;
}
let q = place.at(x, y);
let (mut v, n) = split(*p, channels);
for (c, value) in v.iter_mut().enumerate().take(n) {
let t = threshold(kind, 16 + c as u64, q);
*value = if f64::from(*value) > t { 1.0 } else { 0.0 };
}
*p = join(v, channels, p[3]);
});
}
/// Mezzotint's threshold in [0, 1) at point `q` (document units around the centre) of pattern
/// `salt`.
fn threshold(kind: Mezzotint, salt: u64, q: Point) -> f64 {
let grain = |size: f64| match (cell(q.x / size), cell(q.y / size)) {
(Some(i), Some(j)) => rand(salt, i, j, 1),
_ => 0.5,
};
match kind {
Mezzotint::Dots { size, smooth: false, .. } => grain(f64::from(size)),
Mezzotint::Dots { size, grainy, .. } => {
let s = f64::from(size);
let blob = value_noise(salt, q.x / s, q.y / s);
if grainy { 0.5 * (blob + grain(1.0)) } else { blob }
}
// Blended along x only: whole rows.
Mezzotint::Lines { length, width } => value_noise(salt, q.x / f64::from(length), (q.y / f64::from(width)).floor()),
}
}
+561 -4
View File
@@ -5,9 +5,17 @@ use vectorcraft_geom::{Affine, Point, Rect};
use super::*;
use crate::{RasterFx, raster_effects};
/// A `w` × `h` raster whose pixels are document points (origin top-left).
/// A `w` × `h` raster whose pixels are document points (origin top-left), of an object that fills it.
fn space(w: usize, h: usize) -> PixelSpace {
PixelSpace { to_doc: Affine::IDENTITY, px: 1.0, center: Point::new(w as f64 / 2.0, h as f64 / 2.0) }
let (w, h) = (w as f64, h as f64);
PixelSpace {
to_doc: Affine::IDENTITY,
px: 1.0,
center: Point::new(w / 2.0, h / 2.0),
channels: Channels::Rgb,
line: 1.0,
extent: 0.5 * w.hypot(h),
}
}
fn image(w: usize, h: usize, f: impl Fn(usize, usize) -> [u8; 4]) -> Vec<u8> {
@@ -224,7 +232,14 @@ fn lengths_are_document_units() {
let edge = |scale: usize| image(40 * scale, 4, move |x, _| grey(if x < 20 * scale { 100 } else { 150 }));
let run = |scale: usize| {
let mut d = edge(scale);
let s = PixelSpace { to_doc: Affine::scale(1.0 / scale as f64), px: 1.0 / scale as f64, center: Point::ZERO };
let s = PixelSpace {
to_doc: Affine::scale(1.0 / scale as f64),
px: 1.0 / scale as f64,
center: Point::ZERO,
channels: Channels::Rgb,
line: 1.0,
extent: 0.0,
};
fx("sharpen.unsharpMask", json!({"amount": 100, "radius": 2})).apply(&mut d, 40 * scale, 4, &s);
(0..40 * scale).filter(|x| at(&d, 40 * scale, *x, 2)[0] < 99).count()
};
@@ -253,7 +268,7 @@ fn gaussian_and_plane_blurs_keep_their_mass() {
fn pixel_timings() {
let (w, h) = (2000, 2000);
let src = image(w, h, |x, y| [(x % 256) as u8, (y % 256) as u8, ((x ^ y) % 256) as u8, 255]);
let s = PixelSpace { to_doc: Affine::IDENTITY, px: 1.0, center: Point::new(1000.0, 1000.0) };
let s = PixelSpace { to_doc: Affine::IDENTITY, px: 1.0, center: Point::new(1000.0, 1000.0), channels: Channels::Rgb, line: 1.0, extent: 0.0 };
let cases = [
("blur.radial", json!({"quality": "draft"})),
("blur.radial", json!({"quality": "good"})),
@@ -261,6 +276,20 @@ fn pixel_timings() {
("blur.smart", json!({"quality": "low"})),
("blur.smart", json!({"quality": "high", "radius": 30})),
("sharpen.unsharpMask", json!({"radius": 20})),
("pixelate.colorHalftone", json!({"maxRadius": 4})),
("pixelate.crystallize", json!({"cellSize": 3})),
("pixelate.crystallize", json!({"cellSize": 300})),
("pixelate.mezzotint", json!({"type": "grainyDots"})),
("pixelate.pointillize", json!({"cellSize": 3})),
("pixelate.pointillize", json!({"cellSize": 300})),
("texture.craquelure", json!({"crackSpacing": 2})),
("texture.craquelure", json!({"crackSpacing": 100})),
("texture.grain", json!({"grainType": "clumped"})),
("texture.mosaicTiles", json!({"tileSize": 2})),
("texture.patchwork", json!({"squareSize": 0})),
("texture.stainedGlass", json!({"cellSize": 2})),
("texture.texturizer", json!({"texture": "sandstone", "scaling": 50})),
("texture.texturizer", json!({"texture": "burlap"})),
];
for (id, p) in cases {
let mut d = src.clone();
@@ -269,3 +298,531 @@ fn pixel_timings() {
println!("{id} {p}: {:?}", t.elapsed());
}
}
#[test]
fn pixelate_params_take_defaults_and_stay_in_range() {
assert_eq!(fx("pixelate.colorHalftone", json!({})), PixelFx::ColorHalftone { max_radius: 8.0, angles: [108.0, 162.0, 90.0, 45.0] });
assert_eq!(
fx("pixelate.colorHalftone", json!({"maxRadius": 1e9, "channel1": -1e9, "channel2": 30, "channel4": "x"})),
PixelFx::ColorHalftone { max_radius: 127.0, angles: [-360.0, 30.0, 90.0, 45.0] }
);
assert_eq!(fx("pixelate.colorHalftone", json!({"maxRadius": 0})), PixelFx::ColorHalftone { max_radius: 4.0, angles: [108.0, 162.0, 90.0, 45.0] });
assert_eq!(fx("pixelate.crystallize", json!({})), PixelFx::Crystallize { cell: 10.0 });
assert_eq!(fx("pixelate.crystallize", json!({"cellSize": -4})), PixelFx::Crystallize { cell: 3.0 });
assert_eq!(fx("pixelate.pointillize", json!({})), PixelFx::Pointillize { cell: 5.0 });
assert_eq!(fx("pixelate.pointillize", json!({"cellSize": 1e300})), PixelFx::Pointillize { cell: 300.0 });
assert_eq!(fx("pixelate.mezzotint", json!({})), PixelFx::Mezzotint { kind: Mezzotint::FINE_DOTS });
assert_eq!(fx("pixelate.mezzotint", json!({"type": "LONGSTROKES"})), PixelFx::Mezzotint { kind: Mezzotint::Lines { length: 24, width: 2 } });
for junk in [json!({"type": 7}), json!({"type": "nonsense"})] {
assert_eq!(fx("pixelate.mezzotint", junk), PixelFx::Mezzotint { kind: Mezzotint::FINE_DOTS });
}
// Every Type is a pattern of its own, documented in the catalogue.
let doc = crate::effect_info("pixelate.mezzotint").unwrap().params;
let kinds: Vec<Mezzotint> = MEZZOTINT_TYPES.iter().map(|(_, v)| Mezzotint::parse(v)).collect();
for (i, (_, v)) in MEZZOTINT_TYPES.iter().enumerate() {
assert!(doc.contains(&format!("\"{v}\"")), "{v}");
assert!(kinds.iter().skip(i + 1).all(|k| *k != kinds[i]), "{v}");
}
// Dots and crystals reach beyond the object; screens and grain stay on it.
let b = Rect::new(0.0, 0.0, 100.0, 50.0);
assert_eq!(fx("pixelate.crystallize", json!({"cellSize": 20})).outset(b), 30.0);
assert_eq!(fx("pixelate.pointillize", json!({"cellSize": 20})).outset(b), 30.0);
assert_eq!(fx("pixelate.colorHalftone", json!({})).outset(b), 0.0);
assert_eq!(fx("pixelate.mezzotint", json!({})).reach(), Some(0.0));
}
/// Fraction of `d`'s pixels for which `f` holds.
fn share(d: &[u8], f: impl Fn(&[u8; 4]) -> bool) -> f64 {
let px = d.as_chunks::<4>().0;
px.iter().filter(|p| f(p)).count() as f64 / px.len() as f64
}
#[test]
fn color_halftone_screens_midtones_into_dots_and_keeps_white_and_black() {
let (w, h) = (64, 64);
let halftone = fx("pixelate.colorHalftone", json!({}));
for v in [0, 255] {
let mut d = image(w, h, |_, _| grey(v));
halftone.apply(&mut d, w, h, &space(w, h));
assert_eq!(d, image(w, h, |_, _| grey(v)), "{v} stays");
}
// Mid grey: each channel's dots cover about half its screen, so the tone stays.
let mut d = image(w, h, |_, _| grey(128));
halftone.apply(&mut d, w, h, &space(w, h));
let px = d.as_chunks::<4>().0;
for c in 0..3 {
let mean = px.iter().map(|p| f64::from(p[c])).sum::<f64>() / px.len() as f64;
assert!((mean - 128.0).abs() < 16.0, "channel {c}: {mean}");
}
assert!(share(&d, |p| p[0] == 255) > 0.25 && share(&d, |p| p[0] == 0) > 0.25, "dots, not grey");
assert!(share(&d, |p| p[0] != p[1]) > 0.1, "the channels' screens lie at different angles");
assert!(px.iter().all(|p| p[3] == 255), "coverage stays");
// Transparency stays transparent.
let mut half = image(w, h, |x, _| if x < 32 { grey(160) } else { [0; 4] });
halftone.apply(&mut half, w, h, &space(w, h));
assert!((0..h).all(|y| (32..w).all(|x| at(&half, w, x, y) == [0; 4])));
// Dots are bigger where the image is brighter.
let mut ramp = image(w, h, |x, _| grey((x * 4) as u8));
halftone.apply(&mut ramp, w, h, &space(w, h));
let lit = |x0: usize| (0..h).flat_map(|y| (x0..x0 + 16).map(move |x| (x, y))).filter(|(x, y)| at(&ramp, w, *x, *y)[0] > 127).count();
assert!(lit(8) < lit(40), "{} < {}", lit(8), lit(40));
}
#[test]
fn color_halftone_screens_inks_in_cmyk_documents() {
let (w, h) = (64, 64);
let halftone = fx("pixelate.colorHalftone", json!({}));
let cmyk = PixelSpace { channels: Channels::Cmyk, ..space(w, h) };
// A neutral grey prints with all four inks: dots of each on white paper, black where they
// all overlap.
let mut d = image(w, h, |_, _| grey(128));
halftone.apply(&mut d, w, h, &cmyk);
assert!(share(&d, |p| *p == grey(255)) > 0.02 && share(&d, |p| *p == grey(0)) > 0.02);
for (ink, colour) in [("cyan", [0, 255, 255, 255]), ("magenta", [255, 0, 255, 255]), ("yellow", [255, 255, 0, 255])] {
assert!(share(&d, |p| *p == colour) > 0.0, "{ink} dots alone");
}
// White paper stays bare; pure cyan ink fills its cells.
let mut white = image(w, h, |_, _| grey(255));
halftone.apply(&mut white, w, h, &cmyk);
assert_eq!(white, image(w, h, |_, _| grey(255)));
let mut cyan = image(w, h, |_, _| [0, 255, 255, 255]);
halftone.apply(&mut cyan, w, h, &cmyk);
assert_eq!(cyan, image(w, h, |_, _| [0, 255, 255, 255]));
// Ink planes: the complemented K plane screens one grey channel at the black angle (45°,
// the same in every quarter turn).
let mut k = image(w, h, |_, _| grey(128));
halftone.apply(&mut k, w, h, &PixelSpace { channels: Channels::KPlane, ..space(w, h) });
assert!(k.as_chunks::<4>().0.iter().all(|p| p[0] == p[1] && p[1] == p[2]), "one grey channel");
assert!(share(&k, |p| p[0] == 255) > 0.25 && share(&k, |p| p[0] == 0) > 0.25);
let mut turned = image(w, h, |_, _| grey(128));
fx("pixelate.colorHalftone", json!({"channel4": 135})).apply(&mut turned, w, h, &PixelSpace { channels: Channels::KPlane, ..space(w, h) });
let mut other = image(w, h, |_, _| grey(128));
fx("pixelate.colorHalftone", json!({"channel4": 30})).apply(&mut other, w, h, &PixelSpace { channels: Channels::KPlane, ..space(w, h) });
assert_eq!(turned, k, "channel 4 sets the K plane's angle");
assert_ne!(other, k);
}
#[test]
fn crystallize_redraws_in_solid_polygons() {
let (w, h) = (48, 48);
let crystals = fx("pixelate.crystallize", json!({"cellSize": 6}));
// One colour stays that colour (away from the raster's edges, where crystals around points
// beyond it take the transparency there).
let mut d = image(w, h, |_, _| [200, 100, 50, 255]);
crystals.apply(&mut d, w, h, &space(w, h));
assert!((9..39).all(|y| (9..39).all(|x| at(&d, w, x, y) == [200, 100, 50, 255])));
// Two halves: every pixel takes one of the colours, and the border between them zigzags.
let (red, blue) = ([200, 0, 0, 255], [0, 0, 200, 255]);
let src = image(w, h, |x, _| if x < 24 { red } else { blue });
let mut d = src.clone();
crystals.apply(&mut d, w, h, &space(w, h));
assert!((9..39).all(|y| (9..39).all(|x| [red, blue].contains(&at(&d, w, x, y)))));
let crossed = (9..39).flat_map(|y| (9..39).map(move |x| (x, y))).filter(|(x, y)| at(&d, w, *x, *y) != at(&src, w, *x, *y)).count();
assert!(crossed > 10, "{crossed}");
let border: std::collections::BTreeSet<usize> = (9..39).map(|y| (9..39).find(|x| at(&d, w, *x, y) == blue).unwrap()).collect();
assert!(border.len() > 2, "{border:?}");
}
#[test]
fn mezzotint_turns_channels_fully_on_or_off() {
let (w, h) = (64, 64);
let grey_square = image(w, h, |x, y| if (8..56).contains(&x) && (8..56).contains(&y) { grey(128) } else { [0; 4] });
let mut seen = Vec::new();
for (_, kind) in MEZZOTINT_TYPES {
let f = fx("pixelate.mezzotint", json!({ "type": kind }));
for v in [0, 255] {
let mut d = image(w, h, |_, _| grey(v));
f.apply(&mut d, w, h, &space(w, h));
assert_eq!(d, image(w, h, |_, _| grey(v)), "{kind}: {v} stays");
}
let mut d = grey_square.clone();
f.apply(&mut d, w, h, &space(w, h));
let px = d.as_chunks::<4>().0;
assert!(px.iter().all(|p| p[..3].iter().all(|c| *c == 0 || *c == p[3])), "{kind}: pure colours");
let on = px.iter().filter(|p| p[3] > 0 && p[0] > 0).count() as f64 / (48.0 * 48.0);
assert!((0.25..0.75).contains(&on), "{kind}: {on}");
assert!(px.iter().any(|p| p[3] > 0 && p[0] != p[1]), "{kind}: each channel its own pattern");
assert!(d.iter().zip(&grey_square).skip(3).step_by(4).all(|(a, b)| a == b), "{kind}: coverage stays");
assert!(!seen.contains(&d), "{kind}: a pattern of its own");
seen.push(d);
}
}
#[test]
fn pointillize_paints_dots_on_a_white_canvas() {
let (w, h) = (80, 80);
let red = [200, 0, 0, 255];
let src = image(w, h, |x, y| if (20..60).contains(&x) && (20..60).contains(&y) { red } else { [0; 4] });
let mut d = src.clone();
fx("pixelate.pointillize", json!({})).apply(&mut d, w, h, &space(w, h));
let inside: Vec<[u8; 4]> = (26..54).flat_map(|y| (26..54).map(move |x| (x, y))).map(|(x, y)| at(&d, w, x, y)).collect();
let canvas = inside.iter().filter(|p| **p == grey(255)).count();
let dots = inside.iter().filter(|p| **p == red).count();
assert!(canvas > inside.len() / 20 && dots > inside.len() / 3, "{canvas} canvas, {dots} dots of {}", inside.len());
assert!(inside.iter().all(|p| p[3] == 255 && p[1] == p[2] && p[0] >= p[1]), "red over white only");
// Dots at the edges reach a little past the object; far from it stays transparent.
assert!((0..h).any(|y| (14..20).any(|x| at(&d, w, x, y)[3] > 0)));
assert!((0..h).all(|y| (0..10).all(|x| at(&d, w, x, y) == [0; 4])));
assert!(d.as_chunks::<4>().0.iter().all(|p| p[0] <= p[3] && p[1] <= p[3] && p[2] <= p[3]));
}
/// The patterns lie in document space around the object's centre: the same at twice the
/// resolution, and moving with the object.
#[test]
fn pixelate_patterns_follow_the_object_at_any_resolution() {
let (w, h) = (40, 40);
let art = |x: f64, y: f64| -> [u8; 4] {
if (8.0..32.0).contains(&x) && (8.0..32.0).contains(&y) { [(x * 6.0) as u8, (y * 6.0) as u8, 120, 255] } else { [0; 4] }
};
for (id, p) in [
("pixelate.colorHalftone", json!({"maxRadius": 4})),
("pixelate.crystallize", json!({"cellSize": 4})),
("pixelate.mezzotint", json!({"type": "coarseDots"})),
("pixelate.pointillize", json!({"cellSize": 4})),
] {
let f = fx(id, p);
let mut one = image(w, h, |x, y| art(x as f64 + 0.5, y as f64 + 0.5));
f.apply(&mut one, w, h, &space(w, h));
// Twice the pixels for the same art, averaged back down.
let mut two = image(2 * w, 2 * h, |x, y| art((x as f64 + 0.5) / 2.0, (y as f64 + 0.5) / 2.0));
let fine = PixelSpace { to_doc: Affine::scale(0.5), px: 0.5, ..space(w, h) };
f.apply(&mut two, 2 * w, 2 * h, &fine);
let down = image(w, h, |x, y| {
let q = [(0, 0), (1, 0), (0, 1), (1, 1)].map(|(dx, dy)| at(&two, 2 * w, 2 * x + dx, 2 * y + dy));
std::array::from_fn(|c| (q.iter().map(|p| u32::from(p[c])).sum::<u32>() / 4) as u8)
});
let diff = one.iter().zip(&down).map(|(a, b)| f64::from(a.abs_diff(*b))).sum::<f64>() / one.len() as f64;
assert!(diff < 24.0, "{id}: {diff}");
// The object and its raster 7 × 3 points further: the same pixels.
let moved = PixelSpace { to_doc: Affine::translate((7.0, 3.0)), center: Point::new(27.0, 23.0), ..space(w, h) };
let mut shifted = image(w, h, |x, y| art(x as f64 + 0.5, y as f64 + 0.5));
f.apply(&mut shifted, w, h, &moved);
assert_eq!(shifted, one, "{id}");
}
}
/// Video › De-Interlace: lines 1, 3, 5… (or 2, 4, 6…) are made again from the others, by copying
/// the line above or by averaging the lines above and below; the lines are the document's raster
/// rows, whatever the raster's own resolution.
#[test]
fn deinterlace_remakes_one_field_from_the_other() {
let (w, h) = (2, 6);
// Line n (from 1) is grey 40 n.
let src = image(w, h, |_, y| grey(40 * (y as u8 + 1)));
let run = |p: serde_json::Value, s: &PixelSpace| {
let mut d = src.clone();
fx("video.deinterlace", p).apply(&mut d, w, h, s);
(0..h).map(|y| at(&d, w, 1, y)[0]).collect::<Vec<_>>()
};
let s = space(w, h);
// Odd lines (1, 3, 5) copy the line above; line 1 has none, so it takes line 2.
assert_eq!(run(json!({}), &s), [80, 80, 80, 160, 160, 240]);
assert_eq!(run(json!({"eliminate": "even"}), &s), [40, 40, 120, 120, 200, 200]);
// Interpolated: the average of the lines around (one side at the edges).
assert_eq!(run(json!({"eliminate": "even", "create": "interpolation"}), &s), [40, 80, 120, 160, 200, 200]);
// Lines two pixels tall: lines 1 and 3 (pixels 0–1 and 4–5) copy their neighbour, row by row.
let coarse = PixelSpace { line: 2.0, ..space(w, h) };
assert_eq!(run(json!({}), &coarse), [120, 160, 120, 160, 120, 160]);
// A damaged space changes nothing.
assert_eq!(run(json!({}), &PixelSpace { line: f64::NAN, ..space(w, h) }), [40, 80, 120, 160, 200, 240]);
}
/// Video › NTSC Colors: saturated yellow and cyan lose saturation (their composite signal is too
/// strong) but keep their brightness; greys, dark colours and transparency stay as they are.
#[test]
fn ntsc_colors_tame_only_colours_too_strong_for_the_signal() {
let (w, h) = (5, 1);
let px = [[255, 255, 0, 255], [0, 255, 255, 255], [128, 128, 128, 255], [100, 20, 20, 255], [0, 0, 0, 0]];
let mut d: Vec<u8> = px.concat();
fx("video.ntscColors", json!({})).apply(&mut d, w, h, &space(w, h));
let luma = |p: [u8; 4]| 0.299 * f64::from(p[0]) + 0.587 * f64::from(p[1]) + 0.114 * f64::from(p[2]);
for (x, &a) in px.iter().enumerate() {
let b = at(&d, w, x, 0);
if x >= 2 {
assert_eq!(b, a, "{x}: unchanged");
continue;
}
let spread = |p: [u8; 4]| p[..3].iter().max().unwrap() - p[..3].iter().min().unwrap();
assert!(spread(b) < spread(a), "{x}: less saturated {b:?}");
assert!((luma(a) - luma(b)).abs() < 3.0, "{x}: as bright {a:?} {b:?}");
}
}
#[test]
fn texture_params_take_defaults_and_stay_in_range() {
assert_eq!(fx("texture.craquelure", json!({})), PixelFx::Craquelure { spacing: 15.0, depth: 6.0, brightness: 9.0 });
assert_eq!(
fx("texture.craquelure", json!({"crackSpacing": 1e9, "crackDepth": -1, "crackBrightness": "x"})),
PixelFx::Craquelure { spacing: 100.0, depth: 0.0, brightness: 9.0 }
);
assert_eq!(fx("texture.grain", json!({})), PixelFx::Grain { intensity: 40.0, contrast: 50.0, kind: Grain::Regular });
assert_eq!(
fx("texture.grain", json!({"intensity": 1e300, "contrast": -5, "grainType": "SPECKLE"})),
PixelFx::Grain { intensity: 100.0, contrast: 0.0, kind: Grain::Speckle }
);
assert_eq!(fx("texture.mosaicTiles", json!({})), PixelFx::MosaicTiles { tile: 12.0, grout: 3.0, lighten: 9.0 });
assert_eq!(
fx("texture.mosaicTiles", json!({"tileSize": 0, "groutWidth": 99, "lightenGrout": 11})),
PixelFx::MosaicTiles { tile: 2.0, grout: 15.0, lighten: 10.0 }
);
assert_eq!(fx("texture.patchwork", json!({})), PixelFx::Patchwork { square: 4.0, relief: 8.0 });
assert_eq!(fx("texture.patchwork", json!({"squareSize": -1, "relief": 26})), PixelFx::Patchwork { square: 0.0, relief: 25.0 });
assert_eq!(fx("texture.stainedGlass", json!({})), PixelFx::StainedGlass { cell: 10.0, border: 4.0, light: 3.0 });
assert_eq!(
fx("texture.stainedGlass", json!({"cellSize": 1, "borderThickness": 21, "lightIntensity": "1e999"})),
PixelFx::StainedGlass { cell: 2.0, border: 20.0, light: 3.0 }
);
let canvas = PixelFx::Texturizer { texture: Texture::Canvas, scaling: 1.0, relief: 4.0, light: Light::Top, invert: false };
assert_eq!(fx("texture.texturizer", json!({})), canvas);
assert_eq!(
fx("texture.texturizer", json!({"texture": "Brick", "scaling": 10, "relief": 60, "lightDirection": "bottomRight", "invert": true})),
PixelFx::Texturizer { texture: Texture::Brick, scaling: 0.5, relief: 50.0, light: Light::BottomRight, invert: true }
);
assert_eq!(
fx("texture.texturizer", json!({"texture": 3, "lightDirection": "up", "scaling": 1e9})),
PixelFx::Texturizer { texture: Texture::Canvas, scaling: 2.0, relief: 4.0, light: Light::Top, invert: false }
);
assert_eq!(fx("texture.grain", json!({"grainType": ["x"]})), fx("texture.grain", json!({})));
// Every menu value is documented in the catalogue and parses to a choice of its own.
for (id, key, table) in [
("texture.grain", "grainType", GRAIN_TYPES.as_slice()),
("texture.texturizer", "texture", TEXTURES.as_slice()),
("texture.texturizer", "lightDirection", LIGHT_DIRECTIONS.as_slice()),
] {
let doc = crate::effect_info(id).unwrap().params;
let parsed: Vec<PixelFx> = table.iter().map(|(_, v)| fx(id, json!({ key: v }))).collect();
for (i, (_, v)) in table.iter().enumerate() {
assert!(doc.contains(&format!("\"{v}\"")), "{id} {v}");
assert!(parsed.iter().skip(i + 1).all(|p| *p != parsed[i]), "{id} {v}");
}
}
// Panes and squares reach beyond the object; the surfaces stay on it.
let b = Rect::new(0.0, 0.0, 100.0, 50.0);
assert_eq!(fx("texture.stainedGlass", json!({"cellSize": 20})).outset(b), 30.0);
assert_eq!(fx("texture.patchwork", json!({"squareSize": 0})).outset(b), 1.0);
for id in ["texture.craquelure", "texture.grain", "texture.mosaicTiles", "texture.texturizer"] {
assert_eq!(fx(id, json!({})).outset(b), 0.0, "{id}");
}
assert_eq!(fx("texture.grain", json!({})).reach(), Some(0.0));
assert_eq!(fx("texture.craquelure", json!({"crackSpacing": 12})).reach(), Some(6.0));
}
/// The pixels of `d` (`w` wide) in the square `lo..hi` on both axes.
fn square(d: &[u8], w: usize, lo: usize, hi: usize) -> Vec<[u8; 4]> {
(lo..hi).flat_map(|y| (lo..hi).map(move |x| (x, y))).map(|(x, y)| at(d, w, x, y)).collect()
}
/// A `w`-square raster of one colour.
fn flat(w: usize, c: [u8; 4]) -> Vec<u8> {
image(w, w, |_, _| c)
}
#[test]
fn craquelure_cracks_plates_and_contours() {
let w = 96;
let tan = [180, 150, 110, 255];
let mut d = flat(w, tan);
fx("texture.craquelure", json!({"crackBrightness": 10})).apply(&mut d, w, w, &space(w, w));
let px = square(&d, w, 16, 80);
let dark = px.iter().filter(|p| p[0] < 120).count() as f64 / px.len() as f64;
assert!((0.03..0.4).contains(&dark), "a network of dark cracks: {dark}");
assert!(px.iter().all(|p| p[3] == 255 && p[0] >= p[1] && p[1] >= p[2]), "the colour stays, darkened or lit");
assert!(px.iter().any(|p| p[0] > 182), "plate edges catch the light");
// Without depth there are no cracks and no relief; brightness lights the surface.
let mut flat_lit = flat(w, tan);
fx("texture.craquelure", json!({"crackDepth": 0, "crackBrightness": 10})).apply(&mut flat_lit, w, w, &space(w, w));
assert_eq!(flat_lit, flat(w, tan));
let mut dim = flat(w, tan);
fx("texture.craquelure", json!({"crackDepth": 0, "crackBrightness": 0})).apply(&mut dim, w, w, &space(w, w));
assert_eq!(at(&dim, w, 40, 40), [90, 75, 55, 255]);
// A dark half and a light half: a crack follows the contour between them, straight down (a
// dark line between lighter pixels in nearly every row), where one tone has none.
let craquelure = fx("texture.craquelure", json!({"crackSpacing": 100, "crackBrightness": 10}));
let rows_cracked = |src: Vec<u8>| {
let mut d = src;
craquelure.apply(&mut d, w, w, &space(w, w));
let v = |x: usize, y: usize| i32::from(at(&d, w, x, y)[0]);
(20..76).filter(|y| (26..35).any(|x| v(x, *y) + 8 < v(x - 2, *y) && v(x, *y) + 8 < v(x + 2, *y))).count()
};
let (contour, plain) = (rows_cracked(image(w, w, |x, _| grey(if x < 30 { 60 } else { 200 }))), rows_cracked(flat(w, grey(200))));
assert!(contour >= 48 && contour >= plain + 25, "{contour} rows cracked along the contour, {plain} without one");
}
#[test]
fn grain_types_each_add_their_own_noise() {
let w = 48;
let mid = grey(128);
let mut seen: Vec<Vec<u8>> = Vec::new();
for (_, kind) in GRAIN_TYPES {
let mut d = flat(w, mid);
fx("texture.grain", json!({ "grainType": kind })).apply(&mut d, w, w, &space(w, w));
assert_ne!(d, flat(w, mid), "{kind} adds grain");
assert!(d.as_chunks::<4>().0.iter().all(|p| p[3] == 255), "{kind}: coverage stays");
assert!(!seen.contains(&d), "{kind}: a grain of its own");
seen.push(d);
}
// Regular grain is 1-point grains of every colour around the image's own.
let mut d = flat(w, mid);
fx("texture.grain", json!({})).apply(&mut d, w, w, &space(w, w));
let px = d.as_chunks::<4>().0;
let mean = px.iter().map(|p| f64::from(p[0])).sum::<f64>() / px.len() as f64;
assert!((mean - 128.0).abs() < 8.0, "{mean}");
assert!(px.iter().any(|p| p[0] != p[1]), "coloured grains");
// Horizontal grain runs in streaks along the rows.
let mut d = flat(w, mid);
fx("texture.grain", json!({"grainType": "horizontal", "intensity": 100})).apply(&mut d, w, w, &space(w, w));
let along = (0..w - 1).map(|x| u32::from(at(&d, w, x, 10)[0].abs_diff(at(&d, w, x + 1, 10)[0]))).sum::<u32>();
let across = (0..w - 1).map(|y| u32::from(at(&d, w, 10, y)[0].abs_diff(at(&d, w, 10, y + 1)[0]))).sum::<u32>();
assert!(along * 3 < across, "{along} along, {across} across");
// No intensity at neutral contrast leaves the image; contrast spreads its tones.
let ramp = image(w, w, |x, _| grey((x * 5) as u8));
let mut d = ramp.clone();
fx("texture.grain", json!({"intensity": 0})).apply(&mut d, w, w, &space(w, w));
assert_eq!(d, ramp);
fx("texture.grain", json!({"intensity": 0, "contrast": 100})).apply(&mut d, w, w, &space(w, w));
assert!(at(&d, w, 2, 0)[0] < at(&ramp, w, 2, 0)[0] && at(&d, w, 45, 0)[0] > at(&ramp, w, 45, 0)[0]);
// Sprinkles are the background colour, white.
let mut d = flat(w, [0, 0, 160, 255]);
fx("texture.grain", json!({"grainType": "sprinkles", "intensity": 100})).apply(&mut d, w, w, &space(w, w));
assert!(d.as_chunks::<4>().0.iter().any(|p| p[0] > 200 && p[2] > 240));
}
#[test]
fn mosaic_tiles_lay_tiles_in_light_grout() {
let w = 96;
let blue = [40, 60, 160, 255];
let mut d = flat(w, blue);
fx("texture.mosaicTiles", json!({})).apply(&mut d, w, w, &space(w, w));
let px = square(&d, w, 8, 88);
let grout = px.iter().filter(|p| p[0] > 170).count() as f64 / px.len() as f64;
assert!((0.08..0.5).contains(&grout), "light grout between the tiles: {grout}");
assert!(px.iter().filter(|p| **p == blue).count() > px.len() / 10, "tile faces keep the colour");
// Dark grout without lightening.
let mut d = flat(w, blue);
fx("texture.mosaicTiles", json!({"lightenGrout": 0})).apply(&mut d, w, w, &space(w, w));
let px = square(&d, w, 8, 88);
let grout = px.iter().filter(|p| p[2] < 100).count() as f64 / px.len() as f64;
assert!((0.08..0.5).contains(&grout) && px.iter().all(|p| p[0] < 170), "dark grout: {grout}");
// Transparency around the object stays.
let mut d = image(w, w, |x, _| if x < 48 { blue } else { [0; 4] });
fx("texture.mosaicTiles", json!({})).apply(&mut d, w, w, &space(w, w));
assert!((0..w).all(|y| (48..w).all(|x| at(&d, w, x, y) == [0; 4])));
}
#[test]
fn patchwork_fills_squares_with_one_colour_in_relief() {
let w = 64;
let src = image(w, w, |x, y| [(x * 4) as u8, (y * 4) as u8, 90, 255]);
let mut d = src.clone();
fx("texture.patchwork", json!({"squareSize": 8, "relief": 0})).apply(&mut d, w, w, &space(w, w));
// Squares of 8 points (the object's centre is a corner), each one colour.
for (i, j) in [(1, 1), (2, 5), (6, 3)] {
let first = at(&d, w, 8 * i, 8 * j);
assert!((0..8).all(|y| (0..8).all(|x| at(&d, w, 8 * i + x, 8 * j + y) == first)), "square {i} {j}");
}
assert_ne!(at(&d, w, 8, 8), at(&d, w, 16, 8));
// In relief, the squares' sides catch the light from the top left and fall into shade.
let mut lit = src.clone();
fx("texture.patchwork", json!({"squareSize": 8, "relief": 25})).apply(&mut lit, w, w, &space(w, w));
let (face, top, bottom) = (at(&lit, w, 28, 28)[1], at(&lit, w, 28, 24)[1], at(&lit, w, 28, 31)[1]);
assert!(top > face && bottom < face, "{top} {face} {bottom}");
// Squares around the object's edge are drawn whole.
let mut d = image(w, w, |x, _| if x < 30 { [200, 0, 0, 255] } else { [0; 4] });
fx("texture.patchwork", json!({"squareSize": 8, "relief": 0})).apply(&mut d, w, w, &space(w, w));
assert!((0..w).all(|y| at(&d, w, 31, y)[3] == 255 && at(&d, w, 33, y)[3] == 0));
}
#[test]
fn stained_glass_leads_panes_and_lights_the_centre() {
let w = 96;
let src = image(w, w, |x, y| [(40 + x) as u8, (40 + y) as u8, 120, 255]);
let mut d = src.clone();
fx("texture.stainedGlass", json!({"lightIntensity": 0})).apply(&mut d, w, w, &space(w, w));
let px = square(&d, w, 16, 80);
let lead = px.iter().filter(|p| p[0] == 0 && p[1] == 0 && p[2] == 0).count() as f64 / px.len() as f64;
assert!((0.1..0.6).contains(&lead), "black lead: {lead}");
let same = (16..80).flat_map(|y| (16..79).map(move |x| (x, y))).filter(|(x, y)| at(&d, w, *x, *y) == at(&d, w, x + 1, *y)).count();
assert!(same * 5 > 2 * 64 * 63, "single-coloured panes: {same}");
assert!(px.iter().all(|p| p[3] == 255));
// The light shines through the middle, not the corners.
let mut lit = src.clone();
fx("texture.stainedGlass", json!({"lightIntensity": 10})).apply(&mut lit, w, w, &space(w, w));
let brighter = |x: usize, y: usize| i32::from(at(&lit, w, x, y)[2]) - i32::from(at(&d, w, x, y)[2]);
let centre = (44..52).flat_map(|y| (44..52).map(move |x| (x, y))).map(|(x, y)| brighter(x, y)).max().unwrap();
let corner = (0..6).flat_map(|y| (0..6).map(move |x| (x, y))).map(|(x, y)| brighter(x, y)).max().unwrap();
assert!(centre > 40 && corner * 3 < centre, "{centre} at the centre, {corner} in the corner");
// Panes beyond the object stay clear; lead outlines it.
let mut d = image(w, w, |x, y| if (24..72).contains(&x) && (24..72).contains(&y) { [200, 40, 40, 255] } else { [0; 4] });
fx("texture.stainedGlass", json!({})).apply(&mut d, w, w, &space(w, w));
assert!((0..6).all(|y| (0..w).all(|x| at(&d, w, x, y) == [0; 4])));
assert!(d.as_chunks::<4>().0.iter().all(|p| p[0] <= p[3] && p[1] <= p[3] && p[2] <= p[3]));
}
#[test]
fn texturizer_lights_each_surface_from_its_side() {
let w = 64;
let mid = grey(150);
let run = |p: serde_json::Value| {
let mut d = flat(w, mid);
fx("texture.texturizer", p).apply(&mut d, w, w, &space(w, w));
d
};
let mut seen: Vec<Vec<u8>> = Vec::new();
for (_, texture) in TEXTURES {
let d = run(json!({ "texture": texture, "relief": 20 }));
assert_ne!(d, flat(w, mid), "{texture}");
assert!(!seen.contains(&d), "{texture}: a surface of its own");
// Inverted, the lit slopes are the shaded ones, and the other way round.
let inv = run(json!({ "texture": texture, "relief": 20, "invert": true }));
let shift = |d: &[u8]| d.as_chunks::<4>().0.iter().map(|p| i32::from(p[0]) - 150).collect::<Vec<_>>();
let (a, b) = (shift(&d), shift(&inv));
let opposite = a.iter().zip(&b).filter(|(a, b)| a.abs() > 4 && b.abs() > 4 && a.signum() != b.signum()).count();
let lit_or_shaded = a.iter().filter(|a| a.abs() > 4).count();
assert!(lit_or_shaded > 100 && opposite * 10 > lit_or_shaded * 8, "{texture}: {opposite} of {lit_or_shaded}");
// From the other side as well.
assert_ne!(run(json!({ "texture": texture, "relief": 20, "lightDirection": "bottom" })), d, "{texture}");
// No relief, no change.
assert_eq!(run(json!({ "texture": texture, "relief": 0 })), flat(w, mid), "{texture}");
seen.push(d);
}
// Scaling enlarges the surface: 200 % at 2 points a pixel draws as 100 % at 1.
let mut big = flat(w, mid);
let coarse = PixelSpace { to_doc: Affine::scale(2.0), px: 2.0, center: Point::new(64.0, 64.0), ..space(w, w) };
fx("texture.texturizer", json!({"texture": "brick", "scaling": 200, "relief": 20})).apply(&mut big, w, w, &coarse);
assert_eq!(big, run(json!({"texture": "brick", "relief": 20})));
}
/// The Texture filters' patterns lie in document space around the object's centre: the same at
/// twice the resolution, and moving with the object.
#[test]
fn texture_patterns_follow_the_object_at_any_resolution() {
let (w, h) = (48, 48);
let art = |x: f64, y: f64| -> [u8; 4] {
if (8.0..40.0).contains(&x) && (8.0..40.0).contains(&y) { [(x * 5.0) as u8, (y * 5.0) as u8, 120, 255] } else { [0; 4] }
};
for (id, p) in [
("texture.craquelure", json!({"crackSpacing": 8})),
("texture.grain", json!({"grainType": "enlarged"})),
("texture.mosaicTiles", json!({"tileSize": 8, "groutWidth": 2})),
("texture.patchwork", json!({"squareSize": 6})),
("texture.stainedGlass", json!({"cellSize": 8, "borderThickness": 2})),
("texture.texturizer", json!({"texture": "sandstone", "relief": 10})),
] {
let f = fx(id, p);
let mut one = image(w, h, |x, y| art(x as f64 + 0.5, y as f64 + 0.5));
f.apply(&mut one, w, h, &space(w, h));
let mut two = image(2 * w, 2 * h, |x, y| art((x as f64 + 0.5) / 2.0, (y as f64 + 0.5) / 2.0));
let fine = PixelSpace { to_doc: Affine::scale(0.5), px: 0.5, ..space(w, h) };
f.apply(&mut two, 2 * w, 2 * h, &fine);
let down = image(w, h, |x, y| {
let q = [(0, 0), (1, 0), (0, 1), (1, 1)].map(|(dx, dy)| at(&two, 2 * w, 2 * x + dx, 2 * y + dy));
std::array::from_fn(|c| (q.iter().map(|p| u32::from(p[c])).sum::<u32>() / 4) as u8)
});
let diff = one.iter().zip(&down).map(|(a, b)| f64::from(a.abs_diff(*b))).sum::<f64>() / one.len() as f64;
assert!(diff < 12.0, "{id}: {diff}");
let moved = PixelSpace { to_doc: Affine::translate((7.0, 3.0)), center: Point::new(31.0, 27.0), ..space(w, h) };
let mut shifted = image(w, h, |x, y| art(x as f64 + 0.5, y as f64 + 0.5));
f.apply(&mut shifted, w, h, &moved);
assert_eq!(shifted, one, "{id}");
}
}
+576
View File
@@ -0,0 +1,576 @@
//! Texture › Craquelure, Grain, Mosaic Tiles, Patchwork, Stained Glass and Texturizer.
//!
//! Like the Pixelate filters' patterns, theirs (cracks, grain, tiles, squares, panes and the
//! Texturizer's surfaces, all generated here, no image files) lie in document space around the
//! object's centre: the same at any zoom or resolution, moving with the object, and the same on
//! every redraw (hash noise of the pattern's cell indices). Surfaces in relief are height fields
//! whose slopes, taken in document units, are lit from one side.
use std::f64::consts::{FRAC_1_SQRT_2, PI};
use vectorcraft_geom::{Point, Vec2};
use super::pixelate::{AROUND, Around, Place, Soft, cell, gap, point_in, rand, value_noise};
use super::{PixelSpace, blur_plane, pick};
/// Grain's types, in its Grain Type menu's order: (label, parameter value).
pub const GRAIN_TYPES: [(&str, &str); 10] = [
("Regular", "regular"),
("Soft", "soft"),
("Sprinkles", "sprinkles"),
("Clumped", "clumped"),
("Contrasty", "contrasty"),
("Enlarged", "enlarged"),
("Stippled", "stippled"),
("Horizontal", "horizontal"),
("Vertical", "vertical"),
("Speckle", "speckle"),
];
/// A Grain type ([`GRAIN_TYPES`]).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Grain {
Regular,
Soft,
Sprinkles,
Clumped,
Contrasty,
Enlarged,
Stippled,
Horizontal,
Vertical,
Speckle,
}
impl Grain {
const ALL: [Grain; 10] = [
Grain::Regular,
Grain::Soft,
Grain::Sprinkles,
Grain::Clumped,
Grain::Contrasty,
Grain::Enlarged,
Grain::Stippled,
Grain::Horizontal,
Grain::Vertical,
Grain::Speckle,
];
/// The type of Grain Type `value` (a [`GRAIN_TYPES`] value, any case); Regular otherwise.
pub fn parse(value: &str) -> Self {
pick(&GRAIN_TYPES, &Self::ALL, value, Grain::Regular)
}
}
/// Texturizer's textures, in its Texture menu's order: (label, parameter value).
pub const TEXTURES: [(&str, &str); 4] = [("Brick", "brick"), ("Burlap", "burlap"), ("Canvas", "canvas"), ("Sandstone", "sandstone")];
/// A Texturizer surface ([`TEXTURES`]), made in code.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Texture {
Brick,
Burlap,
Canvas,
Sandstone,
}
impl Texture {
const ALL: [Texture; 4] = [Texture::Brick, Texture::Burlap, Texture::Canvas, Texture::Sandstone];
/// The texture of `value` (a [`TEXTURES`] value, any case); Canvas otherwise.
pub fn parse(value: &str) -> Self {
pick(&TEXTURES, &Self::ALL, value, Texture::Canvas)
}
/// The surface's height at `p` (points at 100 %), in points: slopes of about 1 at most.
fn height(self, p: Point) -> f64 {
match self {
Texture::Brick => brick(p),
Texture::Burlap => weave(p, 7.0, true),
Texture::Canvas => weave(p, 4.0, false),
Texture::Sandstone => sandstone(p),
}
}
}
/// Texturizer's light directions, in its Light menu's order: (label, parameter value).
pub const LIGHT_DIRECTIONS: [(&str, &str); 8] = [
("Bottom", "bottom"),
("Bottom Left", "bottomLeft"),
("Left", "left"),
("Top Left", "topLeft"),
("Top", "top"),
("Top Right", "topRight"),
("Right", "right"),
("Bottom Right", "bottomRight"),
];
/// Where a surface is lit from ([`LIGHT_DIRECTIONS`]), on the page.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Light {
Bottom,
BottomLeft,
Left,
TopLeft,
Top,
TopRight,
Right,
BottomRight,
}
impl Light {
const ALL: [Light; 8] =
[Light::Bottom, Light::BottomLeft, Light::Left, Light::TopLeft, Light::Top, Light::TopRight, Light::Right, Light::BottomRight];
/// The direction of `value` (a [`LIGHT_DIRECTIONS`] value, any case); Top otherwise.
pub fn parse(value: &str) -> Self {
pick(&LIGHT_DIRECTIONS, &Self::ALL, value, Light::Top)
}
/// The unit vector towards the light (document axes, y down).
fn toward(self) -> Vec2 {
let d = FRAC_1_SQRT_2;
match self {
Light::Bottom => Vec2::new(0.0, 1.0),
Light::BottomLeft => Vec2::new(-d, d),
Light::Left => Vec2::new(-1.0, 0.0),
Light::TopLeft => Vec2::new(-d, -d),
Light::Top => Vec2::new(0.0, -1.0),
Light::TopRight => Vec2::new(d, -d),
Light::Right => Vec2::new(1.0, 0.0),
Light::BottomRight => Vec2::new(d, d),
}
}
}
/// Seeds of the Texture filters' patterns (Pixelate's use smaller salts).
const SALT_CRACKS: u64 = 64;
const SALT_GRAIN: u64 = 72;
const SALT_TILES: u64 = 88;
const SALT_PATCH: u64 = 96;
const SALT_GLASS: u64 = 104;
const SALT_SURFACE: u64 = 112;
/// How soft Craquelure's tones are, as a share of the crack spacing: their contours become cracks.
pub(super) const CONTOUR_SOFTNESS: f64 = 1.0 / 6.0;
/// A pixel's straight colour (0..1).
fn straight(p: [u8; 4]) -> [f32; 3] {
let a = f32::from(p[3]);
[p[0], p[1], p[2]].map(|v| if a > 0.0 { (f32::from(v) / a).min(1.0) } else { 0.0 })
}
/// The pixel of straight colour `c` at coverage `alpha` (0..1), premultiplied.
fn premultiply(c: [f32; 3], alpha: f32) -> [u8; 4] {
let a = (alpha.clamp(0.0, 1.0) * 255.0).round();
let [r, g, b] = c.map(|v| (v.clamp(0.0, 1.0) * a).round().min(a) as u8);
[r, g, b, a as u8]
}
/// Colour `c` lit by `s`: darkened towards black below 0, lightened towards white above.
fn lit(c: [f32; 3], s: f64) -> [f32; 3] {
let s = if s.is_finite() { s.clamp(-1.0, 1.0) as f32 } else { 0.0 };
c.map(|v| if s < 0.0 { v * (1.0 + s) } else { v + (1.0 - v) * s })
}
/// How a surface whose height rises along `slope` (height per length) faces a light from
/// `toward`: 0 flat, positive facing it, negative turned away.
fn facing(slope: Vec2, toward: Vec2) -> f64 {
-slope.dot(toward)
}
/// The share of a pixel `px` wide that lies within `d` of a line (negative `d`: beyond it).
fn cover(d: f64, px: f64) -> f64 {
(d / px + 0.5).clamp(0.0, 1.0)
}
/// The luma of colour `c` (Rec. 601 weights).
fn luma(c: [f32; 3]) -> f32 {
0.299 * c[0] + 0.587 * c[1] + 0.114 * c[2]
}
/// The two random points nearest to (`u`, `v`) (in cells), `point(i, j)` being cell (`i`, `j`)'s,
/// found among the cells [`AROUND`] the one holding it.
fn nearest_two(u: f64, v: f64, around: &mut Around<Point>, point: impl Fn(i64, i64) -> Point) -> Option<(Point, Point)> {
let (i, j) = (cell(u)?, cell(v)?);
let mut best: [(f64, Option<Point>); 2] = [(f64::INFINITY, None); 2];
for (k, (di, dj)) in AROUND.iter().enumerate() {
// A cell farther than the second point so far can't hold a nearer one.
if gap(i.wrapping_add(*di), u).hypot(gap(j.wrapping_add(*dj), v)) >= best[1].0 {
continue;
}
let Some(p) = around.get((i, j), k, &point) else { continue };
let d = (p.x - u).hypot(p.y - v);
if d < best[0].0 {
best = [(d, Some(p)), best[0]];
} else if d < best[1].0 {
best[1] = (d, Some(p));
}
}
Some((best[0].1?, best[1].1?))
}
/// How far (`u`, `v`) lies from the border between the cells of `near` and `next` (its nearest
/// points, [`nearest_two`]), and the way away from it (unit), in cells.
fn edge(u: f64, v: f64, near: Point, next: Point) -> (f64, Vec2) {
let q = Point::new(u, v);
let apart = near - next;
let len = apart.hypot();
if !(len.is_finite() && len > 1e-12) {
return (0.0, Vec2::ZERO);
}
(((q - next).hypot2() - (q - near).hypot2()) / (2.0 * len), apart / len)
}
/// The slope of a rise of 1 over `width` from a border at distance `d` (smooth at both ends).
fn bevel(d: f64, width: f64) -> f64 {
if !(width.is_finite() && width > 0.0) {
return 0.0;
}
let t = (d / width).clamp(0.0, 1.0);
6.0 * t * (1.0 - t)
}
/// The object's straight luminance (0..1) softened by a Gaussian of `sigma` pixels, weighted by
/// coverage so the transparency around it doesn't darken its edges.
struct Tones {
v: Vec<f32>,
w: usize,
h: usize,
}
impl Tones {
fn new(px: &[[u8; 4]], w: usize, h: usize, sigma: f64) -> Self {
let mut l: Vec<f32> = px.iter().map(|p| luma([p[0], p[1], p[2]].map(f32::from)) / 255.0).collect();
let mut a: Vec<f32> = px.iter().map(|p| f32::from(p[3]) / 255.0).collect();
blur_plane(&mut l, w, h, sigma);
blur_plane(&mut a, w, h, sigma);
let v = l.iter().zip(&a).map(|(l, a)| if *a > 1e-4 { (l / a).clamp(0.0, 1.0) } else { 0.0 }).collect();
Self { v, w, h }
}
fn get(&self, x: usize, y: usize) -> f64 {
let (x, y) = (x.min(self.w.saturating_sub(1)), y.min(self.h.saturating_sub(1)));
self.v.get(y * self.w + x).map_or(0.0, |v| f64::from(*v))
}
/// The tone at pixel (`x`, `y`) and its slope per document unit along the document's axes.
fn slope(&self, x: usize, y: usize, place: &Place) -> (f64, Vec2) {
let gx = 0.5 * (self.get(x + 1, y) - self.get(x.saturating_sub(1), y));
let gy = 0.5 * (self.get(x, y + 1) - self.get(x, y.saturating_sub(1)));
// Per pixel → per document unit: through the transpose of the document → pixel map.
let [a, b, c, d, _, _] = place.to_px.as_coeffs();
(self.get(x, y), Vec2::new(gx * a + gy * b, gx * c + gy * d))
}
}
/// Craquelure: the object painted on relief plaster that cracks into plates about `spacing` apart
/// and along the contours of its tones. `depth` (0..10) sets how deep and wide the cracks are and
/// how high the plates and the tones stand; `brightness` (0..10) how brightly the surface is lit.
pub(super) fn craquelure(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, spacing: f64, depth: f64, brightness: f64) {
let Some(place) = Place::new(space, w, h) else { return };
if !(spacing.is_finite() && spacing > 0.0) {
return;
}
let depth = (depth / 10.0).clamp(0.0, 1.0);
let light = (0.5 + 0.05 * brightness.clamp(0.0, 10.0)) as f32;
let soft = CONTOUR_SOFTNESS * spacing;
let tones = Tones::new(px, w, h, soft / place.px);
let half = (0.15 + 0.6 * depth).min(0.15 * spacing);
let rise = (3.0 * half).max(0.12 * spacing);
let toward = Light::TopLeft.toward();
let mut around = Around::new();
place.each(px, |x, y, p| {
if p[3] == 0 {
return;
}
let q = place.at(x, y);
let (u, v) = (q.x / spacing, q.y / spacing);
// The cracks between the plates; each plate's edge bevels down into them.
let (mut crack, mut slope) = (0.0, Vec2::ZERO);
if let Some((near, next)) = nearest_two(u, v, &mut around, |i, j| point_in(SALT_CRACKS, i, j)) {
let (e, away) = edge(u, v, near, next);
crack = cover(half - e * spacing, place.px);
slope = away * (0.6 * bevel(e * spacing - half, rise));
}
// The cracks along the tones' contours (quarter, half and three-quarter tone).
let (tone, grad) = tones.slope(x, y, &place);
let steep = grad.hypot();
if steep > 1e-4 {
let level = (tone * 4.0).round().clamp(1.0, 3.0) / 4.0;
crack = crack.max(cover(0.8 * half - (tone - level).abs() / steep, place.px));
}
slope += grad * (0.8 * soft);
let c = lit(straight(*p), 0.5 * depth * facing(slope, toward));
let dark = (1.0 - 0.85 * crack * depth) as f32;
*p = premultiply(c.map(|v| v * light * dark), f32::from(p[3]) / 255.0);
});
}
/// A random value in [-1, 1) for the `size`-point cell holding `q`; `k` picks one of several.
fn cell_noise(salt: u64, q: Point, size: f64, k: u64) -> f64 {
match (cell(q.x / size), cell(q.y / size)) {
(Some(i), Some(j)) => 2.0 * rand(salt, i, j, k) - 1.0,
_ => 0.0,
}
}
/// [`value_noise`] in [-1, 1) over cells `size` points wide.
fn smooth_noise(salt: u64, q: Point, size: f64) -> f64 {
2.0 * value_noise(salt, q.x / size, q.y / size) - 1.0
}
/// Grain: noise of `kind` over the object in 1-point grains (2 for Enlarged, clumps for Clumped),
/// as strong as `intensity` (0..100); `contrast` (0..100, 50 leaves it) sets the image's
/// contrast. Sprinkles and Stippled use the background colour, white.
pub(super) fn grain(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, intensity: f64, contrast: f64, kind: Grain) {
let Some(place) = Place::new(space, w, h) else { return };
let a = (intensity / 100.0).clamp(0.0, 1.0);
let k = (0.5 + contrast / 100.0).clamp(0.5, 1.5) as f32;
let s = SALT_GRAIN;
place.each(px, |x, y, p| {
if p[3] == 0 {
return;
}
let q = place.at(x, y);
let mut c = straight(*p).map(|v| (v - 0.5) * k + 0.5);
let chance = 0.5 * (cell_noise(s, q, 1.0, 3) + 1.0);
match kind {
Grain::Regular => add(&mut c, |ch| cell_noise(s, q, 1.0, ch), 0.5 * a),
Grain::Soft => add(&mut c, |ch| smooth_noise(s + ch, q, 1.0), 0.4 * a),
Grain::Clumped => add(&mut c, |ch| 0.65 * smooth_noise(s + ch, q, 3.0) + 0.35 * cell_noise(s, q, 1.0, ch), 0.6 * a),
Grain::Enlarged => add(&mut c, |ch| 0.5 * (smooth_noise(s + ch, q, 2.0) + cell_noise(s, q, 2.0, ch)), 0.5 * a),
Grain::Contrasty => {
c = c.map(|v| (v - 0.5) * 1.4 + 0.5);
add(&mut c, |_| cell_noise(s, q, 1.0, 4), 0.6 * a);
}
Grain::Horizontal | Grain::Vertical => {
// Streaks one point thick: a value per row (column) drifting along it.
let (along, across) = if kind == Grain::Horizontal { (q.x, q.y) } else { (q.y, q.x) };
let line = cell(across).unwrap_or(0);
let n = 0.6 * (2.0 * rand(s, 0, line, 6) - 1.0) + 0.4 * (2.0 * value_noise(s + 7, along / 12.0, line as f64) - 1.0);
add(&mut c, |_| n, 0.5 * a);
}
Grain::Sprinkles => {
if chance < 0.35 * a {
c = c.map(|v| v + (1.0 - v) * 0.9);
}
}
Grain::Speckle => {
if chance < 0.25 * a {
c = c.map(|v| v * 0.25);
} else {
add(&mut c, |_| cell_noise(s, q, 1.0, 5), 0.1 * a);
}
}
Grain::Stippled => {
// Dots of the colour where it is dark, the background between them.
let dots = f64::from(1.0 - luma(c).clamp(0.0, 1.0)) * (0.35 + 0.65 * a);
c = if chance < dots { c.map(|v| v * 0.6) } else { [1.0; 3] };
}
}
*p = premultiply(c, f32::from(p[3]) / 255.0);
});
}
/// Add noise `n(channel)` (-1..1) times `amount` to colour `c`.
fn add(c: &mut [f32; 3], n: impl Fn(u64) -> f64, amount: f64) {
for (ch, v) in c.iter_mut().enumerate() {
*v += (amount * n(ch as u64)) as f32;
}
}
/// A random point of cell (`i`, `j`) of pattern `salt` within `jitter` (0..1, a share of the
/// cell) of its centre, in cells.
fn jittered(salt: u64, i: i64, j: i64, jitter: f64) -> Point {
Point::new(i as f64 + 0.5 + jitter * (rand(salt, i, j, 0) - 0.5), j as f64 + 0.5 + jitter * (rand(salt, i, j, 1) - 0.5))
}
/// Mosaic Tiles: the object laid in irregular tiles about `tile` across, bevelled at their edges,
/// with sunken grout between them half a point wide per step of `grout` (1..15), as light as
/// `lighten` (0..10) says.
pub(super) fn mosaic_tiles(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, tile: f64, grout: f64, lighten: f64) {
let Some(place) = Place::new(space, w, h) else { return };
if !(tile.is_finite() && tile > 0.0 && grout.is_finite()) {
return;
}
let half = (0.25 * grout).clamp(0.0, 0.225 * tile);
let rise = (1.2 * half).max(0.08 * tile);
let lighten = (0.08 * lighten.clamp(0.0, 10.0)) as f32;
let toward = Light::TopLeft.toward();
let mut around = Around::new();
place.each(px, |x, y, p| {
if p[3] == 0 {
return;
}
let q = place.at(x, y);
let (u, v) = (q.x / tile, q.y / tile);
let Some((near, next)) = nearest_two(u, v, &mut around, |i, j| jittered(SALT_TILES, i, j, 0.5)) else { return };
let (e, away) = edge(u, v, near, next);
let e = e * tile;
let c = straight(*p);
let face = lit(c, 0.35 * facing(away * bevel(e - half, rise), toward));
// Sunk in shade, then lightened.
let sunk = c.map(|v| 0.5 * v + (1.0 - 0.5 * v) * lighten);
let g = cover(half - e, place.px) as f32;
let mut out = face;
for (o, s) in out.iter_mut().zip(sunk) {
*o = *o * (1.0 - g) + s * g;
}
*p = premultiply(out, f32::from(p[3]) / 255.0);
});
}
/// Patchwork: the object in squares `square` points across, each filled with the colour around
/// its centre and raised to a height of its own (brighter squares stand higher, each a little
/// more or less at random), its bevelled sides lit from the top left as strongly as `relief`
/// (0..25) says.
pub(super) fn patchwork(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, square: f64, relief: f64) {
let Some(place) = Place::new(space, w, h) else { return };
let side = square.max(1.0);
if !side.is_finite() {
return;
}
let soft = Soft::new(px, w, side / 3.0 / place.px);
let relief = (relief / 25.0).clamp(0.0, 1.0);
let rise = 0.25 * side;
let toward = Light::TopLeft.toward();
place.each(px, |x, y, p| {
let q = place.at(x, y);
let (Some(i), Some(j)) = (cell(q.x / side), cell(q.y / side)) else { return };
let (x0, y0) = (i as f64 * side, j as f64 * side);
let [r, g, b, alpha] = soft.at(place.index(Point::new(x0 + 0.5 * side, y0 + 0.5 * side)));
if alpha <= 0.0 {
*p = [0; 4];
return;
}
let c = [r, g, b].map(|v| v / alpha);
let height = relief * (0.35 + 0.65 * (0.5 * rand(SALT_PATCH, i, j, 0) + 0.5 * f64::from(luma(c))));
// The bevels rise inwards from each side.
let (fx, fy) = (q.x - x0, q.y - y0);
let ramp = |d: f64| cover(rise - d, place.px);
let slope = Vec2::new(ramp(fx) - ramp(side - fx), ramp(fy) - ramp(side - fy)) * (1.5 * height);
*p = premultiply(lit(c, 0.9 * facing(slope, toward)), alpha);
});
}
/// Stained Glass: the object as panes of single colours around random points about `cell_size`
/// apart, leaded in the foreground colour (black) half a point wide per step of `border` (1..20),
/// lit from behind at its centre as strongly as `light` (0..10) says, fading out towards its
/// corners.
pub(super) fn stained_glass(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, cell_size: f64, border: f64, light: f64) {
let Some(place) = Place::new(space, w, h) else { return };
if !(cell_size.is_finite() && cell_size > 0.0 && border.is_finite()) {
return;
}
let soft = Soft::new(px, w, cell_size / place.px / 4.0);
let half = (0.25 * border).clamp(0.0, 0.25 * cell_size);
let light = (light / 10.0).clamp(0.0, 1.0);
let extent = space.extent;
let mut around = Around::new();
place.each(px, |x, y, p| {
let q = place.at(x, y);
let (u, v) = (q.x / cell_size, q.y / cell_size);
let Some((near, next)) = nearest_two(u, v, &mut around, |i, j| point_in(SALT_GLASS, i, j)) else { return };
let pane = |at: Point| soft.at(place.index(Point::new(at.x * cell_size, at.y * cell_size)));
let (glass, other) = (pane(near), pane(next));
let (e, _) = edge(u, v, near, next);
let lead = cover(half - e * cell_size, place.px) as f32;
let glow = if extent.is_finite() && extent > 0.0 { light * (1.0 - (q.to_vec2().hypot() / extent).powi(2)).max(0.0) } else { 0.0 };
let [r, g, b, a] = glass;
let c = if a > 0.0 { lit([r, g, b].map(|v| v / a), 0.6 * glow) } else { [0.0; 3] };
// The lead holds the panes on both sides of it, as opaque as the more opaque one.
let alpha = a * (1.0 - lead) + a.max(other[3]) * lead;
let rgb = c.map(|v| v * a * (1.0 - lead));
*p = if alpha > 0.0 { premultiply(rgb.map(|v| v / alpha), alpha) } else { [0; 4] };
});
}
/// Brick in stretcher bond (bricks 20 × 8 points, 1.5-point mortar): each brick raised with
/// 1-point bevels and a little higher or lower than the next, rough faced, the mortar sandy.
fn brick(p: Point) -> f64 {
const LENGTH: f64 = 20.0;
const COURSE: f64 = 8.0;
const MORTAR: f64 = 1.5;
let Some(row) = cell(p.y / COURSE) else { return 0.0 };
let x = p.x + if row.rem_euclid(2) == 1 { 0.5 * LENGTH } else { 0.0 };
let Some(col) = cell(x / LENGTH) else { return 0.0 };
let (fx, fy) = (x - col as f64 * LENGTH, p.y - row as f64 * COURSE);
let edge = fx.min(LENGTH - fx).min(fy).min(COURSE - fy) - 0.5 * MORTAR;
if edge <= 0.0 {
return 0.2 * value_noise(SALT_SURFACE, p.x / 0.6, p.y / 0.6);
}
edge.min(1.0) + 0.4 * rand(SALT_SURFACE, col, row, 2) + 0.3 * value_noise(SALT_SURFACE + 1, p.x / 1.5, p.y / 1.5)
}
/// A plain weave of threads `period` points apart, each passing over one thread and under the
/// next; `coarse` threads (burlap) wander and are hairy, fine ones (canvas) are even.
fn weave(p: Point, period: f64, coarse: bool) -> f64 {
let (wander, hair) = if coarse { (0.35, 0.35) } else { (0.08, 0.12) };
let drift = |salt: u64, t: f64| wander * (2.0 * value_noise(salt, t / (4.0 * period), 0.5) - 1.0);
let x = p.x / period + drift(SALT_SURFACE + 3, p.y);
let y = p.y / period + drift(SALT_SURFACE + 4, p.x);
let (Some(i), Some(j)) = (cell(x), cell(y)) else { return 0.0 };
let (fx, fy) = (x - i as f64, y - j as f64);
// Each thread is round across and rises and falls along its length, over one crossing thread
// and under the next; the higher of the two threads shows.
let profile = |t: f64| (PI * t).sin().max(0.0).sqrt();
let over = if i.wrapping_add(j).rem_euclid(2) == 0 { 0.5 } else { -0.5 };
let down = profile(fx) * (0.5 + over * (PI * fy).sin());
let across = profile(fy) * (0.5 - over * (PI * fx).sin());
let thread = down.max(across);
let fibres = hair * value_noise(SALT_SURFACE + 5, p.x / (0.25 * period), p.y / (0.25 * period));
(thread + fibres) * period / PI
}
/// Sandstone: four octaves of smooth noise (8 down to 1 point) over a fine sandy grain.
fn sandstone(p: Point) -> f64 {
let (mut h, mut size, mut amp) = (0.0, 8.0, 1.6);
for octave in 0..4 {
h += amp * value_noise(SALT_SURFACE + 8 + octave, p.x / size, p.y / size);
size *= 0.5;
amp *= 0.5;
}
h + 0.06 * cell_noise(SALT_SURFACE + 12, p, 0.5, 0)
}
/// Texturizer: the object on a `texture` surface scaled by `scaling` (0.5..2), in relief as
/// strong as `relief` (0..50) and lit from `light`; `invert` turns the surface's heights over.
#[allow(clippy::too_many_arguments)] // the effect's five options
pub(super) fn texturizer(
px: &mut [[u8; 4]],
w: usize,
h: usize,
space: &PixelSpace,
texture: Texture,
scaling: f64,
relief: f64,
light: Light,
invert: bool,
) {
let Some(place) = Place::new(space, w, h) else { return };
if !(scaling.is_finite() && scaling > 0.0) {
return;
}
// Slopes over a quarter point of the texture, or half a pixel where pixels are larger.
let step = (0.25f64).max(0.5 * place.px / scaling);
let strength = 0.1 * relief.clamp(0.0, 50.0) * if invert { -1.0 } else { 1.0 };
let toward = light.toward();
place.each(px, |x, y, p| {
if p[3] == 0 {
return;
}
let q = place.at(x, y);
let t = Point::new(q.x / scaling, q.y / scaling);
if !(t.x.is_finite() && t.y.is_finite()) {
return;
}
let at = |dx: f64, dy: f64| texture.height(Point::new(t.x + dx, t.y + dy));
let (left, right, up, down) = (at(-step, 0.0), at(step, 0.0), at(0.0, -step), at(0.0, step));
let slope = Vec2::new(right - left, down - up) / (2.0 * step);
// Ridges catch some light from any side and hollows stay in shade, so the surface shows
// whichever way it runs.
let ridge = (at(0.0, 0.0) - 0.25 * (left + right + up + down)) / step;
*p = premultiply(lit(straight(*p), strength * (facing(slope, toward) + ridge)), f32::from(p[3]) / 255.0);
});
}
+74
View File
@@ -0,0 +1,74 @@
//! Effect › Video: De-Interlace and NTSC Colors.
use vectorcraft_geom::Point;
use super::PixelSpace;
/// The tallest field line De-Interlace reads across (document units): one pixel at the lowest
/// document raster resolution, 1 ppi.
pub(super) const MAX_LINE: f64 = 72.0;
/// De-Interlace: the odd (or, with `even`, the even) fields of the image are made again from the
/// others. The fields are the lines of the document's raster grid (each [`PixelSpace::line`]
/// tall, numbered from 1 at the top of the page): a line taken out becomes a copy of the line
/// above it (the one below at the top), or with `interpolate` the average of the two.
pub(super) fn deinterlace(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, even: bool, interpolate: bool) {
let line = space.line;
let to_px = space.to_doc.inverse();
if !(line.is_finite() && line > 0.0 && to_px.is_finite() && space.to_doc.is_finite()) {
return;
}
let src = px.to_vec();
// The pixel holding document point `q`.
let at = |q: Point| {
let p = to_px * q;
(p.x >= 0.0 && p.y >= 0.0 && p.x < w as f64 && p.y < h as f64).then(|| src.get(p.y as usize * w + p.x as usize).copied()).flatten()
};
for (i, out) in px.iter_mut().enumerate().take(w.saturating_mul(h)) {
let q = space.to_doc * Point::new((i % w) as f64 + 0.5, (i / w) as f64 + 0.5);
let k = (q.y / line).floor();
// Line k + 1: odd lines have an even k.
if !k.is_finite() || ((k as i64).rem_euclid(2) == 1) != even {
continue;
}
let (above, below) = (at(Point::new(q.x, q.y - line)), at(Point::new(q.x, q.y + line)));
let made = match (interpolate, above, below) {
(true, Some(a), Some(b)) => Some([0, 1, 2, 3].map(|c| (u16::from(a[c]) + u16::from(b[c])).div_ceil(2) as u8)),
(_, a, b) => a.or(b),
};
if let Some(v) = made {
*out = v;
}
}
}
/// NTSC Colors: colours a television's composite signal can't carry are made less saturated,
/// keeping their brightness. In YIQ, the luma plus the chroma's amplitude must stay within 110 %
/// of white and the luma minus it above −20 %; a colour past either has its chroma cut to fit.
pub(super) fn ntsc(px: &mut [[u8; 4]]) {
for p in px.iter_mut() {
let a = f32::from(p[3]) / 255.0;
if a <= 0.0 {
continue;
}
// Straight colour (the pixels are premultiplied).
let [r, g, b] = [p[0], p[1], p[2]].map(|v| (f32::from(v) / 255.0 / a).min(1.0));
let y = 0.299 * r + 0.587 * g + 0.114 * b;
let (i, q) = (0.596 * r - 0.274 * g - 0.322 * b, 0.211 * r - 0.523 * g + 0.312 * b);
let c = i.hypot(q);
let mut keep = 1.0f32;
if y + c > 1.1 {
keep = keep.min((1.1 - y) / c);
}
if y - c < -0.2 {
keep = keep.min((y + 0.2) / c);
}
if keep >= 1.0 || !keep.is_finite() {
continue;
}
let (i, q) = (i * keep.max(0.0), q * keep.max(0.0));
let rgb = [y + 0.956 * i + 0.621 * q, y - 0.272 * i - 0.647 * q, y - 1.106 * i + 1.703 * q];
let [r, g, b] = rgb.map(|v| (v.clamp(0.0, 1.0) * a * 255.0).round() as u8);
*p = [r, g, b, p[3]];
}
}
+3 -2
View File
@@ -197,14 +197,15 @@ pub(crate) fn geometry_effects(n: &Node) -> Vec<&Effect> {
}
/// Apply `fx` to every path under `art` as one piece: each effect takes the whole art's bounds as
/// its reference box.
/// its reference box and the art's seed (a random Transform moves the pieces together).
pub(crate) fn reshape_leaves(art: &mut Node, fx: &[&Effect]) {
let seed = art.id.0;
for e in fx {
let Some(bounds) = art.geometric_bounds() else { break };
let params = merged_params(&e.id, &e.params);
for_each_leaf(art, &mut |leaf| {
let Some((path, rule)) = leaf_geometry(leaf) else { return };
let out = apply_one(&e.id, &params, &path, bounds, &GeomContext::of(leaf));
let out = apply_one(&e.id, &params, &path, bounds, &GeomContext { seed, ..GeomContext::of(leaf) });
set_geometry(leaf, out, rule);
});
}
+41
View File
@@ -171,6 +171,47 @@ fn transform_copies_and_move() {
assert!(close(f.y0, 5.0, 1e-9));
}
#[test]
fn transform_scales_and_rotates_about_its_reference_point() {
// Bottom right (8) stays put while the square halves; the top left (0) while it rotates.
let r = run("distort.transform", json!({"scaleH": 50, "scaleV": 50, "reference": 8}), &square()).bounds().unwrap();
assert!(close(r.x0, 50.0, 1e-9) && close(r.y0, 50.0, 1e-9) && close(r.x1, 100.0, 1e-9) && close(r.y1, 100.0, 1e-9), "{r:?}");
let r = run("distort.transform", json!({"rotate": 90, "reference": 0}), &square()).bounds().unwrap();
assert!(close(r.x0, 0.0, 1e-9) && close(r.x1, 100.0, 1e-9) && close(r.y0, -100.0, 1e-9) && close(r.y1, 0.0, 1e-9), "{r:?}");
// Reflect X about the right edge's middle (5): a mirrored copy beside the original.
let out = run("distort.transform", json!({"reflectX": true, "reference": 5, "copies": 1}), &square());
assert_eq!(out.bounds().map(|b| (b.x0.round(), b.x1.round())), Some((0.0, 200.0)));
// A point off the grid clamps to it.
assert_eq!(run("distort.transform", json!({"scaleH": 50, "reference": 99}), &square()).bounds().map(|b| b.x0), Some(50.0));
}
#[test]
fn random_transform_is_stable_per_object_and_within_the_values() {
let b = Rect::new(0.0, 0.0, 100.0, 100.0);
let bounds = |params: Value, seed| {
apply_geometry_with(&[fx("distort.transform", params)], &square(), b, &GeomContext { seed, ..Default::default() }).bounds().unwrap()
};
let random = |seed| bounds(json!({"scaleH": 50, "moveH": 40, "random": true}), seed);
let first = random(7);
assert_eq!(random(7), first, "the same object always gets the same result");
assert_ne!(random(8), first, "another object varies its own way");
for seed in 0..50 {
let r = random(seed);
// Scale between 100 % and 50 %, move between 0 and 40 pt.
assert!(r.width() >= 50.0 - 1e-9 && r.width() <= 100.0 + 1e-9, "{r:?}");
assert!(r.center().x >= 50.0 - 1e-9 && r.center().x <= 90.0 + 1e-9, "{r:?}");
}
// Without Random the values apply in full whatever the seed.
let r = bounds(json!({"scaleH": 50, "moveH": 40}), 3);
assert!(close(r.width(), 50.0, 1e-9) && close(r.center().x, 90.0, 1e-9), "{r:?}");
}
#[test]
fn transform_copies_stop_before_they_overflow() {
let out = run("distort.transform", json!({"scaleH": 100000, "scaleV": 100000, "copies": 1000}), &square());
assert!(all_finite(&out) && out.subpaths.len() < 10, "{}", out.subpaths.len());
}
#[test]
fn pucker_bloat_moves_anchors_in_opposite_directions() {
let bloat = run("distort.puckerBloat", json!({"amount": 50}), &square());
+19 -1
View File
@@ -318,7 +318,22 @@ fn require_targets(s: &Session, p: &Value, cmd: &str) -> Result<Vec<NodeId>> {
/// One undo step (`label`) running `f` on each of `ids` with the item a fill (`fill`) or stroke
/// edit aimed at `item` changes there (`None`: the topmost one; text then edits its characters).
/// Nothing targeted is not an edit.
pub(crate) fn edit_items(
s: &mut Session,
ids: &[NodeId],
item: ItemTarget,
cmd: &str,
label: &str,
fill: bool,
f: impl FnMut(&mut Node, Option<usize>) -> Result<()>,
) -> Result<()> {
edit_items_then(s, ids, item, cmd, label, fill, f, |_, _| Ok(()))
}
/// [`edit_items`], then `after` on the ids `f` changed, inside the same undo step.
#[allow(clippy::too_many_arguments)]
pub(crate) fn edit_items_then(
s: &mut Session,
ids: &[NodeId],
item: ItemTarget,
@@ -326,17 +341,20 @@ pub(crate) fn edit_items(
label: &str,
fill: bool,
mut f: impl FnMut(&mut Node, Option<usize>) -> Result<()>,
mut after: impl FnMut(&mut vectorcraft_doc::Document, &[NodeId]) -> Result<()>,
) -> Result<()> {
if ids.is_empty() {
return Ok(());
}
s.edit(label, |d, _| {
let mut changed = Vec::new();
for id in ids {
let Some(n) = d.node_mut(*id) else { continue };
let index = item.resolve(&n.appearance, fill, cmd)?;
f(n, index)?;
changed.push(*id);
}
Ok(())
after(d, &changed)
})
}
+10 -4
View File
@@ -63,7 +63,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Brush Options…",
[],
None,
"{name, params?: {…fields to change}, newName?} edit a brush definition (strokes using it update) → {name}",
"{name, params?: {…fields to change (brush.get shows them; values outside their ranges are clamped). calligraphic: angle −180..180°, roundness 0..100%, size 0..1296 pt, variation: [angle°, roundness%, size pt], modes: [fixed|random|pressure for angle, roundness, size] (pressure goes from value − variation at the lightest pen pressure to value + variation at the heaviest). scatter: size [a, b] 1..10000%, spacing [a, b] 1..10000%, scatter [a, b] −1000..1000%, rotation [a, b] −180..180°, modes: [fixed|random|pressure for size, spacing, scatter, rotation] (fixed: a; random: between a and b; pressure: a at the lightest pen pressure to b at the heaviest), rotation_relative_to_path. art: width 1..1000%, scale: {mode: proportional|stretch|betweenGuides, start, end: 0..1 of the art length}, direction: leftToRight|rightToLeft|topToBottom|bottomToTop, flip_along, flip_across. pattern: scale 1..10000%, spacing 0..10000%, fit: stretch|addSpace|approximate, flip_along, flip_across. scatter, art and pattern: colorization: {method: none|tints|tintsAndShades|hueShift, key: colour (hueShift: the art colour that becomes the stroke colour)}}, newName?} edit a brush definition (strokes using it update) → {name}",
has_doc,
brush_options
),
@@ -383,12 +383,18 @@ fn brush_new(s: &mut Session, p: &Value) -> Result<Value> {
}
v[k] = serde_json::to_value(&art).unwrap_or_default();
}
let b: Brush = serde_json::from_value(v).map_err(|e| bad(C, format!("invalid brush: {e}")))?;
lib.push(b);
lib.push(parse_brush(C, v)?);
save_library(s, "New Brush", lib, None)?;
Ok(json!({ "name": name }))
}
/// A brush definition from command params, its values kept within their ranges.
fn parse_brush(cmd: &str, v: Value) -> Result<Brush> {
let mut b: Brush = serde_json::from_value(v).map_err(|e| bad(cmd, format!("invalid brush: {e}")))?;
b.sanitize();
Ok(b)
}
fn brush_delete(s: &mut Session, p: &Value) -> Result<Value> {
let name = name_param(p, "brush.delete")?.to_string();
let mut lib = brush::library(&s.doc()?.doc);
@@ -431,7 +437,7 @@ fn brush_options(s: &mut Session, p: &Value) -> Result<Value> {
v["name"] = json!(name);
}
v["type"] = json!(lib[i].kind.type_id());
lib[i] = serde_json::from_value(v).map_err(|e| bad(C, format!("invalid brush: {e}")))?;
lib[i] = parse_brush(C, v)?;
let out = lib[i].name.clone();
let rename = new_name.map(|nn| (name, Some(nn)));
save_library(s, "Brush Options", lib, rename)?;
+1
View File
@@ -181,6 +181,7 @@ fn color_settings(s: &mut Session, p: &Value) -> Result<Value> {
fn load_profile(_: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "color.loadProfile";
let path = str_param(p, "path").ok_or_else(|| bad(C, "missing `path`"))?;
crate::file_access::check_read(path).map_err(EngineError::Other)?;
let info = cms::load_icc_file(std::path::Path::new(path)).map_err(|e| bad(C, e.to_string()))?;
Ok(json!({"name": info.name, "kind": info.kind}))
}
+2 -1
View File
@@ -246,8 +246,9 @@ fn candidates(s: &Session, f: impl Fn(&Node) -> bool) -> Result<Vec<NodeId>> {
fn finish_same(s: &mut Session, cmd: &str, ids: Vec<NodeId>) -> Result<Value> {
let n = ids.len();
let from = s.doc()?.selection.objects.clone();
s.select(|_, sel| sel.set(ids))?;
s.doc_mut()?.last_selection_cmd = Some((cmd.to_string(), json!({})));
s.doc_mut()?.last_selection_cmd = Some((cmd.to_string(), json!({}), from));
Ok(json!({ "count": n }))
}
+60 -11
View File
@@ -6,7 +6,7 @@ use std::sync::Arc;
use kurbo::{ParamCurve, ParamCurveNearest};
use serde_json::{Value, json};
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::{Appearance, Document, Node, NodeId, NodeKind, Selection};
use vectorcraft_doc::{Appearance, Document, Node, NodeId, NodeKind, PressureProfile, Selection};
use vectorcraft_geom::hit::fill_contains;
use vectorcraft_geom::{Anchor, AnchorKind, BezPath, FillRule, PathData, Point, Rect, SubPath, Vec2};
use vectorcraft_pathops as po;
@@ -23,7 +23,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Pencil",
[],
None,
"{points: [[x,y]…], fidelity?: pt (1.5), closed?, style?: \"pencil\"|\"brush\", fill?: bool, extend?: {id, end: \"start\"|\"end\"}} fit a freehand stroke → {id}",
"{points: [[x,y]…] or [[x,y,pressure]…] (pen pressure 0..1: the stroke keeps it, for Calligraphic brushes with Pressure variation; a point without one presses fully), fidelity?: pt (1.5), closed?, style?: \"pencil\"|\"brush\", fill?: bool, extend?: {id, end: \"start\"|\"end\"}} fit a freehand stroke → {id}",
has_doc,
freehand
),
@@ -151,15 +151,21 @@ pub fn specs() -> Vec<CommandSpec> {
/// `[[x,y]…]` or `[{x,y}…]`; non-finite points are dropped.
fn points_param(p: &Value, key: &str, cmd: &str) -> Result<Vec<Point>> {
Ok(samples_param(p, key, cmd)?.into_iter().map(|(pt, _)| pt).collect())
}
/// [`points_param`] with each point's pen pressure, if it has one: `[[x,y,pressure]…]` or
/// `[{x,y,pressure}…]` (0..1).
fn samples_param(p: &Value, key: &str, cmd: &str) -> Result<Vec<(Point, Option<f64>)>> {
let a = p.get(key).and_then(Value::as_array).ok_or_else(|| bad(cmd, format!("missing array `{key}`")))?;
let pts: Vec<Point> = a
let pts: Vec<(Point, Option<f64>)> = a
.iter()
.filter_map(|v| match v {
Value::Array(xy) => Some(Point::new(xy.first()?.as_f64()?, xy.get(1)?.as_f64()?)),
Value::Object(_) => Some(Point::new(v.get("x")?.as_f64()?, v.get("y")?.as_f64()?)),
Value::Array(xy) => Some((Point::new(xy.first()?.as_f64()?, xy.get(1)?.as_f64()?), xy.get(2).and_then(Value::as_f64))),
Value::Object(_) => Some((Point::new(v.get("x")?.as_f64()?, v.get("y")?.as_f64()?), v.get("pressure").and_then(Value::as_f64))),
_ => None,
})
.filter(|p| p.x.is_finite() && p.y.is_finite())
.filter(|(p, _)| p.x.is_finite() && p.y.is_finite())
.collect();
if pts.is_empty() {
return Err(bad(cmd, "need at least one point"));
@@ -167,6 +173,31 @@ fn points_param(p: &Value, key: &str, cmd: &str) -> Result<Vec<Point>> {
Ok(pts)
}
/// The pen pressure along a freehand stroke (positions measured along its polyline), when any of
/// its samples has one; samples without one count as full pressure, as a pen event without one.
fn stroke_pressure(samples: &[(Point, Option<f64>)]) -> Option<PressureProfile> {
if samples.iter().all(|(_, p)| p.is_none()) {
return None;
}
let mut at = 0.0;
let mut prev: Option<Point> = None;
let along: Vec<(f64, f64)> = samples
.iter()
.map(|(pt, p)| {
at += prev.map_or(0.0, |q| q.distance(*pt));
prev = Some(*pt);
(at, p.unwrap_or(1.0))
})
.collect();
PressureProfile::from_samples(along.into_iter().map(|(s, p)| (if at > 1e-9 { s / at } else { 0.0 }, p)))
}
/// The length of a subpath.
fn subpath_len(sp: &SubPath) -> f64 {
use kurbo::ParamCurveArclen;
PathData::single(sp.clone()).to_bezpath().segments().map(|s| s.arclen(0.01)).sum()
}
fn usize_req(p: &Value, key: &str, cmd: &str) -> Result<usize> {
p.get(key).and_then(Value::as_u64).map(|v| v as usize).ok_or_else(|| bad(cmd, format!("missing integer `{key}`")))
}
@@ -501,7 +532,9 @@ fn fit_freehand(pts: &[Point], tol: f64, closed: bool) -> SubPath {
fn freehand(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "path.freehand";
let mut pts = points_param(p, "points", C)?;
let samples = samples_param(p, "points", C)?;
let pressure = stroke_pressure(&samples);
let mut pts: Vec<Point> = samples.into_iter().map(|(pt, _)| pt).collect();
let tol = f64_or(p, "fidelity", 1.5).clamp(0.05, 100.0);
let closed = bool_or(p, "closed", false);
let brush = str_param(p, "style") == Some("brush");
@@ -511,6 +544,7 @@ fn freehand(s: &mut Session, p: &Value) -> Result<Value> {
let (pd, _) = path_of(&s.doc()?.doc, id)?;
let si = pd.subpaths.iter().rposition(|s| !s.closed && !s.anchors.is_empty()).ok_or_else(|| bad(C, "path has no open end"))?;
let mut sp = pd.subpaths[si].clone();
let old_len = subpath_len(&sp);
if at_start {
sp.reverse();
}
@@ -547,12 +581,24 @@ fn freehand(s: &mut Session, p: &Value) -> Result<Value> {
for a in &mut sp.anchors {
*a = Anchor::with_handles(a.p, a.h_in, a.h_out);
}
// The path's pressure (in the direction it is continued in) followed by the stroke's.
let old_pressure = s.doc()?.doc.node(id).and_then(|n| n.appearance.stroke()).and_then(|st| st.pressure.clone());
let joined = (old_pressure.is_some() || pressure.is_some()).then(|| {
let old = old_pressure.map(|p| if at_start { p.reversed() } else { p });
let total = subpath_len(&sp);
PressureProfile::extended(old.as_ref(), old_len, pressure.as_ref(), total - old_len)
});
s.edit(if brush { "Paintbrush" } else { "Pencil" }, |d, sel| {
let path = path_mut(d, id)?;
if at_start && !sp.closed {
let reverse = at_start && !sp.closed;
if reverse {
sp.reverse();
}
path.subpaths[si] = sp;
path_mut(d, id)?.subpaths[si] = sp;
if let Some(joined) = joined
&& let Some(st) = d.node_mut(id).and_then(|n| n.appearance.stroke_mut())
{
st.pressure = if reverse { joined.map(|p| p.reversed()) } else { joined };
}
sel.set([id]);
Ok(())
})?;
@@ -563,7 +609,10 @@ fn freehand(s: &mut Session, p: &Value) -> Result<Value> {
}
let sp = fit_freehand(&pts, tol, closed);
let fill = if bool_or(p, "fill", false) { s.paint.fill.clone() } else { Paint::None };
let look = s.new_art_look(fill, stroke_paint(s), s.paint.stroke_width.max(0.1));
let mut look = s.new_art_look(fill, stroke_paint(s), s.paint.stroke_width.max(0.1));
if let Some(st) = look.appearance.stroke_mut() {
st.pressure = pressure;
}
add_look(s, if brush { "Paintbrush" } else { "Pencil" }, path_kind(PathData::single(sp)), look, None)
}
+39 -23
View File
@@ -8,7 +8,7 @@ use vectorcraft_geom::Affine;
use super::super::*;
use super::pdfimport::LoadOptions;
use super::{Format, SAVE_FORMATS, absolute_path, file_stamp, format, format_for_name, read_file};
use super::{Format, SAVE_FORMATS, absolute_path, file_stamp, format, format_for_name, psdread, read_file};
use crate::EngineError;
/// A file read into a document, with its format and non-fatal import notes.
@@ -76,6 +76,9 @@ pub fn detect(name: &str, bytes: &[u8]) -> Option<&'static Format> {
if let Some(kind) = vectorcraft_metafile::sniff(bytes) {
return format(kind.id());
}
if psdread::is_psd(bytes) {
return format(if psdread::is_psb(bytes) { "psb" } else { "psd" });
}
if let Some(f) = image::guess_format(bytes).ok().and_then(image_format) {
return Some(f);
}
@@ -355,37 +358,50 @@ const MAX_RASTER_ALLOC: u64 = if cfg!(target_arch = "wasm32") { 512 << 20 } else
/// 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
/// the decoder can't read, become RGBA in the active colour settings' CMYK.
/// the decoder can't read, become RGBA in the active colour settings' CMYK, as do CMYK Photoshop
/// documents, whose merged image is read ([`psdread`]).
pub fn raster_image(bytes: &[u8]) -> Result<RasterImage> {
let ppi = super::ppi::resolution(bytes);
if psdread::is_psd(bytes) {
let cms = vectorcraft_color::cms::active();
return as_png(psdread::decode(bytes, MAX_RASTER_ALLOC, |c| cms.cmyk_to_srgb(c, false)).map_err(err)?, ppi);
}
let mut reader = image::ImageReader::new(Cursor::new(bytes)).with_guessed_format().map_err(err)?;
let mut limits = image::Limits::default();
limits.max_alloc = Some(MAX_RASTER_ALLOC);
reader.limits(limits);
let kind = reader.format().ok_or_else(|| err("not an image VectorCraft reads (see document.formats)"))?;
let f = image_format(kind).ok_or_else(|| err(format!("{kind:?} images can't be opened (see document.formats)")))?;
let ppi = super::ppi::resolution(bytes);
let cmyk = || ImageBlob::new(f.mime, bytes.to_vec()).cmyk().is_some();
let (bytes, mime, (width, height)) = if matches!(f.id, "png" | "jpg" | "gif" | "webp") || (f.id == "tiff" && cmyk()) {
(bytes.to_vec(), f.mime, reader.into_dimensions().map_err(err)?)
} else {
let cmyka = || {
let cms = vectorcraft_color::cms::active();
vectorcraft_doc::cmyk::cmyka_tiff_rgba(bytes, |c| cms.cmyk_to_srgb(c, false))
};
let img = match (f.id == "tiff").then(cmyka).flatten() {
Some(img) => img,
None => reader.decode().map_err(err)?.to_rgba8(),
};
let size = img.dimensions();
let mut png = Vec::new();
img.write_to(&mut Cursor::new(&mut png), image::ImageFormat::Png).map_err(err)?;
// The stored PNG keeps the file's resolution.
let png = match ppi {
Some(r) => super::ppi::with_png_resolution(&png, r),
None => png,
};
(png, "image/png", size)
if matches!(f.id, "png" | "jpg" | "gif" | "webp") || (f.id == "tiff" && cmyk()) {
let (width, height) = reader.into_dimensions().map_err(err)?;
return stored(bytes.to_vec(), f.mime, (width, height), ppi);
}
let cmyka = || {
let cms = vectorcraft_color::cms::active();
vectorcraft_doc::cmyk::cmyka_tiff_rgba(bytes, |c| cms.cmyk_to_srgb(c, false))
};
let img = match (f.id == "tiff").then(cmyka).flatten() {
Some(img) => img,
None => reader.decode().map_err(err)?.to_rgba8(),
};
as_png(img, ppi)
}
/// `img` stored as a PNG that keeps the file's resolution.
fn as_png(img: image::RgbaImage, ppi: Option<(f64, f64)>) -> Result<RasterImage> {
let size = img.dimensions();
let mut png = Vec::new();
img.write_to(&mut Cursor::new(&mut png), image::ImageFormat::Png).map_err(err)?;
let png = match ppi {
Some(r) => super::ppi::with_png_resolution(&png, r),
None => png,
};
stored(png, "image/png", size, ppi)
}
/// An image's encoded `bytes`, `width` × `height` pixels, ready to embed.
fn stored(bytes: Vec<u8>, mime: &'static str, (width, height): (u32, u32), ppi: Option<(f64, f64)>) -> Result<RasterImage> {
if width == 0 || height == 0 {
return Err(err("the image is empty"));
}
+43 -4
View File
@@ -34,6 +34,7 @@ pub mod pdf;
mod pdfimport;
pub mod pngtext;
pub mod ppi;
pub mod psdread;
mod save;
mod screens;
mod svg;
@@ -558,10 +559,12 @@ pub const FORMATS: &[Format] = &[
Format { id: "emf", label: "EMF", extensions: &["emf"], mime: "image/emf", read: true, write: true, raster: false, options: metafile::OPTIONS },
Format { id: "wmf", label: "WMF", extensions: &["wmf"], mime: "image/wmf", read: true, write: true, raster: false, options: metafile::OPTIONS },
Format { id: "tga", label: "Targa", extensions: &["tga"], mime: "image/x-tga", read: false, write: true, raster: true, options: TGA_OPTIONS },
Format { id: "psd", label: "PSD", extensions: &["psd"], mime: "image/x-psd", read: false, write: true, raster: true, options: PSD_OPTIONS },
Format { id: "psd", label: "PSD", extensions: &["psd"], mime: "image/x-psd", read: true, write: true, raster: true, options: PSD_OPTIONS },
// Affinity Photo's `.afphoto` opens by its content but isn't listed: Finder shouldn't offer a
// vector app for a raster editor's documents.
reader("affinity", "Affinity", &["af", "afdesign", "afpub"], "application/vnd.affinity", false),
// Photoshop's large document format: read like a PSD (its merged image), never written.
reader("psb", "PSB", &["psb"], "image/x-psb", true),
];
/// Every extension `document.open` reads (the "All readable files" filter of open dialogs).
@@ -581,6 +584,8 @@ pub const OPEN_EXTS: &[&str] = &[
"tif",
"tiff",
"bmp",
"psd",
"psb",
"vctemplate",
"dxf",
"emf",
@@ -591,6 +596,11 @@ pub const OPEN_EXTS: &[&str] = &[
"afpub",
];
/// Extensions in [`OPEN_EXTS`] the desktop packaging doesn't associate with VectorCraft: Photoshop
/// documents belong to raster editors, so Finder and file managers don't offer a vector app for
/// them (File › Open and Place still read them).
pub const UNASSOCIATED_EXTS: &[&str] = &["psd", "psb"];
/// The extension that picks each writable format when exporting (the format's first; PNG-8 shares
/// `.png` with PNG, so `.png` comes once), in [`FORMATS`] order.
pub fn export_extensions() -> Vec<&'static str> {
@@ -623,6 +633,8 @@ pub const PLACE_EXTS: &[&str] = &[
"tif",
"tiff",
"bmp",
"psd",
"psb",
"vctemplate",
"dxf",
"emf",
@@ -640,11 +652,13 @@ fn format_filters() -> impl Iterator<Item = (&'static str, &'static [&'static st
}
/// Open-dialog filters: "All readable files" first, then one per readable format, then swatch
/// libraries (which open in the library panel), flattener, PDF and print presets (imported).
/// libraries (which open in the library panel), Libraries panel libraries, flattener, PDF and print
/// presets (imported).
pub fn open_filters() -> impl Iterator<Item = (&'static str, &'static [&'static str])> {
std::iter::once(("All readable files", OPEN_EXTS))
.chain(format_filters())
.chain(std::iter::once(("Swatch libraries", super::swatchlib::LIBRARY_EXTS)))
.chain(std::iter::once(("Libraries", super::library::LIBRARY_EXTS)))
.chain(std::iter::once(("Flattener presets", super::flatten::PRESET_EXTS)))
.chain(std::iter::once(("PDF presets", super::pdfcmds::PRESET_EXTS)))
.chain(std::iter::once(("Print presets", super::printpresets::PRESET_EXTS)))
@@ -708,20 +722,39 @@ fn formats(_: &mut Session, _: &Value) -> Result<Value> {
}
// ---------- the file system (none on the web, where commands take and return bytes) ----------
//
// Every path a command reads or writes goes through these, which check it against the automation
// roots in force ([`crate::file_access`]): a path outside them is an error (`file_stamp` and
// `file_created`: no file), as is any path once roots are in force without its kind of access.
/// `path` checked for reading against the automation roots in force.
#[cfg(not(target_arch = "wasm32"))]
fn may_read(path: &str) -> Result<()> {
crate::file_access::check_read(path).map_err(EngineError::Other)
}
/// `path` checked for writing against the automation roots in force.
#[cfg(not(target_arch = "wasm32"))]
fn may_write(path: &str) -> Result<()> {
crate::file_access::check_write(path).map_err(EngineError::Other)
}
#[cfg(not(target_arch = "wasm32"))]
pub(crate) fn read_file(path: &str) -> Result<Vec<u8>> {
may_read(path)?;
std::fs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))
}
#[cfg(not(target_arch = "wasm32"))]
pub(crate) fn write_file(path: &str, bytes: &[u8]) -> Result<()> {
may_write(path)?;
// Never a half-written file: see [`write_atomic`].
write_atomic(std::path::Path::new(path), bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))
}
#[cfg(not(target_arch = "wasm32"))]
pub(crate) fn create_dir(path: &str) -> Result<()> {
may_write(path)?;
std::fs::create_dir_all(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))
}
@@ -730,6 +763,7 @@ pub(crate) fn create_dir(path: &str) -> Result<()> {
#[cfg(not(target_arch = "wasm32"))]
pub(crate) fn write_new_file(path: &str, bytes: &[u8]) -> Result<()> {
use std::io::Write as _;
may_write(path)?;
let err = |e: std::io::Error| EngineError::Other(format!("{path}: {e}"));
let mut f = std::fs::OpenOptions::new().write(true).create_new(true).open(path).map_err(err)?;
let written = f.write_all(bytes).and_then(|()| f.sync_all());
@@ -742,17 +776,20 @@ pub(crate) fn write_new_file(path: &str, bytes: &[u8]) -> Result<()> {
}
/// A file's size (bytes) and modification time (ms since the Unix epoch, when the file system
/// keeps one); `None` when there is no file at `path`.
/// keeps one); `None` when there is no file at `path` (or automation may not read it).
#[cfg(not(target_arch = "wasm32"))]
pub(crate) fn file_stamp(path: &str) -> Option<(u64, Option<u64>)> {
may_read(path).ok()?;
let m = std::fs::metadata(path).ok().filter(std::fs::Metadata::is_file)?;
let modified = m.modified().ok().and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()).map(|d| d.as_millis() as u64);
Some((m.len(), modified))
}
/// When the file at `path` was created (ms since the Unix epoch), when the file system keeps it.
/// When the file at `path` was created (ms since the Unix epoch), when the file system keeps it
/// (and automation may read it).
#[cfg(not(target_arch = "wasm32"))]
pub(crate) fn file_created(path: &str) -> Option<u64> {
may_read(path).ok()?;
let t = std::fs::metadata(path).ok()?.created().ok()?;
t.duration_since(std::time::UNIX_EPOCH).ok().map(|d| d.as_millis() as u64)
}
@@ -956,6 +993,8 @@ mod tests_tiffbmp;
mod tests_pdfx;
#[cfg(test)]
mod tests_psd;
#[cfg(test)]
mod tests_psdread;
#[cfg(test)]
mod tests_epsimport;
+5 -2
View File
@@ -1,6 +1,7 @@
//! Image resolution metadata (pixels per inch): PNG `pHYs`, JPEG JFIF density or EXIF, TIFF and
//! WebP EXIF resolution tags, BMP pixels per metre. Place sizes an image by it (72 ppi when the file
//! has none); images converted to PNG on import keep it as a `pHYs` chunk.
//! WebP EXIF resolution tags, BMP pixels per metre, a Photoshop document's ResolutionInfo. Place
//! sizes an image by it (72 ppi when the file has none); images converted to PNG on import keep it
//! as a `pHYs` chunk.
const INCH_M: f64 = 0.0254;
const INCH_CM: f64 = 2.54;
@@ -18,6 +19,8 @@ pub fn resolution(bytes: &[u8]) -> Option<(f64, f64)> {
webp(bytes)
} else if bytes.starts_with(b"BM") {
bmp(bytes)
} else if super::psdread::is_psd(bytes) {
super::psdread::resolution(bytes)
} else {
None
};
+370
View File
@@ -0,0 +1,370 @@
//! Reading Photoshop documents (PSD, and PSB, the large document format) for File › Open and
//! File › Place (#918): the merged image, the flattened picture every PSD carries after its layers,
//! as a linked PSD shows. Written from Adobe's public "Photoshop File Formats Specification"
//! (<https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/>). Layers aren't read.
//!
//! Every mode Photoshop saves a merged image in is read: Bitmap, Grayscale, Duotone (as its
//! grayscale data), Indexed, RGB, CMYK and Lab, at 1, 8, 16 and 32 bits per channel, stored raw,
//! PackBits-compressed or zipped. The merged image of a document with transparency is stored on
//! white with its alpha in the first extra channel: it comes back with straight colours. The file
//! is untrusted: every read is checked and the image's size is capped before anything is
//! allocated.
use std::io::Read;
use vectorcraft_color::cms::{Lab, lab::lab_to_srgb};
/// Photoshop's colour modes (the header's numbers), as far as the merged image is concerned.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Mode {
Bitmap,
Gray,
Indexed,
Rgb,
Cmyk,
Lab,
}
impl Mode {
/// The channels holding colour (an alpha channel may follow them).
fn colors(self) -> usize {
match self {
Mode::Bitmap | Mode::Gray | Mode::Indexed => 1,
Mode::Rgb | Mode::Lab => 3,
Mode::Cmyk => 4,
}
}
}
/// Is `bytes` a Photoshop document (PSD or PSB)?
pub fn is_psd(bytes: &[u8]) -> bool {
bytes.starts_with(b"8BPS") && matches!(bytes.get(4..6), Some([0, 1] | [0, 2]))
}
/// Is `bytes` a PSB, Photoshop's large document format?
pub fn is_psb(bytes: &[u8]) -> bool {
is_psd(bytes) && bytes.get(4..6) == Some(&[0, 2])
}
const TRUNCATED: &str = "the PSD file is cut short";
const DAMAGED: &str = "the PSD file's header is damaged";
/// Big-endian reads through `b`, each checked against its end.
struct Reader<'a> {
b: &'a [u8],
at: usize,
}
impl<'a> Reader<'a> {
fn new(b: &'a [u8]) -> Self {
Self { b, at: 0 }
}
fn take(&mut self, n: usize) -> Result<&'a [u8], String> {
let s = self.at.checked_add(n).and_then(|end| self.b.get(self.at..end)).ok_or(TRUNCATED)?;
self.at += n;
Ok(s)
}
fn array<const N: usize>(&mut self) -> Result<[u8; N], String> {
self.take(N)?.try_into().map_err(|_| TRUNCATED.to_string())
}
fn u8(&mut self) -> Result<u8, String> {
self.array::<1>().map(|[v]| v)
}
fn u16(&mut self) -> Result<u16, String> {
self.array().map(u16::from_be_bytes)
}
fn u32(&mut self) -> Result<u32, String> {
self.array().map(u32::from_be_bytes)
}
/// A section length: 4 bytes in a PSD, 8 in a PSB.
fn length(&mut self, psb: bool) -> Result<usize, String> {
let n = if psb { self.array().map(u64::from_be_bytes)? } else { u64::from(self.u32()?) };
usize::try_from(n).map_err(|_| TRUNCATED.to_string())
}
/// A section: its length, then that many bytes.
fn section(&mut self, psb: bool) -> Result<&'a [u8], String> {
let n = self.length(psb)?;
self.take(n)
}
fn rest(&self) -> &'a [u8] {
self.b.get(self.at..).unwrap_or_default()
}
}
/// The fixed-size header and the sections before the image data.
struct Header<'a> {
psb: bool,
channels: usize,
width: u32,
height: u32,
depth: u16,
mode: Mode,
/// Colour mode data: an indexed image's palette (256 reds, then greens, then blues).
palette: &'a [u8],
/// The image resources.
resources: &'a [u8],
/// The layer and mask information.
layers: &'a [u8],
}
fn header<'a>(r: &mut Reader<'a>) -> Result<Header<'a>, String> {
if !is_psd(r.b) {
return Err("not a PSD file".into());
}
r.take(4)?;
let psb = r.u16()? == 2;
r.take(6)?;
let channels = usize::from(r.u16()?);
let height = r.u32()?;
let width = r.u32()?;
let depth = r.u16()?;
let mode = match r.u16()? {
0 => Mode::Bitmap,
// Duotone documents keep their grayscale data in the merged image.
1 | 8 => Mode::Gray,
2 => Mode::Indexed,
3 => Mode::Rgb,
4 => Mode::Cmyk,
9 => Mode::Lab,
7 => return Err("Multichannel PSD files can't be read: save it in RGB, CMYK or Grayscale mode".into()),
m => return Err(format!("the PSD file has an unknown colour mode ({m})")),
};
let max_side = if psb { 300_000 } else { 30_000 };
if !(1..=56).contains(&channels) || channels < mode.colors() || !(1..=max_side).contains(&width) || !(1..=max_side).contains(&height) {
return Err(DAMAGED.into());
}
let depth_ok = match mode {
Mode::Bitmap => depth == 1,
Mode::Indexed => depth == 8,
Mode::Cmyk | Mode::Lab => matches!(depth, 8 | 16),
Mode::Gray | Mode::Rgb => matches!(depth, 8 | 16 | 32),
};
if !depth_ok {
return Err(format!("{depth}-bit PSD files in this colour mode can't be read"));
}
let palette = r.section(false)?;
let resources = r.section(false)?;
let layers = r.section(psb)?;
Ok(Header { psb, channels, width, height, depth, mode, palette, resources, layers })
}
/// The resolution a Photoshop document declares (pixels per inch, across and down), from its
/// ResolutionInfo resource (1005).
pub fn resolution(bytes: &[u8]) -> Option<(f64, f64)> {
let mut r = Reader::new(bytes);
let h = header(&mut r).ok()?;
let mut r = Reader::new(h.resources);
while !r.rest().is_empty() {
r.take(4).ok()?;
let id = r.u16().ok()?;
// A Pascal string padded to an even size, length byte included.
let name = usize::from(r.u8().ok()?);
r.take(name + (name + 1) % 2).ok()?;
let size = usize::try_from(r.u32().ok()?).ok()?;
let data = r.take(size).ok()?;
r.take(size % 2).ok()?;
if id == 0x03ed {
let mut d = Reader::new(data);
// Fixed 16.16 values, each with its unit (1: per inch, 2: per centimetre) and the
// unit the size is shown in.
let mut axis = || -> Option<f64> {
let v = f64::from(d.u32().ok()?) / 65536.0;
let unit = d.u16().ok()?;
d.u16().ok()?;
Some(if unit == 2 { v * 2.54 } else { v })
};
return Some((axis()?, axis()?));
}
}
None
}
/// Does the merged image keep its transparency in the first extra channel? Yes when the layer
/// count is negative: in the layer info, or for 16- and 32-bit documents (whose layer info is
/// empty) in their `Lr16` / `Lr32` block.
fn merged_alpha(layers: &[u8], psb: bool) -> bool {
let count_negative = |info: &[u8]| info.get(..2).is_some_and(|c| i16::from_be_bytes([c[0], c[1]]) < 0);
let mut r = Reader::new(layers);
let Ok(info) = r.section(psb) else { return false };
if !info.is_empty() {
return count_negative(info);
}
// The global layer mask info, then tagged blocks: signature, key, length (8 bytes for the
// layer blocks of a PSB), data padded to 4 bytes.
if r.section(false).is_err() {
return false;
}
while r.rest().len() >= 12 {
let (Ok(_), Ok(key)) = (r.take(4), r.array::<4>()) else { return false };
let layer_block = matches!(&key, b"Lr16" | b"Lr32" | b"Layr");
let long = layer_block || matches!(&key, b"LMsk" | b"Mt16" | b"Mt32" | b"Mtrn" | b"Alph" | b"FMsk" | b"lnk2" | b"FEid" | b"FXid" | b"PxSD");
let Ok(data) = r.section(psb && long) else {
return false;
};
if layer_block {
return count_negative(data);
}
if r.take((4 - data.len() % 4) % 4).is_err() {
return false;
}
}
false
}
/// The most bytes one channel row may take (a PSB at 32 bits).
fn row_bytes(width: usize, depth: u16) -> usize {
if depth == 1 { width.div_ceil(8) } else { width * usize::from(depth / 8) }
}
/// Undo PackBits: `src` into `dst` (a row; a short or overlong run leaves the rest as it is).
fn unpack_bits(mut src: &[u8], dst: &mut [u8]) {
let mut at = 0;
while let Some((&n, rest)) = src.split_first() {
src = rest;
let n = n as i8;
if n >= 0 {
let len = usize::from(n.unsigned_abs()) + 1;
let (run, rest) = src.split_at(len.min(src.len()));
src = rest;
for &b in run {
if let Some(d) = dst.get_mut(at) {
*d = b;
}
at += 1;
}
} else if n != -128 {
let Some((&b, rest)) = src.split_first() else { break };
src = rest;
let len = usize::from(n.unsigned_abs()) + 1;
for d in dst.iter_mut().skip(at).take(len) {
*d = b;
}
at += len;
}
if at >= dst.len() {
break;
}
}
}
/// The first `channels` channels of the image data (after its compression method), each as
/// `height` rows of `row` bytes, one after the other.
fn channel_data(data: &[u8], h: &Header<'_>, channels: usize, row: usize) -> Result<Vec<u8>, String> {
let (rows, all) = (h.height as usize, h.channels);
let mut r = Reader::new(data);
let method = r.u16()?;
let wanted = channels * rows * row;
match method {
0 => r.take(wanted).map(<[u8]>::to_vec),
1 => {
// Every row's byte count (4 bytes each in a PSB), channel by channel: those of the
// channels read, then the others'.
let size = if h.psb { 4 } else { 2 };
let counts = r.take(channels * rows * size)?;
r.take((all - channels) * rows * size)?;
let counts = counts.chunks_exact(size).map(|c| c.iter().fold(0usize, |n, &b| n << 8 | usize::from(b)));
let mut out = vec![0; wanted];
for (dst, n) in out.chunks_exact_mut(row).zip(counts) {
unpack_bits(r.take(n)?, dst);
}
Ok(out)
}
2 | 3 => {
let mut out = Vec::new();
flate2::read::ZlibDecoder::new(r.rest())
.take(wanted as u64)
.read_to_end(&mut out)
.map_err(|e| format!("the PSD file's image data can't be read: {e}"))?;
if out.len() < wanted {
return Err(TRUNCATED.into());
}
if method == 3 {
// Each row stores the difference from the sample before it.
match h.depth {
8 => out.chunks_exact_mut(row).for_each(|r| (1..r.len()).for_each(|i| r[i] = r[i].wrapping_add(r[i - 1]))),
16 => out.chunks_exact_mut(row).for_each(|r| {
let mut prev = 0u16;
for s in r.as_chunks_mut::<2>().0 {
prev = prev.wrapping_add(u16::from_be_bytes(*s));
*s = prev.to_be_bytes();
}
}),
_ => return Err("zipped 32-bit PSD image data with prediction can't be read".into()),
}
}
Ok(out)
}
m => Err(format!("the PSD file's image data uses an unknown compression ({m})")),
}
}
/// sRGB's transfer curve, for 32-bit documents, whose values are linear.
fn srgb_encode(v: f32) -> f32 {
if v <= 0.003_130_8 { v * 12.92 } else { 1.055 * v.powf(1.0 / 2.4) - 0.055 }
}
/// Sample `x` of a channel row, 0..1.
fn sample(row: &[u8], x: usize, depth: u16, linear: bool) -> f32 {
match depth {
1 => {
// Bitmap: a set bit is black.
let bit = row.get(x / 8).map_or(0, |b| (b >> (7 - x % 8)) & 1);
if bit == 1 { 0.0 } else { 1.0 }
}
16 => row.get(x * 2..x * 2 + 2).map_or(0.0, |s| f32::from(u16::from_be_bytes([s[0], s[1]])) / 65535.0),
32 => {
let v = row.get(x * 4..x * 4 + 4).map_or(0.0, |s| f32::from_be_bytes([s[0], s[1], s[2], s[3]]));
let v = if v.is_finite() { v.clamp(0.0, 1.0) } else { 0.0 };
if linear { srgb_encode(v) } else { v }
}
_ => row.get(x).map_or(0.0, |&b| f32::from(b) / 255.0),
}
}
fn byte(v: f32) -> u8 {
(v.clamp(0.0, 1.0) * 255.0).round() as u8
}
/// A Photoshop document's merged image as straight RGBA. CMYK converts through `cmyk` (ink
/// amounts 0..1 → sRGB). Fails, without allocating, when the image would need more than
/// `max_alloc` bytes.
pub fn decode(bytes: &[u8], max_alloc: u64, cmyk: impl Fn([f32; 4]) -> [f32; 3]) -> Result<image::RgbaImage, String> {
let mut r = Reader::new(bytes);
let h = header(&mut r)?;
let colors = h.mode.colors();
let alpha = h.channels > colors && merged_alpha(h.layers, h.psb);
let channels = colors + usize::from(alpha);
let (w, rows) = (h.width as usize, h.height as usize);
let row = row_bytes(w, h.depth);
// The channels read and the RGBA image made of them.
let need = (channels as u64 * row as u64).saturating_add(4 * w as u64).saturating_mul(rows as u64);
if need > max_alloc {
return Err(format!("the PSD image is too large to read ({} × {} pixels)", h.width, h.height));
}
let data = channel_data(r.rest(), &h, channels, row)?;
let mut out = image::RgbaImage::new(h.width, h.height);
let linear = h.depth == 32;
for (y, line) in out.rows_mut().enumerate() {
let planes: Vec<&[u8]> = (0..channels).map(|c| data.get((c * rows + y) * row..(c * rows + y + 1) * row).unwrap_or_default()).collect();
for (x, px) in line.enumerate() {
let v = |c: usize| planes.get(c).map_or(0.0, |p| sample(p, x, h.depth, linear && c < colors));
let rgb = match h.mode {
Mode::Bitmap | Mode::Gray => [v(0); 3],
Mode::Indexed => {
let i = usize::from(byte(v(0)));
[0, 256, 512].map(|at| h.palette.get(at + i).map_or(0.0, |&c| f32::from(c) / 255.0))
}
Mode::Rgb => [v(0), v(1), v(2)],
// Inks are stored as 1 − amount.
Mode::Cmyk => cmyk([0, 1, 2, 3].map(|c| 1.0 - v(c))),
Mode::Lab => lab_to_srgb(Lab { l: v(0) * 100.0, a: v(1) * 255.0 - 128.0, b: v(2) * 255.0 - 128.0 }),
};
let a = if alpha { v(colors) } else { 1.0 };
// The merged image is stored on white: take the white back out.
let straight = |c: f32| if a <= 0.0 { 0.0 } else { (c - (1.0 - a)) / a };
let rgb = if alpha { rgb.map(straight) } else { rgb };
px.0 = [byte(rgb[0]), byte(rgb[1]), byte(rgb[2]), byte(a)];
}
}
Ok(out)
}
+5 -1
View File
@@ -157,7 +157,11 @@ fn open_exts_cover_every_readable_format() {
}
let filters: Vec<_> = open_filters().collect();
assert_eq!(filters[0], ("All readable files", OPEN_EXTS));
assert_eq!(filters.len(), 6 + FORMATS.iter().filter(|f| f.read).count(), "and swatch libraries, flattener, PDF and print presets, plug-ins");
assert_eq!(
filters.len(),
7 + FORMATS.iter().filter(|f| f.read).count(),
"and swatch libraries, libraries, flattener, PDF and print presets, plug-ins"
);
assert_eq!(filters.last(), Some(&("Plug-ins", crate::cmd::plugin::EXTS)), "File › Open installs plug-ins");
}
+1 -1
View File
@@ -77,7 +77,7 @@ fn psd_is_listed_with_its_options_and_writes_one_file_per_artboard() {
let psd = r["formats"].as_array().unwrap().iter().find(|f| f["id"] == "psd").unwrap().clone();
assert_eq!(
(psd["label"].as_str(), psd["write"].as_bool(), psd["read"].as_bool(), psd["raster"].as_bool()),
(Some("PSD"), Some(true), Some(false), Some(true))
(Some("PSD"), Some(true), Some(true), Some(true))
);
for k in ["colorModel", "layers", "maxEditability", "hiddenLayers", "embedIcc", "ppi", "antiAlias", "background"] {
assert!(psd["options"].get(k).is_some(), "psd takes {k}");
@@ -0,0 +1,244 @@
//! Reading Photoshop documents (#918): the merged image of every colour mode, depth and
//! compression, PSB files, transparency, the resolution, and damaged files refused without a panic.
use serde_json::{Value, json};
use super::psdread::{decode, is_psd, resolution};
use super::*;
/// A Photoshop document built from its parts: `version` 1 (PSD) or 2 (PSB), the header's fields,
/// then the colour mode data, resources, layer and mask section and image data as given.
#[allow(clippy::too_many_arguments)]
fn psd(
version: u16,
channels: u16,
(w, h): (u32, u32),
depth: u16,
mode: u16,
palette: &[u8],
resources: &[u8],
layers: &[u8],
image: &[u8],
) -> Vec<u8> {
let mut f = b"8BPS".to_vec();
f.extend(version.to_be_bytes());
f.extend([0; 6]);
f.extend(channels.to_be_bytes());
f.extend(h.to_be_bytes());
f.extend(w.to_be_bytes());
f.extend(depth.to_be_bytes());
f.extend(mode.to_be_bytes());
for part in [palette, resources] {
f.extend((part.len() as u32).to_be_bytes());
f.extend(part);
}
if version == 2 {
f.extend((layers.len() as u64).to_be_bytes());
} else {
f.extend((layers.len() as u32).to_be_bytes());
}
f.extend(layers);
f.extend(image);
f
}
/// Raw image data: the compression method (0), then the planes.
fn raw(planes: &[&[u8]]) -> Vec<u8> {
let mut d = vec![0, 0];
planes.iter().for_each(|p| d.extend(*p));
d
}
/// A layer and mask section whose layer info holds just the layer `count` (negative: the merged
/// image's first extra channel is its transparency).
fn layer_count(count: i16, psb: bool) -> Vec<u8> {
let mut s = if psb { 2u64.to_be_bytes().to_vec() } else { 2u32.to_be_bytes().to_vec() };
s.extend(count.to_be_bytes());
s
}
/// Straight sRGB through a naive CMYK conversion (tests don't depend on colour settings).
fn naive(c: [f32; 4]) -> [f32; 3] {
[0, 1, 2].map(|i| (1.0 - c[i]) * (1.0 - c[3]))
}
fn pixels(f: &[u8]) -> Vec<[u8; 4]> {
decode(f, 1 << 30, naive).unwrap().pixels().map(|p| p.0).collect()
}
#[test]
fn every_colour_mode_and_depth_reads() {
// Grayscale, 16 bits.
let g = psd(1, 1, (2, 1), 16, 1, &[], &[], &[], &raw(&[&[0, 0, 0xff, 0xff]]));
assert!(is_psd(&g));
assert_eq!(pixels(&g), [[0, 0, 0, 255], [255, 255, 255, 255]]);
// Bitmap: a set bit is black.
let b = psd(1, 1, (9, 1), 1, 0, &[], &[], &[], &raw(&[&[0b1000_0000, 0b1000_0000]]));
let px = pixels(&b);
assert_eq!((px[0], px[1], px[8]), ([0, 0, 0, 255], [255, 255, 255, 255], [0, 0, 0, 255]));
// Indexed: 256 reds, greens, blues.
let mut palette = vec![0; 768];
(palette[1], palette[257], palette[513]) = (10, 20, 30);
let i = psd(1, 1, (1, 1), 8, 2, &palette, &[], &[], &raw(&[&[1]]));
assert_eq!(pixels(&i), [[10, 20, 30, 255]]);
// RGB, 32 bits: linear values, shown through sRGB's curve.
let half = 0.5f32.to_be_bytes();
let r = psd(1, 3, (1, 1), 32, 3, &[], &[], &[], &raw(&[&half, &half, &1.0f32.to_be_bytes()]));
assert_eq!(pixels(&r), [[188, 188, 255, 255]]);
// CMYK, inks stored as 255 − amount: 100 % K is black, no ink white.
let c = psd(1, 4, (2, 1), 8, 4, &[], &[], &[], &raw(&[&[255, 255], &[255, 255], &[255, 255], &[0, 255]]));
assert_eq!(pixels(&c), [[0, 0, 0, 255], [255, 255, 255, 255]]);
// Lab: L 100, a 0, b 0 is white.
let l = psd(1, 3, (1, 1), 8, 9, &[], &[], &[], &raw(&[&[255], &[128], &[128]]));
assert!(pixels(&l)[0].iter().all(|&v| v >= 253), "{:?}", pixels(&l));
// Duotone keeps its grayscale data in the merged image.
assert_eq!(pixels(&psd(1, 1, (1, 1), 8, 8, &[0; 40], &[], &[], &raw(&[&[64]]))), [[64, 64, 64, 255]]);
}
#[test]
fn transparency_comes_back_straight_and_extra_channels_without_it_stay_out() {
// Half-transparent red, stored on white: (255, 127, 127) with alpha 128.
let planes: [&[u8]; 4] = [&[255], &[127], &[127], &[128]];
let with = psd(1, 4, (1, 1), 8, 3, &[], &[], &layer_count(-1, false), &raw(&planes));
let [r, g, b, a] = pixels(&with)[0];
assert!(r == 255 && g <= 1 && b <= 1 && a == 128, "{:?}", [r, g, b, a]);
// A flat document's extra channel is a saved selection, not transparency.
let flat = psd(1, 4, (1, 1), 8, 3, &[], &[], &[], &raw(&planes));
assert_eq!(pixels(&flat), [[255, 127, 127, 255]]);
// A 16-bit document says so in its Lr16 block (its layer info is empty).
let mut layers = 0u32.to_be_bytes().to_vec();
layers.extend(0u32.to_be_bytes());
layers.extend(b"8BIMLr16");
layers.extend(4u32.to_be_bytes());
layers.extend((-1i16).to_be_bytes());
layers.extend([0, 0]);
let wide: [&[u8]; 4] = [&[0xff, 0xff], &[0x7f, 0x7f], &[0x7f, 0x7f], &[0x80, 0x80]];
let deep = psd(1, 4, (1, 1), 16, 3, &[], &[], &layers, &raw(&wide));
let [r, g, _, a] = pixels(&deep)[0];
assert!(r == 255 && g <= 1 && a == 128, "{:?}", pixels(&deep));
}
#[test]
fn packbits_zip_and_psb_files_read() {
// PackBits in a PSB: 4-byte row counts, transparency from a negative layer count.
let mut rle = vec![0, 1];
for _ in 0..4 {
rle.extend(2u32.to_be_bytes());
}
for v in [200u8, 100, 50, 255] {
rle.extend([0xfe, v]); // a run of three
}
let big = psd(2, 4, (3, 1), 8, 3, &[], &[], &layer_count(-1, true), &rle);
assert_eq!(pixels(&big), [[200, 100, 50, 255]; 3]);
let mut s = Session::new();
let r = s.execute("document.open", &json!({"name": "big.psb", "dataBase64": vectorcraft_format::base64_encode(&big)})).unwrap();
assert_eq!(r["format"], "psb");
// Zipped with prediction, 16 bits: each sample is the difference from the one before.
let deltas: Vec<u8> = [0x1000u16, 0x1000, 0x1000].iter().flat_map(|d| d.to_be_bytes()).collect();
let mut z = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default());
std::io::Write::write_all(&mut z, &deltas).unwrap();
let mut zip = vec![0, 3];
zip.extend(z.finish().unwrap());
let px = pixels(&psd(1, 1, (3, 1), 16, 1, &[], &[], &[], &zip));
assert_eq!(px.iter().map(|p| p[0]).collect::<Vec<_>>(), [16, 32, 48]);
}
#[test]
fn the_resolution_resource_sizes_the_placed_image() {
// ResolutionInfo (1005): 300 ppi across, 118.11 per cm (300 ppi) down.
let mut res = b"8BIM".to_vec();
res.extend(0x03edu16.to_be_bytes());
res.extend([0, 0]); // an empty name, padded
res.extend(16u32.to_be_bytes());
res.extend((300u32 << 16).to_be_bytes());
res.extend([0, 1, 0, 1]);
res.extend(((118.11f64 * 65536.0) as u32).to_be_bytes());
res.extend([0, 2, 0, 1]);
let f = psd(1, 3, (300, 150), 8, 3, &[], &res, &[], &raw(&[&[9; 45_000], &[9; 45_000], &[9; 45_000]]));
let (x, y) = resolution(&f).unwrap();
assert!((x - 300.0).abs() < 1e-9 && (y - 300.0).abs() < 0.01, "{x} {y}");
let mut s = Session::new();
s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap();
let r = s.execute("file.place", &json!({"name": "photo.psd", "dataBase64": vectorcraft_format::base64_encode(&f)})).unwrap();
assert!(
(r["width"].as_f64().unwrap() - 72.0).abs() < 0.01 && (r["height"].as_f64().unwrap() - 36.0).abs() < 0.01,
"an inch by half an inch: {r}"
);
}
/// A 60 × 40 pt document: a red square on a transparent page, and a half-transparent blue one.
fn art() -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 60, "height": 40})).unwrap();
s.execute("paint.setStroke", &json!({"none": true})).unwrap();
s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap();
s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 30, "height": 40})).unwrap();
s.execute("select.none", &json!({})).unwrap();
s.execute("paint.setFill", &json!({"color": "#0000ff"})).unwrap();
s.execute("shape.rectangle", &json!({"x": 40, "y": 0, "width": 20, "height": 40})).unwrap();
s.execute("transparency.set", &json!({"opacity": 50})).unwrap();
s
}
fn exported(s: &mut Session, p: Value) -> Vec<u8> {
let r = s.execute("document.export", &p).unwrap();
vectorcraft_format::base64_decode(r["dataBase64"].as_str().unwrap()).unwrap()
}
#[test]
fn psd_exports_open_and_place_again() {
let mut s = art();
let png = image::load_from_memory(&exported(&mut s, json!({"format": "png"}))).unwrap().to_rgba8();
for (options, what) in [(json!({}), "layered RGB"), (json!({"layers": false}), "flat RGB"), (json!({"colorModel": "gray"}), "grey")] {
let f = exported(&mut s, merge(json!({"format": "psd"}), options));
let back = decode(&f, 1 << 30, naive).unwrap();
assert_eq!(back.dimensions(), png.dimensions(), "{what}");
let (red, clear, blue) = (back.get_pixel(10, 20).0, back.get_pixel(35, 20).0, back.get_pixel(50, 20).0);
if what == "layered RGB" {
assert_eq!((red, clear[3]), ([255, 0, 0, 255], 0), "{what}");
assert!(blue[2] >= 250 && blue[0] <= 3 && (blue[3] as i32 - png.get_pixel(50, 20).0[3] as i32).abs() <= 1, "{what}: {blue:?}");
} else if what == "flat RGB" {
assert_eq!((red, clear), ([255, 0, 0, 255], [255, 255, 255, 255]), "{what}: flat files are on white");
} else {
assert!(red[0] == red[1] && red[3] == 255, "{what}: {red:?}");
}
// Open and Place take it by its content, whatever its name.
let r = s.execute("document.open", &json!({"name": "back.bin", "dataBase64": vectorcraft_format::base64_encode(&f)})).unwrap();
assert_eq!(r["format"], "psd", "{what}");
let d = &s.doc().unwrap().doc;
assert!((d.artboards[0].rect.width() - 60.0).abs() < 1e-6, "{what}");
}
let r = s.execute("document.formats", &json!({})).unwrap();
assert!(r["readable"].as_array().unwrap().iter().any(|v| v == "psd"));
assert!(PLACE_EXTS.contains(&"psd") && PLACE_EXTS.contains(&"psb") && OPEN_EXTS.contains(&"psb"));
}
#[test]
fn damaged_and_unsupported_files_are_refused_without_a_panic() {
let good = psd(2, 4, (3, 2), 8, 3, &[], &[], &layer_count(-1, true), &raw(&[&[1; 6], &[2; 6], &[3; 6], &[4; 6]]));
assert!(decode(&good, 1 << 30, naive).is_ok());
for n in 0..good.len() {
assert!(decode(&good[..n], 1 << 30, naive).is_err(), "cut at {n}");
let _ = resolution(&good[..n]);
}
// Every byte changed in turn: an error or an image, never a panic.
for i in 0..good.len() {
for v in [0, 1, 0x7f, 0x80, 0xff] {
let mut f = good.clone();
f[i] = v;
let _ = decode(&f, 1 << 30, naive);
let _ = resolution(&f);
}
}
// An image too large for the memory allowed is refused before anything is allocated.
let huge = psd(2, 3, (300_000, 300_000), 16, 3, &[], &[], &[], &[0, 0]);
assert!(decode(&huge, 1 << 30, naive).unwrap_err().contains("too large"));
// Multichannel, and depths a mode doesn't have.
assert!(decode(&psd(1, 2, (1, 1), 8, 7, &[], &[], &[], &raw(&[&[0], &[0]])), 1 << 30, naive).unwrap_err().contains("Multichannel"));
assert!(decode(&psd(1, 4, (1, 1), 32, 4, &[], &[], &[], &[0, 0]), 1 << 30, naive).is_err());
// A placed file with a damaged image reports it.
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let cut = &good[..good.len() - 3];
assert!(s.execute("file.place", &json!({"name": "cut.psd", "dataBase64": vectorcraft_format::base64_encode(cut)})).is_err());
}
+2
View File
@@ -257,6 +257,8 @@ pub(crate) fn start_in(
if cfg!(target_arch = "wasm32") {
return Err("searching a folder isn't available on the web".into());
}
// The walkers follow no links, so a folder automation may read holds all they read.
crate::file_access::check_read(&folder.to_string_lossy())?;
let seconds = if limits.seconds.is_finite() { limits.seconds.clamp(0.0, 3600.0) } else { 60.0 };
let n = pool.reserve(threads.clamp(1, MAX_THREADS));
if n == 0 {
+5 -1
View File
@@ -345,7 +345,11 @@ fn add(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(f) = files.iter().find(|f| !found.contains(f)) {
return Err(bad(C, format!("`{}` is not a font file the last text.findFontFiles search found", f.display())));
}
super::fileio::create_dir(&dir.to_string_lossy())?;
for f in &files {
crate::file_access::check_read(&f.to_string_lossy()).map_err(EngineError::Other)?;
}
// VectorCraft's own Fonts folder: no automation root applies to it.
crate::file_access::unconfined(|| super::fileio::create_dir(&dir.to_string_lossy()))?;
let report = copy_into(&files, dir);
let pairs = |v: &[(PathBuf, PathBuf)]| {
v.iter().map(|(from, to)| json!({ "from": from.to_string_lossy(), "to": to.to_string_lossy() })).collect::<Vec<_>>()
+1 -1
View File
@@ -44,7 +44,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Font List",
[],
None,
"{family?} → {families: [names]} sorted: the bundled fonts, fonts added and the fonts installed on the system (none on the web), as the font menus list them (without the system's hidden families, whose names start with a dot; they still resolve by name); with family: {family, styles: [names]} (upright styles by weight, then italics; an error when the family isn't available)",
"{family?} → {families: [names]} sorted: the bundled fonts, fonts added and the fonts installed on the system (none on the web), each the family's English (canonical) name for `text.setStyle` and documents (without the system's hidden families, whose names start with a dot; they still resolve by name). Preferences › Type › Show Font Names in English only changes the UI menu labels, not this list; with family: {family, styles: [names]} (upright styles by weight, then italics; an error when the family isn't available)",
always,
font_list
),
File diff suppressed because it is too large. Load diff
+17 -8
View File
@@ -135,7 +135,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Artboard Options…",
["Window", "Artboards"],
None,
"{index, name?, x?, y?, width?, height?, scaleArt?: bool (Scale Artwork with Artboard: resized, the artboard takes the art fully inside it (locked and hidden art only with lockedAndHidden?: bool, default prefs moveLockedWithArtboard) and its guides from its old rectangle onto the new one, each side by its own ratio; strokes/corners/patterns as object.scale)} → null, or with scaleArt {scaled: the art scaled}",
"{index, name?, x?, y?, width?, height?, moveArt?: bool (default false; a position-only change moves the contained art with its artboard), scaleArt?: bool (Scale Artwork with Artboard: resized, the artboard takes the art fully inside it (locked and hidden art only with lockedAndHidden?: bool, default prefs moveLockedWithArtboard) and its guides from its old rectangle onto the new one, each side by its own ratio; strokes/corners/patterns as object.scale)} → null, or with scaleArt {scaled: the art scaled}",
has_doc,
artboard_set
),
@@ -163,7 +163,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Target",
[],
None,
"{id} target a layer, group or object as clicking its target circle in the Layers panel does: a layer gets its visible, unlocked art (its sublayers' too) selected and becomes the current layer, and appearance.*, effect.*, transparency.* and the opacity-mask commands without `ids` then act on the layer itself; anything else is selected. Any other selection change ends the targeting (`document.inspect` → target) → {id, selected: [..]}",
"{id, add?: bool (Shift-click: the item joins the selection, or leaves it when all selected; nothing is targeted)} target a layer, group or object as clicking its target circle in the Layers panel does: a layer gets its visible, unlocked art (its sublayers' too) selected and becomes the current layer, and appearance.*, effect.*, transparency.* and the opacity-mask commands without `ids` then act on the layer itself; anything else is selected. Any other selection change ends the targeting (`document.inspect` → target) → {id, selected: [..]}",
has_doc,
target
),
@@ -362,7 +362,11 @@ fn target(s: &mut Session, p: &Value) -> Result<Value> {
}
let layer = n.is_layer();
let rows = st.layer_rows.clone();
s.select(|d, sel| sel.set_target(d, id))?;
if p.get("add").and_then(Value::as_bool).unwrap_or(false) {
s.select(|d, sel| sel.toggle_target(d, id))?;
} else {
s.select(|d, sel| sel.set_target(d, id))?;
}
let st = s.doc_mut()?;
// Targeting doesn't change which rows are highlighted.
st.layer_rows = rows;
@@ -767,8 +771,13 @@ fn artboard_set(s: &mut Session, p: &Value) -> Result<Value> {
// Artboard gathers it) and its guides from the old rectangle onto the new one.
let scale_art = bool_or(p, "scaleArt", false);
let fit = if scale_art && resized { rect_map(was, now) } else { None };
let art = if fit.is_some() {
// Locked and hidden art too? Noted in the journal, so a replay scales the same objects
// Position fields honor Move Artwork independently of Scale Artwork. A resize still
// leaves the art alone unless scaling was requested. Fit-to-art callers keep the default.
let delta = now.origin() - was.origin();
let moving = !resized && bool_or(p, "moveArt", false) && delta != vectorcraft_geom::Vec2::ZERO;
let transform = fit.or_else(|| moving.then(|| Affine::translate(delta)));
let art = if transform.is_some() {
// Locked and hidden art too? Noted in the journal, so a replay transforms the same objects
// whatever the preference is then.
let all = bool_or(p, "lockedAndHidden", s.prefs.move_locked_with_artboard);
s.note_journal("lockedAndHidden", json!(all));
@@ -788,7 +797,7 @@ fn artboard_set(s: &mut Session, p: &Value) -> Result<Value> {
a.name = n.to_string();
}
let id = a.id;
if let Some(xf) = fit {
if let Some(xf) = transform {
d.map_artboard_guides(id, xf);
for id in &art {
if let Some(n) = d.node_mut(*id) {
@@ -797,14 +806,14 @@ fn artboard_set(s: &mut Session, p: &Value) -> Result<Value> {
}
} else if !resized {
// Moved (not resized), it takes its guides along.
d.move_artboard_guides(id, now.origin() - was.origin());
d.move_artboard_guides(id, delta);
}
Ok(())
})?;
if !scale_art {
return ok();
}
Ok(json!({ "scaled": art.iter().map(|i| i.0).collect::<Vec<_>>() }))
Ok(json!({ "scaled": if fit.is_some() { art.iter().map(|i| i.0).collect::<Vec<_>>() } else { vec![] } }))
}
/// The map taking artboard rectangle `from` onto `to`, each side scaled by its own ratio. None
+428 -36
View File
@@ -4,7 +4,10 @@
//! live for the session only. A graphic is kept as a native document with a PNG thumbnail: adding
//! one copies the selection as Copy does, and placing it pastes a copy as Paste does (its images,
//! symbols, patterns and swatches come along; a swatch name the document gives another colour is
//! merged into the document's).
//! merged into the document's). Items can be put in user-named groups, and a library can be
//! exported to a `.vclibrary` file and imported from one (#926).
use std::collections::{HashMap, HashSet};
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value, json};
@@ -16,10 +19,20 @@ use crate::Clipboard;
/// The extension of a library file.
pub const LIBRARY_EXT: &str = "vclibrary";
/// The extensions of library files (open dialogs).
pub const LIBRARY_EXTS: &[&str] = &[LIBRARY_EXT];
/// Bytes a library file may have when read (graphics with large images included).
const MAX_FILE: u64 = 512 << 20;
/// Items of one kind a library may hold.
const MAX_ITEMS: usize = 10_000;
/// Groups a library may hold.
const MAX_GROUPS: usize = 1_000;
/// Characters of a library, group or item name kept from a file.
const MAX_NAME: usize = 256;
/// Base64 bytes a graphic's thumbnail may have (a 256-pixel PNG is far smaller).
const MAX_THUMBNAIL: usize = 4 << 20;
/// Pixels a side a thumbnail may claim (they are made at most 256).
const MAX_THUMBNAIL_SIDE: u32 = 1024;
/// A library: its name and its items, newest last.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
@@ -34,6 +47,38 @@ pub struct Library {
pub para_styles: Vec<TextStyleDef>,
#[serde(default)]
pub graphics: Vec<LibraryGraphic>,
/// User-named groups of its items, in the order they were made (#926); an item is in one
/// group at most, the others are ungrouped. Files written before groups have none.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub groups: Vec<LibraryGroup>,
}
/// A user-named group of a library's items.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct LibraryGroup {
pub name: String,
#[serde(default)]
pub items: Vec<ItemRef>,
}
/// A library item: its kind and its key (its name; a graphic's id).
#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct ItemRef {
pub kind: ItemKind,
pub item: String,
}
/// What a library item is, as groups name it.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum ItemKind {
Color,
CharStyle,
ParaStyle,
Graphic,
/// A kind this version doesn't know (a newer one wrote it): dropped when the file is read.
#[serde(other)]
Unknown,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
@@ -74,19 +119,20 @@ impl Libraries {
self.dir.as_deref()
}
/// Set the library folder and read its libraries (unreadable files are skipped).
/// Set the library folder and read its libraries (unreadable files are skipped). The folder
/// is the app's own: automation roots don't apply to it ([`crate::file_access`]).
pub fn set_dir(&mut self, dir: Option<String>) {
self.dir = dir;
self.libs.clear();
if let Some(dir) = self.dir.clone() {
for path in library_paths(&dir) {
let id = stem_of(&path);
let lib = super::fileio::file_stamp(&path)
.filter(|(len, _)| *len <= MAX_FILE)
.and_then(|_| super::fileio::read_file(&path).ok())
.and_then(|b| serde_json::from_slice::<Library>(&b).ok());
let lib = crate::file_access::unconfined(|| {
super::fileio::file_stamp(&path).filter(|(len, _)| *len <= MAX_FILE).and_then(|_| super::fileio::read_file(&path).ok())
})
.and_then(|b| serde_json::from_slice::<Library>(&b).ok());
if let Some(mut lib) = lib {
lib.cap();
lib.sanitize();
self.libs.push((id, lib));
}
}
@@ -119,12 +165,15 @@ impl Libraries {
/// A new empty library called `name` → its id.
fn create(&mut self, name: &str) -> Result<String> {
let base: String = name.chars().map(|c| if c.is_alphanumeric() || c == ' ' || c == '-' || c == '_' { c } else { '-' }).collect();
let base = base.trim();
let base = if base.is_empty() { "Library" } else { base };
self.insert(Library { name: name.to_string(), ..Default::default() })
}
/// `lib` as a new library, its file named after it, made the current one → its id.
fn insert(&mut self, lib: Library) -> Result<String> {
let base = file_stem_for(&lib.name);
let taken = |id: &str| self.libs.iter().any(|(i, _)| i.eq_ignore_ascii_case(id)) || self.file_exists(id);
let id = std::iter::once(base.to_string()).chain((2..).map(|n| format!("{base} {n}"))).find(|id| !taken(id)).unwrap_or_default();
self.libs.push((id.clone(), Library { name: name.to_string(), ..Default::default() }));
let id = std::iter::once(base.clone()).chain((2..).map(|n| format!("{base} {n}"))).find(|id| !taken(id)).unwrap_or_default();
self.libs.push((id.clone(), lib));
if let Err(e) = self.save(&id) {
self.libs.retain(|(i, _)| i != &id);
return Err(e);
@@ -149,14 +198,17 @@ impl Libraries {
Some(std::path::Path::new(dir).join(format!("{id}.{LIBRARY_EXT}")).to_string_lossy().into_owned())
}
/// Write library `id` to its file (nothing to do without a folder).
/// Write library `id` to its file (nothing to do without a folder). The folder is the app's
/// own and ids are safe file names: automation roots don't apply ([`crate::file_access`]).
fn save(&self, id: &str) -> Result<()> {
let (Some(path), Some(lib)) = (self.path(id), self.get(id)) else { return Ok(()) };
if let Some(dir) = self.dir.as_deref() {
super::fileio::create_dir(dir)?;
}
let bytes = serde_json::to_vec(lib).map_err(|e| EngineError::Other(e.to_string()))?;
super::fileio::write_file(&path, &bytes)
crate::file_access::unconfined(|| {
if let Some(dir) = self.dir.as_deref() {
super::fileio::create_dir(dir)?;
}
super::fileio::write_file(&path, &bytes)
})
}
/// Change library `id` with `f` and save it; on a failed save the change is undone.
@@ -190,15 +242,123 @@ impl Libraries {
}
impl Library {
/// At most [`MAX_ITEMS`] of each kind (a hand-edited file may hold more).
fn cap(&mut self) {
/// Every item as (kind, key: its name, a graphic's id, name shown): colours, then character
/// and paragraph styles, then graphics.
pub fn items(&self) -> impl Iterator<Item = (ItemKind, &str, &str)> {
fn styles(kind: ItemKind, list: &[TextStyleDef]) -> impl Iterator<Item = (ItemKind, &str, &str)> {
list.iter().map(move |t| (kind, t.name.as_str(), t.name.as_str()))
}
self.colors
.iter()
.map(|c| (ItemKind::Color, c.name.as_str(), c.name.as_str()))
.chain(styles(ItemKind::CharStyle, &self.char_styles))
.chain(styles(ItemKind::ParaStyle, &self.para_styles))
.chain(self.graphics.iter().map(|g| (ItemKind::Graphic, g.id.as_str(), g.name.as_str())))
}
/// The group of each grouped item: (kind, key) → its index in [`Library::groups`].
pub fn group_index(&self) -> HashMap<(ItemKind, &str), usize> {
self.groups.iter().enumerate().flat_map(|(i, g)| g.items.iter().map(move |r| ((r.kind, r.item.as_str()), i))).collect()
}
/// Group `name` (else the one of that name ignoring case): its index.
fn group_position(&self, name: &str) -> Option<usize> {
let lower = name.to_lowercase();
self.groups.iter().position(|g| g.name == name).or_else(|| self.groups.iter().position(|g| g.name.to_lowercase() == lower))
}
/// Item `item` of `kind` (a graphic by id, else by name) as a group names it.
fn item_ref(&self, kind: ItemKind, item: &str) -> Option<ItemRef> {
let found = self.items().find(|(k, key, _)| *k == kind && *key == item);
let found = found.or_else(|| self.items().find(|(k, _, name)| *k == kind && kind == ItemKind::Graphic && *name == item));
found.map(|(kind, key, _)| ItemRef { kind, item: key.to_string() })
}
/// Take `r` out of its group.
fn ungroup(&mut self, r: &ItemRef) {
for g in &mut self.groups {
g.items.retain(|x| x != r);
}
}
/// Make a library read from a file (hand-edited, imported) safe and consistent: at most
/// [`MAX_ITEMS`] items of each kind and [`MAX_GROUPS`] groups, names clipped to [`MAX_NAME`]
/// characters and unique within their kind (a repeated one gets a number), sizes finite, a
/// thumbnail only when it is a small PNG, and groups holding only items there are, each in one
/// group at most.
fn sanitize(&mut self) {
clip(&mut self.name);
self.colors.truncate(MAX_ITEMS);
self.char_styles.truncate(MAX_ITEMS);
self.para_styles.truncate(MAX_ITEMS);
self.graphics.truncate(MAX_ITEMS);
self.groups.truncate(MAX_GROUPS);
uniquify(self.colors.iter_mut().map(|c| &mut c.name), "Color", " ", false);
uniquify(self.char_styles.iter_mut().map(|t| &mut t.name), "Character Style", " ", false);
uniquify(self.para_styles.iter_mut().map(|t| &mut t.name), "Paragraph Style", " ", false);
uniquify(self.graphics.iter_mut().map(|g| &mut g.name), "Graphic", " ", false);
uniquify(self.graphics.iter_mut().map(|g| &mut g.id), "graphic", "-", false);
uniquify(self.groups.iter_mut().map(|g| &mut g.name), "Group", " ", true);
for g in &mut self.graphics {
for v in [&mut g.width, &mut g.height] {
if !(v.is_finite() && *v >= 0.0) {
*v = 0.0;
}
}
if !thumbnail_ok(&g.thumbnail) {
g.thumbnail.clear();
}
}
let mut groups = std::mem::take(&mut self.groups);
let present: HashSet<(ItemKind, &str)> = self.items().map(|(k, key, _)| (k, key)).collect();
let mut seen = HashSet::new();
for g in &mut groups {
g.items.retain(|r| present.contains(&(r.kind, r.item.as_str())) && seen.insert(r.clone()));
}
self.groups = groups;
}
}
/// `name` cut to [`MAX_NAME`] characters.
fn clip(name: &mut String) {
if let Some((at, _)) = name.char_indices().nth(MAX_NAME) {
name.truncate(at);
}
}
/// Clip each of `names` ([`clip`]) and make it unique among them (ignoring case when `fold`): an
/// empty one becomes `fallback`, a repeated one gets a number after `sep` (`Blue 2`).
fn uniquify<'a>(names: impl Iterator<Item = &'a mut String>, fallback: &str, sep: &str, fold: bool) {
let key = |n: &str| if fold { n.to_lowercase() } else { n.to_string() };
let mut seen = HashSet::new();
for name in names {
clip(name);
if name.trim().is_empty() {
*name = fallback.to_string();
}
if seen.contains(&key(name)) {
let base = name.clone();
*name = (2..).map(|n| format!("{base}{sep}{n}")).find(|n| !seen.contains(&key(n))).unwrap_or_default();
}
seen.insert(key(name));
}
}
/// Whether `b64` is empty or a PNG of at most [`MAX_THUMBNAIL_SIDE`] pixels a side, judged by its
/// header: the panel decodes it.
fn thumbnail_ok(b64: &str) -> bool {
if b64.is_empty() {
return true;
}
// 32 base64 characters: 24 bytes, the signature then the IHDR chunk's length, type, width and
// height.
let Some(head) = b64.get(..32).filter(|_| b64.len() <= MAX_THUMBNAIL).and_then(vectorcraft_format::base64_decode) else { return false };
let side = |at: usize| {
head.get(at..at + 4).and_then(|b| <[u8; 4]>::try_from(b).ok()).map(u32::from_be_bytes).is_some_and(|v| (1..=MAX_THUMBNAIL_SIDE).contains(&v))
};
head.starts_with(b"\x89PNG\r\n\x1a\n") && head.get(12..16) == Some(b"IHDR".as_slice()) && side(16) && side(20)
}
#[cfg(not(target_arch = "wasm32"))]
fn library_paths(dir: &str) -> Vec<String> {
let Ok(rd) = std::fs::read_dir(dir) else { return vec![] };
@@ -230,6 +390,14 @@ fn remove_file(_: &str) -> Result<()> {
Ok(())
}
/// Library `name` as a file name without its extension: letters, digits, spaces, `-` and `_`
/// kept, other characters as `-` ("Library" when nothing is left).
pub fn file_stem_for(name: &str) -> String {
let safe: String = name.chars().map(|c| if c.is_alphanumeric() || c == ' ' || c == '-' || c == '_' { c } else { '-' }).collect();
let safe = safe.trim();
if safe.is_empty() { "Library".to_string() } else { safe.to_string() }
}
fn stem_of(path: &str) -> String {
std::path::Path::new(path).file_stem().map_or_else(String::new, |s| s.to_string_lossy().into_owned())
}
@@ -256,6 +424,16 @@ impl Kind {
_ => return Err(bad(cmd, "kind must be graphic, fillColor, strokeColor, charStyle or paraStyle")),
})
}
/// The kind as a group names it (fill and stroke colours are one list).
fn item_kind(self) -> ItemKind {
match self {
Kind::Graphic => ItemKind::Graphic,
Kind::FillColor | Kind::StrokeColor => ItemKind::Color,
Kind::CharStyle => ItemKind::CharStyle,
Kind::ParaStyle => ItemKind::ParaStyle,
}
}
}
pub fn specs() -> Vec<CommandSpec> {
@@ -265,7 +443,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Libraries",
[],
None,
"{} → {libraries: [{id, name, graphics, colors, charStyles, paraStyles} (counts)], current: the current library's id or null, folder: the library folder, or null when libraries last for the session only (the web, headless)}",
"{} → {libraries: [{id, name, graphics, colors, charStyles, paraStyles, groups} (counts)], current: the current library's id or null, folder: the library folder, or null when libraries last for the session only (the web, headless)}",
always,
list
),
@@ -274,7 +452,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Library",
[],
None,
"{library?: id or name (default: the current library)} → {id, name, graphics: [{id, name, width, height, thumbnail: PNG base64}], colors: [{name, hex, color}], charStyles: [{name, attrs}], paraStyles: [{name, attrs}]}",
"{library?: id or name (default: the current library)} → {id, name, graphics: [{id, name, width, height, thumbnail: PNG base64}], colors: [{name, hex, color}], charStyles: [{name, attrs}], paraStyles: [{name, attrs}], groups: [{name, items: [{kind: color|charStyle|paraStyle|graphic, item: name (graphic: id)}]}] (an item is in one group at most; the others are ungrouped)}",
always,
get
),
@@ -332,7 +510,69 @@ pub fn specs() -> Vec<CommandSpec> {
has_doc,
use_item
),
cmd!("library.removeItem", "Delete Library Item", [], None, "{library?, kind, item: name (graphic: id or name)} → null", always, remove_item),
cmd!(
"library.removeItem",
"Delete Library Item",
[],
None,
"{library?, kind, item: name (graphic: id or name)} delete an item (out of its group too) → null",
always,
remove_item
),
cmd!(
"library.createGroup",
"New Library Group",
[],
None,
"{library?, name?: \"Group\" (a name the library's groups have, ignoring case, gets a number), items?: [{kind, item}] (moved into it out of their groups)} a new group of items, after the others → {library, group: its name}",
always,
create_group
),
cmd!(
"library.renameGroup",
"Rename Library Group",
[],
None,
"{library?, group: name, name} rename a group (another group's name, ignoring case, is an error) → {library, group: the new name}",
always,
rename_group
),
cmd!(
"library.deleteGroup",
"Delete Library Group",
[],
None,
"{library?, group: name} delete a group: its items stay in the library, ungrouped → {library, ungrouped: how many}",
always,
delete_group
),
cmd!(
"library.moveItem",
"Move Library Item to Group",
[],
None,
"{library?, kind, item: name (graphic: id or name), group?: name (null or none: out of its group)} put an item in a group (out of the one it was in) → {library, kind, item, group: name or null}",
always,
move_item
),
cmd!(
query "library.export",
"Export Library",
[],
None,
"{library?: id or name (default: the current library), path?} write a library (its colours, styles, graphics with their art and thumbnails, and groups) as a .vclibrary file (JSON, as libraries are kept) → {path, name}; without path → {data: the file's text, name}",
always,
export
),
cmd!(
query "library.import",
"Import Library",
[],
None,
"{path? | data?: file text | dataBase64?, name?: the library's name (default: the file's)} add the library of a .vclibrary file (as library.export writes) as a new library, made the current one; a name another library has gets a number (`Brand (2)`) → {id, name, graphics, colors, charStyles, paraStyles, groups (counts)}",
always,
import
),
]
}
@@ -358,6 +598,7 @@ fn counts(lib: &Library) -> Value {
"colors": lib.colors.len(),
"charStyles": lib.char_styles.len(),
"paraStyles": lib.para_styles.len(),
"groups": lib.groups.len(),
})
}
@@ -387,6 +628,7 @@ fn get(s: &mut Session, p: &Value) -> Result<Value> {
"colors": lib.colors.iter().map(|c| json!({"name": c.name, "hex": c.color.to_hex(), "color": c.color})).collect::<Vec<_>>(),
"charStyles": lib.char_styles.iter().map(|t| json!({"name": t.name, "attrs": t.attrs})).collect::<Vec<_>>(),
"paraStyles": lib.para_styles.iter().map(|t| json!({"name": t.name, "attrs": t.attrs})).collect::<Vec<_>>(),
"groups": lib.groups,
}))
}
@@ -453,7 +695,8 @@ fn add(s: &mut Session, p: &Value) -> Result<Value> {
}
let base = given.clone().unwrap_or_else(|| g.name.clone());
g.name = unique(&base, |n| lib.graphics.iter().any(|o| o.name == n));
g.id = unique("graphic", |n| lib.graphics.iter().any(|o| o.id == n)).replace(' ', "-");
// Ids are `graphic`, `graphic-2`…: taken ones are looked for as they are kept.
g.id = unique("graphic", |n| lib.graphics.iter().any(|o| o.id == n.replace(' ', "-"))).replace(' ', "-");
let v = json!({ "name": g.name, "id": g.id, "existing": false });
lib.graphics.push(g);
v
@@ -678,22 +921,171 @@ fn remove_item(s: &mut Session, p: &Value) -> Result<Value> {
let (id, kind, item) = item_param(s, p, C)?;
let item = item.to_string();
s.libraries.change(&id, |lib| {
let before = (lib.graphics.len(), lib.colors.len(), lib.char_styles.len(), lib.para_styles.len());
let r = lib.item_ref(kind.item_kind(), &item).ok_or_else(|| bad(C, format!("the library has no item `{item}`")))?;
match kind {
Kind::Graphic => {
let at = lib.graphics.iter().position(|g| g.id == item).or_else(|| lib.graphics.iter().position(|g| g.name == item));
if let Some(i) = at {
lib.graphics.remove(i);
}
}
Kind::FillColor | Kind::StrokeColor => lib.colors.retain(|c| c.name != item),
Kind::CharStyle => lib.char_styles.retain(|t| t.name != item),
Kind::ParaStyle => lib.para_styles.retain(|t| t.name != item),
}
if before == (lib.graphics.len(), lib.colors.len(), lib.char_styles.len(), lib.para_styles.len()) {
return Err(bad(C, format!("the library has no item `{item}`")));
Kind::Graphic => lib.graphics.retain(|g| g.id != r.item),
Kind::FillColor | Kind::StrokeColor => lib.colors.retain(|c| c.name != r.item),
Kind::CharStyle => lib.char_styles.retain(|t| t.name != r.item),
Kind::ParaStyle => lib.para_styles.retain(|t| t.name != r.item),
}
lib.ungroup(&r);
Ok(())
})?;
ok()
}
/// `p`'s `name`, trimmed and clipped, else `default`.
fn name_param(p: &Value, default: &str) -> String {
let mut name = str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()).unwrap_or(default).to_string();
clip(&mut name);
name
}
/// `p`'s `group`.
fn group_param<'a>(p: &'a Value, cmd: &str) -> Result<&'a str> {
str_param(p, "group").ok_or_else(|| bad(cmd, "missing `group`"))
}
/// Group `name` of `lib`: its index.
fn group_at(lib: &Library, name: &str, cmd: &str) -> Result<usize> {
lib.group_position(name).ok_or_else(|| bad(cmd, format!("the library has no group `{name}`")))
}
fn create_group(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "library.createGroup";
let id = library_id(s, p, C)?;
let base = name_param(p, "Group");
let wanted = match p.get("items") {
None | Some(Value::Null) => vec![],
Some(Value::Array(items)) if items.len() <= 4 * MAX_ITEMS => items
.iter()
.map(|v| Ok((Kind::of(v, C)?.item_kind(), str_param(v, "item").ok_or_else(|| bad(C, "each of `items` is {kind, item}"))?)))
.collect::<Result<Vec<_>>>()?,
Some(_) => return Err(bad(C, "`items` is a list of {kind, item}")),
};
let name = s.libraries.change(&id, |lib| {
if lib.groups.len() >= MAX_GROUPS {
return Err(bad(C, format!("the library holds {MAX_GROUPS} groups already")));
}
let refs = wanted
.iter()
.map(|&(kind, item)| lib.item_ref(kind, item).ok_or_else(|| bad(C, format!("the library has no item `{item}`"))))
.collect::<Result<Vec<_>>>()?;
let name = unique(&base, |n| lib.group_position(n).is_some());
let mut items = vec![];
let mut seen = HashSet::new();
for r in refs {
lib.ungroup(&r);
if seen.insert(r.clone()) {
items.push(r);
}
}
lib.groups.push(LibraryGroup { name: name.clone(), items });
Ok(name)
})?;
Ok(json!({ "library": id, "group": name }))
}
fn rename_group(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "library.renameGroup";
let id = library_id(s, p, C)?;
let group = group_param(p, C)?;
if str_param(p, "name").is_none_or(|n| n.trim().is_empty()) {
return Err(bad(C, "missing `name`"));
}
let name = name_param(p, "");
s.libraries.change(&id, |lib| {
let i = group_at(lib, group, C)?;
if lib.group_position(&name).is_some_and(|j| j != i) {
return Err(bad(C, format!("the library has a group `{name}` already")));
}
if let Some(g) = lib.groups.get_mut(i) {
g.name.clone_from(&name);
}
Ok(())
})?;
Ok(json!({ "library": id, "group": name }))
}
fn delete_group(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "library.deleteGroup";
let id = library_id(s, p, C)?;
let group = group_param(p, C)?;
let ungrouped = s.libraries.change(&id, |lib| {
let i = group_at(lib, group, C)?;
Ok(if i < lib.groups.len() { lib.groups.remove(i).items.len() } else { 0 })
})?;
Ok(json!({ "library": id, "ungrouped": ungrouped }))
}
fn move_item(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "library.moveItem";
let (id, kind, item) = item_param(s, p, C)?;
let group = str_param(p, "group");
let (key, to) = s.libraries.change(&id, |lib| {
let r = lib.item_ref(kind.item_kind(), item).ok_or_else(|| bad(C, format!("the library has no item `{item}`")))?;
let at = group.map(|g| group_at(lib, g, C)).transpose()?;
lib.ungroup(&r);
let key = r.item.clone();
let to = at.and_then(|i| lib.groups.get_mut(i)).map(|g| {
g.items.push(r);
g.name.clone()
});
Ok((key, to))
})?;
Ok(json!({ "library": id, "kind": str_param(p, "kind"), "item": key, "group": to }))
}
fn export(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "library.export";
let id = library_id(s, p, C)?;
let lib = s.libraries.get(&id).ok_or_else(|| bad(C, format!("no library `{id}`")))?;
let text = serde_json::to_string(lib).map_err(|e| bad(C, e.to_string()))?;
let name = lib.name.clone();
match str_param(p, "path") {
Some(path) => {
super::fileio::write_file(path, text.as_bytes())?;
Ok(json!({ "path": path, "name": name }))
}
None => Ok(json!({ "data": text, "name": name })),
}
}
fn import(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "library.import";
let too_big = || bad(C, format!("the file is larger than {} MB", MAX_FILE >> 20));
let bytes: std::borrow::Cow<[u8]> = match (str_param(p, "path"), str_param(p, "data"), str_param(p, "dataBase64")) {
(Some(path), ..) => {
if super::fileio::file_stamp(path).is_some_and(|(len, _)| len > MAX_FILE) {
return Err(too_big());
}
super::fileio::read_file(path)?.into()
}
(None, Some(text), _) => text.as_bytes().into(),
(None, None, Some(b64)) => vectorcraft_format::base64_decode(b64).ok_or_else(|| bad(C, "bad dataBase64"))?.into(),
_ => return Err(bad(C, "give `path`, `data` or `dataBase64`")),
};
if bytes.len() as u64 > MAX_FILE {
return Err(too_big());
}
let not_library = |e: String| bad(C, format!("not a library file: {e}"));
let v: Value = serde_json::from_slice(&bytes).map_err(|e| not_library(e.to_string()))?;
drop(bytes);
// A library names itself; another file of ours (swatches, styles) says what it is.
if !v.get("name").is_some_and(Value::is_string) || v.get("format").is_some_and(|f| f.as_str() != Some(LIBRARY_EXT)) {
return Err(not_library("it has no library name".into()));
}
let mut lib: Library = serde_json::from_value(v).map_err(|e| not_library(e.to_string()))?;
lib.sanitize();
let base = str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()).unwrap_or(lib.name.trim()).to_string();
let mut base = if base.is_empty() { "Library".to_string() } else { base };
clip(&mut base);
let taken = |n: &str| s.libraries.all().iter().any(|(_, l)| l.name.to_lowercase() == n.to_lowercase());
lib.name = std::iter::once(base.clone()).chain((2..).map(|n| format!("{base} ({n})"))).find(|n| !taken(n)).unwrap_or(base);
let mut r = json!({ "name": lib.name });
if let (Some(o), Value::Object(c)) = (r.as_object_mut(), counts(&lib)) {
o.extend(c);
}
r["id"] = json!(s.libraries.insert(lib)?);
Ok(r)
}
+55 -10
View File
@@ -196,7 +196,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Live Shape Properties",
[],
None,
"{id?, ids?, radius?: pt, kind?: \"round\"|\"invertedRound\"|\"chamfer\", corners?: [i…], sides?: n, pieStart?, pieEnd?: degrees (an ellipse's pie, counterclockwise from 3 o'clock; 0 to 360 is the whole ellipse), invertPie?: true (swaps them: the other part of the ellipse)} (Live Corners on any path: radius and kind set its corners (anchors without handles between two straight sides): `corners` (anchor indices of the path with its corners uncut, counting every subpath's anchors in order: a rectangle's 0 top-left, 1 top-right, 2 bottom-right, 3 bottom-left; a polygon's from the first vertex clockwise), else the corners with a Direct-Selected anchor, else every corner; each radius is drawn no larger than half the corner's shorter side allows; a path that isn't a live shape keeps its uncut outline so its corners stay editable, and is plain again once none is cut; sides: a polygon's, which keep the radius they shared)",
"{id?, ids?, radius?: pt, kind?: \"round\"|\"invertedRound\"|\"chamfer\", corners?: [i…], items?: [{id, corners?: [i…]}] (several objects, each with its own corners, in place of ids and corners: the canvas's corner widgets across a selection), sides?: n, polygonAngle?: degrees (counterclockwise from its first vertex straight up), polygonRadius?: pt (centre to vertex), sideLength?: pt (sets the radius), makeSidesEqual?: true (a polygon scaled unevenly: drops the uneven scale and shear, keeping its centre, angle and mean radius), pieStart?, pieEnd?: degrees (an ellipse's pie, counterclockwise from 3 o'clock; 0 to 360 is the whole ellipse), invertPie?: true (swaps them: the other part of the ellipse)} (Live Corners on any path: radius and kind set its corners (anchors without handles between two straight sides): `corners` (anchor indices of the path with its corners uncut, counting every subpath's anchors in order: a rectangle's 0 top-left, 1 top-right, 2 bottom-right, 3 bottom-left; a polygon's from the first vertex clockwise), else the corners with a Direct-Selected anchor, else every corner; each radius is drawn no larger than half the corner's shorter side allows; a path that isn't a live shape keeps its uncut outline so its corners stay editable, and is plain again once none is cut; sides: a polygon's, which keep the radius they shared; the polygon params apply in that order: sides, makeSidesEqual, polygonAngle, then sideLength or else polygonRadius)",
has_selection,
set_live_shape
),
@@ -1083,8 +1083,32 @@ fn corners_param(p: &Value) -> Result<Option<BTreeSet<usize>>> {
}
fn set_live_shape(s: &mut Session, p: &Value) -> Result<Value> {
let ids = targets(s, p)?;
let corners = corners_param(p)?;
// `items`: objects each with its own `corners`; else `ids` (or the selection) sharing them.
let items = match p.get("items").filter(|v| !v.is_null()) {
None => None,
Some(Value::Array(a)) => Some(
a.iter()
.map(|it| {
let id = it
.get("id")
.and_then(Value::as_u64)
.map(NodeId)
.ok_or_else(|| bad("object.setLiveShape", format!("each of `items` is {{id, corners?}} with an object id, not {it}")))?;
Ok((id, corners_param(it)?))
})
.collect::<Result<Vec<_>>>()?,
),
Some(v) => return Err(bad("object.setLiveShape", format!("`items` must be an array of {{id, corners?}}, not {v}"))),
};
let ids = match &items {
Some(items) => items.iter().map(|(id, _)| *id).collect(),
None => targets(s, p)?,
};
let shared = corners_param(p)?;
let corners_of = |id: NodeId| match &items {
Some(items) => items.iter().find(|(i, _)| *i == id).and_then(|(_, c)| c.clone()),
None => shared.clone(),
};
let kind = p
.get("kind")
.filter(|v| !v.is_null())
@@ -1095,24 +1119,44 @@ fn set_live_shape(s: &mut Session, p: &Value) -> Result<Value> {
.transpose()?;
let radius = p.get("radius").and_then(Value::as_f64);
let sides = p.get("sides").and_then(Value::as_u64);
// An ellipse's pie (Ellipse Properties: Pie Start and End Angle, degrees; Invert Pie swaps them).
let angle = |k: &str| -> Result<Option<f64>> {
// A number that must be `what` (None when absent).
let number = |k: &str, ok: fn(f64) -> bool, what: &str| -> Result<Option<f64>> {
match p.get(k) {
None | Some(Value::Null) => Ok(None),
Some(v) => v
.as_f64()
.filter(|a| a.is_finite())
.map(|a| Some(a.rem_euclid(360.0)))
.ok_or_else(|| bad("object.setLiveShape", format!("`{k}` must be an angle in degrees, not {v}"))),
.filter(|x| x.is_finite() && ok(*x))
.map(Some)
.ok_or_else(|| bad("object.setLiveShape", format!("`{k}` must be {what}, not {v}"))),
}
};
let angle = |k: &str| Ok::<_, EngineError>(number(k, |_| true, "an angle in degrees")?.map(|a| a.rem_euclid(360.0)));
let length = |k: &str| number(k, |x| x > 0.0 && x <= crate::MAX_COORD, "a length in points above 0");
// An ellipse's pie (Ellipse Properties: Pie Start and End Angle, degrees; Invert Pie swaps them).
let (pie_start, pie_end) = (angle("pieStart")?, angle("pieEnd")?);
let invert = p.get("invertPie").and_then(Value::as_bool) == Some(true);
// A polygon's Polygon Properties: its angle, radius or side length, Make Sides Equal.
let (polygon_angle, polygon_radius, side) = (angle("polygonAngle")?, length("polygonRadius")?, length("sideLength")?);
let equal = p.get("makeSidesEqual").and_then(Value::as_bool) == Some(true);
s.edit("Live Shape", |d, sel| {
for id in &ids {
let Some(NodeKind::Path { path, live, .. }) = d.node_mut(*id).map(|n| &mut n.kind) else { continue };
if let (Some(l @ LiveShape::Polygon { .. }), Some(n)) = (live.as_mut(), sides) {
l.set_sides(n);
if let Some(l @ LiveShape::Polygon { .. }) = live.as_mut()
&& (sides.is_some() || polygon_angle.is_some() || polygon_radius.is_some() || side.is_some() || equal)
{
if let Some(n) = sides {
l.set_sides(n);
}
if equal {
l.make_sides_equal();
}
if let Some(a) = polygon_angle {
l.set_polygon_angle(a);
}
// A side length sets the radius that gives it (with the new side count).
if let Some(r) = side.and_then(|len| l.polygon_radius_for_side(len)).or(polygon_radius) {
l.set_polygon_radius(r);
}
*path = l.to_path();
}
if let Some(l @ LiveShape::Ellipse { .. }) = live.as_mut()
@@ -1134,6 +1178,7 @@ fn set_live_shape(s: &mut Session, p: &Value) -> Result<Value> {
let partial = sel.anchors.get_mut(id);
// The corners to edit, the Direct-Selected ones and the anchor layout before the edit.
let Some(c) = LiveCorners::new(path, live.as_ref()) else { continue };
let corners = corners_of(*id);
let picked = match &corners {
Some(k) if k.last().is_some_and(|k| *k >= c.base.anchor_count()) => {
let n = c.base.anchor_count();
+37 -23
View File
@@ -4,7 +4,7 @@ use serde_json::{Value, json};
use vectorcraft_color::{BlendMode, Color, GradientPaint, Paint};
use vectorcraft_doc::{Appearance, CharStyle, Document, Node, NodeId, NodeKind};
use super::appearance::{ItemTarget, appearance_targets, edit_items, item_target};
use super::appearance::{ItemTarget, appearance_targets, edit_items, edit_items_then, item_target};
use super::gradient::{item_paint_bounds, place_paint, place_run_paint, run_paint_mut, run_stroke_weight, unplaced};
use super::*;
use crate::{DocState, EngineError};
@@ -385,30 +385,44 @@ fn apply_paint(s: &mut Session, p: &Value, paint: Paint, fill: bool) -> Result<V
s.fill_active = fill;
}
let item = item_target(s, p, cmd)?.of_kind(s, fill);
let ids = item.targets(s, p)?;
edit_items(s, &ids, item, cmd, if fill { "Fill Color" } else { "Stroke Color" }, fill, |n, index| {
if index.is_none()
&& let NodeKind::Text(t) = &mut n.kind
{
let (xf, lb) = (t.xf, t.local_bounds());
for r in &mut t.runs {
if !fill && !paint.is_none() {
run_stroke_weight(&mut r.style);
let selected = item.targets(s, p)?;
let ids = super::graph::paint_targets(&s.doc()?.doc, &selected);
let label = if fill { "Fill Color" } else { "Stroke Color" };
edit_items_then(
s,
&ids,
item,
cmd,
label,
fill,
|n, index| {
if index.is_none()
&& let NodeKind::Text(t) = &mut n.kind
{
let (xf, lb) = (t.xf, t.local_bounds());
for r in &mut t.runs {
if !fill && !paint.is_none() {
run_stroke_weight(&mut r.style);
}
let (cur, b) = run_paint_mut(r, !fill, lb);
*cur = place_run_paint(&paint, p, xf, b);
}
let (cur, b) = run_paint_mut(r, !fill, lb);
*cur = place_run_paint(&paint, p, xf, b);
return Ok(());
}
return Ok(());
}
// A missing top fill or stroke is created first: a new stroke's weight sizes the box its
// gradient fits.
if n.appearance.paint_at(index, fill).is_none() {
n.appearance.set_paint_at(index, fill, Paint::None);
}
let placed = place_paint(&paint, p, item_paint_bounds(n, index, fill));
n.appearance.set_paint_at(index, fill, placed);
Ok(())
})?;
// A missing top fill or stroke is created first: a new stroke's weight sizes the box its
// gradient fits.
if n.appearance.paint_at(index, fill).is_none() {
n.appearance.set_paint_at(index, fill, Paint::None);
}
let placed = place_paint(&paint, p, item_paint_bounds(n, index, fill));
n.appearance.set_paint_at(index, fill, placed);
Ok(())
},
|d, changed| {
super::graph::capture_series_paints(d, changed, fill);
Ok(())
},
)?;
s.remember_paint(&paint);
ok()
}
+6 -1
View File
@@ -110,6 +110,7 @@ fn install(_: &mut Session, p: &Value) -> Result<Value> {
#[cfg(not(target_arch = "wasm32"))]
fn load_path(path: &str) -> Result<Plugin> {
crate::file_access::check_read(path).map_err(EngineError::Other)?;
registry::load_file(std::path::Path::new(path), vectorcraft_plugins::Limits::default()).map_err(err)
}
@@ -137,7 +138,11 @@ pub(crate) fn sync_prefs(s: &Session) {
#[cfg(not(target_arch = "wasm32"))]
fn load_folder(folder: &str) -> Value {
match registry::load_folder(std::path::Path::new(folder)) {
if let Err(e) = crate::file_access::check_read(folder) {
return json!({"folder": folder, "error": e});
}
// A file in it may be a link to one elsewhere.
match registry::load_folder_where(std::path::Path::new(folder), |f| crate::file_access::check_read(&f.to_string_lossy())) {
Ok(r) => {
json!({"folder": folder, "loaded": r.loaded, "failed": r.failed.iter().map(|(f, e)| json!({"file": f, "error": e})).collect::<Vec<_>>()})
}
+41 -1
View File
@@ -449,6 +449,38 @@ impl Prefs {
Ok(())
}
/// The folders the app reads (fonts, plug-ins) or writes (Data Recovery copies, the Templates
/// folder it makes for its dialogs) on its own, with the access each needs.
fn folders_mut(&mut self) -> [(&mut String, crate::file_access::Access); 4] {
use crate::file_access::Access;
[
(&mut self.fonts_folder, Access::Read),
(&mut self.plugins_folder, Access::Read),
(&mut self.recovery_folder, Access::Write),
(&mut self.templates_folder, Access::Write),
]
}
/// Put back the folders `next` sets in place of these preferences' that the automation roots
/// in force refuse: automation may only point them inside its roots ([`crate::file_access`]).
/// → why each was refused. Nothing is refused without roots, nor a folder cleared or kept.
fn keep_folders(&self, next: &mut Prefs) -> Vec<String> {
let Some(roots) = crate::file_access::current() else { return vec![] };
let mut before = self.clone();
let mut refused = vec![];
for ((old, _), (new, access)) in before.folders_mut().into_iter().zip(next.folders_mut()) {
let folder = new.trim();
if folder.is_empty() || folder == old.trim() {
continue;
}
if let Err(e) = roots.check(folder, access) {
refused.push(e);
*new = old.clone();
}
}
refused
}
/// Values of one category (`None` = all) reset to defaults.
pub fn reset(&mut self, category: Option<&str>) {
let d = Prefs::default().to_json();
@@ -470,7 +502,12 @@ impl Prefs {
impl Session {
/// Replace the preferences and push the ones with engine-side consumers into open documents
/// and the renderer (grid, history depth, render threads).
pub fn apply_prefs(&mut self, p: Prefs) {
pub fn apply_prefs(&mut self, mut p: Prefs) {
// However they are set (a dialog driven by automation too), the folders stay inside the
// automation roots in force: a refused one keeps its value.
for e in self.prefs.keep_folders(&mut p) {
log::warn!("{e}");
}
let grid_changed = p.gridline_every != self.prefs.gridline_every || p.grid_subdivisions != self.prefs.grid_subdivisions;
let history_changed = p.history_states != self.prefs.history_states;
let tile_edge_changed = p.pattern_tile_edge_color != self.prefs.pattern_tile_edge_color;
@@ -554,6 +591,9 @@ fn set(s: &mut Session, p: &Value) -> Result<Value> {
}
let mut next = s.prefs.clone();
next.set_values(&values).map_err(|e| bad("prefs.set", e))?;
if let Some(e) = s.prefs.keep_folders(&mut next).into_iter().next() {
return Err(EngineError::Other(e));
}
s.apply_prefs(next);
// Units ▸ General is also the open document's units (as Document Setup sets them).
if values.contains_key("unitsGeneral") && s.active().is_some() {
+16 -3
View File
@@ -216,8 +216,20 @@ fn none(s: &mut Session, _: &Value) -> Result<Value> {
}
fn reselect(s: &mut Session, _: &Value) -> Result<Value> {
let Some((c, p)) = s.doc()?.last_selection_cmd.clone() else { return ok() };
s.execute(&c, &p)
let st = s.doc()?;
let Some((c, p, from)) = st.last_selection_cmd.clone() else { return ok() };
// After Deselect, a Same command takes its reference again from the objects that were selected
// when it ran, those still there (#903).
let from: Vec<NodeId> = from.into_iter().filter(|id| st.doc.node(*id).is_some()).collect();
let restore = st.selection.objects.is_empty() && !from.is_empty();
if restore {
s.select(|_, sel| sel.set(from))?;
}
let r = s.execute(&c, &p);
if restore && r.is_err() {
s.select(|_, sel| sel.clear())?;
}
r
}
fn inverse(s: &mut Session, _: &Value) -> Result<Value> {
@@ -401,8 +413,9 @@ fn select_where(s: &mut Session, cmd: &str, p: &Value, f: impl Fn(&Document, &No
for l in st.doc.layers.iter().filter(|l| l.visible && !l.locked) {
visit(&st.doc, l, &f, &mut ids);
}
let from = st.selection.objects.clone();
s.select(|_, sel| sel.set(ids.iter().copied()))?;
s.doc_mut()?.last_selection_cmd = Some((cmd.to_string(), p.clone()));
s.doc_mut()?.last_selection_cmd = Some((cmd.to_string(), p.clone(), from));
Ok(json!({ "count": ids.len() }))
}
+2 -2
View File
@@ -215,9 +215,9 @@ fn unitized(mut ap: Appearance, from: Option<Rect>) -> Appearance {
/// The appearance a new or redefined style takes from `n`: its own. A group without fills or
/// strokes of its own lends those of its topmost painted object, and type its characters' fill
/// and stroke. Placed gradients come relative to the unit box (from the bounds of the object
/// they're on).
/// they're on). The pen pressure of strokes stays with their own path.
pub(crate) fn captured(n: &Node) -> Appearance {
let mut ap = n.appearance.clone();
let mut ap = n.appearance.clone().without_pressure();
if !ap.items.is_empty() {
return unitized(ap, n.geometric_bounds());
}
+11 -9
View File
@@ -151,13 +151,14 @@ impl<L: LibraryFile> Libraries<L> {
self.rescan();
}
/// Re-read the library files of the user library folder (unreadable ones are skipped).
/// Re-read the library files of the user library folder (unreadable ones are skipped). The
/// folder is the app's own: automation roots don't apply to it ([`crate::file_access`]).
pub fn rescan(&mut self) {
self.extra.retain(|e| e.info.category != "user");
let Some(dir) = self.user_dir.clone() else { return };
for path in library_files(&dir, L::EXTS) {
let file = file_name(&path);
let Some(lib) = read_file(&path).ok().and_then(|b| L::read(&b, stem(&file)).ok()) else { continue };
let Some(lib) = crate::file_access::unconfined(|| read_file(&path)).ok().and_then(|b| L::read(&b, stem(&file)).ok()) else { continue };
let info = LibraryInfo { id: format!("user/{file}"), name: lib.name().to_string(), category: "user" };
self.extra.push(Extra { info, path: Some(path), lib: Arc::new(lib) });
}
@@ -183,9 +184,10 @@ impl<L: LibraryFile> Libraries<L> {
/// The User Defined library whose file has the extension of file name `file` and holds `bytes`.
fn user_file(&self, file: &str, bytes: &[u8]) -> Option<&Extra<L>> {
let ext = extension(file);
self.extra
.iter()
.find(|e| e.info.category == "user" && e.path.as_deref().is_some_and(|p| extension(p) == ext && read_file(p).is_ok_and(|b| b == bytes)))
self.extra.iter().find(|e| {
e.info.category == "user"
&& e.path.as_deref().is_some_and(|p| extension(p) == ext && crate::file_access::unconfined(|| read_file(p)).is_ok_and(|b| b == bytes))
})
}
/// Write library file `data` as the save commands do: into the user library folder as
@@ -203,8 +205,8 @@ impl<L: LibraryFile> Libraries<L> {
.to_string();
let file = library_file_name(name, ext);
let path = std::path::Path::new(&dir).join(&file).to_string_lossy().to_string();
create_dir(&dir)?;
write_file(&path, bytes)?;
// The app's own folder, under a name made safe: no root applies.
crate::file_access::unconfined(|| create_dir(&dir).and_then(|()| write_file(&path, bytes)))?;
self.rescan();
out["path"] = json!(path);
out["library"] = json!(format!("user/{file}"));
@@ -277,11 +279,11 @@ impl<L: LibraryFile> Libraries<L> {
let (user_id, written) = match self.user_file(&file, &bytes).map(|e| e.info.id.clone()) {
Some(same) => (same, None),
None => {
create_dir(&dir)?;
let mut taken: HashSet<String> = library_files(&dir, L::EXTS).iter().map(|p| file_name(p).to_lowercase()).collect();
let file = free_name(&file, &mut taken);
let path = std::path::Path::new(&dir).join(&file).to_string_lossy().to_string();
write_new_file(&path, &bytes)?;
// The app's own folder, under a name made safe: no root applies.
crate::file_access::unconfined(|| create_dir(&dir).and_then(|()| write_new_file(&path, &bytes)))?;
self.rescan();
(format!("user/{file}"), Some(path))
}
+441
View File
@@ -0,0 +1,441 @@
//! The folders automation may read and write (#832).
//!
//! `vectorcraft-cli mcp --automation-read-root <dir> --automation-write-root <dir>`, and
//! `vectorcraft --control <port>` with the same flags, confine the files an agent's commands
//! reach. [`AutomationRoots`] holds the two folders. While they are in force, every path a command
//! reads goes through [`check_read`] and every path it writes through [`check_write`]:
//!
//! - The path is made absolute against the working directory, as the file system reads it. Its
//! links are followed: for a file that doesn't exist yet, the links of its nearest existing
//! folder, the rest of the path being plain names (`..` below a missing folder is refused, and so
//! is a link that leads nowhere).
//! - The result must lie inside the root, compared by whole components (`/work2` isn't inside
//! `/work`), without regard to case on Windows.
//! - Read and write access are separate: a root given alone grants only its own kind of access.
//!
//! Roots are in force on a thread inside [`confine`], and in the whole process after
//! [`confine_process`] (`vectorcraft-cli mcp`, the desktop app with a control port), except inside
//! [`unconfined`]: what the person at the keyboard does, and the app's own folders (preferences,
//! Data Recovery, the library folders, VectorCraft's Fonts folder), which no agent names. Without
//! roots nothing is checked.
//!
//! A path is checked, then opened: a process that can change the folders in between (swapping in
//! a link) can still win that race. The roots keep an agent to the files it was given; they don't
//! guard against other programs on the computer.
use std::cell::RefCell;
use std::ffi::OsString;
use std::path::{Component, Path, PathBuf};
use std::sync::{Arc, OnceLock};
/// Read access or write access.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Access {
Read,
Write,
}
/// The folders automation may read and write; a root left out grants no access of its kind.
#[derive(Debug)]
pub struct AutomationRoots {
read: Option<Root>,
write: Option<Root>,
}
#[derive(Debug)]
struct Root {
/// The folder made absolute, links followed: what paths are compared with.
resolved: PathBuf,
/// The same without Windows' `\\?\` prefix, for people.
shown: PathBuf,
}
impl Root {
/// The folder at `path`, which must exist.
fn open(path: &Path, access: Access) -> Result<Self, String> {
let kind = match access {
Access::Read => "read",
Access::Write => "write",
};
let resolved = std::fs::canonicalize(path)
.and_then(|p| if p.is_dir() { Ok(p) } else { Err(std::io::ErrorKind::NotADirectory.into()) })
.map_err(|e| format!("cannot open the automation {kind} root `{}`: {e}", path.display()))?;
Ok(Self { shown: crate::cmd::findfiles::plain(resolved.clone()), resolved })
}
}
impl AutomationRoots {
/// The roots `read` and `write`, which must be existing folders; `None` when neither is given
/// (nothing is confined then).
pub fn new(read: Option<&Path>, write: Option<&Path>) -> Result<Option<Arc<Self>>, String> {
if read.is_none() && write.is_none() {
return Ok(None);
}
let read = read.map(|p| Root::open(p, Access::Read)).transpose()?;
let write = write.map(|p| Root::open(p, Access::Write)).transpose()?;
Ok(Some(Arc::new(Self { read, write })))
}
/// The read root, absolute with its links followed.
pub fn read_root(&self) -> Option<&Path> {
self.read.as_ref().map(|r| r.shown.as_path())
}
/// The write root, absolute with its links followed.
pub fn write_root(&self) -> Option<&Path> {
self.write.as_ref().map(|r| r.shown.as_path())
}
/// May the file or folder at `path` be read (or written)? `Err` says why not.
pub fn check(&self, path: &str, access: Access) -> Result<(), String> {
let root = match access {
Access::Read => self.read.as_ref(),
Access::Write => self.write.as_ref(),
};
let Some(Root { resolved: folder, shown }) = root else {
return match access {
Access::Read => Err(format!("automation filesystem access is not granted: read authority is absent: {path}")),
Access::Write => Err(format!("automation filesystem access is not granted: write authority is absent: {path}")),
};
};
if inside(&resolve(path)?, folder) {
return Ok(());
}
let root = shown.display();
match access {
Access::Read => Err(format!("automation path rejected: outside the read root {root}: {path}")),
Access::Write => Err(format!("automation path rejected: outside the write root {root}: {path}")),
}
}
}
/// Where the roots are in force on a thread ([`confine`], [`unconfined`]).
#[derive(Clone, Debug)]
enum Scope {
Confined(Arc<AutomationRoots>),
Unconfined,
}
thread_local! {
/// This thread's scope; `None`: the process's roots ([`confine_process`]).
static SCOPE: RefCell<Option<Scope>> = const { RefCell::new(None) };
}
/// The roots in force in every thread outside [`confine`] and [`unconfined`].
static PROCESS: OnceLock<Arc<AutomationRoots>> = OnceLock::new();
/// Put `roots` in force in the whole process, for good. `false` when roots were in force already
/// (they stay as they were).
pub fn confine_process(roots: Arc<AutomationRoots>) -> bool {
PROCESS.set(roots).is_ok()
}
/// Puts the scope it replaced back when dropped (also when `f` panics).
struct Restore(Option<Scope>);
impl Drop for Restore {
fn drop(&mut self) {
let previous = self.0.take();
SCOPE.with(|s| *s.borrow_mut() = previous);
}
}
fn with_scope<T>(scope: Option<Scope>, f: impl FnOnce() -> T) -> T {
let _restore = Restore(SCOPE.with(|s| s.replace(scope)));
f()
}
/// Run `f` with `roots` in force on this thread (`None`: as things stand).
pub fn confine<T>(roots: Option<&Arc<AutomationRoots>>, f: impl FnOnce() -> T) -> T {
match roots {
Some(r) => with_scope(Some(Scope::Confined(r.clone())), f),
None => f(),
}
}
/// Run `f` with no roots in force on this thread: what the person at the keyboard does, and the
/// app's own folders.
pub fn unconfined<T>(f: impl FnOnce() -> T) -> T {
with_scope(Some(Scope::Unconfined), f)
}
/// This thread's scope, to carry to work it hands to another thread ([`ScopeHandle::run`]).
#[derive(Clone, Debug)]
pub struct ScopeHandle(Option<Scope>);
impl ScopeHandle {
/// The scope of the calling thread.
pub fn capture() -> Self {
Self(SCOPE.with(|s| s.borrow().clone()))
}
/// Run `f` in the captured scope.
pub fn run<T>(self, f: impl FnOnce() -> T) -> T {
with_scope(self.0, f)
}
}
/// The roots in force on this thread, if any.
pub fn current() -> Option<Arc<AutomationRoots>> {
match SCOPE.with(|s| s.borrow().clone()) {
Some(Scope::Confined(r)) => Some(r),
Some(Scope::Unconfined) => None,
None => PROCESS.get().cloned(),
}
}
/// May `path` be read here? Always, without roots in force.
pub fn check_read(path: &str) -> Result<(), String> {
current().map_or(Ok(()), |r| r.check(path, Access::Read))
}
/// May `path` be written here? Always, without roots in force.
pub fn check_write(path: &str) -> Result<(), String> {
current().map_or(Ok(()), |r| r.check(path, Access::Write))
}
/// `path` made absolute with its links followed; for a path that doesn't exist (yet), its nearest
/// existing folder's links followed and the rest appended.
fn resolve(path: &str) -> Result<PathBuf, String> {
if path.is_empty() {
return Err("automation path rejected: path is empty".into());
}
// Checked as given: making `…\NUL` absolute gives the device path `\\.\NUL` on Windows.
if cfg!(windows) && Path::new(path).components().any(|c| matches!(c, Component::Normal(n) if is_device_name(&n.to_string_lossy()))) {
return Err(format!("automation path rejected: reserved device names are not allowed: {path}"));
}
let absolute = std::path::absolute(path).map_err(|e| format!("automation path rejected: cannot resolve {path}: {e}"))?;
// The names below the nearest existing folder, last first.
let mut missing: Vec<OsString> = vec![];
let mut at = absolute.as_path();
loop {
match std::fs::canonicalize(at) {
Ok(mut found) => {
found.extend(missing.iter().rev());
return Ok(found);
}
Err(e) => match std::fs::symlink_metadata(at) {
Ok(m) if m.file_type().is_symlink() => return Err(format!("automation path rejected: a link that leads nowhere: {path}")),
// There, yet its real path can't be had (no permission): nothing to compare.
Ok(_) => return Err(format!("automation path rejected: cannot resolve {path}: {e}")),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => return Err(format!("automation path rejected: cannot resolve {path}: {e}")),
},
}
let parent = at.parent();
match (at.components().next_back(), parent) {
(Some(Component::Normal(name)), Some(parent)) => {
missing.push(name.to_os_string());
at = parent;
}
(Some(Component::ParentDir), _) => {
return Err(format!("automation path rejected: parent traversal below a folder that doesn't exist: {path}"));
}
// Down to the root (or drive) and it doesn't exist either.
_ => {
let e = std::io::Error::from(std::io::ErrorKind::NotFound);
return Err(format!("automation path rejected: cannot resolve {path}: {e}"));
}
}
}
}
/// Is `path` (resolved) `root` or inside it, component by component (without regard to case on
/// Windows, whose file systems ignore it)?
fn inside(path: &Path, root: &Path) -> bool {
let mut path = path.components();
root.components().all(|r| path.next().is_some_and(|p| same_component(p, r)))
}
fn same_component(a: Component, b: Component) -> bool {
if a == b {
return true;
}
if !cfg!(windows) {
return false;
}
match (a.as_os_str().to_str(), b.as_os_str().to_str()) {
(Some(a), Some(b)) => a.to_lowercase() == b.to_lowercase(),
_ => false,
}
}
/// A Windows device name (`CON`, `NUL`, `COM1`… also with an extension), which names no file.
fn is_device_name(component: &str) -> bool {
let stem = component.split('.').next().unwrap_or(component).trim_end_matches(' ').to_ascii_uppercase();
let numbered = |prefix: &str| stem.strip_prefix(prefix).is_some_and(|n| matches!(n, "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9"));
matches!(stem.as_str(), "CON" | "PRN" | "AUX" | "NUL" | "CLOCK$" | "CONIN$" | "CONOUT$") || numbered("COM") || numbered("LPT")
}
#[cfg(test)]
mod tests {
use super::*;
/// A fresh folder holding `in` (read root), `out` (write root) and `outside`.
fn folders(name: &str) -> (PathBuf, Arc<AutomationRoots>) {
let base = std::env::temp_dir().join(format!("vc-file-access-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&base);
for d in ["in/sub", "out", "outside", "in2"] {
std::fs::create_dir_all(base.join(d)).unwrap();
}
std::fs::write(base.join("in/a.svg"), "<svg/>").unwrap();
std::fs::write(base.join("outside/secret.svg"), "<svg/>").unwrap();
let roots = AutomationRoots::new(Some(&base.join("in")), Some(&base.join("out"))).unwrap().unwrap();
(base, roots)
}
fn s(p: PathBuf) -> String {
p.to_string_lossy().into_owned()
}
#[test]
fn paths_inside_the_roots_pass_and_others_dont() {
let (base, roots) = folders("inside");
assert_eq!(roots.check(&s(base.join("in/a.svg")), Access::Read), Ok(()));
assert_eq!(roots.check(&s(base.join("in")), Access::Read), Ok(()), "the root itself");
assert_eq!(roots.check(&s(base.join("in/sub/new.svg")), Access::Read), Ok(()), "missing files are checked by their folder");
assert_eq!(roots.check(&s(base.join("out/new/deeper/x.png")), Access::Write), Ok(()), "and by their nearest folder");
let e = roots.check(&s(base.join("outside/secret.svg")), Access::Read).unwrap_err();
assert!(e.starts_with("automation path rejected: outside the read root"), "{e}");
// Read and write access are separate.
let e = roots.check(&s(base.join("in/a.svg")), Access::Write).unwrap_err();
assert!(e.contains("outside the write root"), "{e}");
assert!(roots.check(&s(base.join("out/x.png")), Access::Read).unwrap_err().contains("outside the read root"));
let _ = std::fs::remove_dir_all(base);
}
#[test]
fn a_sibling_with_the_same_prefix_is_outside() {
let (base, roots) = folders("prefix");
std::fs::write(base.join("in2/b.svg"), "<svg/>").unwrap();
assert!(roots.check(&s(base.join("in2/b.svg")), Access::Read).is_err(), "in2 is not inside in");
assert!(roots.check(&format!("{}2/c.svg", s(base.join("in"))), Access::Read).is_err());
let _ = std::fs::remove_dir_all(base);
}
#[test]
fn parent_traversal_is_resolved_or_refused() {
let (base, roots) = folders("dotdot");
let sep = std::path::MAIN_SEPARATOR;
let up = format!("{}{sep}..{sep}outside{sep}secret.svg", s(base.join("in")));
assert!(roots.check(&up, Access::Read).unwrap_err().contains("outside the read root"), "`..` out of the root");
let back = format!("{}{sep}..{sep}a.svg", s(base.join("in/sub")));
assert_eq!(roots.check(&back, Access::Read), Ok(()), "`..` that stays inside");
let missing = format!("{}{sep}nope{sep}..{sep}..{sep}outside{sep}x.svg", s(base.join("out")));
let e = roots.check(&missing, Access::Write).unwrap_err();
// Windows resolves `..` by name first (so does opening the file there): then it's outside.
assert!(e.contains("parent traversal below a folder that doesn't exist") || e.contains("outside the write root"), "{e}");
let _ = std::fs::remove_dir_all(base);
}
#[test]
fn relative_paths_resolve_against_the_working_directory() {
let (base, roots) = folders("relative");
// The tests' working directory is the crate's folder: not inside the roots.
assert!(roots.check("a.svg", Access::Read).unwrap_err().contains("outside the read root"));
assert!(roots.check("", Access::Read).unwrap_err().contains("path is empty"));
let _ = std::fs::remove_dir_all(base);
}
#[test]
fn a_missing_root_is_an_error_and_none_is_no_confinement() {
let (base, _) = folders("missing");
let e = AutomationRoots::new(Some(&base.join("nope")), None).unwrap_err();
assert!(e.starts_with("cannot open the automation read root"), "{e}");
let e = AutomationRoots::new(None, Some(&base.join("in/a.svg"))).unwrap_err();
assert!(
e.starts_with("cannot open the automation write root")
&& e.ends_with(&std::io::Error::from(std::io::ErrorKind::NotADirectory).to_string()),
"{e}"
);
assert!(AutomationRoots::new(Some(Path::new("")), None).unwrap_err().starts_with("cannot open the automation read root ``"));
assert!(AutomationRoots::new(None, None).unwrap().is_none());
// One root alone grants only its own access.
let read_only = AutomationRoots::new(Some(&base.join("in")), None).unwrap().unwrap();
let e = read_only.check(&s(base.join("in/x.svg")), Access::Write).unwrap_err();
assert!(e.contains("write authority is absent"), "{e}");
let _ = std::fs::remove_dir_all(base);
}
#[test]
fn links_are_followed_before_comparing() {
let (base, roots) = folders("links");
#[cfg(unix)]
let made = |target: &Path, link: &Path| std::os::unix::fs::symlink(target, link).is_ok();
#[cfg(windows)]
let made = |target: &Path, link: &Path| {
let made =
if target.is_dir() { std::os::windows::fs::symlink_dir(target, link) } else { std::os::windows::fs::symlink_file(target, link) };
made.is_ok()
};
#[cfg(not(any(unix, windows)))]
let made = |_: &Path, _: &Path| false;
// Windows needs Developer Mode or elevation to make links: nothing to test without them.
if !made(&base.join("outside/secret.svg"), &base.join("in/leak.svg")) {
eprintln!("skipped: symbolic links can't be made here");
let _ = std::fs::remove_dir_all(base);
return;
}
assert!(roots.check(&s(base.join("in/leak.svg")), Access::Read).unwrap_err().contains("outside the read root"), "a file link out");
assert!(made(&base.join("outside"), &base.join("out/away")));
let e = roots.check(&s(base.join("out/away/new.png")), Access::Write).unwrap_err();
assert!(e.contains("outside the write root"), "a folder link out: {e}");
assert!(made(&base.join("outside/gone.svg"), &base.join("out/dangling.png")));
let e = roots.check(&s(base.join("out/dangling.png")), Access::Write).unwrap_err();
assert!(e.contains("a link that leads nowhere"), "writing would create the file outside: {e}");
// A link to a folder inside the root stays inside.
assert!(made(&base.join("in/sub"), &base.join("in/alias")));
assert_eq!(roots.check(&s(base.join("in/alias/x.svg")), Access::Read), Ok(()));
// A root given through a link is the folder it leads to.
assert!(made(&base.join("in"), &base.join("in-link")));
let linked = AutomationRoots::new(Some(&base.join("in-link")), None).unwrap().unwrap();
assert_eq!(linked.check(&s(base.join("in/a.svg")), Access::Read), Ok(()));
let _ = std::fs::remove_dir_all(base);
}
#[cfg(windows)]
#[test]
fn windows_ignores_case_and_refuses_devices() {
let (base, roots) = folders("case");
let upper = s(base.join("IN/A.SVG"));
assert_eq!(roots.check(&upper, Access::Read), Ok(()), "{upper}");
assert_eq!(roots.check(&s(base.join("In/Sub/New.svg")), Access::Read), Ok(()));
let e = roots.check(&s(base.join("out/NUL")), Access::Write).unwrap_err();
assert!(e.contains("reserved device names"), "{e}");
assert!(roots.check(&s(base.join("out/com1.txt")), Access::Write).is_err());
assert_eq!(roots.check(&s(base.join("out/console.txt")), Access::Write), Ok(()));
let _ = std::fs::remove_dir_all(base);
}
#[test]
fn scopes_nest_and_carry_to_other_threads() {
let (base, roots) = folders("scopes");
let outside = s(base.join("outside/secret.svg"));
assert_eq!(check_read(&outside), Ok(()), "nothing is confined by default");
confine(Some(&roots), || {
assert!(check_read(&outside).is_err());
assert_eq!(unconfined(|| check_read(&outside)), Ok(()));
assert!(check_read(&outside).is_err(), "restored after unconfined");
let handle = ScopeHandle::capture();
let o = outside.clone();
let there = std::thread::spawn(move || (check_read(&o).is_ok(), handle.run(|| check_read(&o).is_ok()))).join().unwrap();
assert_eq!(there, (true, false), "another thread is confined only in the captured scope");
});
assert_eq!(check_read(&outside), Ok(()), "restored after confine");
let r = std::panic::catch_unwind(|| confine(Some(&roots), || panic!("boom")));
assert!(r.is_err());
assert_eq!(check_read(&outside), Ok(()), "restored after a panic");
assert_eq!(confine(None, || check_read(&outside)), Ok(()));
let _ = std::fs::remove_dir_all(base);
}
#[test]
fn device_names() {
for d in ["CON", "nul", "Com1", "LPT9.txt", "aux.svg", "NUL "] {
assert!(is_device_name(d), "{d}");
}
for f in ["console", "com10", "COM0", "lpt", "a.svg", "nul-x"] {
assert!(!is_device_name(f), "{f}");
}
}
}
+4 -2
View File
@@ -7,6 +7,7 @@
#![forbid(unsafe_code)]
pub mod cmd;
pub mod file_access;
pub mod guard;
pub mod inspect;
pub mod steps;
@@ -126,8 +127,9 @@ pub struct DocState {
pub undo_group: Option<UndoGroup>,
/// For Object → Transform → Transform Again (⌘D).
pub last_transform: Option<(Affine, bool)>,
/// Selection saved by Select → Reselect.
pub last_selection_cmd: Option<(String, Value)>,
/// What Select → Reselect repeats: the last selection command, its params and the objects
/// selected when it ran (a Same command's reference objects).
pub last_selection_cmd: Option<(String, Value, Vec<NodeId>)>,
/// Process-unique id of this open document (tab indices shift when tabs close).
pub uid: u64,
/// View Opacity Mask (Alt-click the mask thumbnail): the masked object whose mask the canvas
+12
View File
@@ -149,6 +149,18 @@ fn select_same_fill() {
let r = s.execute("select.same.fillColor", &json!({})).unwrap();
assert_eq!(r["count"], 2);
assert!(!s.doc().unwrap().selection.contains(a));
// Reselect after Deselect repeats it from the same reference object (#903).
let blue = s.doc().unwrap().selection.objects.clone();
s.execute("select.none", &json!({})).unwrap();
s.execute("select.reselect", &json!({})).unwrap();
assert_eq!(s.doc().unwrap().selection.objects, blue);
// With its reference object gone, it fails as Select Same does, and selects nothing.
s.execute("select.none", &json!({})).unwrap();
for id in &blue {
s.execute("edit.clear", &json!({"ids": [id.0]})).unwrap();
}
assert!(s.execute("select.reselect", &json!({})).is_err());
assert!(s.doc().unwrap().selection.objects.is_empty());
}
#[test]
+176 -1
View File
@@ -1,7 +1,7 @@
//! Brushes and symbols: commands, tools, rendering and persistence.
use serde_json::{Value, json};
use vectorcraft_doc::{Node, NodeKind};
use vectorcraft_doc::{Document, Node, NodeKind};
use vectorcraft_geom::Rect;
use vectorcraft_tools::{Mods, PointerEvent, PointerKind};
@@ -193,6 +193,181 @@ fn paintbrush_tool_uses_the_current_brush() {
assert!(s.execute("brush.setCurrent", &json!({"name": "Nope"})).is_err());
}
/// A Size: Pressure variant of "3 pt. Round" (3 ± 3 pt).
fn pressure_round(s: &mut Session) {
let params = json!({"variation": [0, 0, 3], "modes": ["fixed", "fixed", "pressure"]});
s.execute("brush.options", &json!({"name": "3 pt. Round", "params": params})).unwrap();
}
fn pressure_of(s: &Session, id: NodeId) -> Option<vectorcraft_doc::PressureProfile> {
node(s, id).appearance.stroke().and_then(|st| st.pressure.clone())
}
/// #852: the Paintbrush records the pen pressure on the stroke, the brush draws it, and it lasts
/// through saving and every export; a mouse stroke records none.
#[test]
fn paintbrush_records_pen_pressure_on_the_stroke() {
let mut s = session();
pressure_round(&mut s);
s.execute("brush.setCurrent", &json!({"name": "3 pt. Round"})).unwrap();
let v = ViewInfo::default();
s.select_tool("paintbrush", v).unwrap();
let strokes = |s: &mut Session, y: f64, pressure: [f32; 4]| {
let kinds = [PointerKind::Down, PointerKind::Drag, PointerKind::Drag, PointerKind::Up];
for ((k, x), p) in kinds.into_iter().zip([20.0, 120.0, 220.0, 320.0]).zip(pressure) {
s.pointer(&PointerEvent { pressure: p, ..PointerEvent::new(k, x, y) }, v).unwrap();
}
let id = s.doc().unwrap().selection.objects[0];
// Deselected, so the next stroke starts a path of its own.
select(s, &[]);
id
};
let pen = strokes(&mut s, 60.0, [0.0, 0.4, 0.8, 1.0]);
let p = pressure_of(&s, pen).expect("pressure recorded");
assert!(p.at(0.0) < 0.05 && p.at(1.0) > 0.95 && (p.at(0.5) - 0.6).abs() < 0.05, "{p:?}");
let mouse = strokes(&mut s, 200.0, [1.0; 4]);
assert_eq!(pressure_of(&s, mouse), None, "a mouse presses fully: nothing to record");
// Drawn: thin where the pen pressed lightly, 6 pt where it pressed fully.
let doc: Document = (*s.doc().unwrap().doc).clone();
let art = |d: &Document, id: NodeId| vectorcraft_brush::node_pieces(d, d.node(id).unwrap()).unwrap();
let heavy_end = vectorcraft_brush::pieces_bounds(&art(&doc, pen)).unwrap();
assert!((heavy_end.height() - 6.0).abs() < 0.3, "{heavy_end:?}");
let even = vectorcraft_brush::pieces_bounds(&art(&doc, mouse)).unwrap();
assert!((even.height() - 3.0).abs() < 0.1, "no pressure draws the size itself: {even:?}");
// Saved and reopened as it was.
let back = vectorcraft_format::load(&vectorcraft_format::save(&doc, false)).unwrap();
assert_eq!(back.node(pen).unwrap().appearance.stroke().unwrap().pressure, Some(p));
// SVG and PDF draw the brush through the same stroke code: without the pressure they differ.
let mut flat = doc.clone();
flat.node_mut(pen).unwrap().appearance.stroke_mut().unwrap().pressure = None;
assert_ne!(vectorcraft_svg::export(&doc, &Default::default()), vectorcraft_svg::export(&flat, &Default::default()));
let pdf = |d: &Document| {
vectorcraft_pdf::export(d, &vectorcraft_pdf::PdfOptions { created: Some(0), ..vectorcraft_pdf::PdfOptions::uncompressed() }).unwrap()
};
assert_ne!(pdf(&doc), pdf(&flat));
}
/// Continuing a pressure stroke keeps its pressure where it was and adds the new stroke's after it.
#[test]
fn continuing_a_stroke_joins_its_pressure() {
let mut s = session();
let r = s.execute("path.freehand", &json!({"points": [[0, 50, 0], [100, 50, 1]], "style": "brush"})).unwrap();
let id = NodeId(r["id"].as_u64().unwrap());
s.execute("path.freehand", &json!({"points": [[100, 50, 1], [200, 50, 0]], "style": "brush", "extend": {"id": id.0, "end": "end"}})).unwrap();
let p = pressure_of(&s, id).unwrap();
assert!(p.at(0.0) < 0.05 && (p.at(0.5) - 1.0).abs() < 0.05 && p.at(1.0) < 0.05, "{p:?}");
// Continued at its start: the new stroke comes first, running backwards from where it joined.
let r = s.execute("path.freehand", &json!({"points": [[0, 150], [100, 150]], "style": "brush"})).unwrap();
let id = NodeId(r["id"].as_u64().unwrap());
s.execute("path.freehand", &json!({"points": [[0, 150, 1], [-100, 150, 0]], "style": "brush", "extend": {"id": id.0, "end": "start"}})).unwrap();
let p = pressure_of(&s, id).unwrap();
assert!(p.at(0.0) < 0.05 && (p.at(0.5) - 1.0).abs() < 0.05, "{p:?}");
assert_eq!(p.at(1.0), vectorcraft_doc::PressureProfile::MID, "the old part had no pressure");
}
/// Pen pressure belongs to its path: new art, graphic styles and Brush Options' edits leave it.
#[test]
fn pressure_stays_with_its_own_path() {
let mut s = session();
s.execute("appearance.setNewArtBasic", &json!({"on": false})).unwrap();
let r = s.execute("brush.freehand", &json!({"points": [[0, 50, 0.2], [100, 50, 0.9]], "style": "brush", "brush": "3 pt. Round"})).unwrap();
let id = NodeId(r["id"].as_u64().unwrap());
assert!(pressure_of(&s, id).is_some());
select(&mut s, &[id]);
s.execute("graphicStyle.new", &json!({"name": "Inked"})).unwrap();
let style = s.doc().unwrap().doc.graphic_style("Inked").unwrap().appearance.clone();
assert!(style.stroke().is_some_and(|st| st.pressure.is_none()));
let other = rect(&mut s, 10.0, 100.0, 50.0, 50.0);
assert_eq!(pressure_of(&s, other), None, "new art takes the look, not the pressure");
pressure_round(&mut s);
assert!(pressure_of(&s, id).is_some(), "editing the brush keeps the strokes' pressure");
}
/// Brush Options sets the variation modes; bad modes are refused, never a panic.
#[test]
fn brush_options_set_variation_modes() {
let mut s = session();
pressure_round(&mut s);
let b = s.execute("brush.get", &json!({"name": "3 pt. Round"})).unwrap();
assert_eq!(b["modes"], json!(["fixed", "fixed", "pressure"]));
assert_eq!(b["variation"], json!([0.0, 0.0, 3.0]));
for bad in [json!({"modes": ["fixed", "fixed", "tilt"]}), json!({"modes": "pressure"}), json!({"variation": "x"}), json!({"modes": [1, 2, 3]})] {
assert!(s.execute("brush.options", &json!({"name": "3 pt. Round", "params": bad})).is_err(), "{bad}");
}
assert_eq!(s.execute("brush.get", &json!({"name": "3 pt. Round"})).unwrap(), b, "a refused edit changes nothing");
}
/// The bounds of the brush art `id`'s strokes draw.
fn brushed_bounds(s: &Session, id: NodeId) -> Rect {
let st = s.doc().unwrap();
let pieces = vectorcraft_brush::node_pieces(&st.doc, &node(s, id)).expect("brushed");
vectorcraft_brush::pieces_bounds(&pieces).expect("draws")
}
/// Scatter, Art and Pattern Brush Options: the params reach the brush and every stroke painted
/// with it redraws; values out of range are clamped, unknown choices refused.
#[test]
fn scatter_art_and_pattern_brush_options_update_strokes() {
let mut s = session();
// A rectangle as an art brush (straight edges: no flattening between sizes).
let r = rect(&mut s, 10.0, 200.0, 40.0, 8.0);
let bar = s.execute("brush.new", &json!({"type": "art", "name": "Bar", "ids": [r.0]})).unwrap()["name"].as_str().unwrap().to_string();
let id = line(&mut s);
select(&mut s, &[id]);
let edit = |s: &mut Session, name: &str, params: Value| -> f64 {
s.execute("brush.apply", &json!({ "name": name })).unwrap();
let before = brushed_bounds(s, id).height();
s.execute("brush.options", &json!({ "name": name, "params": params })).unwrap();
brushed_bounds(s, id).height() / before
};
// Art: Width 300 % draws the art three times as wide across the path.
let art = json!({"width": 300, "direction": "rightToLeft", "flip_across": true});
assert!((edit(&mut s, &bar, art) - 3.0).abs() < 1e-6);
s.execute("brush.options", &json!({"name": bar, "params": {"scale": {"mode": "betweenGuides", "start": 0.2, "end": 0.6}}})).unwrap();
let b = s.execute("brush.get", &json!({ "name": bar })).unwrap();
assert_eq!(
(&b["direction"], &b["flip_across"], &b["scale"]),
(&json!("rightToLeft"), &json!(true), &json!({"mode": "betweenGuides", "start": 0.2, "end": 0.6}))
);
// Pattern: Scale 200 % doubles the tiles.
assert!((edit(&mut s, "Stitches", json!({"scale": 200, "spacing": 50, "fit": "addSpace", "flip_along": true})) - 2.0).abs() < 0.05);
let b = s.execute("brush.get", &json!({"name": "Stitches"})).unwrap();
assert_eq!((&b["spacing"], &b["fit"]), (&json!(50.0), &json!("addSpace")));
// Scatter: a fixed Size of 200 % doubles the dots; the modes and Hue Shift are kept.
let key = json!({"model": "rgb", "r": 0.0, "g": 0.0, "b": 0.0});
let scatter = json!({"size": [200, 50], "modes": ["fixed", "fixed", "fixed", "random"], "rotation_relative_to_path": true, "colorization": {"method": "hueShift", "key": key}});
assert!((edit(&mut s, "Dots", scatter) - 2.0).abs() < 0.05);
let b = s.execute("brush.get", &json!({"name": "Dots"})).unwrap();
assert_eq!(
(&b["modes"], &b["colorization"]["method"], &b["colorization"]["key"]),
(&json!(["fixed", "fixed", "fixed", "random"]), &json!("hueShift"), &key)
);
// Out of range: clamped. Unknown choices: refused, nothing changes.
let junk = json!({"size": [1e308, -1e308], "spacing": [0, 1e308], "rotation": [720, -1e9], "scatter": [-1e308, 5]});
s.execute("brush.options", &json!({"name": "Dots", "params": junk})).unwrap();
let b = s.execute("brush.get", &json!({"name": "Dots"})).unwrap();
assert_eq!(
(&b["size"], &b["spacing"], &b["rotation"], &b["scatter"]),
(&json!([10000.0, 1.0]), &json!([1.0, 10000.0]), &json!([180.0, -180.0]), &json!([-1000.0, 5.0]))
);
assert!(brushed_bounds(&s, id).is_finite());
for (name, bad) in [
("Dots", json!({"modes": ["fixed", "tilt", "fixed", "fixed"]})),
("Dots", json!({"modes": ["fixed"]})),
("Dots", json!({"colorization": {"method": "sparkle"}})),
("Arrow", json!({"direction": "diagonal"})),
("Arrow", json!({"width": "wide"})),
("Chain", json!({"fit": "squeeze"})),
] {
let before = s.execute("brush.get", &json!({ "name": name })).unwrap();
assert!(s.execute("brush.options", &json!({ "name": name, "params": bad })).is_err(), "{bad}");
assert_eq!(s.execute("brush.get", &json!({ "name": name })).unwrap(), before);
}
s.execute("brush.options", &json!({"name": "Chain", "params": {"scale": 1e308, "spacing": -1e308}})).unwrap();
let b = s.execute("brush.get", &json!({"name": "Chain"})).unwrap();
assert_eq!((&b["scale"], &b["spacing"]), (&json!(10000.0), &json!(0.0)));
}
// ---------- symbols ----------
fn make_symbol(s: &mut Session) -> (NodeId, String, Rect) {
+178
View File
@@ -161,3 +161,181 @@ fn libraries_are_kept_in_files_across_sessions() {
assert_eq!(run(&mut v, "library.list", json!({}))["libraries"], json!([]));
let _ = std::fs::remove_dir_all(&dir);
}
/// A library "Brand" holding a graphic, a colour and a character style, each added from the
/// selection → (session, the graphic's id, the colour's name, the style's name).
fn brand() -> (Session, String, String, String) {
let mut s = session();
run(&mut s, "library.create", json!({"name": "Brand"}));
let r = run(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 20, "height": 20}))["id"].clone();
run(&mut s, "paint.setFill", json!({"color": "#336699", "ids": [r]}));
run(&mut s, "select.set", json!({"ids": [r]}));
let g = run(&mut s, "library.add", json!({"kind": "graphic", "name": "Logo"}))["id"].as_str().unwrap().to_string();
let c = run(&mut s, "library.add", json!({"kind": "fillColor"}))["name"].as_str().unwrap().to_string();
let t = run(&mut s, "text.create", json!({"x": 10, "y": 80, "text": "Type", "size": 18}))["id"].clone();
run(&mut s, "select.set", json!({"ids": [t]}));
let st = run(&mut s, "library.add", json!({"kind": "charStyle"}))["name"].as_str().unwrap().to_string();
(s, g, c, st)
}
/// The current library's groups as `library.get` reports them: (name, [(kind, item)]).
fn groups(s: &mut Session) -> Vec<(String, Vec<(String, String)>)> {
let got = run(s, "library.get", json!({}));
got["groups"]
.as_array()
.unwrap()
.iter()
.map(|g| {
let items = g["items"].as_array().unwrap().iter().map(|r| (r["kind"].as_str().unwrap().into(), r["item"].as_str().unwrap().into()));
(g["name"].as_str().unwrap().to_string(), items.collect())
})
.collect()
}
fn refs(v: &[(&str, &str)]) -> Vec<(String, String)> {
v.iter().map(|(k, i)| (k.to_string(), i.to_string())).collect()
}
/// Graphics added one after another get ids of their own (the third used to repeat the second's).
#[test]
fn every_graphic_gets_an_id_of_its_own() {
let mut s = session();
run(&mut s, "library.create", json!({}));
let mut ids = vec![];
for x in [0, 30, 60, 90] {
let r = run(&mut s, "shape.rectangle", json!({"x": x, "y": 0, "width": 10 + x, "height": 10}))["id"].clone();
ids.push(run(&mut s, "library.add", json!({"kind": "graphic", "ids": [r]}))["id"].as_str().unwrap().to_string());
}
assert_eq!(ids, ["graphic", "graphic-2", "graphic-3", "graphic-4"]);
}
/// Items go into user-named groups and back out (#926); deleting a group keeps its items.
#[test]
fn items_are_grouped_moved_and_ungrouped() {
let (mut s, g, c, st) = brand();
assert!(groups(&mut s).is_empty(), "no groups yet");
// A group made with items; a graphic is named by its name or its id.
let r = run(&mut s, "library.createGroup", json!({"name": "Logos", "items": [{"kind": "graphic", "item": "Logo"}]}));
assert_eq!(r["group"], "Logos");
assert_eq!(groups(&mut s), [("Logos".into(), refs(&[("graphic", &g)]))]);
// A name taken (ignoring case) gets a number.
assert_eq!(run(&mut s, "library.createGroup", json!({"name": "logos"}))["group"], "logos 2");
run(&mut s, "library.renameGroup", json!({"group": "logos 2", "name": "Palette"}));
assert!(s.execute("library.renameGroup", &json!({"group": "Palette", "name": "LOGOS"})).is_err(), "another group's name");
assert!(s.execute("library.renameGroup", &json!({"group": "Nope", "name": "X"})).is_err());
// Moving an item takes it out of the group it was in; a colour is named as fill or stroke.
run(&mut s, "library.moveItem", json!({"kind": "strokeColor", "item": c, "group": "palette"}));
run(&mut s, "library.moveItem", json!({"kind": "charStyle", "item": st, "group": "Palette"}));
let moved = run(&mut s, "library.moveItem", json!({"kind": "graphic", "item": "Logo", "group": "Palette"}));
assert_eq!((moved["item"].as_str(), moved["group"].as_str()), (Some(g.as_str()), Some("Palette")));
assert_eq!(groups(&mut s), [("Logos".into(), vec![]), ("Palette".into(), refs(&[("color", &c), ("charStyle", &st), ("graphic", &g)]))]);
// Without a group, out of its group.
assert!(run(&mut s, "library.moveItem", json!({"kind": "fillColor", "item": c}))["group"].is_null());
assert!(s.execute("library.moveItem", &json!({"kind": "fillColor", "item": c, "group": "Nope"})).is_err());
assert!(s.execute("library.moveItem", &json!({"kind": "fillColor", "item": "#nope", "group": "Palette"})).is_err());
assert!(s.execute("library.createGroup", &json!({"items": [{"kind": "graphic", "item": "nope"}]})).is_err(), "no such item");
assert_eq!(groups(&mut s).len(), 2, "a failed command makes no group");
// A deleted item leaves its group.
run(&mut s, "library.removeItem", json!({"kind": "charStyle", "item": st}));
assert_eq!(groups(&mut s)[1].1, refs(&[("graphic", &g)]));
// A deleted group's items stay, ungrouped.
assert_eq!(run(&mut s, "library.deleteGroup", json!({"group": "Palette"}))["ungrouped"], 1);
assert_eq!(run(&mut s, "library.get", json!({}))["graphics"].as_array().unwrap().len(), 1);
assert_eq!(groups(&mut s), [("Logos".into(), vec![])]);
assert_eq!(run(&mut s, "library.list", json!({}))["libraries"][0]["groups"], 1);
}
/// A library goes out to a file with its art and groups and comes back as a new library, numbered
/// when its name is taken; the graphic it brings places in a document (#926).
#[test]
fn a_library_is_exported_and_imported() {
let (mut s, g, c, _) = brand();
run(&mut s, "library.createGroup", json!({"name": "Logos", "items": [{"kind": "graphic", "item": g}, {"kind": "fillColor", "item": c}]}));
let out = run(&mut s, "library.export", json!({}));
assert_eq!(out["name"], "Brand");
let data = out["data"].as_str().unwrap().to_string();
let r = run(&mut s, "library.import", json!({"data": data}));
let counts = ["graphics", "colors", "charStyles", "paraStyles", "groups"].map(|k| r[k].as_u64().unwrap());
assert_eq!((r["name"].as_str(), counts), (Some("Brand (2)"), [1, 1, 1, 0, 1]));
let id = r["id"].as_str().unwrap().to_string();
assert_eq!(run(&mut s, "library.list", json!({}))["current"], id, "the imported library is the current one");
let a = run(&mut s, "library.get", json!({"library": "Brand"}));
let b = run(&mut s, "library.get", json!({"library": id}));
for k in ["graphics", "colors", "charStyles", "paraStyles", "groups"] {
assert_eq!(a[k], b[k], "{k}");
}
// Again: the next number; given a name, that name.
let b64 = vectorcraft_format::base64_encode(data.as_bytes());
assert_eq!(run(&mut s, "library.import", json!({"dataBase64": b64}))["name"], "Brand (3)");
assert_eq!(run(&mut s, "library.import", json!({"data": data, "name": "Client"}))["name"], "Client");
// Its graphic places in another document.
run(&mut s, "file.new", json!({"width": 100, "height": 100}));
let placed = run(&mut s, "library.use", json!({"library": id, "kind": "graphic", "item": "Logo", "center": [50, 50]}));
assert_eq!(placed["ids"].as_array().unwrap().len(), 1);
// Not a library file.
let swatches = json!({"format": "vcswatches", "name": "S"}).to_string();
for bad in [json!({"data": "{not json"}), json!({"data": "[1, 2]"}), json!({"data": swatches}), json!({"dataBase64": "%%"}), json!({})] {
assert!(s.execute("library.import", &bad).is_err(), "{bad}");
}
}
/// A file written before groups loads; one with repeated names, groups naming items that aren't
/// there or kinds this version doesn't know, and a thumbnail claiming a huge image is made safe.
#[test]
fn imported_libraries_are_made_consistent() {
let mut s = session();
let old = json!({"name": "Old", "colors": [{"name": "Red", "color": {"model": "rgb", "r": 1, "g": 0, "b": 0}}]});
let r = run(&mut s, "library.import", json!({"data": old.to_string()}));
assert_eq!((r["colors"].as_u64(), r["groups"].as_u64()), (Some(1), Some(0)));
// A PNG header claiming 100000 × 100000 pixels.
let mut png = b"\x89PNG\r\n\x1a\n\0\0\0\x0dIHDR".to_vec();
png.extend(100_000u32.to_be_bytes());
png.extend(100_000u32.to_be_bytes());
png.extend([8, 6, 0, 0, 0]);
let messy = json!({
"name": " ",
"colors": [{"name": "Red", "color": {"model": "rgb", "r": 1, "g": 0, "b": 0}}, {"name": "Red", "color": {"model": "rgb", "r": 0, "g": 1, "b": 0}}, {"name": "", "color": {"model": "rgb", "r": 0, "g": 0, "b": 1}}],
"graphics": [
{"id": "g", "name": "A", "width": 1, "height": 1, "data": "", "thumbnail": vectorcraft_format::base64_encode(&png)},
{"id": "g", "name": "B", "width": -5, "height": 1, "data": ""}
],
"groups": [
{"name": "One", "items": [{"kind": "color", "item": "Red 2"}, {"kind": "graphic", "item": "nope"}, {"kind": "brush", "item": "Red"}]},
{"name": "one", "items": [{"kind": "color", "item": "Red 2"}, {"kind": "graphic", "item": "g-2"}]}
]
});
let r = run(&mut s, "library.import", json!({"data": messy.to_string()}));
assert_eq!(r["name"], "Library", "a blank name");
let got = run(&mut s, "library.get", json!({}));
let names: Vec<&str> = got["colors"].as_array().unwrap().iter().map(|c| c["name"].as_str().unwrap()).collect();
assert_eq!(names, ["Red", "Red 2", "Color"]);
let graphics = got["graphics"].as_array().unwrap();
assert_eq!((graphics[0]["id"].as_str(), graphics[1]["id"].as_str()), (Some("g"), Some("g-2")));
assert_eq!((graphics[0]["thumbnail"].as_str(), graphics[1]["width"].as_f64()), (Some(""), Some(0.0)));
assert_eq!(
got["groups"],
json!([{"name": "One", "items": [{"kind": "color", "item": "Red 2"}]}, {"name": "one 2", "items": [{"kind": "graphic", "item": "g-2"}]}])
);
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn a_library_is_exported_to_a_file_and_imported_from_it() {
let dir = vectorcraft_testkit::temp_dir("library-export");
let path = dir.join("Brand.vclibrary").to_string_lossy().to_string();
let (mut s, g, ..) = brand();
run(&mut s, "library.createGroup", json!({"name": "Logos", "items": [{"kind": "graphic", "item": g}]}));
assert_eq!(run(&mut s, "library.export", json!({"library": "Brand", "path": path}))["path"], path.as_str());
let mut t = session();
let r = run(&mut t, "library.import", json!({"path": path}));
assert_eq!((r["name"].as_str(), r["graphics"].as_u64(), r["groups"].as_u64()), (Some("Brand"), Some(1), Some(1)));
assert!(t.execute("library.import", &json!({"path": dir.join("missing.vclibrary").to_string_lossy()})).is_err());
// Groups are kept in the library folder's files too.
let folder = dir.join("folder").to_string_lossy().to_string();
let mut u = session();
u.libraries.set_dir(Some(folder.clone()));
run(&mut u, "library.import", json!({"path": path}));
let mut v = session();
v.libraries.set_dir(Some(folder));
assert_eq!(groups(&mut v), [("Logos".into(), refs(&[("graphic", &g)]))]);
}
+30
View File
@@ -127,6 +127,36 @@ fn a_placed_link_records_the_file_and_the_document_saves_only_a_preview() {
assert_eq!(saved["images"][&im.key]["proxy"], Value::Null, "an embedded image shows it too");
}
/// A flat `w`×`h` Photoshop document of one colour (raw RGB, no layers).
fn psd(w: u32, h: u32, rgb: [u8; 3]) -> Vec<u8> {
let mut f = b"8BPS\0\x01\0\0\0\0\0\0\0\x03".to_vec();
f.extend(h.to_be_bytes());
f.extend(w.to_be_bytes());
f.extend([0, 8, 0, 3, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]);
for v in rgb {
f.extend(std::iter::repeat_n(v, (w * h) as usize));
}
f
}
/// #918: a Photoshop document placed linked shows its merged image, and updates when it changes.
#[test]
fn a_linked_photoshop_document_places_and_updates() {
let dir = Folder::new("psd");
let pic = dir.file("art/photo.psd");
write(&pic, &psd(600, 300, RED));
let mut s = session();
let id = place(&mut s, &pic);
let link = image(&s, id).link.unwrap();
assert_eq!(link.path, pic);
assert!(near(centre_colour(&s.doc().unwrap().doc), RED));
write(&pic, &psd(400, 400, BLUE));
assert_eq!(s.execute("links.update", &json!({})).unwrap(), json!({"updated": [id.0], "missing": []}));
let im = image(&s, id);
assert_eq!((im.width, im.height), (400, 400));
assert!(near(centre_colour(&s.doc().unwrap().doc), BLUE));
}
#[test]
fn the_relative_path_finds_the_file_after_the_folder_moves() {
let a = Folder::new("move-a");
+58
View File
@@ -58,6 +58,25 @@ fn a_whole_rectangle_rounds_all_corners_and_given_corners_alone() {
assert_eq!(corners(&s, id).1[3], CornerKind::Chamfer);
}
/// #938: `items` rounds several objects in one step, each its own corners (or all of them).
#[test]
fn items_round_several_objects_each_its_own_corners() {
let (mut s, a) = session();
let b = NodeId(s.execute("shape.rectangle", &json!({"x": 200, "y": 10, "width": 100, "height": 60})).unwrap()["id"].as_u64().unwrap());
let undo = s.doc().unwrap().history.undo.len();
run(&mut s, json!({"items": [{"id": a.0, "corners": [2]}, {"id": b.0}], "radius": 6}));
assert_eq!(corners(&s, a).0, [0.0, 0.0, 6.0, 0.0]);
assert_eq!(corners(&s, b).0, [6.0; 4]);
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "one step");
for p in [
json!({"items": 3, "radius": 1}),
json!({"items": [{"corners": [1]}], "radius": 1}),
json!({"items": [{"id": a.0, "corners": [9]}], "radius": 1}),
] {
assert!(s.execute("object.setLiveShape", &p).is_err(), "{p}");
}
}
#[test]
fn bad_corners_and_kinds_are_errors() {
let (mut s, id) = session();
@@ -307,3 +326,42 @@ fn an_ellipse_takes_pie_angles_and_inverts() {
assert_eq!(s.doc().unwrap().doc.node(id).unwrap().path_data().unwrap().anchor_count(), 4);
assert!(s.execute("object.setLiveShape", &json!({"pieStart": "half"})).is_err());
}
/// #812: a polygon's Polygon Properties: its angle, radius and side length, each one undo step;
/// Make Sides Equal after an uneven scale; bad values are errors.
#[test]
fn a_polygon_takes_its_angle_radius_side_length_and_equal_sides() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap();
let id = NodeId(s.execute("shape.polygon", &json!({"cx": 200, "cy": 200, "radius": 50, "sides": 6})).unwrap()["id"].as_u64().unwrap());
let live = |s: &Session| match &s.doc().unwrap().doc.node(id).unwrap().kind {
NodeKind::Path { live: Some(l @ LiveShape::Polygon { .. }), path, .. } => (l.clone(), path.bounds().unwrap()),
k => panic!("{k:?}"),
};
let close = |a: f64, b: f64| (a - b).abs() < 1e-6;
run(&mut s, json!({"polygonAngle": 90}));
let (l, b) = live(&s);
assert!(close(l.polygon_angle().unwrap(), 90.0), "{l:?}");
// Its first vertex, straight up before, points left now: the hexagon is 100 wide, 86.6 tall.
assert!(close(b.width(), 100.0) && close(b.height(), 86.602_540_378) && close(b.center().x, 200.0), "{b:?}");
run(&mut s, json!({"polygonRadius": 80}));
assert!(close(live(&s).0.polygon_radius().unwrap(), 80.0));
// A hexagon's side is as long as its radius; with 4 sides the radius is side / √2.
run(&mut s, json!({"sideLength": 30}));
assert!(close(live(&s).0.polygon_radius().unwrap(), 30.0));
run(&mut s, json!({"sides": 4, "sideLength": 30}));
assert!(close(live(&s).0.polygon_radius().unwrap(), 30.0 / 2f64.sqrt()));
s.execute("edit.undo", &json!({})).unwrap();
let LiveShape::Polygon { sides, .. } = live(&s).0 else { panic!("polygon") };
assert_eq!(sides, 6, "one undo step");
// Scaled unevenly its sides differ; Make Sides Equal makes them equal again, in place.
s.execute("object.scale", &json!({"sx": 200, "sy": 100})).unwrap();
let (l, b) = live(&s);
assert!(!l.polygon_sides_equal() && l.polygon_angle().is_some());
run(&mut s, json!({"makeSidesEqual": true}));
let (l, after) = live(&s);
assert!(l.polygon_sides_equal() && close(after.center().x, b.center().x) && close(after.center().y, b.center().y), "{b:?} → {after:?}");
for bad in [json!({"polygonRadius": 0}), json!({"sideLength": -3}), json!({"polygonRadius": "big"}), json!({"polygonAngle": "left"})] {
assert!(s.execute("object.setLiveShape", &bad).is_err(), "{bad}");
}
}
+11
View File
@@ -671,6 +671,17 @@ world", "size": 20}),
assert_eq!((t.first_style().size, t.first_style().fill.color().map(|c| c.to_hex())), (20.0, Some("#00ff00".into())));
}
/// Preferences › Type › Show Font Names in English (#394): stored and read by the font menus.
#[test]
fn show_font_names_in_english_preference_is_stored() {
let mut s = Session::new();
assert!(s.prefs.font_names_in_english, "on by default");
set_pref(&mut s, "fontNamesInEnglish", json!(false));
assert!(!s.prefs.font_names_in_english);
set_pref(&mut s, "fontNamesInEnglish", json!(true));
assert!(s.prefs.font_names_in_english);
}
/// Preferences › Hyphenation › Exceptions (#394): `prefs.set` pushes the list into the hyphenator
/// and bumps open documents so type reflows. Parsing of the list is covered in `vectorcraft-text`.
#[test]
+55 -5
View File
@@ -1,6 +1,8 @@
//! The Photoshop-style raster effects (Effect › Blur › Radial Blur and Smart Blur, Sharpen ›
//! Unsharp Mask): applied and edited as commands, listed in the catalogue, drawn on the canvas,
//! and written to PDF and SVG as images of the effected object.
//! The Photoshop-style raster effects (Effect › Blur › Radial Blur and Smart Blur, Pixelate ›
//! Color Halftone, Crystallize, Mezzotint and Pointillize, Sharpen › Unsharp Mask, Texture ›
//! Craquelure, Grain, Mosaic Tiles, Patchwork, Stained Glass and Texturizer, Video): applied and
//! edited as commands, listed in the catalogue, drawn on the canvas, and written to PDF and SVG
//! as images of the effected object.
use serde_json::{Value, json};
use vectorcraft_doc::NodeKind;
@@ -16,6 +18,24 @@ const EFFECTS: [(&str, &str); 3] = [
("sharpen.unsharpMask", r#"{"amount": 300, "radius": 3}"#),
];
/// Effect › Pixelate, at their defaults.
const PIXELATE: [(&str, &str); 4] =
[("pixelate.colorHalftone", "{}"), ("pixelate.crystallize", "{}"), ("pixelate.mezzotint", "{}"), ("pixelate.pointillize", "{}")];
/// Effect › Texture, at their defaults.
const TEXTURE: [(&str, &str); 6] = [
("texture.craquelure", "{}"),
("texture.grain", "{}"),
("texture.mosaicTiles", "{}"),
("texture.patchwork", "{}"),
("texture.stainedGlass", "{}"),
("texture.texturizer", "{}"),
];
/// Effect › Video (NTSC Colors leaves the striped art's legal colours as they are: see
/// `ntsc_colors_draws_saturated_yellow_safer`).
const VIDEO: [(&str, &str); 1] = [("video.deinterlace", r#"{"create": "interpolation"}"#)];
/// A group of a red square and a darker red stripe across it; returns the group's id.
fn striped() -> (Session, u64) {
let mut s = Session::new();
@@ -52,6 +72,17 @@ fn listed_applied_edited_and_undone() {
let menus: Vec<Value> =
EFFECTS.iter().map(|(id, _)| list["catalog"].as_array().unwrap().iter().find(|e| e["id"] == *id).unwrap()["menu"].clone()).collect();
assert_eq!(menus, [json!(["Effect", "Blur"]), json!(["Effect", "Blur"]), json!(["Effect", "Sharpen"])]);
for (group, menu) in [
(PIXELATE.as_slice(), "Pixelate"),
(TEXTURE.as_slice(), "Texture"),
(VIDEO.as_slice(), "Video"),
([("video.ntscColors", "{}")].as_slice(), "Video"),
] {
for (id, _) in group {
let e = list["catalog"].as_array().unwrap().iter().find(|e| e["id"] == *id).unwrap_or_else(|| panic!("{id} listed"));
assert_eq!((&e["raster"], &e["menu"]), (&json!(true), &json!(["Effect", menu])), "{id}");
}
}
// Junk parameters are stored as given and read clamped; the canvas still draws.
for (id, _) in EFFECTS {
s.execute("effect.apply", &json!({"effect": id, "ids": [g], "params": {"amount": 1e308, "radius": -1e308, "threshold": "x", "quality": 7}}))
@@ -71,7 +102,7 @@ fn listed_applied_edited_and_undone() {
#[test]
fn each_effect_changes_the_canvas_and_draws_the_same_twice() {
for (id, params) in EFFECTS {
for (id, params) in EFFECTS.into_iter().chain(PIXELATE).chain(TEXTURE).chain(VIDEO) {
let (mut s, g) = striped();
let plain = render(&s);
let params: Value = serde_json::from_str(params).unwrap();
@@ -100,7 +131,7 @@ fn spin_blur_reaches_past_the_corners() {
#[test]
fn pdf_and_svg_write_the_effected_object_as_an_image() {
for (id, params) in EFFECTS {
for (id, params) in EFFECTS.into_iter().chain(PIXELATE).chain(TEXTURE).chain(VIDEO) {
let (mut s, g) = striped();
let params: Value = serde_json::from_str(params).unwrap();
s.execute("effect.apply", &json!({"effect": id, "ids": [g], "params": params})).unwrap();
@@ -131,3 +162,22 @@ fn expand_appearance_makes_an_image() {
s.execute("effect.expandAppearance", &json!({"ids": [g]})).unwrap();
assert!(matches!(s.doc().unwrap().doc.node(NodeId(g)).unwrap().kind, NodeKind::Image(_)));
}
/// Video › NTSC Colors on the canvas: a saturated yellow, too strong for a television signal, draws
/// less saturated; the effect is listed and applies without options.
#[test]
fn ntsc_colors_draws_saturated_yellow_safer() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap();
let id = s.execute("shape.rectangle", &json!({"x": 50, "y": 50, "width": 100, "height": 100})).unwrap()["id"].as_u64().unwrap();
s.execute("paint.setFill", &json!({"color": "#ffff00", "ids": [id]})).unwrap();
s.execute("paint.setStroke", &json!({"none": true, "ids": [id]})).unwrap();
let before = render(&s);
s.execute("effect.apply", &json!({"effect": "video.ntscColors", "ids": [id]})).unwrap();
let after = render(&s);
let px = |r: &Rendered| r.pixel(100, 100);
let (a, b) = (px(&before), px(&after));
assert_eq!(&a[..3], &[255, 255, 0]);
assert!(b[2] > 20 && b[0] < 255 && b[3] == 255, "less saturated: {b:?}");
assert_eq!(after.pixels, render(&s).pixels, "deterministic");
}
+46
View File
@@ -371,6 +371,52 @@ fn artboard_set_props_scales_art_only_when_asked_and_resized() {
assert!(s.execute("artboard.setProps", &json!({"index": 9, "width": 10, "scaleArt": true})).is_err());
}
/// Move Artwork is independent of scaling and takes only fully contained art and board guides.
#[test]
fn coordinate_moves_honor_move_art_and_keep_resize_behavior() {
let mut s = session();
let inside = rect(&mut s, 100.0, 100.0, 200.0, 100.0);
s.execute("stroke.set", &json!({"weight": 4})).unwrap();
let partly = rect(&mut s, 700.0, 500.0, 200.0, 200.0);
s.execute("guide.add", &json!({"vertical": true, "pos": 400, "artboard": 0})).unwrap();
s.execute("guide.add", &json!({"vertical": true, "pos": 400})).unwrap();
let before = s.doc().unwrap().doc.clone();
for scale in [false, true] {
let out = s.execute("artboard.setProps", &json!({"index": 0, "x": 30, "y": -10, "moveArt": true, "scaleArt": scale})).unwrap();
assert_eq!(bounds(&s, inside), Rect::new(130.0, 90.0, 330.0, 190.0));
assert_eq!(bounds(&s, partly), Rect::new(700.0, 500.0, 900.0, 700.0));
assert_eq!(stroke_width(&s, inside), 4.0);
assert_eq!(guide_positions(&s), vec![430.0, 400.0]);
assert_eq!(out, if scale { json!({"scaled": []}) } else { Value::Null });
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(*s.doc().unwrap().doc, *before);
}
for p in [json!({"index": 0, "x": 30, "moveArt": false}), json!({"index": 0, "x": 30, "width": 400, "moveArt": true})] {
s.execute("artboard.setProps", &p).unwrap();
assert_eq!(bounds(&s, inside), Rect::new(100.0, 100.0, 300.0, 200.0));
s.execute("edit.undo", &json!({})).unwrap();
}
}
#[test]
fn coordinate_moves_journal_the_locked_art_preference() {
let mut s = session();
let locked = rect(&mut s, 100.0, 100.0, 200.0, 100.0);
s.execute("object.lock", &json!({})).unwrap();
s.execute("artboard.setProps", &json!({"index": 0, "x": 30, "moveArt": true})).unwrap();
assert_eq!(bounds(&s, locked), Rect::new(100.0, 100.0, 300.0, 200.0));
s.execute("edit.undo", &json!({})).unwrap();
s.prefs.move_locked_with_artboard = true;
s.execute("artboard.setProps", &json!({"index": 0, "x": 30, "moveArt": true})).unwrap();
assert_eq!(bounds(&s, locked), Rect::new(130.0, 100.0, 330.0, 200.0));
let (id, p) = s.journal.last().unwrap().clone();
assert_eq!(p["lockedAndHidden"], true);
s.execute("edit.undo", &json!({})).unwrap();
s.prefs.move_locked_with_artboard = false;
s.execute(&id, &p).unwrap();
assert_eq!(bounds(&s, locked), Rect::new(130.0, 100.0, 330.0, 200.0));
}
/// Locked and hidden art scales only with Move Locked and Hidden Artwork with Artboard, as it
/// moves only with it.
#[test]
+39
View File
@@ -145,3 +145,42 @@ fn several_spreads_preserve_each_board_and_leave_room_between_spreads() {
assert_eq!(loaded.doc.artboards[0].rect, Rect::new(0.0, 0.0, 100.0, 60.0));
assert_eq!(loaded.doc.artboards[1].rect, Rect::new(136.0, 0.0, 236.0, 60.0));
}
/// Coordinate fields use setProps rather than the drag tool's artboard.move command.
#[test]
fn coordinate_edits_move_imported_clipping_groups_and_undo_together() {
use serde_json::json;
use vectorcraft_engine::Session;
use vectorcraft_geom::Vec2;
for current in [false, true] {
let child = shape(20, "Oversized", false, false, [-10.0, -10.0, 110.0, 70.0], IDENTITY, vec![]);
let mut first = shape(10, "First", true, false, [0.0, 0.0, 100.0, 60.0], IDENTITY, vec![child]);
if current {
let F::Def(_, _, fields) = &mut first else { panic!() };
fields.retain(|(t, _)| *t != tag(b"ABEn"));
fields.push(field(b"phrp", F::Def(18, vec![tag(b"aprp")], vec![])));
}
let second = shape(30, "Second", true, false, [0.0, 0.0, 40.0, 60.0], [1.0, 0.0, 200.0, 0.0, 1.0, 0.0], vec![]);
let loaded = vectorcraft_engine::cmd::fileio::load("synthetic.af", &document(vec![vec![first, second]], 72.0, Method::Zstd)).unwrap();
let mut s = Session::new();
s.add_document(loaded.doc, None);
let before = s.doc().unwrap().doc.clone();
let first_bounds = find(&before.layers, "First").unwrap().geometric_bounds().unwrap();
let second_node = find(&before.layers, "Second").unwrap().clone();
let child_bounds = find(&before.layers, "First").unwrap().children().unwrap()[1].geometric_bounds().unwrap();
let delta = Vec2::new(13.0, -7.0);
s.execute("artboard.setProps", &json!({"index": 0, "x": 13, "y": -7, "moveArt": true})).unwrap();
let after = s.doc().unwrap().doc.clone();
assert_eq!(after.artboards[0].rect, before.artboards[0].rect + delta);
let first = find(&after.layers, "First").unwrap();
assert_eq!(first.geometric_bounds().unwrap(), first_bounds + delta);
assert_eq!(first.children().unwrap()[1].geometric_bounds().unwrap(), child_bounds + delta);
assert_eq!(find(&after.layers, "Second").unwrap(), &second_node);
assert_eq!(after.artboards[1], before.artboards[1]);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(*s.doc().unwrap().doc, *before);
s.execute("edit.redo", &json!({})).unwrap();
assert_eq!(*s.doc().unwrap().doc, *after);
}
}
+73
View File
@@ -214,6 +214,33 @@ fn structured_junk() {
("path.freehand", json!({"points": [[0, 0]]})),
("path.freehand", json!({"points": [[0, 0], [0, 0], [0, 0], [0, 0]]})),
("path.freehand", json!({"points": [[1e308, 1e308], [-1e308, 0]], "fidelity": 0})),
("path.freehand", json!({"points": [[0, 0, -5], [10, 0, 1e308], [20, 0, "x"], [30, 0, null], [40, 0, 0.5]], "style": "brush"})),
("path.freehand", json!({"points": [[0, 0, 0.2], [0, 0, 0.9], [0, 0, 0.1]]})),
(
"path.freehand",
json!({"points": [{"x": 0, "y": 0, "pressure": 2}, {"x": 1e308, "y": 0, "pressure": -1}], "extend": {"id": 2, "end": "start"}}),
),
("brush.freehand", json!({"points": [[0, 0, 0], [50, 50, 1]], "brush": "3 pt. Round"})),
("brush.options", json!({"name": "3 pt. Round", "params": {"modes": ["pressure", "random", "nope"]}})),
("brush.options", json!({"name": "3 pt. Round", "params": {"modes": "pressure", "variation": [1e308, -1e308, 1e308]}})),
(
"brush.options",
json!({"name": "3 pt. Round", "params": {"modes": ["pressure", "pressure", "pressure"], "variation": [1e308, 1e308, 1e308], "size": 1e308, "angle": -1e308}}),
),
(
"brush.options",
json!({"name": "Confetti", "params": {"size": [1e308, -1e308], "spacing": [0, 1e308], "scatter": [-1e308, 1e308], "rotation": [1e308, -1e308], "modes": ["pressure", "random", "fixed", "pressure"]}}),
),
("brush.options", json!({"name": "Dots", "params": {"modes": ["fixed", "tilt", "fixed", "fixed"], "size": "x"}})),
("brush.options", json!({"name": "Dots", "params": {"modes": ["fixed"], "colorization": {"method": "sparkle"}}})),
(
"brush.options",
json!({"name": "Dots", "params": {"colorization": {"method": "hueShift", "key": {"model": "rgb", "r": 1e308, "g": -1e308, "b": 0}}}}),
),
("brush.options", json!({"name": "Arrow", "params": {"width": 1e308, "scale": {"mode": "betweenGuides", "start": 1e308, "end": -1e308}}})),
("brush.options", json!({"name": "Arrow", "params": {"width": -5, "direction": "diagonal", "flip_along": "yes"}})),
("brush.options", json!({"name": "Chain", "params": {"scale": 1e308, "spacing": -1e308, "fit": "approximate", "flip_across": true}})),
("brush.options", json!({"name": "Chain", "params": {"scale": 0, "spacing": 1e308, "fit": "nope", "side": null}})),
("path.curvature", json!({"points": [{"x": 0, "y": 0}]})),
("path.curvatureEdit", json!({"id": 2, "op": "move", "anchor": 1000, "x": 0, "y": 0})),
("path.curvatureEdit", json!({"id": 2, "op": "move", "anchor": 0, "x": 1e308, "y": -1e308})),
@@ -265,6 +292,21 @@ fn structured_junk() {
("effect.apply", json!({"effect": "blur.radial", "params": {"amount": 1e308, "method": 5, "quality": "best"}})),
("effect.apply", json!({"effect": "blur.smart", "params": {"radius": 1e308, "threshold": -1e308, "quality": null}})),
("effect.apply", json!({"effect": "sharpen.unsharpMask", "params": {"amount": -1, "radius": "1e999", "threshold": 1e308}})),
("effect.apply", json!({"effect": "pixelate.colorHalftone", "params": {"maxRadius": 1e308, "channel1": "1e999", "channel4": null}})),
("effect.apply", json!({"effect": "pixelate.crystallize", "params": {"cellSize": -1e308}})),
("effect.apply", json!({"effect": "pixelate.mezzotint", "params": {"type": 5}})),
("effect.apply", json!({"effect": "pixelate.pointillize", "params": {"cellSize": "NaN"}})),
("effect.apply", json!({"effect": "texture.craquelure", "params": {"crackSpacing": -1e308, "crackDepth": "NaN", "crackBrightness": 1e308}})),
("effect.apply", json!({"effect": "texture.grain", "params": {"intensity": 1e308, "contrast": [1], "grainType": 7}})),
("effect.apply", json!({"effect": "texture.mosaicTiles", "params": {"tileSize": "1e999", "groutWidth": -1, "lightenGrout": null}})),
("effect.apply", json!({"effect": "texture.patchwork", "params": {"squareSize": 1e308, "relief": -1e308}})),
("effect.apply", json!({"effect": "texture.stainedGlass", "params": {"cellSize": 0, "borderThickness": 1e308, "lightIntensity": "x"}})),
(
"effect.apply",
json!({"effect": "texture.texturizer", "params": {"texture": {}, "scaling": -1e308, "relief": 1e308, "lightDirection": 3, "invert": "yes"}}),
),
("effect.apply", json!({"effect": "video.deinterlace", "params": {"eliminate": 7, "create": ["x"]}})),
("effect.apply", json!({"effect": "video.ntscColors", "params": {"junk": 1e308}})),
("effect.apply", json!({"effect": "no.such.effect"})),
("effect.remove", json!({"index": 99})),
("effect.setParams", json!({"index": 0, "params": null})),
@@ -384,6 +426,37 @@ fn area_options_fit_junk() {
assert!(failures.is_empty(), "{} failures:\n{}", failures.len(), failures.join("\n"));
}
/// A live polygon's Polygon Properties with junk values (the fixtures have no polygon), drawn
/// plain, scaled unevenly and flattened to nothing: never a panic, and the document stays sound.
#[test]
fn polygon_properties_junk() {
let mut cases: Vec<Value> = vec![json!({"makeSidesEqual": true}), json!({"sides": u64::MAX, "sideLength": 1e-300, "makeSidesEqual": true})];
for key in ["polygonRadius", "sideLength", "polygonAngle"] {
for v in junk_values() {
cases.push(json!({ key: v }));
}
}
let mut failures = vec![];
for scale in [None, Some((300.0, 20.0)), Some((1e-300, 100.0))] {
for p in &cases {
let mut s = Fixture::Multi.session();
let Ok(_) = s.execute("shape.polygon", &json!({"cx": 100, "cy": 100, "radius": 40, "sides": 7, "rotation": 10})) else { continue };
if let Some((sx, sy)) = scale {
let _ = s.execute("object.scale", &json!({"sx": sx, "sy": sy}));
}
match catch_quiet(|| s.execute("object.setLiveShape", p)) {
Err(m) => failures.push(format!("PANIC object.setLiveShape {p} [{scale:?}]: {m}")),
Ok(_) => {
if let Err(e) = catch_quiet(|| check_all(&mut s)).unwrap_or_else(|m| Err(format!("panic in checks: {m}"))) {
failures.push(format!("object.setLiveShape {p} [{scale:?}]: {e}"));
}
}
}
}
}
assert!(failures.is_empty(), "{} failures:\n{}", failures.len(), failures.join("\n"));
}
/// After a successful fuzzed call, the document still round-trips and exports.
#[test]
fn fuzzed_calls_keep_documents_serializable() {
+72 -3
View File
@@ -1,5 +1,5 @@
//! Untrusted files never crash the app: garbage, truncated, mutated and hostile SVG, PDF and DXF input,
//! mutated raster images placed with File → Place, and swatch (`.vcswatches`, `.gpl`, `.ase`), graphic style (`.vcstyles`) and flattener preset
//! mutated raster images (Photoshop documents among them) placed with File → Place, and swatch (`.vcswatches`, `.gpl`, `.ase`), graphic style (`.vcstyles`) and flattener preset
//! (`.vcflattener`) libraries, and native files (compressed, damaged, saved for older versions),
//! must load as an error or as a document that then renders and exports, without a panic; nor may
//! bitmaps, PDF and text pasted from other apps, nor EMF and WMF pictures (damaged files, records
@@ -804,6 +804,53 @@ fn styles(what: &str, data: &str) -> Result<(), TestCaseError> {
})
}
/// Libraries panel library file `data` imported, then each kind's first item used on the rich
/// document and moved into its first group, the group deleted and the library exported (#926).
fn libraries(what: &str, data: &str) -> Result<(), TestCaseError> {
survive_library(what, "library.import", data, |s, r| {
let lib = r["id"].clone();
let Ok(got) = no_panic(s.execute("library.get", &json!({"library": lib}))) else { return };
let _ = no_panic(s.execute("select.all", &json!({})));
for (list, kind, key) in
[("graphics", "graphic", "id"), ("colors", "fillColor", "name"), ("charStyles", "charStyle", "name"), ("paraStyles", "paraStyle", "name")]
{
if let Some(item) = got[list].get(0) {
let _ = no_panic(s.execute("library.use", &json!({"library": lib, "kind": kind, "item": item[key]})));
let to = json!({"library": lib, "kind": kind, "item": item[key], "group": got["groups"][0]["name"]});
let _ = no_panic(s.execute("library.moveItem", &to));
}
}
if let Some(g) = got["groups"].get(0) {
let _ = no_panic(s.execute("library.deleteGroup", &json!({"library": lib, "group": g["name"]})));
}
let _ = no_panic(s.execute("library.export", &json!({"library": lib})));
})
}
/// A Libraries panel library of the rich document's first object (a graphic), a colour, its
/// type's character and paragraph styles and a group, exported, made once.
fn saved_library() -> String {
static TEXT: std::sync::OnceLock<String> = std::sync::OnceLock::new();
TEXT.get_or_init(|| {
let mut s = rich_session();
s.execute("library.create", &json!({"name": "Fuzz"})).unwrap();
let first = s.doc().unwrap().doc.layers[0].children().unwrap()[0].id.0;
let g = s.execute("library.add", &json!({"kind": "graphic", "ids": [first]})).unwrap()["id"].clone();
s.execute("library.add", &json!({"kind": "fillColor", "color": "#336699"})).unwrap();
s.execute("select.all", &json!({})).unwrap();
for kind in ["charStyle", "paraStyle"] {
s.execute("library.add", &json!({"kind": kind})).unwrap();
}
s.execute(
"library.createGroup",
&json!({"name": "Logos", "items": [{"kind": "graphic", "item": g}, {"kind": "fillColor", "item": "#336699"}]}),
)
.unwrap();
s.execute("library.export", &json!({})).unwrap()["data"].as_str().unwrap().to_string()
})
.clone()
}
fn flattener_presets(what: &str, data: &str) -> Result<(), TestCaseError> {
survive_library(what, "flattener.presets.import", data, |s, r| {
let _ = no_panic(s.execute("select.all", &json!({})));
@@ -865,11 +912,12 @@ proptest! {
#![proptest_config(config())]
#[test]
fn library_garbage_never_panics(s in ".{0,300}", head in prop::sample::select(vec!["", "GIMP Palette\n","{\"format\": \"vcswatches\", ", "{\"format\": \"vcstyles\", ", "{\"format\": \"vcflattener\", "])) {
fn library_garbage_never_panics(s in ".{0,300}", head in prop::sample::select(vec!["", "GIMP Palette\n","{\"format\": \"vcswatches\", ", "{\"format\": \"vcstyles\", ", "{\"format\": \"vcflattener\", ", "{\"name\": \"L\", "])) {
let data = format!("{head}{s}");
swatches("swatch library garbage", &data)?;
styles("style library garbage", &data)?;
flattener_presets("flattener preset garbage", &data)?;
libraries("library garbage", &data)?;
}
#[test]
@@ -890,6 +938,14 @@ proptest! {
flattener_presets("mutated flattener presets", &mutate_text(&text, cut, &edits))?;
}
#[test]
fn mutated_libraries_never_panic(keep in 0.0f64..1.2, edits in prop::collection::vec(arb_edit(), 0..10)) {
// Cut anywhere in the file (whole most of the time), the edits anywhere in it.
let text = saved_library();
let cut = (text.chars().count() as f64 * keep) as usize;
libraries("mutated library", &mutate_text(&text, cut, &edits))?;
}
#[test]
fn swatch_exchange_garbage_never_panics(blocks in prop::collection::vec(arb_ase_block(), 0..12), tail in prop::collection::vec(any::<u8>(), 0..16)) {
let mut bytes = b"ASEF\0\x01\0\0".to_vec();
@@ -945,6 +1001,19 @@ fn raster_samples() -> Vec<(&'static str, Vec<u8>)> {
out.push((name, b));
}
out[0].1 = vectorcraft_engine::cmd::fileio::ppi::with_png_resolution(&out[0].1, (300.0, 150.0));
// A layered PSD with transparency (as VectorCraft exports it), and a PackBits PSB by hand.
let mut s = rich_session();
let r = s.execute("document.export", &json!({"format": "psd", "ppi": 2})).unwrap();
out.push(("a.psd", vectorcraft_format::base64_decode(r["dataBase64"].as_str().unwrap()).unwrap()));
let mut psb = b"8BPS\0\x02\0\0\0\0\0\0\0\x04\0\0\0\x02\0\0\0\x03\0\x08\0\x03".to_vec();
psb.extend([0; 8]);
psb.extend(10u64.to_be_bytes());
psb.extend(2u64.to_be_bytes());
psb.extend((-1i16).to_be_bytes());
psb.extend([0, 1]);
psb.extend((0..8).flat_map(|_| 2u32.to_be_bytes()));
psb.extend((0..8u8).flat_map(|v| [0xfe, v * 30]));
out.push(("a.psb", psb));
out
}
@@ -954,7 +1023,7 @@ proptest! {
/// Mutated image headers (resolution metadata, chunk and segment lengths) never crash reading
/// their resolution or placing them.
#[test]
fn mutated_images_place_without_panics(which in 0usize..4, cut in 0usize..400, edits in prop::collection::vec((0usize..120, any::<u8>()), 0..12)) {
fn mutated_images_place_without_panics(which in 0usize..6, cut in 0usize..4000, edits in prop::collection::vec((0usize..120, any::<u8>()), 0..12)) {
let (name, mut bytes) = raster_samples().swap_remove(which);
for (at, b) in edits {
if let Some(x) = bytes.get_mut(at) {
+2
View File
@@ -17,6 +17,8 @@ vectorcraft-brush = { workspace = true }
vectorcraft-text = { workspace = true }
# Windows metafile previews, opened when a file's PostScript can't be read.
vectorcraft-metafile = { workspace = true }
# Non-native art in Illustrator editing data (a placed PDF's content, kept as a PDF).
vectorcraft-pdf = { workspace = true }
# The TIFF writer (previews) and PackBits, shared with raster export.
vectorcraft-render = { workspace = true }
kurbo = { workspace = true }
+101 -6
View File
@@ -37,6 +37,7 @@ use vectorcraft_geom::{FillRule, PathData};
use super::lex::{Lexer, Tok};
use super::obj::{self, Obj, V};
use super::paint::{self, GradientDef, Instance, Named};
use crate::ps;
/// Deepest nesting of layers, groups and dictionaries.
const MAX_DEPTH: usize = 256;
@@ -248,6 +249,8 @@ struct Reader<'a> {
hidden_ids: HashSet<NodeId>,
/// CMYK samples converted, by value.
cmyk_cache: HashMap<[u8; 4], [u8; 3]>,
/// A `/ForeignObject`'s `/Data`: its PDF as ASCII85 in comment lines, until the object closes.
foreign_data: Option<Vec<u8>>,
/// The page ended: what follows isn't art.
done: bool,
/// The dictionary closed last was opened on a `%_` line.
@@ -316,8 +319,11 @@ const SKIPPED: &[(&str, &str)] = &[
("Alternate_Content", "End_Versioned_Content"),
];
/// The note for a `/ForeignObject`, art the editor shows but doesn't edit.
pub const NON_NATIVE_ART: &str = "non-native art (a placed PDF's content) is left out: VectorCraft doesn't draw it from the editing data yet";
/// The note for a `/ForeignObject` (non-native art, the content of a placed PDF, which the editor
/// shows but doesn't edit) whose PDF can't be read.
pub const NON_NATIVE_ART_UNREAD: &str = "non-native art (a placed PDF's content) that VectorCraft can't read is left out";
/// The name of the group a `/ForeignObject`'s art comes in as.
pub const NON_NATIVE_ART: &str = "Non-native art";
/// A section comment's marker without its `AI<version>_` prefix and value (`AI14_BeginSymbol` →
/// `BeginSymbol`); DSC comments (`%%BeginProlog`) keep their `%`.
@@ -350,6 +356,7 @@ impl<'a> Reader<'a> {
pending_stroke: None,
spots: Vec::new(),
raster: None,
foreign_data: None,
after_object: false,
nodes: 0,
unsupported: BTreeSet::new(),
@@ -659,8 +666,10 @@ impl<'a> Reader<'a> {
// of it may start with `%_` and read as hidden tokens: a `(` there would open
// a string that swallows the rest of the layer.
let key = o.entries.last().and_then(|(k, _)| k.as_deref());
if (o.ty == "Binary" && key == Some("ASCII85Decode")) || (o.ty == "ForeignObject" && key == Some("Data")) {
if o.ty == "Binary" && key == Some("ASCII85Decode") {
self.lex.skip_past(b"~>");
} else if o.ty == "ForeignObject" && key == Some("Data") {
self.foreign_data = Some(self.lex.skip_past(b"~>").to_vec());
}
}
return Ok(());
@@ -839,9 +848,7 @@ impl<'a> Reader<'a> {
"Document" => self.document(&o),
"AI11Text" => self.text_slot(&o)?,
"SymbolInstance" => self.unreadable("symbols"),
// Art Illustrator shows but doesn't edit (a placed PDF's content): its PDF is kept in
// the dictionary, which nothing draws from yet, so the file opens without it.
"ForeignObject" => self.warn(NON_NATIVE_ART),
"ForeignObject" => self.foreign_art(&o)?,
_ => {}
}
Ok(())
@@ -858,6 +865,94 @@ impl<'a> Reader<'a> {
self.add(n, hidden)
}
/// Non-native art (`/ForeignObject`): the content of a placed PDF, which Illustrator shows but
/// doesn't edit. Its `/Data` is that PDF (ASCII85 in comment lines, kept by the `,` handler),
/// `/Bounds` the art's box in a space measured from `/Origin` with y down, and `/RTransform`
/// maps that space onto the art (both read off files: the box lands on the clipping paths
/// around it). The PDF importer reads the PDF, and its art is fitted to the box, as one
/// group. Without data, or with a PDF that can't be read, nothing is drawn.
fn foreign_art(&mut self, o: &Obj) -> Result<(), String> {
let Some(text) = self.foreign_data.take() else { return Ok(()) };
// One `%` starts each line; the rest, `_` included, is data.
let mut encoded = String::with_capacity(text.len());
for line in text.split(|b| matches!(b, b'\r' | b'\n')) {
encoded.push_str(&String::from_utf8_lossy(line.strip_prefix(b"%").unwrap_or(line)));
}
let Some(pdf) = ps::ascii85_decode(&encoded) else {
self.warn(NON_NATIVE_ART_UNREAD);
return Ok(());
};
let (rt, origin, bounds) = (o.get("RTransform").map(Self::nums), o.get("Origin").map(Self::nums), o.get("Bounds").map(Self::nums));
let (Some([a, b, c, d, tx, ty]), Some([ox, oy]), Some([x0, y0, x1, y1])) = (rt.as_deref(), origin.as_deref(), bounds.as_deref()) else {
self.warn(NON_NATIVE_ART_UNREAD);
return Ok(());
};
let place = Affine::new([*a, *b, *c, *d, *tx, *ty]) * Affine::new([1.0, 0.0, 0.0, -1.0, *ox, *oy]);
let art_box = place.transform_rect_bbox(Rect::new(*x0, *y0, *x1, *y1));
if !sane(art_box) || art_box.width() <= 0.0 || art_box.height() <= 0.0 {
self.warn(NON_NATIVE_ART_UNREAD);
return Ok(());
}
let target = self.ensure_space().transform_rect_bbox(art_box);
let opts = vectorcraft_pdf::ImportOptions { layers: false, ..Default::default() };
let read = match vectorcraft_pdf::import_with_report(&pdf, &opts) {
Ok(r) => r,
Err(e) => {
self.warn(&format!("{NON_NATIVE_ART_UNREAD} ({e})"));
return Ok(());
}
};
let mut art: Vec<Node> = read.document.layers.iter().filter_map(|l| l.children()).flatten().map(|n| n.as_ref().clone()).collect();
let Some(bbox) = art.iter().fold(None, |acc, n| vectorcraft_geom::union_opt(acc, n.geometric_bounds())) else { return Ok(()) };
if bbox.width() <= 0.0 || bbox.height() <= 0.0 {
return Ok(());
}
// The art's own box onto the object's box (its measure of the same art), upside down:
// the object's space has y down, and the PDF's content is kept in its own y-up
// coordinates, so the PDF's bottom is the top of the box.
let fit = Affine::translate((target.x0, target.y1))
* Affine::scale_non_uniform(target.width() / bbox.width(), -target.height() / bbox.height())
* Affine::translate((-bbox.x0, -bbox.y0));
for n in &mut art {
n.transform(fit, true);
// The PDF's swatches stay with it: its colours keep their values, unlinked.
self.reid(n);
}
for (key, blob) in read.document.images {
self.doc.images.entry(key).or_insert(blob);
}
let gs = self.gs.clone();
let mut group = self.new_node(NodeKind::Group { children: art.into_iter().map(Arc::new).collect(), clip: false }, &gs);
group.name = Some(NON_NATIVE_ART.into());
self.add(group, self.obj_hidden)
}
/// Give `n` and its descendants ids of this document, and unlink their swatches.
fn reid(&mut self, n: &mut Node) {
n.id = self.doc.alloc_id();
for it in &mut n.appearance.items {
let p = match it {
AppearanceItem::Fill(f) => &mut f.paint,
AppearanceItem::Stroke(s) => &mut s.paint,
};
if let Paint::Gradient(g) = p {
g.swatch = None;
}
p.map_links(&mut |_, link, _| {
*link = None;
true
});
}
if let Some(m) = &mut n.mask {
self.reid(Arc::make_mut(&mut m.art));
}
if let Some(children) = n.children_mut() {
for c in children {
self.reid(Arc::make_mut(c));
}
}
}
/// Note something the data has that isn't read: an error on a layer that shows, left out of a
/// layer that doesn't.
fn unreadable(&mut self, what: &str) {
+94 -5
View File
@@ -5,7 +5,7 @@ use std::sync::Arc;
use kurbo::{Point, Rect};
use vectorcraft_color::gradient::GradientKind;
use vectorcraft_color::{BlendMode, Color, Paint};
use vectorcraft_doc::{ColorMode, Document, Knockout, LayerColor, LineCap, LineJoin, Node, NodeKind};
use vectorcraft_doc::{AppearanceItem, ColorMode, Document, Knockout, LayerColor, LineCap, LineJoin, Node, NodeKind};
use super::*;
@@ -332,13 +332,102 @@ fn sections_it_skips() {
/// Non-native art (a placed PDF's content) keeps its PDF as ASCII85 in comment lines after
/// `/Data ,`. ASCII85 has `_`, so a line may start with `%_`, which otherwise reads as hidden
/// tokens, and a `(` in it would swallow the rest of the layer (a letterhead saved by Illustrator
/// 28 wouldn't open: "it ends inside a layer"). The art is left out with a note.
/// 28 wouldn't open: "it ends inside a layer"). Data that isn't a PDF is left out with a note.
#[test]
fn non_native_art_is_skipped_with_its_ascii85_data() {
let foreign = "/ForeignObject :\n1 /Version ,\n2 0 0 2 10 20 /RTransform ,\n/Data ,\n%,u@!!/MSk8%41#ocdN=10d&\n%_O%*mGn(RduU.]4`[u[.f\n%_;(]<[%\n%1H@<P2`V<S,pbuU7L]\\~>\n;\n0 0 Xd\n6 () XW\n0 Ae\n";
fn non_native_art_that_is_not_a_pdf_is_left_out_with_a_note() {
let foreign = "/ForeignObject :\n1 /Version ,\n2 0 0 2 10 20 /RTransform ,\n10 20 /Origin ,\n1 2 5 8 /Bounds ,\n/Data ,\n%,u@!!/MSk8%41#ocdN=10d&\n%_O%*mGn(RduU.]4`[u[.f\n%_;(]<[%\n%1H@<P2`V<S,pbuU7L]\\~>\n;\n0 0 Xd\n6 () XW\n0 Ae\n";
let s = read_ok(&layer(&format!("{foreign}{}", rect_path(0.0, 0.0, 1.0, 1.0) + "f\n")));
assert_eq!(art(&s.doc).len(), 1);
assert!(s.warnings.iter().any(|w| w == read::NON_NATIVE_ART), "{:?}", s.warnings);
assert!(s.warnings.iter().any(|w| w.starts_with(read::NON_NATIVE_ART_UNREAD)), "{:?}", s.warnings);
}
/// A PDF with one page of `size` points holding `content`.
fn tiny_pdf(size: f64, content: &str) -> Vec<u8> {
let objects = [
"<< /Type /Catalog /Pages 2 0 R >>".to_string(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_string(),
format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {size} {size}] /Contents 4 0 R >>"),
format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len() + 1),
];
let mut pdf = b"%PDF-1.4\n".to_vec();
let mut offsets = vec![];
for (i, o) in objects.iter().enumerate() {
offsets.push(pdf.len());
pdf.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
}
let xref = pdf.len();
pdf.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).as_bytes());
for o in offsets {
pdf.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
}
pdf.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).as_bytes());
pdf
}
/// `bytes` as ASCII85 in comment lines of 60 characters, ending with `~>`.
fn ascii85_comment_lines(bytes: &[u8]) -> String {
let mut text = String::new();
for chunk in bytes.chunks(4) {
let mut word = [0u8; 4];
word[..chunk.len()].copy_from_slice(chunk);
let mut v = u32::from_be_bytes(word);
let mut digits = [0u8; 5];
for d in digits.iter_mut().rev() {
*d = (v % 85) as u8 + b'!';
v /= 85;
}
text.extend(digits.iter().take(chunk.len() + 1).map(|d| char::from(*d)));
}
let mut lines = String::new();
for line in text.as_bytes().chunks(60) {
lines.push('%');
lines.push_str(std::str::from_utf8(line).unwrap());
lines.push('\n');
}
lines.push_str("%~>\n");
lines
}
/// The PDF of non-native art is drawn by the PDF importer, its art fitted to the object's box:
/// `/Bounds` in a space measured from `/Origin` with y down, then `/RTransform`. Two squares at
/// 1..5 × 2..8 of a 10-point page, blue below red: bounds (1, 2)–(5, 8) → from the origin
/// (10, 20), y down, (11, 18)–(15, 12) → twice that plus (100, 50): x 122..130, y 74..86 in art
/// space, which is y 14..26 in the document (crop marks 100 high). The space has y down, so the
/// PDF's bottom (blue) is the top of the box.
#[test]
fn non_native_art_is_drawn_from_its_pdf_fitted_to_its_bounds() {
let pdf = tiny_pdf(10.0, "0 0 1 rg 1 2 4 3 re f 1 0 0 rg 1 5 4 3 re f");
let foreign = format!(
"q\n/ForeignObject :\n1 /Version ,\n2 0 0 2 100 50 /RTransform ,\n10 20 /Origin ,\n1 2 5 8 /Bounds ,\n/Data ,\n{};\n0 0 Xd\n6 () XW\n0 Ae\n{}h\nW\nn\nQ\n",
ascii85_comment_lines(&pdf),
rect_path(120.0, 70.0, 130.0, 90.0)
);
let s = read_ok(&layer(&foreign));
assert!(s.warnings.is_empty(), "{:?}", s.warnings);
let objects = art(&s.doc);
assert_eq!(objects.len(), 1, "{objects:?}");
// A clipping group: the clip path first, then the non-native art's group.
let NodeKind::Group { children, clip: true } = &objects[0].kind else { panic!("{:?}", objects[0].kind) };
assert_eq!(children.len(), 2);
let group = &children[1];
assert_eq!(group.name.as_deref(), Some(read::NON_NATIVE_ART));
let bounds = group.geometric_bounds().unwrap();
for (got, want) in [(bounds.x0, 122.0), (bounds.y0, 14.0), (bounds.x1, 130.0), (bounds.y1, 26.0)] {
assert!((got - want).abs() < 1e-6, "{bounds:?}");
}
let paths = group.children().unwrap();
assert_eq!(paths.len(), 2);
let fill =
|n: &Node| n.appearance.items.iter().find_map(|it| if let AppearanceItem::Fill(f) = it { Some(f.paint.clone()) } else { None }).unwrap();
let (blue, red) = (fill(&paths[0]), fill(&paths[1]));
assert!(matches!(&blue, Paint::Solid { color, swatch: None, .. } if color.to_rgba8(1.0) == [0, 0, 255, 255]), "{blue:?}");
assert!(matches!(&red, Paint::Solid { color, swatch: None, .. } if color.to_rgba8(1.0) == [255, 0, 0, 255]), "{red:?}");
let (b, r) = (paths[0].geometric_bounds().unwrap(), paths[1].geometric_bounds().unwrap());
assert!((b.y0 - 14.0).abs() < 1e-6 && (b.y1 - 20.0).abs() < 1e-6, "blue {b:?}");
assert!((r.y0 - 20.0).abs() < 1e-6 && (r.y1 - 26.0).abs() < 1e-6, "red {r:?}");
// Its ids are this document's.
assert_ne!(group.id, paths[0].id);
assert_ne!(paths[0].id, paths[1].id);
}
#[test]
+37 -9
View File
@@ -34,6 +34,9 @@ const MAX_PATH: usize = 1 << 20;
const MAX_DRAWN: usize = 1 << 24;
/// Samples taken of a shading function that isn't a plain interpolation.
const SHADING_SAMPLES: usize = 32;
/// Most bounds of a stitched linear function that become a gradient's stops: past it, the
/// function is sampled like any other (each stop evaluates the whole function).
const MAX_STITCH_STOPS: usize = 1024;
pub(super) const FAR_AWAY: &str = "objects far outside the page were left out";
pub(super) const TOO_MUCH: &str = "the file draws more than VectorCraft reads: the rest was left out";
@@ -1007,8 +1010,10 @@ impl Interp<'_> {
let domain = nums(get("Domain"));
let (d0, d1) = (domain.first().copied().unwrap_or(0.0), domain.get(1).copied().unwrap_or(1.0));
let f = get("Function").ok_or(PsError::Ps("undefined", "Function".into()))?;
// Where the colour changes: the ends, and the bounds of stitched linear functions.
let ts: Vec<f64> = match linear_points(&f) {
// Where the colour changes: the ends, and the bounds of stitched linear functions (unless
// there are too many).
let mut visits = 0;
let ts: Vec<f64> = match linear_points(&f, 0, &mut visits).filter(|p| p.len() <= MAX_STITCH_STOPS) {
Some(mut pts) => {
pts.retain(|t| (d0.min(d1)..=d0.max(d1)).contains(t));
pts.iter().map(|t| if d1 != d0 { (t - d0) / (d1 - d0) } else { 0.0 }).chain([0.0, 1.0]).collect()
@@ -1085,16 +1090,21 @@ impl Interp<'_> {
Ok(c0.iter().zip(&c1).map(|(a, b)| a + x * (b - a)).collect())
}
Some(3.0) => {
let fs = get("Functions").and_then(|o| o.items().map(|i| i.to_vec())).unwrap_or_default();
// Read the pieces through the borrow and clone only the one we recurse into: a
// `Functions` array of millions (shared, or reached again through a cycle) must
// not be copied on every call and at every live recursion level.
let functions = get("Functions");
let fs = functions.as_ref().and_then(Obj::items);
let n = fs.map_or(0, Shared::len);
let bounds = nums(get("Bounds"));
let encode = nums(get("Encode"));
let (lo, hi) = (domain.first().copied().unwrap_or(0.0), domain.get(1).copied().unwrap_or(1.0));
let k = bounds.iter().take_while(|b| t >= **b).count().min(fs.len().saturating_sub(1));
let k = bounds.iter().take_while(|b| t >= **b).count().min(n.saturating_sub(1));
let a = if k == 0 { lo } else { bounds.get(k - 1).copied().unwrap_or(lo) };
let b = bounds.get(k).copied().unwrap_or(hi);
let (e0, e1) = (encode.get(2 * k).copied().unwrap_or(0.0), encode.get(2 * k + 1).copied().unwrap_or(1.0));
let u = if b != a { e0 + (t - a) / (b - a) * (e1 - e0) } else { e0 };
let g = fs.get(k).cloned().ok_or(PsError::Ps("rangecheck", "Functions".into()))?;
let g = fs.and_then(|fs| fs.get(k)).ok_or(PsError::Ps("rangecheck", "Functions".into()))?;
self.eval(&g, &[u], depth + 1)
}
_ => {
@@ -1621,16 +1631,34 @@ fn is_mesh(sh: &DictRef) -> bool {
}
/// Where a stitched function of linear interpolations changes slope (its bounds), when it is
/// one; `None` for functions that must be sampled.
fn linear_points(f: &Obj) -> Option<Vec<f64>> {
/// one; `None` for functions that must be sampled. `depth` bounds the nesting the same way
/// [`Interp::eval`] does (`> 8`) and `visits` (shared by the whole recursion, incremented on
/// every call) bounds the total nodes looked at. Both are needed: the depth limit alone stops a
/// function that refers to itself in a cycle, but not one that fans out through shared children
/// (1025 references to a child a few levels deep is 1025^levels nodes without the visit cap). A
/// well-formed stitch of linear pieces is one level, at most `MAX_STITCH_STOPS + 1` pieces, so it
/// costs at most `MAX_STITCH_STOPS + 2` visits and is never cut off.
fn linear_points(f: &Obj, depth: u32, visits: &mut usize) -> Option<Vec<f64>> {
*visits += 1;
if depth > 8 || *visits > 2 * (MAX_STITCH_STOPS + 1) {
return None;
}
let Obj::Dict(d) = f else { return None };
let d = d.borrow();
let num = |k: &str| d.get(&Key::name(k)).and_then(Obj::as_num);
match num("FunctionType")? {
2.0 if num("N").unwrap_or(1.0) == 1.0 => Some(vec![]),
3.0 => {
let fs = d.get(&Key::name("Functions"))?.items()?.to_vec();
if !fs.iter().all(|g| linear_points(g).is_some_and(|p| p.is_empty())) {
let items = d.get(&Key::name("Functions"))?.items()?;
// More pieces than the stop cap allows: sample the function instead (tested before
// `to_vec`, so a huge `Functions` array is not copied only to be rejected). Each stop
// runs [`Interp::eval`], which reads one piece, so a short `Bounds` with a huge
// `Functions` would still be O(stops * Functions) to find each piece without this.
if items.len() > MAX_STITCH_STOPS + 1 {
return None;
}
let fs = items.to_vec();
if !fs.iter().all(|g| linear_points(g, depth + 1, visits).is_some_and(|p| p.is_empty())) {
return None;
}
d.get(&Key::name("Bounds"))?.items().map(|i| i.borrow().iter().filter_map(Obj::as_num).collect())
+93
View File
@@ -897,6 +897,99 @@ fn hostile_patterns_glyphs_and_shadings_end() {
assert_eq!(objects(&r.document).len(), 1);
}
/// The stops of the gradient `body` draws, after asserting it came in cleanly as one gradient.
fn stitch_stops(body: &str) -> Vec<f32> {
let r = read(body);
assert!(r.warnings.is_empty(), "{:?}", r.warnings);
let o = objects(&r.document);
let [node] = o.as_slice() else { panic!("{o:?}") };
let Some(Paint::Gradient(g)) = fill(node) else { panic!("{node:?}") };
g.gradient.stops.iter().map(|s| s.offset).collect()
}
/// A gradient stitched from linear pieces keeps their bounds as its stops up to the cap, and is
/// sampled past it. The cap is on the pieces; a well-formed stitch has one more piece than bounds.
#[test]
fn gradients_stitched_from_linear_pieces_keep_or_sample_their_stops() {
// `bounds` interior bounds at i / (bounds + 1), with one linear piece more than that.
let stitched = |bounds: usize| {
let pieces = bounds + 1;
format!(
"/F << /FunctionType 2 /Domain [0 1] /C0 [0] /C1 [1] /N 1 >> def \
<< /ShadingType 2 /ColorSpace /DeviceGray /Coords [0 0 100 0] /Function << /FunctionType 3 /Domain [0 1] \
/Functions [ {pieces} {{ F }} repeat ] /Bounds [ 1 1 {bounds} {{ {pieces} div }} for ] >> >> shfill"
)
};
// A few pieces: the bounds are kept exactly as stops (three bounds -> five stops with the ends).
assert_eq!(stitch_stops(&stitched(3)), vec![0.0, 0.25, 0.5, 0.75, 1.0]);
// At the cap (1024 bounds, 1025 pieces) the 1024 bounds and the two ends are all kept.
assert_eq!(stitch_stops(&stitched(1024)).len(), 1026);
// One past it (1025 bounds, 1026 pieces) the function is sampled instead.
assert_eq!(stitch_stops(&stitched(1025)).len(), 33);
}
/// A malformed stitching function with a single piece but thousands of bounds is sampled, not
/// turned into thousands of stops. This is the one case the stop-count `.filter` catches on its
/// own: the one piece is under the Functions-length guard, so only the bound count is out of range.
#[test]
fn a_stitching_function_with_one_piece_but_many_bounds_is_sampled() {
let stops = stitch_stops(
"/F << /FunctionType 2 /Domain [0 1] /C0 [0] /C1 [1] /N 1 >> def \
<< /ShadingType 2 /ColorSpace /DeviceGray /Coords [0 0 100 0] /Function << /FunctionType 3 /Domain [0 1] \
/Functions [ F ] /Bounds [ 1 1 2000 { 2001 div } for ] >> >> shfill",
);
// Base keeps all 2000 bounds (2002 stops); the fix samples.
assert_eq!(stops.len(), 33);
}
/// A short `Bounds` with a `Functions` array one past the cap is sampled: without the length
/// guard each of the ~1000 stops would read through the whole array to find its piece.
#[test]
fn a_gradient_with_a_functions_array_past_the_cap_is_sampled() {
let stops = stitch_stops(
"/F << /FunctionType 2 /Domain [0 1] /C0 [0] /C1 [1] /N 1 >> def \
<< /ShadingType 2 /ColorSpace /DeviceGray /Coords [0 0 100 0] /Function << /FunctionType 3 /Domain [0 1] \
/Functions [ 1026 { F } repeat ] /Bounds [ 1 1 1000 { 1001 div } for ] >> >> shfill",
);
// Base keeps the 1000 bounds (1002 stops); the fix samples.
assert_eq!(stops.len(), 33);
}
/// A stitching function (type 3) whose `Functions` array holds the function itself must not
/// recurse without end: `linear_points` (depth- and visit-bounded) and `eval` (depth-bounded)
/// both stop, so the shading raises an error instead of overflowing the stack (an abort
/// `vectorcraft_engine::guard` can't catch, and on wasm there is no net at all).
#[test]
fn a_stitching_function_that_refers_to_itself_is_rejected_not_crashed() {
let r = read(
"0 0 10 10 rectfill \
/F 5 dict def F /FunctionType 3 put F /Domain [0 1] put F /Bounds [] put F /Encode [0 1] put F /Functions [ F ] put \
<< /ShadingType 2 /ColorSpace /DeviceGray /Coords [0 0 100 0] /Function F >> shfill",
);
// Not an abort: the reader stops at the error, keeps the rectangle drawn before it, and leaves
// the rest of the program unread (see `import`: what follows the error is left out).
assert_eq!(objects(&r.document).len(), 1);
assert!(r.warnings.iter().any(|w| w.contains("read up to an error") && w.contains("Function")), "{:?}", r.warnings);
}
/// A stitching function need not point at itself to blow up: a `Functions` array of 1025
/// references to a child that holds 1025 references to a child ... is a tree of 1025^levels nodes
/// though it is tiny in memory (the children are shared). The shared visit counter stops
/// `linear_points` after about 2 * (MAX_STITCH_STOPS + 1) nodes, so it is sampled at once instead
/// of being walked; on the unmodified reader this explores ~1025^3 nodes.
#[test]
fn a_deeply_shared_stitching_function_is_sampled_not_walked() {
// leaf (linear) <- mid2[1025 leaf] <- mid1[1025 mid2] <- top[1025 mid1] (1000 bounds).
let stops = stitch_stops(
"/leaf << /FunctionType 2 /Domain [0 1] /C0 [0] /C1 [1] /N 1 >> def \
/mid2 << /FunctionType 3 /Domain [0 1] /Bounds [] /Functions [ 1025 { leaf } repeat ] >> def \
/mid1 << /FunctionType 3 /Domain [0 1] /Bounds [] /Functions [ 1025 { mid2 } repeat ] >> def \
/top << /FunctionType 3 /Domain [0 1] /Bounds [ 1 1 1000 { 1001 div } for ] /Functions [ 1025 { mid1 } repeat ] >> def \
<< /ShadingType 2 /ColorSpace /DeviceGray /Coords [0 0 100 0] /Function top >> shfill",
);
assert_eq!(stops.len(), 33);
}
/// Executable strings and keys other than names (#474): `cvx` makes a string executable, and
/// running it runs its text (PLRM 3rd ed. §3.3.1, `cvx`, `exec`); `load` takes any key (§8.2).
/// A program closes its shading dictionaries with an executable string (`>>` at Level 2 and up,
+39 -13
View File
@@ -1,6 +1,9 @@
//! An in-process engine session that answers the control-channel methods itself.
use std::sync::Arc;
use serde_json::{Value, json};
use vectorcraft_engine::file_access::{self, AutomationRoots};
use vectorcraft_engine::{Session, UiRequest, ViewInfo};
use vectorcraft_geom::Point;
use vectorcraft_render::Renderer;
@@ -14,6 +17,9 @@ pub struct Headless {
pub renderer: Renderer,
/// View state handed to tools (zoom 1, smart guides on).
pub view: ViewInfo,
/// The folders its methods may read and write (`--automation-read-root`,
/// `--automation-write-root`); `None`: anywhere.
roots: Option<Arc<AutomationRoots>>,
}
impl Default for Headless {
@@ -93,7 +99,13 @@ fn shortcut_matches(shortcut: &str, key: &str, mods: Mods) -> bool {
impl Headless {
/// A session with no document (the first `file.new` / `app.open` creates one).
pub fn new() -> Self {
Self { session: Session::new(), renderer: Renderer::new(), view: ViewInfo::default() }
Self { session: Session::new(), renderer: Renderer::new(), view: ViewInfo::default(), roots: None }
}
/// Confine the files every method reads and writes to `roots` ([`file_access`]).
pub fn with_automation_roots(mut self, roots: Option<Arc<AutomationRoots>>) -> Self {
self.roots = roots;
self
}
/// A session with a fresh default (Letter) document, ready to draw into.
@@ -227,13 +239,20 @@ impl Headless {
vectorcraft_render::raster_size(r, scale)?;
let img = self.renderer.render_region(&doc, r, scale, true);
let png = img.to_png()?;
let mut out = json!({"width": img.width, "height": img.height});
match s(p, "path") {
Some(path) => {
std::fs::write(path, &png).map_err(|e| format!("write {path}: {e}"))?;
Ok(json!({"path": path, "width": img.width, "height": img.height}))
file_access::check_write(path)?;
vectorcraft_format::write_atomic(std::path::Path::new(path), &png).map_err(|e| format!("write {path}: {e}"))?;
out["path"] = json!(path);
// `data: true` asks for the image as well as the file.
if p.get("data").and_then(Value::as_bool) == Some(true) {
out["pngBase64"] = json!(vectorcraft_format::base64_encode(&png));
}
}
None => Ok(json!({"width": img.width, "height": img.height, "pngBase64": vectorcraft_format::base64_encode(&png)})),
None => out["pngBase64"] = json!(vectorcraft_format::base64_encode(&png)),
}
Ok(out)
}
/// `app.open {path}`: any readable file as a new active document (`document.open`).
@@ -260,7 +279,22 @@ impl Headless {
impl Backend for Headless {
fn call(&mut self, method: &str, params: Value) -> Result<Value, String> {
let p = &params;
let roots = self.roots.clone();
file_access::confine(roots.as_ref(), || self.answer(method, &params))
}
fn has_ui(&self) -> bool {
false
}
fn describe(&self) -> String {
"headless".into()
}
}
impl Headless {
/// One control-channel method ([`Backend::call`]).
fn answer(&mut self, method: &str, p: &Value) -> Result<Value, String> {
match method {
"engine.execute" | "ui.menu.invoke" | "command" => {
let id = s(p, "command").or(s(p, "id")).ok_or("missing `command`")?.to_string();
@@ -289,12 +323,4 @@ impl Backend for Headless {
other => Err(format!("unknown method `{other}`")),
}
}
fn has_ui(&self) -> bool {
false
}
fn describe(&self) -> String {
"headless".into()
}
}
+18
View File
@@ -563,6 +563,24 @@ fn shift_marquee_gesture_toggles_the_selection() {
}
}
/// Preferences › Type › Show Font Names in English (#394): agents set it with `prefs.set`;
/// `text.fontList` stays the English (canonical) family names used by `text.setStyle`, not the
/// native UI labels.
#[test]
fn show_font_names_in_english_does_not_change_font_list_for_agents() {
let mut s = server();
let r = call(&mut s, 1, "run_command", json!({"command": "prefs.set", "params": {"key": "fontNamesInEnglish", "value": false}}));
assert_eq!(r["isError"], false, "{r}");
let r = call(&mut s, 2, "run_command", json!({"command": "prefs.get", "params": {"key": "fontNamesInEnglish"}}));
assert_eq!(r["isError"], false, "{r}");
assert_eq!(serde_json::from_str::<Value>(&text_of(&r)).unwrap(), json!(false));
let r = call(&mut s, 3, "run_command", json!({"command": "text.fontList", "params": {}}));
assert_eq!(r["isError"], false, "{r}");
let families = serde_json::from_str::<Value>(&text_of(&r)).unwrap()["families"].as_array().unwrap().clone();
// Canonical English name for commands, whatever the UI labels show with the preference off.
assert!(families.iter().any(|f| f.as_str() == Some("Source Sans 3")), "{families:?}");
}
/// Type preferences reach agents (#394): type the Type tool places starts with placeholder text,
/// selected; Alt+→ tracks it by Tracking and Cmd+Shift+. steps its size by Size/Leading.
#[test]
+44 -37
View File
@@ -419,7 +419,7 @@ pub fn tool_definitions() -> Vec<Value> {
tool(
"create_graph",
"Create graph",
"Create a graph (graph tools) in the plot rectangle. Data as `csv` (first row: empty cell then series names; then one row per category: label, values…) or `series`/`categories`/`rows`. Edit later with run_command graph.setData / graph.setType.",
"Create a graph (graph tools) in the plot rectangle. Data as `csv` (first row: empty cell then series names; then one row per category: label, values…) or `series`/`categories`/`rows`. An empty CSV cell or a null in `rows` is a blank value (no column; lines break around it); a number in straight quotes is a label. Edit later with run_command graph.setData / graph.setType.",
obj(
json!({
"type": {"type": "string", "enum": ["column", "stackedColumn", "bar", "stackedBar", "line", "area", "scatter", "pie", "radar"]},
@@ -430,7 +430,7 @@ pub fn tool_definitions() -> Vec<Value> {
"csv": {"type": "string"},
"series": {"type": "array", "items": {"type": "string"}},
"categories": {"type": "array", "items": {"type": "string"}},
"rows": {"type": "array", "items": {"type": "array", "items": {"type": "number"}}},
"rows": {"type": "array", "items": {"type": "array", "items": {"type": ["number", "null"]}}},
}),
&["x", "y", "width", "height"],
),
@@ -649,43 +649,50 @@ fn transform(b: &mut dyn Backend, a: &Args) -> Result<Value, String> {
fn screenshot(b: &mut dyn Backend, a: &Args) -> Result<ToolResult, String> {
let path = a.get("path").and_then(Value::as_str);
if a.get("window").and_then(Value::as_bool) == Some(true) {
need_ui(b, "screenshot {window:true}")?;
// The app writes the capture to disk (loopback: same machine), then we read it back.
let tmp;
let target = match path {
Some(p) => p,
None => {
tmp = std::env::temp_dir().join(format!("vectorcraft-window-{}.png", std::process::id())).to_string_lossy().to_string();
&tmp
}
};
let r = b.call("ui.screenshot", json!({"path": target}))?;
let png = std::fs::read(target).map_err(|e| format!("read {target}: {e}"))?;
if path.is_none() {
std::fs::remove_file(target).ok();
}
return Ok(image_result(&png, json!({"window": true, "width": r.get("width"), "height": r.get("height"), "path": path})));
}
let mut p = json!({});
for k in ["scale", "artboard"] {
if let Some(v) = a.get(k) {
p[k] = v.clone();
}
}
let r = b.call("ui.render", p)?;
let b64 = r.get("pngBase64").and_then(Value::as_str).ok_or("renderer returned no image")?;
let window = a.get("window").and_then(Value::as_bool) == Some(true);
// The backend writes the file, where its automation roots allow, and sends the image back too
// (`data`): this process writes nothing.
let mut p = json!({"data": true});
if let Some(path) = path {
let png = vectorcraft_format::base64_decode(b64).ok_or("renderer returned bad base64")?;
std::fs::write(path, png).map_err(|e| format!("write {path}: {e}"))?;
p["path"] = json!(path);
}
Ok(ToolResult {
content: vec![
json!({"type": "image", "data": b64, "mimeType": "image/png"}),
json!({"type": "text", "text": json!({"width": r.get("width"), "height": r.get("height"), "path": path}).to_string()}),
],
is_error: false,
})
let r = if window {
need_ui(b, "screenshot {window:true}")?;
b.call("ui.screenshot", p)?
} else {
for k in ["scale", "artboard"] {
if let Some(v) = a.get(k) {
p[k] = v.clone();
}
}
b.call("ui.render", p)?
};
let png = match r.get("pngBase64").and_then(Value::as_str) {
Some(b64) => vectorcraft_format::base64_decode(b64).ok_or("the backend returned bad base64")?,
// An app from before `data` wrote the file without sending the image: read it back
// (loopback: the same machine).
None => match path {
Some(path) => std::fs::read(path).map_err(|e| format!("read {path}: {e}"))?,
None if window => return old_window_capture(b),
None => return Err("the renderer returned no image".into()),
},
};
let info = if window {
json!({"window": true, "width": r.get("width"), "height": r.get("height"), "path": path})
} else {
json!({"width": r.get("width"), "height": r.get("height"), "path": path})
};
Ok(image_result(&png, info))
}
/// The window of an app from before `ui.screenshot {data}`, captured through a temporary file.
fn old_window_capture(b: &mut dyn Backend) -> Result<ToolResult, String> {
let tmp = std::env::temp_dir().join(format!("vectorcraft-window-{}.png", std::process::id())).to_string_lossy().to_string();
let r = b.call("ui.screenshot", json!({"path": tmp}))?;
let png = std::fs::read(&tmp).map_err(|e| format!("read {tmp}: {e}"))?;
// Best effort: a temporary file left behind is harmless.
std::fs::remove_file(&tmp).ok();
Ok(image_result(&png, json!({"window": true, "width": r.get("width"), "height": r.get("height"), "path": Value::Null})))
}
fn image_result(png: &[u8], info: Value) -> ToolResult {
+155
View File
@@ -0,0 +1,155 @@
//! `vectorcraft-cli mcp --automation-read-root <dir> --automation-write-root <dir>` (#832): through
//! the MCP protocol, files open only from inside the read root and are saved, exported and
//! captured only inside the write root; paths that leave a root (`..`, another folder, a sibling
//! with the same prefix) are refused, and nothing is written outside.
// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness).
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use std::path::{Path, PathBuf};
use serde_json::{Value, json};
use vectorcraft_engine::file_access::AutomationRoots;
use vectorcraft_mcp::{Headless, Server};
const SVG: &str = r#"<svg xmlns="http://www.w3.org/2000/svg" width="40" height="30"><rect width="20" height="10" fill="red"/></svg>"#;
/// A folder holding `in` (the read root, with `a.svg`), `out` (the write root), `outside` (with
/// `b.svg`) and `in2`, and a server confined to `in` and `out` (either may be left out).
fn confined(tag: &str, read: bool, write: bool) -> (Server, PathBuf) {
let base = vectorcraft_testkit::temp_dir(&format!("roots-{tag}"));
for d in ["in", "out", "outside", "in2"] {
std::fs::create_dir_all(base.join(d)).unwrap();
}
std::fs::write(base.join("in/a.svg"), SVG).unwrap();
std::fs::write(base.join("outside/b.svg"), SVG).unwrap();
std::fs::write(base.join("in2/c.svg"), SVG).unwrap();
let in_dir = base.join("in");
let out_dir = base.join("out");
let roots = AutomationRoots::new(read.then_some(in_dir.as_path()), write.then_some(out_dir.as_path())).unwrap();
(Server::new(Box::new(Headless::with_document().with_automation_roots(roots))), base)
}
fn s(p: &Path) -> String {
p.to_string_lossy().into_owned()
}
/// `tools/call` → (isError, the first text block).
fn call(server: &mut Server, name: &str, args: Value) -> (bool, String) {
let line = json!({"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {"name": name, "arguments": args}}).to_string();
let reply: Value = serde_json::from_str(&server.handle_line(&line).unwrap()).unwrap();
let result = &reply["result"];
let text = result["content"].as_array().unwrap().iter().find_map(|c| c["text"].as_str()).unwrap_or_default().to_string();
(result["isError"] == true, text)
}
fn ok(server: &mut Server, name: &str, args: Value) -> String {
let (error, text) = call(server, name, args.clone());
assert!(!error, "{name} {args}: {text}");
text
}
fn refused(server: &mut Server, name: &str, args: Value, why: &str) {
let (error, text) = call(server, name, args.clone());
assert!(error && text.contains(why), "{name} {args} should be refused ({why}): {text}");
}
#[test]
fn files_open_only_from_inside_the_read_root() {
let (mut server, base) = confined("open", true, true);
let sep = std::path::MAIN_SEPARATOR;
ok(&mut server, "open_file", json!({"path": s(&base.join("in/a.svg"))}));
ok(&mut server, "run_command", json!({"command": "file.place", "params": {"path": s(&base.join("in/a.svg"))}}));
refused(&mut server, "open_file", json!({"path": s(&base.join("outside/b.svg"))}), "outside the read root");
refused(&mut server, "open_file", json!({"path": format!("{}{sep}..{sep}outside{sep}b.svg", s(&base.join("in")))}), "outside the read root");
refused(&mut server, "open_file", json!({"path": s(&base.join("in2/c.svg"))}), "outside the read root");
refused(
&mut server,
"run_command",
json!({"command": "file.place", "params": {"path": s(&base.join("outside/b.svg"))}}),
"outside the read root",
);
// The write root isn't readable: the two kinds of access are separate.
std::fs::write(base.join("out/d.svg"), SVG).unwrap();
refused(&mut server, "open_file", json!({"path": s(&base.join("out/d.svg"))}), "outside the read root");
// A relative path is the working directory's (the crate's folder here): outside.
refused(&mut server, "open_file", json!({"path": "Cargo.toml"}), "outside the read root");
}
#[test]
fn saves_exports_and_captures_go_only_into_the_write_root() {
let (mut server, base) = confined("write", true, true);
let sep = std::path::MAIN_SEPARATOR;
ok(&mut server, "draw_shape", json!({"shape": "rectangle", "x": 10, "y": 10, "width": 50, "height": 40, "fill": "#1e88e5"}));
ok(&mut server, "export", json!({"path": s(&base.join("out/art.png"))}));
assert!(base.join("out/art.png").is_file());
ok(&mut server, "export", json!({"path": s(&base.join("out/art.svg"))}));
ok(&mut server, "save_file", json!({"path": s(&base.join("out/doc.vectorcraft"))}));
ok(&mut server, "screenshot", json!({"path": s(&base.join("out/shot.png"))}));
assert!(base.join("out/shot.png").is_file(), "the backend wrote the capture");
refused(&mut server, "export", json!({"path": s(&base.join("in/art.png"))}), "outside the write root");
refused(&mut server, "export", json!({"path": s(&base.join("outside/art.svg"))}), "outside the write root");
refused(&mut server, "export", json!({"path": format!("{}{sep}..{sep}outside{sep}up.png", s(&base.join("out")))}), "outside the write root");
refused(&mut server, "save_file", json!({"path": s(&base.join("outside/doc.vectorcraft"))}), "outside the write root");
refused(&mut server, "screenshot", json!({"path": s(&base.join("outside/shot.png"))}), "outside the write root");
refused(
&mut server,
"run_command",
json!({"command": "document.exportForScreens", "params": {"folder": s(&base.join("outside/screens"))}}),
"outside the write root",
);
refused(
&mut server,
"run_command",
json!({"command": "file.package", "params": {"folder": s(&base.join("outside/pkg"))}}),
"outside the write root",
);
// Folders the app writes on its own later: only inside the roots.
refused(
&mut server,
"run_command",
json!({"command": "prefs.set", "params": {"key": "recoveryFolder", "value": s(&base.join("outside"))}}),
"outside the write root",
);
ok(&mut server, "run_command", json!({"command": "prefs.set", "params": {"key": "recoveryFolder", "value": s(&base.join("out"))}}));
refused(&mut server, "run_command", json!({"command": "plugin.reload", "params": {"path": s(&base.join("outside"))}}), "outside the read root");
// The document saved inside the write root saves again in place.
ok(&mut server, "save_file", json!({}));
let written: Vec<String> =
std::fs::read_dir(base.join("outside")).unwrap().map(|e| e.unwrap().file_name().to_string_lossy().into_owned()).collect();
assert_eq!(written, ["b.svg"], "nothing was written outside the roots");
assert!(!base.join("in/art.png").exists());
}
#[test]
fn a_root_left_out_grants_none_of_its_access() {
let (mut server, base) = confined("read-only", true, false);
ok(&mut server, "open_file", json!({"path": s(&base.join("in/a.svg"))}));
refused(&mut server, "export", json!({"path": s(&base.join("out/art.png"))}), "write authority is absent");
// Bytes that come back instead of a file are fine.
ok(&mut server, "export", json!({"format": "svg"}));
let (mut server, base) = confined("write-only", false, true);
refused(&mut server, "open_file", json!({"path": s(&base.join("in/a.svg"))}), "read authority is absent");
ok(&mut server, "export", json!({"path": s(&base.join("out/art.png"))}));
}
#[test]
fn links_out_of_a_root_are_refused() {
let (mut server, base) = confined("links", true, true);
#[cfg(unix)]
let linked = std::os::unix::fs::symlink(base.join("outside"), base.join("out/away")).is_ok();
#[cfg(windows)]
let linked = std::os::windows::fs::symlink_dir(base.join("outside"), base.join("out/away")).is_ok();
#[cfg(not(any(unix, windows)))]
let linked = false;
// Windows needs Developer Mode or elevation to make links.
if !linked {
eprintln!("skipped: symbolic links can't be made here");
return;
}
refused(&mut server, "export", json!({"path": s(&base.join("out/away/art.png"))}), "outside the write root");
assert!(!base.join("outside/art.png").exists());
}
+49
View File
@@ -137,6 +137,55 @@ fn every_tool_survives_empty_and_junk_arguments() {
assert!(failures.is_empty(), "{} failures:\n{}", failures.len(), failures.join("\n"));
}
/// Junk below a root: names, `.`, `..` (at most four, which stay inside the test's folder), a
/// device name, a drive, either separator.
fn junk_tail() -> impl Strategy<Value = String> {
let seg =
prop::sample::select(vec!["..", ".", "a", "b.svg", "x.png", "d.vectorcraft", "CON", "nul.txt", "", "c:", "~", "%2e%2e", "ü", "a b", "x."]);
(prop::collection::vec(seg, 1..=4), prop::sample::select(vec!["/", "\\"])).prop_map(|(segs, sep)| segs.join(sep))
}
/// Every file below `dir`.
fn files_below(dir: &std::path::Path) -> Vec<std::path::PathBuf> {
let mut out = vec![];
for e in std::fs::read_dir(dir).into_iter().flatten().flatten() {
let path = e.path();
if e.file_type().is_ok_and(|t| t.is_dir()) {
out.extend(files_below(&path));
} else {
out.push(path);
}
}
out
}
proptest! {
#![proptest_config(ProptestConfig { cases: 64, ..ProptestConfig::default() })]
/// Confined to one folder (#832), the server takes fuzzed paths, unlike the junk test above:
/// every reply is well formed, and whatever it writes lands inside the root.
#[test]
fn a_confined_server_writes_only_inside_its_root(
tail in junk_tail(),
tool in prop::sample::select(vec!["open_file", "save_file", "export", "screenshot"]),
) {
let base = vectorcraft_testkit::temp_dir("mcp-confined");
let root = base.join("a/b/c/root");
std::fs::create_dir_all(&root).unwrap();
std::fs::create_dir_all(base.join("outside")).unwrap();
let roots = vectorcraft_engine::file_access::AutomationRoots::new(Some(root.as_path()), Some(root.as_path())).unwrap();
let mut s = Server::new(Box::new(Headless::with_document().with_automation_roots(roots)));
let path = format!("{}{}{tail}", root.display(), std::path::MAIN_SEPARATOR);
let r = catch_quiet(|| call(&mut s, tool, json!({"path": path})));
prop_assert!(r.is_ok(), "{tool} {path}: panicked");
let real_root = std::fs::canonicalize(&root).unwrap();
for f in files_below(&base) {
let inside = std::fs::canonicalize(&f).map_or_else(|_| f.starts_with(&root), |real| real.starts_with(&real_root));
prop_assert!(inside, "{tool} {path} wrote {}", f.display());
}
}
}
#[test]
fn framing_never_panics_on_malformed_messages() {
let mut s = server();
+58 -1
View File
@@ -503,6 +503,46 @@ fn matching_face(
best.map(|(_, f)| f)
}
/// The characters a glyph no mapping names is looked for among (#811): those single-byte fonts
/// encode (ASCII, Latin-1, Latin Extended-A, general punctuation, the euro and trade mark signs,
/// the fi and fl ligatures).
const IDENTIFIABLE: [std::ops::RangeInclusive<u32>; 6] =
[0x21..=0x7E, 0xA1..=0x17F, 0x2010..=0x2044, 0x20AC..=0x20AC, 0x2122..=0x2122, 0xFB01..=0xFB02];
/// The character whose glyph in installed `face` draws `embedded` (a glyph outline in thousandths
/// of an em, y up): the first of [`IDENTIFIABLE`] with the same outline, point for point. A subset
/// font embedded without a character map still carries the font's own outlines, so a glyph whose
/// subset code names nothing is found this way. `None` when no glyph matches (another version of
/// the font, or a glyph that is no character), and for empty glyphs, which every space draws.
pub(crate) fn identify_glyph(face: &vectorcraft_text::FontFace, embedded: &BezPath) -> Option<char> {
if embedded.elements().is_empty() {
return None;
}
let k = 1000.0 / face.units_per_em();
// Installed outlines are y-down in font units.
let to_pdf = Affine::new([k, 0.0, 0.0, -k, 0.0, 0.0]);
let db = vectorcraft_text::FontDb::global();
IDENTIFIABLE.iter().cloned().flatten().filter_map(char::from_u32).find(|c| {
let gid = face.glyph_for(*c);
gid != 0 && same_shape(embedded, &(to_pdf * db.outline(face, gid).as_ref().clone()))
})
}
/// Do outlines `a` and `b` draw the same segments, every point within 2/1000 em (closing
/// commands aside)?
fn same_shape(a: &BezPath, b: &BezPath) -> bool {
let drawn = |p: &BezPath| p.elements().iter().filter(|e| !matches!(e, PathEl::ClosePath)).copied().collect::<Vec<_>>();
let (a, b) = (drawn(a), drawn(b));
let near = |p: kurbo::Point, q: kurbo::Point| (p - q).hypot() <= 2.0;
a.len() == b.len()
&& a.iter().zip(&b).all(|(x, y)| match (x, y) {
(PathEl::MoveTo(p), PathEl::MoveTo(q)) | (PathEl::LineTo(p), PathEl::LineTo(q)) => near(*p, *q),
(PathEl::QuadTo(p1, p2), PathEl::QuadTo(q1, q2)) => near(*p1, *q1) && near(*p2, *q2),
(PathEl::CurveTo(p1, p2, p3), PathEl::CurveTo(q1, q2, q3)) => near(*p1, *q1) && near(*p2, *q2) && near(*p3, *q3),
_ => false,
})
}
/// The character of glyph `text` when its outline can be compared with an installed font's: a
/// single character, and not CJK punctuation set vertically (its vertical form differs).
fn comparable(text: &str, vertical: bool) -> Option<char> {
@@ -666,6 +706,9 @@ struct Builder<'p> {
/// Font (cache key) → the installed face that draws its glyphs, decided from its first glyph
/// that can be compared ([`matching_face`]); `None`: its glyphs differ, so it stays outlines.
matched: HashMap<u128, Option<Arc<vectorcraft_text::FontFace>>>,
/// (Font, glyph) → the character its outline matches in the installed face, for glyphs no
/// mapping names ([`Builder::identified_char`]).
identified: HashMap<(u128, u32), Option<char>>,
/// Per font: the installed version its type names, when it isn't the one its family and style
/// resolve to (see `CharStyle::font_version`).
versions: HashMap<u128, Option<String>>,
@@ -817,6 +860,7 @@ impl<'p> Builder<'p> {
cid_text: HashMap::new(),
font_chars: HashMap::new(),
matched: HashMap::new(),
identified: HashMap::new(),
versions: HashMap::new(),
mask: None,
masked: vec![],
@@ -1286,6 +1330,17 @@ impl<'p> Builder<'p> {
/// The family and style of font `key` (drawing glyph `o`): the installed face of its PostScript
/// name, else the available family its name reads as; whether it is available.
/// The character of glyph `glyph` of font `key` that no mapping names, found by its outline in
/// the installed face of the font's name ([`identify_glyph`]); cached per glyph.
fn identified_char(&mut self, key: u128, glyph: u32, o: &hayro_interpret::font::OutlineGlyph) -> Option<char> {
if let Some(c) = self.identified.get(&(key, glyph)) {
return *c;
}
let c = self.font_name(key, o).face.and_then(|f| identify_glyph(&f, &o.outline()));
self.identified.insert((key, glyph), c);
c
}
fn font_name(&mut self, key: u128, o: &hayro_interpret::font::OutlineGlyph) -> FontInfo {
if let Some(n) = self.font_names.get(&key) {
return n.clone();
@@ -1345,7 +1400,9 @@ impl<'p> Builder<'p> {
.font_chars
.entry(key)
.or_insert_with(|| o.font_data().and_then(|d| font_chars(d.data.as_ref().as_ref())).map(Arc::new));
(chars.as_ref().and_then(|m| m.get(&glyph)).map(|c| BfString::Char(*c)), false)
let named = chars.as_ref().and_then(|m| m.get(&glyph)).copied();
// Still unnamed: the installed font may draw the same outline (#811).
(named.or_else(|| self.identified_char(key, glyph, o)).map(BfString::Char), false)
}
}
}
+24 -8
View File
@@ -248,17 +248,33 @@ fn digits_unnamed(text: &str) -> Vec<u8> {
out
}
/// #708: a glyph whose ToUnicode value is U+FFFD isn't type showing "�". This subset font has no
/// character map of its own, so the digits keep their outlines, drawn as the file draws them, and
/// a warning names the font.
/// #708, #811: a glyph whose ToUnicode value is U+FFFD isn't type showing "�". This subset font
/// has no character map of its own, but the font is installed: each digit is found by its outline
/// there, so the whole text stays one type object, with no outlines left behind.
#[test]
fn glyphs_mapped_to_the_replacement_character_keep_their_outlines() {
fn unnamed_glyphs_of_an_installed_font_are_found_by_their_outline() {
let bytes = digits_unnamed("Tel 02-12345");
let report = import_with_report(&bytes, &ImportOptions { text_as: TextAs::Text, ..Default::default() }).unwrap();
let all = texts(&report.document).concat();
assert!(!all.contains('\u{FFFD}') && all.contains("Tel"), "no replacement characters: {all:?}");
assert!(ink(&report.document).width() > 50.0, "the digits are outlines");
assert!(report.warnings.iter().any(|w| w.contains("U+FFFD")), "{:?}", report.warnings);
assert_eq!(texts(&report.document), ["Tel 02-12345"]);
let mut paths = 0;
report.document.walk(|n| paths += usize::from(matches!(n.kind, NodeKind::Path { .. } | NodeKind::Compound { .. })));
assert_eq!(paths, 0, "no outlines left: {:?}", report.warnings);
assert!(!report.warnings.iter().any(|w| w.contains("U+FFFD")), "{:?}", report.warnings);
}
/// #811: an outline is named only by a glyph that draws it exactly; one moved off its place, or an
/// empty glyph (which every space draws), stays unnamed and keeps its outlines.
#[test]
fn glyphs_are_named_only_by_an_exact_outline() {
let db = FontDb::global();
let face = db.face("Source Sans 3", "Regular").unwrap();
let k = 1000.0 / face.units_per_em();
let pdf_space = |c: char| Affine::new([k, 0.0, 0.0, -k, 0.0, 0.0]) * db.outline(&face, face.glyph_for(c)).as_ref().clone();
for c in ['R', 'a', '7', '&', 'é'] {
assert_eq!(crate::import::identify_glyph(&face, &pdf_space(c)), Some(c));
}
assert_eq!(crate::import::identify_glyph(&face, &(Affine::translate((100.0, 0.0)) * pdf_space('R'))), None);
assert_eq!(crate::import::identify_glyph(&face, &BezPath::new()), None);
}
/// #708: where the embedded font program has a character map, a glyph's character comes from it.
+11
View File
@@ -95,6 +95,13 @@ pub struct FolderReport {
/// reported and skipped; the others still load.
#[cfg(not(target_arch = "wasm32"))]
pub fn load_folder(dir: &std::path::Path) -> Result<FolderReport> {
load_folder_where(dir, |_| Ok(()))
}
/// [`load_folder`], loading only the files `allow` accepts; the others are reported as failed
/// with its reason.
#[cfg(not(target_arch = "wasm32"))]
pub fn load_folder_where(dir: &std::path::Path, allow: impl Fn(&std::path::Path) -> std::result::Result<(), String>) -> Result<FolderReport> {
let rd = std::fs::read_dir(dir).map_err(|e| Error::Io(format!("{}: {e}", dir.display())))?;
let mut files: Vec<std::path::PathBuf> =
rd.filter_map(|e| e.ok().map(|e| e.path())).filter(|p| p.extension().is_some_and(|x| x.eq_ignore_ascii_case("wasm"))).collect();
@@ -102,6 +109,10 @@ pub fn load_folder(dir: &std::path::Path) -> Result<FolderReport> {
files.truncate(1000);
let mut report = FolderReport::default();
for f in files {
if let Err(why) = allow(&f) {
report.failed.push((f.display().to_string(), why));
continue;
}
match load_file(&f, Limits::default()) {
Ok(p) => report.loaded.push(install(p).id().to_string()),
Err(e) => report.failed.push((f.display().to_string(), e.to_string())),
+10 -1
View File
@@ -595,7 +595,16 @@ impl Renderer {
off.flush();
let mut pm = vello_cpu::Pixmap::new(wu, hu);
off.render(&mut pm, &mut self.resources);
let space = effects::PixelSpace { to_doc, px, center: content.reach.center() };
let channels = match f.ink {
Ink::Cmy => effects::pixel::Channels::CmyPlane,
Ink::K => effects::pixel::Channels::KPlane,
Ink::Display if f.doc.color_mode == vectorcraft_doc::ColorMode::Cmyk => effects::pixel::Channels::Cmyk,
Ink::Display => effects::pixel::Channels::Rgb,
};
// De-Interlace's field lines: the rows of the document's raster grid (1 to 2400 ppi).
let ppi = Some(f.doc.raster_effects_ppi).filter(|v| v.is_finite()).unwrap_or(72.0).clamp(1.0, 2400.0);
let (center, extent) = (content.reach.center(), 0.5 * content.reach.width().hypot(content.reach.height()));
let space = effects::PixelSpace { to_doc, px, center, channels, line: 72.0 / ppi, extent };
let (wz, hz) = (wu as usize, hu as usize);
for fx in chain {
match fx {
+88 -6
View File
@@ -50,6 +50,9 @@ pub struct FontFace {
id: u32,
/// Typographic family name (e.g. "Source Sans 3").
pub family: String,
/// Non-English family name from the name table (`ヒラギノ角ゴシック`), when the font has one.
/// Used when Preferences › Type › Show Font Names in English is off.
pub native_family: Option<String>,
/// Typographic style name (e.g. "Semibold", "Italic").
pub style: String,
/// The font's version string (name ID 5, e.g. "Version 7.200"; empty when it has none). Two
@@ -292,6 +295,8 @@ impl FontFace {
#[derive(Debug, Default)]
struct CatalogFamily {
name: String,
/// Non-English family name from the name table, when the scan found one.
native: Option<String>,
faces: Vec<CatalogFace>,
/// The family's kind (from its first face found).
traits: Option<FontTraits>,
@@ -428,6 +433,34 @@ fn localized(font: &skrifa::FontRef<'_>, id: StringId) -> Vec<(Option<String>, S
font.localized_strings(id).map(|s| (s.language().map(str::to_owned), s.to_string())).filter(|(_, s)| !s.is_empty()).collect()
}
/// Whether a name-table language tag is English (Illustrator's "Show Font Names in English").
fn is_english_lang(lang: Option<&str>) -> bool {
lang.is_some_and(|l| {
let l = l.to_ascii_lowercase();
l == "en" || l == "eng" || l.starts_with("en-") || l.starts_with("en_")
})
}
/// A non-English family label from the name table, for Preferences › Type › Show Font Names in
/// English (off). Prefers a record whose language isn't English; else any label that differs from
/// `english` and isn't plain ASCII. `None` when the font has no other name.
fn pick_native_label(english: &str, pairs: &[(Option<String>, String)]) -> Option<String> {
pairs
.iter()
.find(|(lang, s)| !s.eq_ignore_ascii_case(english) && !is_english_lang(lang.as_deref()))
.or_else(|| pairs.iter().find(|(_, s)| !s.eq_ignore_ascii_case(english) && !s.is_ascii()))
.map(|(_, s)| s.clone())
}
/// The family's native-language name from the font (typographic, else legacy), when it has one.
fn native_family_name(font: &skrifa::FontRef<'_>, english: &str) -> Option<String> {
let pairs = {
let t = localized(font, StringId::TYPOGRAPHIC_FAMILY_NAME);
if t.is_empty() { localized(font, StringId::FAMILY_NAME) } else { t }
};
pick_native_label(english, &pairs)
}
/// Every localized string of `id`.
fn all_names(font: &skrifa::FontRef<'_>, id: StringId) -> Vec<String> {
localized(font, id).into_iter().map(|(_, s)| s).collect()
@@ -692,6 +725,8 @@ fn face_names(f: &skrifa::FontRef<'_>) -> Option<(String, String)> {
#[derive(Clone, Debug)]
pub(crate) struct FaceStyle {
family: String,
/// See [`FontFace::native_family`].
native_family: Option<String>,
style: String,
keys: FaceKeys,
/// A named instance's axis settings (user units); empty for the face itself.
@@ -708,13 +743,14 @@ pub(crate) struct FaceStyle {
/// The styles of a face: the face itself, then its named instances when it is a variable font.
pub(crate) fn face_styles(f: &skrifa::FontRef<'_>) -> Vec<FaceStyle> {
let Some((family, style)) = face_names(f) else { return vec![] };
let native_family = native_family_name(f, &family);
let mut keys = FaceKeys::of(f, &family, &style);
let traits = FontTraits::of(f, &keys);
let mut instances = named_instances(f, &family, &style, &mut keys);
let mut instances = named_instances(f, &family, &style, &mut keys, native_family.clone());
for i in &mut instances {
i.traits = traits;
}
let face = FaceStyle { family, style, keys, variations: vec![], weight: None, italic: false, traits };
let face = FaceStyle { family, native_family, style, keys, variations: vec![], weight: None, italic: false, traits };
std::iter::once(face).chain(instances).collect()
}
@@ -723,7 +759,7 @@ pub(crate) fn face_styles(f: &skrifa::FontRef<'_>) -> Vec<FaceStyle> {
/// names already taken. The default instance's name, when it isn't the face's style name (a face
/// `Regular` whose default instance is `Book`), names the face too (`keys`). Damaged tables give
/// fewer instances or none; the counts read are capped.
fn named_instances(f: &skrifa::FontRef<'_>, family: &str, style: &str, keys: &mut FaceKeys) -> Vec<FaceStyle> {
fn named_instances(f: &skrifa::FontRef<'_>, family: &str, style: &str, keys: &mut FaceKeys, native_family: Option<String>) -> Vec<FaceStyle> {
let axes = f.axes();
if axes.is_empty() || axes.len() > MAX_AXES {
return vec![];
@@ -766,7 +802,16 @@ fn named_instances(f: &skrifa::FontRef<'_>, family: &str, style: &str, keys: &mu
}
let keys = FaceKeys { families: keys.families.clone(), styles, legacy: vec![], postscript };
let variations = tags.iter().zip(&coords).map(|(t, v)| (t.to_be_bytes(), *v)).collect();
out.push(FaceStyle { family: family.to_string(), style, keys, variations, weight, italic, traits: FontTraits::default() });
out.push(FaceStyle {
family: family.to_string(),
native_family: native_family.clone(),
style,
keys,
variations,
weight,
italic,
traits: FontTraits::default(),
});
}
out
}
@@ -1236,7 +1281,7 @@ fn make_face(bytes: FontBytes, index: u32, spec: FaceStyle, path: Option<std::pa
FontBytes::Owned(v) => v.as_slice(),
};
let f = skrifa::FontRef::from_index(data, index).ok()?;
let FaceStyle { family, style, keys, variations, weight, italic, traits } = spec;
let FaceStyle { family, native_family, style, keys, variations, weight, italic, traits } = spec;
let version = name(&f, &[StringId::VERSION_STRING]).unwrap_or_default();
// A named instance: outlines, metrics and advances at its axis settings.
let location = if variations.is_empty() { Location::default() } else { f.axes().location(variations.iter().map(|(t, v)| (Tag::new(t), *v))) };
@@ -1258,6 +1303,7 @@ fn make_face(bytes: FontBytes, index: u32, spec: FaceStyle, path: Option<std::pa
traits,
id: NEXT_ID.fetch_add(1, Ordering::Relaxed),
family,
native_family,
style,
version,
weight: weight.unwrap_or(a.weight.value()),
@@ -1456,6 +1502,23 @@ impl FontDb {
list
}
/// How a font menu labels `family`: the English name when `english` is on (Preferences › Type ›
/// Show Font Names in English, the default), else the font's native-language name when it has
/// one. The document and commands still use the English family ([`Self::canonical`]).
pub fn family_display_name(&self, family: &str, english: bool) -> String {
let (family, _) = self.canonical(family, "");
if english {
return family;
}
if let Some(native) = self.read_faces().iter().find(|f| f.family.eq_ignore_ascii_case(&family)).and_then(|f| f.native_family.clone()) {
return native;
}
if let Some(native) = self.read_catalog().get(&family.to_ascii_lowercase()).and_then(|c| c.native.clone()) {
return native;
}
family
}
/// Style names available for `family`: upright styles by weight, then italics.
pub fn styles(&self, family: &str) -> Vec<String> {
let (family, _) = self.canonical(family, "");
@@ -1599,7 +1662,7 @@ impl FontDb {
if !named && !is_font_file(&p) && !crate::suitcase::is_suitcase(&p) {
continue;
}
for FaceStyle { family, style, keys, weight, italic, traits, .. } in file_face_names(&p) {
for FaceStyle { family, native_family, style, keys, weight, italic, traits, .. } in file_face_names(&p) {
for (k, a) in Alias::of(&family, &style, &keys) {
aliases.entry(k).or_default().push(a);
}
@@ -1612,6 +1675,9 @@ impl FontDb {
if entry.name.is_empty() {
entry.name = family;
}
if entry.native.is_none() {
entry.native = native_family;
}
let (weight, italic) = (weight.unwrap_or_else(|| style_weight(&style)), italic || style_italic(&style));
entry.faces.push(CatalogFace { style, path: p.clone(), weight, italic });
n += 1;
@@ -2024,6 +2090,22 @@ mod alias_tests {
assert!(find("HiraginoSans-W3").iter().any(|a| a.style.as_deref() == Some("W3") && a.paired.is_none()), "the PostScript name");
}
/// #394 Preferences › Type › Show Font Names in English: pick the non-English name-table label.
#[test]
fn pick_native_label_prefers_a_non_english_name_record() {
assert_eq!(
pick_native_label("Hiragino Sans", &[(Some("en".into()), "Hiragino Sans".into()), (Some("ja".into()), "ヒラギノ角ゴシック".into())])
.as_deref(),
Some("ヒラギノ角ゴシック")
);
assert!(pick_native_label("Source Sans 3", &[(Some("en".into()), "Source Sans 3".into())]).is_none());
let font = name_font(HIRAGINO_W3);
let f = skrifa::FontRef::new(&font).unwrap();
let (family, _) = face_names(&f).unwrap();
assert_eq!(family, "Hiragino Sans");
assert_eq!(native_family_name(&f, &family).as_deref(), Some("ヒラギノ角ゴシック"));
}
#[test]
fn without_typographic_names_the_legacy_ones_are_the_familys() {
let (family, style, k) = keys(&[(3, EN, 1, "Example Serif"), (3, EN, 2, "Bold Italic"), (3, JA, 1, "例セリフ"), (3, JA, 2, "太字斜体")]);
+22
View File
@@ -547,3 +547,25 @@ fn the_app_font_folder_is_read_by_the_scan() {
assert_eq!(dirs.iter().filter(|d| **d == dir).count(), 1, "{dirs:?}");
assert!(!dirs.contains(&dir.join("Other")));
}
/// #394 Preferences › Type › Show Font Names in English: off, menus label the family in its
/// native language; on (default), the English name. Documents still resolve by either name.
#[test]
fn family_display_name_follows_show_font_names_in_english() {
let bytes = named(&[
(3, EN, 1, "Hiragino Sans"),
(3, EN, 2, "Regular"),
(3, EN, 6, "HiraginoSans-Regular"),
(3, EN, 16, "Hiragino Sans"),
(3, JA, 16, "ヒラギノ角ゴシック"),
(3, EN, 17, "Regular"),
]);
let db = FontDb::with_font_dirs(vec![]);
assert!(db.add_font(bytes) > 0);
assert_eq!(db.family_display_name("Hiragino Sans", true), "Hiragino Sans");
assert_eq!(db.family_display_name("Hiragino Sans", false), "ヒラギノ角ゴシック");
assert_eq!(db.family_display_name("ヒラギノ角ゴシック", false), "ヒラギノ角ゴシック");
assert_eq!(db.family_display_name("Source Sans 3", false), "Source Sans 3");
let face = db.face("ヒラギノ角ゴシック", "Regular").unwrap();
assert_eq!(face.family, "Hiragino Sans");
}
+172 -69
View File
@@ -1,8 +1,8 @@
//! Live Corners: the widgets inside the corners of a selected path. The Selection tool shows them
//! on a live rectangle or polygon, Direct Selection on any path (a star, a pen path) at each corner
//! anchor: one without handles between two straight sides. Dragging one rounds (or sharpens) the
//! corners whose widgets show together: every corner when the whole path is selected, those with a
//! selected anchor when Direct Selection picked some. Alt-clicking a widget cycles their corner
//! Live Corners: the widgets inside the corners of the selected paths. The Selection tool shows
//! them on live rectangles and polygons, Direct Selection on any path (a star, a pen path) at each
//! corner anchor: one without handles between two straight sides. Dragging one rounds (or
//! sharpens) the corners whose widgets show together, on every selected path (#938): every corner
//! of a path selected whole, those with a selected anchor when Direct Selection picked some. Alt-clicking a widget cycles their corner
//! kind (round, inverted round, chamfer); double-clicking one opens the Corners dialog. The
//! Selection and Direct Selection tools share this, and the canvas draws the widgets from the same
//! geometry.
@@ -38,10 +38,10 @@ struct Widget {
at: Point,
}
/// The corner widgets of a selection that is exactly one editable path with corners.
/// The corner widgets of one selected path.
#[derive(Clone, Debug, PartialEq)]
pub struct CornerWidgets {
pub id: NodeId,
struct PathWidgets {
id: NodeId,
/// Maps the corners' space into the document (a live rectangle's own; else none).
xf: Affine,
/// The widgets that show (and which a drag edits), in anchor order.
@@ -50,11 +50,10 @@ pub struct CornerWidgets {
all: bool,
}
impl CornerWidgets {
/// The widgets at `zoom` (screen px per document point), if the selection has them: on any
/// path with Direct Selection (`any_path`), on live rectangles and polygons with Selection.
pub fn of(doc: &Document, selection: &Selection, zoom: f64, any_path: bool) -> Option<Self> {
let [id] = selection.objects[..] else { return None };
impl PathWidgets {
/// The widgets of path `id` at `zoom` (screen px per document point), if it has any: any path
/// with Direct Selection (`any_path`), live rectangles and polygons with Selection.
fn of(doc: &Document, selection: &Selection, id: NodeId, zoom: f64, any_path: bool) -> Option<Self> {
if !doc.is_editable(id) {
return None;
}
@@ -86,13 +85,36 @@ impl CornerWidgets {
(!widgets.is_empty()).then_some(Self { id, xf: lc.xf, widgets, all })
}
/// The corners whose widgets show: anchor indices of the path's uncut outline.
fn corners(&self) -> Vec<usize> {
self.widgets.iter().map(|w| w.corner.index).collect()
}
}
/// The corner widgets of the selected paths that have some.
#[derive(Clone, Debug, PartialEq)]
pub struct CornerWidgets {
paths: Vec<PathWidgets>,
}
impl CornerWidgets {
/// The widgets at `zoom` (screen px per document point), if the selection has them: on any
/// path with Direct Selection (`any_path`), on live rectangles and polygons with Selection.
pub fn of(doc: &Document, selection: &Selection, zoom: f64, any_path: bool) -> Option<Self> {
let paths: Vec<_> = selection.objects.iter().filter_map(|id| PathWidgets::of(doc, selection, *id, zoom, any_path)).collect();
(!paths.is_empty()).then_some(Self { paths })
}
/// Selection & Anchor Display → Hide Corner Widget for angles greater than: the widgets of
/// corners wider than `max` degrees (in the document) hide; none if all do.
pub fn within_angle(mut self, max: f64) -> Option<Self> {
let n = self.widgets.len();
self.widgets.retain(|w| w.angle <= max + 1e-9);
self.all &= self.widgets.len() == n;
(!self.widgets.is_empty()).then_some(self)
for p in &mut self.paths {
let n = p.widgets.len();
p.widgets.retain(|w| w.angle <= max + 1e-9);
p.all &= p.widgets.len() == n;
}
self.paths.retain(|p| !p.widgets.is_empty());
(!self.paths.is_empty()).then_some(self)
}
/// The widgets on show: [`Self::of`] with View → Show Corner Widget `on`, less those wider
@@ -111,30 +133,61 @@ impl CornerWidgets {
/// The centres of the widgets that show.
pub fn visible(&self) -> impl Iterator<Item = Point> + '_ {
self.widgets.iter().map(|w| w.at)
self.visible_on().map(|(_, at)| at)
}
/// The corners whose widgets show: anchor indices of the path's uncut outline.
/// The centres of the widgets that show, each with the object it belongs to.
pub fn visible_on(&self) -> impl Iterator<Item = (NodeId, Point)> + '_ {
self.paths.iter().flat_map(|p| p.widgets.iter().map(move |w| (p.id, w.at)))
}
/// The corners whose widgets show, path after path: anchor indices of each path's uncut
/// outline.
pub fn corners(&self) -> Vec<usize> {
self.widgets.iter().map(|w| w.corner.index).collect()
self.paths.iter().flat_map(PathWidgets::corners).collect()
}
/// The widget nearest to `p` within `tol` (document units).
fn hit(&self, p: Point, tol: f64) -> Option<usize> {
let d = |k: &usize| self.widgets.get(*k).map_or(f64::INFINITY, |w| w.at.distance(p));
(0..self.widgets.len()).filter(|k| d(k) <= tol).min_by(|a, b| d(a).total_cmp(&d(b)))
/// The widget nearest to `p` within `tol` (document units): its path's and its own index.
fn hit(&self, p: Point, tol: f64) -> Option<(usize, usize)> {
let d = |&(i, k): &(usize, usize)| self.widget(i, k).map_or(f64::INFINITY, |w| w.at.distance(p));
self.paths
.iter()
.enumerate()
.flat_map(|(i, path)| (0..path.widgets.len()).map(move |k| (i, k)))
.filter(|ik| d(ik) <= tol)
.min_by(|a, b| d(a).total_cmp(&d(b)))
}
/// `object.setLiveShape` params setting `key` on the shown corners (every corner unless some
/// are hidden, which `corners` then leaves out).
fn widget(&self, path: usize, k: usize) -> Option<&Widget> {
self.paths.get(path).and_then(|p| p.widgets.get(k))
}
/// `object.setLiveShape` params setting `key` on the shown corners (every corner of a path
/// unless some are hidden, which `corners` then leaves out): one path by its `id`, several as
/// `items`.
fn command(&self, key: &str, value: Value) -> Value {
let mut p = Map::new();
p.insert("id".into(), json!(self.id.0));
p.insert(key.into(), value);
if !self.all {
p.insert("corners".into(), json!(self.corners()));
let corners = |p: &PathWidgets| (!p.all).then(|| p.corners());
let mut out = Map::new();
match self.paths.as_slice() {
[p] => {
out.insert("id".into(), json!(p.id.0));
if let Some(c) = corners(p) {
out.insert("corners".into(), json!(c));
}
}
paths => {
let items: Vec<Value> = paths
.iter()
.map(|p| match corners(p) {
Some(c) => json!({ "id": p.id.0, "corners": c }),
None => json!({ "id": p.id.0 }),
})
.collect();
out.insert("items".into(), Value::Array(items));
}
}
Value::Object(p)
out.insert(key.into(), value);
Value::Object(out)
}
}
@@ -151,11 +204,22 @@ pub fn over_widget(cx: &ToolContext, p: Point, any_path: bool) -> bool {
CornerWidgets::for_tool(cx, any_path).and_then(|w| w.hit(p, cx.tol(5.0))).is_some()
}
/// A double-click on a corner widget opens the Corners dialog for the shown corners.
/// A double-click on a corner widget opens the Corners dialog for the shown corners of its path.
pub fn double_click(cx: &ToolContext, p: Point, any_path: bool) -> Option<Action> {
let w = CornerWidgets::for_tool(cx, any_path)?;
w.hit(p, cx.tol(5.0))?;
Some(Action::Dialog(DIALOG.into(), json!({ "id": w.id.0, "corners": w.corners() })))
let (i, _) = w.hit(p, cx.tol(5.0))?;
let path = w.paths.get(i)?;
Some(Action::Dialog(DIALOG.into(), json!({ "id": path.id.0, "corners": path.corners() })))
}
/// A path's uncut outline with its corners' radii and kinds, and the corners' space (to outline
/// the corners at their largest).
#[derive(Clone, Debug)]
struct Outline {
base: PathData,
radii: Vec<f64>,
kinds: Vec<CornerKind>,
xf: Affine,
}
/// Dragging a corner widget: the radius follows the pointer along the corner's bisector. An
@@ -163,13 +227,10 @@ pub fn double_click(cx: &ToolContext, p: Point, any_path: bool) -> Option<Action
#[derive(Clone, Debug)]
pub struct CornerDrag {
widgets: CornerWidgets,
/// The dragged widget (index into the shown ones).
widget: usize,
/// The path's uncut outline with its corners' radii and kinds (to outline the corners at
/// their largest).
base: PathData,
radii: Vec<f64>,
kinds: Vec<CornerKind>,
/// The dragged widget: its path's and its own index among the shown ones.
widget: (usize, usize),
/// Each shown path's outline, in the order of [`CornerWidgets`]' paths.
outlines: Vec<Outline>,
start: Point,
began: bool,
alt: bool,
@@ -185,20 +246,29 @@ impl CornerDrag {
pub fn hit(cx: &ToolContext, ev: &PointerEvent, any_path: bool) -> Option<Self> {
let (widgets, p) = (CornerWidgets::for_tool(cx, any_path)?, ev.pos);
let widget = widgets.hit(p, cx.tol(5.0))?;
let radius = widgets.widgets.get(widget)?.radius;
let lc = cx.doc.node(widgets.id).and_then(LiveCorners::of)?;
let n = lc.base.anchor_count();
let (radii, kinds) = ((0..n).map(|k| lc.radius(k)).collect(), (0..n).map(|k| lc.kind(k)).collect());
let base = lc.base.into_owned();
Some(Self { widgets, widget, base, radii, kinds, start: p, began: false, alt: ev.mods.alt, radius, at_limit: false, at: p })
let radius = widgets.widget(widget.0, widget.1)?.radius;
let outlines = widgets
.paths
.iter()
.map(|w| {
let lc = cx.doc.node(w.id).and_then(LiveCorners::of)?;
let n = lc.base.anchor_count();
let (radii, kinds) = ((0..n).map(|k| lc.radius(k)).collect(), (0..n).map(|k| lc.kind(k)).collect());
Some(Outline { base: lc.base.into_owned(), radii, kinds, xf: w.xf })
})
.collect::<Option<Vec<_>>>()?;
Some(Self { widgets, widget, outlines, start: p, began: false, alt: ev.mods.alt, radius, at_limit: false, at: p })
}
/// The start radius (as drawn: no larger than fits) plus the pointer's travel along the
/// corner's bisector (in the corners' own units), from square to fully round; and whether the
/// travel reached fully round.
fn radius_at(&self, p: Point) -> (f64, bool) {
let Some(w) = self.widgets.widgets.get(self.widget) else { return (self.radius, self.at_limit) };
let inv = self.widgets.xf.inverse();
let (path, k) = self.widget;
let (Some(w), Some(xf)) = (self.widgets.widget(path, k), self.widgets.paths.get(path).map(|p| p.xf)) else {
return (self.radius, self.at_limit);
};
let inv = xf.inverse();
let max = w.corner.max_radius();
let r = w.corner.fitted(w.radius) + w.corner.radius_change(inv * p - inv * self.start);
(r.min(max).max(0.0), max > 0.0 && r >= max)
@@ -206,25 +276,27 @@ impl CornerDrag {
/// The cuts of the edited corners that are at their largest radius, in document coordinates.
fn corners_at_limit(&self) -> BezPath {
let shown = &self.widgets.widgets;
let mut radii = self.radii.clone();
for w in shown {
if let Some(r) = radii.get_mut(w.corner.index) {
*r = self.radius;
}
}
let full = |k: &usize| shown.iter().any(|w| w.corner.index == *k && self.radius >= w.corner.max_radius() - 1e-9);
let (path, sources) = cut_corners(&self.base, &radii, &self.kinds);
let mut out = BezPath::new();
for (sp, from) in path.transformed(self.widgets.xf).subpaths.iter().zip(&sources) {
// A corner's cut is the segment between its two anchors (the last one, for a closed
// subpath's first corner).
for i in 0..sp.segment_count() {
let (a, b) = (from.get(i), from.get(i + 1).or(from.first()));
if a == b && a.is_some_and(full) {
let c = sp.segment(i);
out.move_to(c.p0);
out.curve_to(c.p1, c.p2, c.p3);
for (shown, o) in self.widgets.paths.iter().zip(&self.outlines) {
let shown = &shown.widgets;
let mut radii = o.radii.clone();
for w in shown {
if let Some(r) = radii.get_mut(w.corner.index) {
*r = self.radius;
}
}
let full = |k: &usize| shown.iter().any(|w| w.corner.index == *k && self.radius >= w.corner.max_radius() - 1e-9);
let (path, sources) = cut_corners(&o.base, &radii, &o.kinds);
for (sp, from) in path.transformed(o.xf).subpaths.iter().zip(&sources) {
// A corner's cut is the segment between its two anchors (the last one, for a
// closed subpath's first corner).
for i in 0..sp.segment_count() {
let (a, b) = (from.get(i), from.get(i + 1).or(from.first()));
if a == b && a.is_some_and(full) {
let c = sp.segment(i);
out.move_to(c.p0);
out.curve_to(c.p1, c.p2, c.p3);
}
}
}
}
@@ -249,7 +321,7 @@ impl CornerDrag {
pub fn finish(self) -> Vec<Action> {
if self.began {
vec![Action::Commit]
} else if let Some(w) = self.widgets.widgets.get(self.widget).filter(|_| self.alt) {
} else if let Some(w) = self.widgets.widget(self.widget.0, self.widget.1).filter(|_| self.alt) {
vec![Action::Exec("object.setLiveShape".into(), self.widgets.command("kind", json!(w.kind.next())))]
} else {
vec![]
@@ -359,6 +431,37 @@ mod tests {
}
}
/// #938: with two rectangles selected, both show their widgets, and a drag on either rounds
/// the corners of both in one step; with Direct Selection, each its shown corners.
#[test]
fn widgets_of_several_selected_paths_drag_together() {
let (mut d, a) = live_rect(0.0, Affine::translate((100.0, 100.0)));
let live = LiveShape::Rectangle { w: 100.0, h: 100.0, radii: [0.0; 4], kinds: Default::default(), xf: Affine::translate((300.0, 100.0)) };
let b = d.alloc_id();
let mut n = Node::path(b, live.to_path(), Appearance::default_art());
if let NodeKind::Path { live: slot, .. } = &mut n.kind {
*slot = Some(live);
}
let l = d.layers[0].id;
d.insert(Some(l), 1, n).unwrap();
let mut s = selected(a);
s.add(b);
assert_eq!(pts(&CornerWidgets::of(&d, &s, 1.0, false).unwrap()).len(), 8);
let p = paint();
let cx = cx(&d, &s, &p);
let mut t = crate::create("selection");
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 390.0, 191.0));
let preview = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 375.0, 176.0));
assert!((radius(&preview[1]) - 15.0).abs() < 1e-9);
let Action::Preview(_, v) = &preview[1] else { panic!() };
assert_eq!(v["items"], json!([{"id": a.0}, {"id": b.0}]));
// Direct Selection with one anchor of the first: its corner, and the whole second.
s.anchors.insert(a, [(0, 2)].into());
let w = CornerWidgets::of(&d, &s, 1.0, true).unwrap();
assert_eq!(w.corners(), [2, 0, 1, 2, 3]);
assert_eq!(w.command("radius", json!(5.0)), json!({"items": [{"id": a.0, "corners": [2]}, {"id": b.0}], "radius": 5.0}));
}
/// #442: on a 200 × 80 rectangle the widgets sit at the centres of the corners as drawn, also
/// with a radius past half the shorter side; a drag past fully round stops every corner there
/// and outlines them in red.
+46 -10
View File
@@ -32,7 +32,7 @@ use vectorcraft_geom::{Anchor, PathData, Point, Rect};
use crate::bbox::move_delta;
use crate::corners::{self, CornerDrag, over_widget};
use crate::guides::{HandleSnap, PointSnap, Targets};
use crate::guides::{HandleSnap, Leave, PointSnap, Targets};
use crate::meshedit::MeshEdit;
use crate::pathtype::{self, BracketDrag, over_bracket};
use crate::rulerguide::GuideEdit;
@@ -428,12 +428,12 @@ impl Tool for DirectSelectionTool {
self.anchor_snap = Some(PointSnap::new(cx, || Targets::for_anchor_drag(cx.doc, cx.selection)));
}
let mut d = move_delta(start, p, ev.mods.shift);
// The grabbed anchor snaps and the others follow it; Shift keeps the angle instead.
// The grabbed anchor snaps and the others follow it. Shift keeps the move at its
// angle, and the anchor slides along it onto what Smart Guides find there (#886).
self.guides.clear();
if !ev.mods.shift
&& let Some(snap) = &self.anchor_snap
{
let (q, guides) = snap.snap(cx, grab + d);
if let Some(snap) = &self.anchor_snap {
let shift = ev.mods.shift.then(|| Leave::segment(cx, grab, true));
let (q, guides) = snap.snap_from(cx, grab + (p - start), shift.as_ref());
(d, self.guides) = (q - grab, guides);
}
out.push(Action::Preview("path.moveAnchors".into(), json!({"dx": d.x, "dy": d.y})));
@@ -574,6 +574,14 @@ impl Tool for DirectSelectionTool {
}
}
});
// Objects without anchors to pick (type, images, symbols…) are selected whole when
// the marquee touches them, as with the Selection tool (#927).
for id in vectorcraft_doc::hit::marquee(cx.doc, r, cx.isolation, true) {
let whole = cx.doc.node(id).is_some_and(|n| !matches!(n.kind, NodeKind::Path { .. } | NodeKind::Compound { .. }));
if whole && !sel.iter().any(|(s, _)| *s == id) {
sel.push((id, vec![]));
}
}
sel.retain(|(id, _)| cx.doc.is_editable(*id));
let items: Vec<Value> = sel.iter().map(|(id, v)| json!({"id": id.0, "anchors": anchors_json(v)})).collect();
vec![Action::Exec("select.anchorsMany".into(), json!({"items": items, "mode": if toggle { "toggle" } else { "set" }}))]
@@ -838,6 +846,28 @@ mod tests {
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 250.0, 150.0));
assert_eq!(a, vec![Action::Exec("select.anchorsMany".into(), json!({"items": [{"id": id.0, "anchors": [[0, 1]]}], "mode": "set"}))]);
}
/// #927: a marquee across type selects the type object whole, beside the anchors it holds.
#[test]
fn marquee_selects_type_it_touches() {
// The rectangle at (100, 100)–(200, 200) and area type at (300, 300).
let (d, text) = doc_with_area_type();
let rect = d.layers[0].children().and_then(|c| c.first()).map(|n| n.id).unwrap();
let s = Selection::default();
let p = paint();
let cx = cx(&d, &s, &p);
let mut t = DirectSelectionTool::new(false);
// From empty canvas across the type to inside the rectangle (its bottom-right corner).
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 450.0, 450.0));
t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 150.0, 150.0));
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 150.0, 150.0));
let items = json!([{"id": rect.0, "anchors": [[0, 2]]}, {"id": text.0, "anchors": []}]);
assert_eq!(a, vec![Action::Exec("select.anchorsMany".into(), json!({"items": items, "mode": "set"}))]);
// Away from the type, only anchors.
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 150.0, 50.0));
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 250.0, 150.0));
assert_eq!(a, vec![Action::Exec("select.anchorsMany".into(), json!({"items": [{"id": rect.0, "anchors": [[0, 1]]}], "mode": "set"}))]);
}
/// Shift-drag a marquee: the anchors inside toggle (#483), with Group Selection too.
#[test]
fn shift_marquee_toggles_anchors() {
@@ -1022,10 +1052,16 @@ mod tests {
// In line with B's own bottom-left corner, which stays put.
let (v, labels) = drag(&mut t, &c, (300.0, 300.0), (303.0, 330.0), none);
assert_eq!((v, labels), (json!({"dx": 0.0, "dy": 30.0}), vec!["align".to_string()]));
// Shift keeps the move at 45° steps, unsnapped.
let (v, labels) = drag(&mut t, &c, (300.0, 300.0), (203.0, 199.0), Mods { shift: true, ..none });
let s = move_delta(Point::new(300.0, 300.0), Point::new(203.0, 199.0), true);
assert_eq!((v, labels), (json!({"dx": s.x, "dy": s.y}), vec![]));
// Shift keeps the move at 45° steps and still snaps along that line (#886): on the
// diagonal from B's corner, onto A's bottom-right corner…
let shift = Mods { shift: true, ..none };
let (v, labels) = drag(&mut t, &c, (300.0, 300.0), (203.0, 199.0), shift);
let near = |v: &serde_json::Value, (x, y): (f64, f64)| (v["dx"].as_f64().unwrap() - x).hypot(v["dy"].as_f64().unwrap() - y) < 1e-9;
assert!(near(&v, (-100.0, -100.0)) && labels == ["anchor"], "{v} {labels:?}");
// …and with nothing in reach, at the pointer's distance along its angle.
let (v, labels) = drag(&mut t, &c, (300.0, 300.0), (380.0, 302.0), shift);
let s = move_delta(Point::new(300.0, 300.0), Point::new(380.0, 302.0), true);
assert!(near(&v, (s.x, s.y)) && labels.is_empty(), "{v} {labels:?}");
// Two anchors: the one pressed on (B's top-right) lands on A's corner.
let two = anchors_of(b, &[(0, 0), (0, 1)]);
let c = cx(&d, &two, &p);
+61 -8
View File
@@ -5,7 +5,9 @@
//! and commit on release; Alt on release (or ending near the start) closes the path, and starting
//! near an end of a selected open path continues it. The Pencil's new path starts and ends on
//! Smart Guides ([`DrawSnap`]: an anchor, a path, in line with the art), hovering too; the points
//! between follow the hand. The other tools collect the drag polyline (shown as an overlay) and run
//! between follow the hand. The Paintbrush records the pen pressure of each point (a stroke a pen
//! pressed less than fully on sends `[x, y, pressure]` points) for pressure-sensitive brushes. The
//! other tools collect the drag polyline (shown as an overlay) and run
//! one command on release.
use serde_json::{Value, json};
@@ -19,6 +21,8 @@ use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, Tool
pub struct GestureTool {
id: &'static str,
points: Vec<Point>,
/// The pen pressure of each of `points` (the Paintbrush only).
pressures: Vec<f32>,
active: bool,
began: bool,
/// Pencil/Paintbrush continuing a selected open path: (path, continue from its start).
@@ -53,6 +57,7 @@ impl GestureTool {
Self {
id,
points: vec![],
pressures: vec![],
active: false,
began: false,
extend: None,
@@ -74,6 +79,30 @@ impl GestureTool {
matches!(self.id, "pencil" | "paintbrush")
}
/// Add a stroke sample.
fn push(&mut self, p: Point, pressure: f32) {
self.points.push(p);
if self.id == "paintbrush" {
self.pressures.push(pressure);
}
}
/// Forget the stroke.
fn clear(&mut self) {
self.points.clear();
self.pressures.clear();
}
/// The stroke's points, each `[x, y, pressure]` when a pen pressed less than fully anywhere
/// along it (a mouse always presses fully).
fn stroke_json(&self) -> Value {
if self.pressures.len() == self.points.len() && self.pressures.iter().any(|p| *p < 1.0) {
Value::Array(self.points.iter().zip(&self.pressures).map(|(p, f)| json!([p.x, p.y, (f64::from(*f) * 1000.0).round() / 1000.0])).collect())
} else {
points_json(&self.points)
}
}
fn label(&self) -> &'static str {
match self.id {
"paintbrush" => "Paintbrush",
@@ -110,7 +139,7 @@ impl GestureTool {
/// Parameters of `path.freehand` for the current stroke.
pub fn freehand_params(&self, closed: bool) -> Value {
let mut v = json!({
"points": points_json(&self.points),
"points": self.stroke_json(),
"fidelity": self.fidelity,
"closed": closed,
"style": if self.id == "paintbrush" { "brush" } else { "pencil" },
@@ -168,7 +197,8 @@ impl Tool for GestureTool {
self.extend = if self.draws_path() { self.find_extend(cx, p) } else { None };
// A path continued starts at its end; a new one where it snaps.
let start = if self.snaps() && self.extend.is_none() { self.snap.press(cx, p, &[], None) } else { p };
self.points = vec![start];
self.clear();
self.push(start, ev.pressure);
vec![]
}
PointerKind::Drag => {
@@ -182,7 +212,7 @@ impl Tool for GestureTool {
if self.points.last().is_some_and(|l| l.distance(p) < cx.tol(1.0)) {
return vec![];
}
self.points.push(p);
self.push(p, ev.pressure);
if !self.draws_path() {
return vec![];
}
@@ -201,7 +231,7 @@ impl Tool for GestureTool {
let end = if self.snaps() { self.snap.drag(cx, p, None) } else { p };
self.snap.clear();
if self.points.last().is_some_and(|l| l.distance(p) >= cx.tol(1.0)) {
self.points.push(end);
self.push(end, ev.pressure);
} else if self.snaps()
&& self.points.len() > 1
&& let Some(last) = self.points.last_mut()
@@ -212,7 +242,7 @@ impl Tool for GestureTool {
let out = self.finish(cx, ev.mods);
self.active = false;
self.began = false;
self.points.clear();
self.clear();
self.extend = None;
out
}
@@ -226,7 +256,7 @@ impl Tool for GestureTool {
self.snap.clear();
self.active = false;
self.began = false;
self.points.clear();
self.clear();
if began { vec![Action::Cancel] } else { vec![] }
}
ToolKey::BracketLeft if matches!(self.id, "blobBrush" | "eraser") => {
@@ -245,7 +275,7 @@ impl Tool for GestureTool {
self.snap.clear();
self.active = false;
self.began = false;
self.points.clear();
self.clear();
if began { vec![Action::Commit] } else { vec![] }
}
fn overlays(&self, cx: &ToolContext) -> Vec<Overlay> {
@@ -321,6 +351,29 @@ mod tests {
assert_eq!(v["points"][0], json!([102.0, 98.0]));
}
/// #852: the Paintbrush sends each point's pen pressure; a mouse (always fully pressed) and
/// the Pencil send plain points.
#[test]
fn paintbrush_sends_pen_pressure() {
let (d, _) = doc_with_rect();
let s = Selection::default();
let p = paint();
let cx = cx(&d, &s, &p);
let stroke = |id: &str, pressures: [f32; 3]| {
let mut t = GestureTool::new(id);
let kinds = [PointerKind::Down, PointerKind::Drag, PointerKind::Up];
let mut last = vec![];
for ((k, x), f) in kinds.into_iter().zip([0.0, 40.0, 80.0]).zip(pressures) {
last = t.pointer(&cx, &PointerEvent { pressure: f, ..PointerEvent::new(k, x, 0.0) });
}
let Some(Action::Preview(_, v)) = last.first() else { panic!("{last:?}") };
v["points"].clone()
};
assert_eq!(stroke("paintbrush", [0.25, 0.5, 1.0]), json!([[0.0, 0.0, 0.25], [40.0, 0.0, 0.5], [80.0, 0.0, 1.0]]));
assert_eq!(stroke("paintbrush", [1.0; 3]), json!([[0.0, 0.0], [40.0, 0.0], [80.0, 0.0]]));
assert_eq!(stroke("pencil", [0.25, 0.5, 1.0]), json!([[0.0, 0.0], [40.0, 0.0], [80.0, 0.0]]));
}
#[test]
fn pencil_previews_and_commits() {
let (d, _) = doc_with_rect();
+122 -10
View File
@@ -1,8 +1,9 @@
//! Flare, Reshape, Shaper and Graph tools.
//!
//! Flare: drag sets the centre and its size (click without dragging opens the Flare Tool Options),
//! then a click sets the end point of the rings. Reshape: drag a point of a selected path; the
//! engine's `path.reshape` adds an anchor there if needed and moves the neighbourhood smoothly.
//! then a click sets the end point of the rings; the rest comes from the tool's options (the Flare
//! Tool Options, which last). Reshape: drag a point of a selected path; the engine's
//! `path.reshape` adds an anchor there if needed and moves the neighbourhood smoothly.
use serde_json::{Value, json};
use vectorcraft_geom::{Point, Vec2};
@@ -15,13 +16,35 @@ pub fn create(id: &str) -> Option<Box<dyn Tool>> {
"flare" => Box::new(FlareTool::default()),
"reshape" => Box::new(ReshapeTool::default()),
"shaper" => Box::new(ShaperTool::default()),
id if vectorcraft_doc::GraphKind::parse(id).is_some() && id.ends_with("Graph") => Box::new(GraphTool::new(id)),
id if is_graph_tool(id) => Box::new(GraphTool::new(id)),
_ => return None,
})
}
/// Whether `id` is one of the graph tools (`columnGraph`, `pieGraph`…).
pub fn is_graph_tool(id: &str) -> bool {
id.ends_with("Graph") && vectorcraft_doc::GraphKind::parse(id).is_some()
}
/// The Flare Tool Options as `shape.flare` takes them: (key, default, least, most). The diameter
/// and the rings' path length are in points, the counts whole, the direction in degrees, the
/// rest percentages.
pub const FLARE_OPTIONS: [(&str, f64, f64, f64); 12] = [
("diameter", 100.0, 0.0, 1000.0),
("opacity", 50.0, 0.0, 100.0),
("brightness", 30.0, 0.0, 100.0),
("growth", 20.0, 0.0, 300.0),
("fuzziness", 50.0, 0.0, 100.0),
("rays", 15.0, 0.0, 50.0),
("longest", 300.0, 0.0, 1000.0),
("rayFuzziness", 100.0, 0.0, 100.0),
("pathLength", 300.0, 0.0, 1000.0),
("rings", 10.0, 0.0, 50.0),
("largest", 50.0, 0.0, 250.0),
("direction", 45.0, 0.0, 360.0),
];
/// Flare Tool: centre drag, then a click for the rings' end point.
#[derive(Default)]
pub struct FlareTool {
/// Pressed at (centre) and the current drag point.
drag: Option<(Point, Point)>,
@@ -29,14 +52,62 @@ pub struct FlareTool {
placed: Option<(Point, f64)>,
hover: Point,
began: bool,
/// Ray count changed with ↑/↓ during the gesture (None = the command's default, 15).
/// Ray count changed with ↑/↓ during the gesture (None = the options' count).
rays: Option<u64>,
/// The options' values, in [`FLARE_OPTIONS`] order.
values: [f64; 12],
/// Whether the flare has rays and rings (the options' Rays and Rings checkboxes).
rays_on: bool,
rings_on: bool,
}
impl Default for FlareTool {
fn default() -> Self {
Self {
drag: None,
placed: None,
hover: Point::ZERO,
began: false,
rays: None,
values: FLARE_OPTIONS.map(|(_, default, _, _)| default),
rays_on: true,
rings_on: true,
}
}
}
/// Whether Flare Tool Options value `key` is a count (whole).
fn is_count(key: &str) -> bool {
matches!(key, "rays" | "rings")
}
/// Flare Tool Options value `x` of `key` as JSON: a count as a whole number.
fn option_json(key: &str, x: f64) -> Value {
if is_count(key) { json!(x as u64) } else { json!(x) }
}
/// `shape.flare`'s params for a flare at `c` drawn with the Flare Tool Options `o` (as the tool
/// reports them, [`Tool::options`]): with Rays or Rings off it has none.
pub fn flare_params(o: &Value, c: Point) -> Value {
let mut v = json!({ "cx": c.x, "cy": c.y });
for (key, default, ..) in FLARE_OPTIONS {
v[key] = o.get(key).cloned().unwrap_or_else(|| option_json(key, default));
}
for (on, key) in [("raysOn", "rays"), ("ringsOn", "rings")] {
if o.get(on).and_then(Value::as_bool) == Some(false) {
v[key] = json!(0);
}
}
v
}
impl FlareTool {
/// `shape.flare`'s params: the options, with the dragged centre and diameter, the ray count
/// ↑/↓ chose and the rings' end point once clicked.
fn params(&self, c: Point, diameter: f64, end: Option<Point>) -> Value {
let mut v = json!({ "cx": c.x, "cy": c.y, "diameter": diameter.max(1.0) });
if let Some(r) = self.rays {
let mut v = flare_params(&self.options(), c);
v["diameter"] = json!(diameter.max(1.0));
if let Some(r) = self.rays.filter(|_| self.rays_on) {
v["rays"] = json!(r);
}
if let Some(e) = end {
@@ -60,6 +131,7 @@ impl Tool for FlareTool {
PointerKind::Down => {
if self.placed.is_none() {
self.drag = Some((ev.pos, ev.pos));
self.rays = None;
}
vec![]
}
@@ -106,8 +178,8 @@ impl Tool for FlareTool {
}
fn key(&mut self, _cx: &ToolContext, key: ToolKey, _mods: Mods) -> Vec<Action> {
// ↑/↓ while drawing add/remove rays.
if matches!(key, ToolKey::Up | ToolKey::Down) && self.began {
let r = self.rays.unwrap_or(15);
if matches!(key, ToolKey::Up | ToolKey::Down) && self.began && self.rays_on {
let r = self.rays.unwrap_or_else(|| self.options()["rays"].as_u64().unwrap_or(0));
self.rays = Some(if key == ToolKey::Up { (r + 1).min(50) } else { r.saturating_sub(1) });
let preview = match (self.drag, self.placed) {
(Some((c, p)), _) => self.params(c, 2.0 * p.distance(c), None),
@@ -126,7 +198,7 @@ impl Tool for FlareTool {
vec![]
}
fn deactivate(&mut self, _cx: &ToolContext) -> Vec<Action> {
// Switching away after the centre drag keeps the flare with its default rings.
// Switching away after the centre drag keeps the flare with the rings its options give.
self.drag = None;
if self.placed.take().is_some() && std::mem::take(&mut self.began) { vec![Action::Commit] } else { vec![] }
}
@@ -142,6 +214,24 @@ impl Tool for FlareTool {
fn cursor(&self, _cx: &ToolContext, _p: Point, _mods: Mods) -> Cursor {
Cursor::Crosshair
}
fn options(&self) -> Value {
let mut v = json!({ "raysOn": self.rays_on, "ringsOn": self.rings_on });
for ((key, ..), x) in FLARE_OPTIONS.iter().zip(self.values) {
v[*key] = option_json(key, x);
}
v
}
fn set_option(&mut self, key: &str, v: &Value) {
match key {
"raysOn" => self.rays_on = v.as_bool().unwrap_or(true),
"ringsOn" => self.rings_on = v.as_bool().unwrap_or(true),
_ => {
let Some((slot, (_, default, lo, hi))) = self.values.iter_mut().zip(FLARE_OPTIONS).find(|(_, (k, ..))| *k == key) else { return };
let x = v.as_f64().filter(|x| x.is_finite()).unwrap_or(default).clamp(lo, hi);
*slot = if is_count(key) { x.round() } else { x };
}
}
}
}
/// Reshape Tool: drag a point on a selected path.
@@ -378,6 +468,28 @@ impl Tool for GraphTool {
mod tests {
use super::*;
/// The Flare Tool Options: kept within their ranges, and what the next drag draws with.
#[test]
fn flare_options_last_and_draw_the_next_flare() {
let mut t = FlareTool::default();
assert_eq!(t.options()["rays"], json!(15));
for (k, v) in [("diameter", json!(5000)), ("rays", json!(7.6)), ("opacity", json!("x")), ("ringsOn", json!(false)), ("nope", json!(1))] {
t.set_option(k, &v);
}
let o = t.options();
assert_eq!(
(o["diameter"].as_f64(), o["rays"].as_u64(), o["opacity"].as_f64(), o["ringsOn"].as_bool()),
(Some(1000.0), Some(8), Some(50.0), Some(false))
);
assert!(o.get("nope").is_none());
// A drag sets the centre and diameter; the options give the rest, rings off.
let p = t.params(Point::new(10.0, 20.0), 40.0, None);
assert_eq!((p["cx"].as_f64(), p["diameter"].as_f64(), p["rays"].as_u64(), p["rings"].as_u64()), (Some(10.0), Some(40.0), Some(8), Some(0)));
assert_eq!(p["longest"].as_f64(), Some(300.0));
t.set_option("raysOn", &json!(false));
assert_eq!(t.params(Point::ZERO, 1.0, None)["rays"].as_u64(), Some(0));
}
#[test]
fn create_covers_both() {
assert_eq!(create("flare").unwrap().id(), "flare");
+23 -1
View File
@@ -14,7 +14,8 @@ type Row = (&'static [&'static str], Option<&'static str>, &'static [&'static st
/// The Liquify tools.
pub const LIQUIFY: &[&str] = &["warp", "twirl", "pucker", "bloat", "scallop", "crystallize", "wrinkle"];
const SYMBOLISM: &[&str] =
/// The Symbolism tools.
pub const SYMBOLISM: &[&str] =
&["symbolSprayer", "symbolShifter", "symbolScruncher", "symbolSizer", "symbolSpinner", "symbolStainer", "symbolScreener", "symbolStyler"];
const ROWS: &[Row] = &[
@@ -35,6 +36,27 @@ const ROWS: &[Row] = &[
(&["star"], None, &["points"]),
(&["freeTransform"], None, &["constrain"]),
(&["artboard"], None, &["moveArt", "scaleArt"]),
// Flare Tool Options (`extra::FLARE_OPTIONS` and the Rays and Rings checkboxes).
(
&["flare"],
None,
&[
"diameter",
"opacity",
"brightness",
"growth",
"fuzziness",
"raysOn",
"rays",
"longest",
"rayFuzziness",
"ringsOn",
"pathLength",
"rings",
"largest",
"direction",
],
),
];
/// The stores `tool`'s persistent options live in, each with the option keys it holds.
+1
View File
@@ -12,6 +12,7 @@ workspace = true
vectorcraft-geom = { workspace = true }
vectorcraft-color = { workspace = true }
vectorcraft-doc = { workspace = true }
vectorcraft-brush = { workspace = true }
vectorcraft-render = { workspace = true }
vectorcraft-text = { workspace = true }
vectorcraft-tools = { workspace = true }
+7 -3
View File
@@ -9,6 +9,7 @@
use std::sync::mpsc::{Receiver, Sender, channel};
use serde_json::{Value, json};
use vectorcraft_engine::file_access::ScopeHandle;
use crate::VectorcraftApp;
@@ -32,6 +33,8 @@ struct Task {
id: u64,
work: Work,
write: SharedWriteFn,
/// The automation roots in force where the work was started, in force where it runs.
scope: ScopeHandle,
}
/// A job in progress.
@@ -65,8 +68,9 @@ impl Background {
std::thread::Builder::new()
.name("vectorcraft-save".into())
.spawn(move || {
while let Ok(Task { id, work, write }) = tasks.recv() {
let r = vectorcraft_engine::guard::catch_panic(|| work(&mut |p: &str, b: &[u8]| write(p, b)))
while let Ok(Task { id, work, write, scope }) = tasks.recv() {
let r = scope
.run(|| vectorcraft_engine::guard::catch_panic(|| work(&mut |p: &str, b: &[u8]| write(p, b))))
.unwrap_or_else(|msg| Err(format!("internal error: {msg} (please report this bug)")));
if done_tx.send((id, r)).is_err() {
break;
@@ -101,7 +105,7 @@ pub fn run(
if let Some(write) = shared {
let id = app.background.next_id;
if let Some(tx) = app.background.worker() {
match tx.send(Task { id, work, write }) {
match tx.send(Task { id, work, write, scope: ScopeHandle::capture() }) {
Ok(()) => {
let bg = &mut app.background;
bg.next_id += 1;
+147 -29
View File
@@ -137,16 +137,17 @@ pub fn fit(app: &mut VectorcraftApp, how: &str) {
_ => st.doc.artboards.get(current).or(st.doc.artboards.first()).map(|a| a.rect),
};
let Some(target) = target else { return };
let Some(v) = app.view_mut() else { return };
v.center = target.center();
v.fitted = true;
if how == "view.actualSize" {
v.zoom = 1.0;
let zoom = if how == "view.actualSize" {
actual_size_zoom(app.session.prefs.display_print_size)
} else {
let zx = (rect.width() as f64 - 60.0) / target.width().max(1.0);
let zy = (rect.height() as f64 - 60.0) / target.height().max(1.0);
v.zoom = zx.min(zy).clamp(0.0313, 640.0);
}
zx.min(zy).clamp(0.0313, 640.0)
};
let Some(v) = app.view_mut() else { return };
v.center = target.center();
v.fitted = true;
v.zoom = zoom;
}
pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
@@ -366,13 +367,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
painter.add(Shape::closed_line(xf.quad(doc.setup.bleed_rect(ab.rect)), Stroke::new(1.0, t.bleed)));
}
if (artboard_tool || doc.artboards.len() > 1) && tool_labelled != u64::try_from(i).ok() {
painter.text(
r.left_top() - vec2(0.0, 4.0),
egui::Align2::LEFT_BOTTOM,
format!("{:02} - {}", i + 1, ab.name),
egui::FontId::proportional(11.0),
t.text_dim,
);
artboard_label(&painter, r, &format!("{:02} - {}", i + 1, ab.name), t.text_dim);
}
}
if app.ui.view.guides {
@@ -512,13 +507,16 @@ fn cursor_icon(c: Cursor) -> egui::CursorIcon {
fn handle_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Response, rect: egui::Rect) {
let line = ui.ctx().options(|o| o.input_options.line_scroll_speed);
let wheel_zooms = app.session.prefs.zoom_with_mouse_wheel;
let alt_id = egui::Id::new("canvas-alt-wheel");
let alt_before = ui.data(|d| d.get_temp(alt_id)).unwrap_or(false);
let mut alt_turn = alt_before;
let turn_id = egui::Id::new("canvas-wheel-turn");
let turn_before: WheelTurn = ui.data(|d| d.get_temp(turn_id)).unwrap_or_default();
let mut turn = turn_before;
let (pointer, m, space, (factor, scroll)) =
ui.input(|i| (i.pointer.clone(), i.modifiers, i.key_down(egui::Key::Space), wheel(i, wheel_zooms, line, rect.height(), &mut alt_turn)));
if alt_turn != alt_before {
ui.data_mut(|d| d.insert_temp(alt_id, alt_turn));
ui.input(|i| (i.pointer.clone(), i.modifiers, i.key_down(egui::Key::Space), wheel(i, wheel_zooms, line, rect.height(), &mut turn)));
if turn != turn_before {
ui.data_mut(|d| d.insert_temp(turn_id, turn));
}
if turn.glide != 0.0 {
ui.ctx().request_repaint();
}
let v = *app.view().unwrap_or(&View::default());
let xf = Xf::new(rect, &v);
@@ -717,6 +715,18 @@ fn handle_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Response, rect:
/// How much one point of wheel motion zooms (as `exp(points × WHEEL_ZOOM)`).
const WHEEL_ZOOM: f64 = 0.01;
/// How fast a wheel notch's zoom glides in with Zoom with Mouse Wheel: the time constant (s) of
/// the part still to come.
const WHEEL_GLIDE: f64 = 0.05;
/// What the wheel did across frames ([`wheel`]).
#[derive(Clone, Copy, Debug, Default, PartialEq)]
struct WheelTurn {
/// The last wheel turn was an Alt-wheel one.
alt: bool,
/// The zoom (natural log) wheel notches still have to glide in.
glide: f64,
}
/// Animated Zoom: how much one point of sideways drag zooms (100 points to the right double it).
const SCRUB_ZOOM: f64 = std::f64::consts::LN_2 / 100.0;
/// Animated Zoom: how far the pointer moves before a press is a drag, in points.
@@ -732,6 +742,14 @@ pub(crate) fn animated_zoom(p: &vectorcraft_engine::Prefs) -> bool {
p.animated_zoom && p.gpu_performance
}
/// Zoom for View → Actual Size (`view.actualSize`). With Preferences › General › Display Print
/// Size at 100% Zoom off, one document point is one screen point. On, one document inch (72 pt)
/// fills an inch of the screen as the system counts it: 96 screen points, the reference density
/// that display scaling is set against (a CSS inch), whatever the scale factor.
pub(crate) fn actual_size_zoom(display_print_size: bool) -> f64 {
if display_print_size { 96.0 / 72.0 } else { 1.0 }
}
/// Zoom view `vm` of the canvas `rect` to `zoom` (clamped to the zoom range), keeping the document
/// point under screen point `p` where it is.
fn zoom_about(vm: &mut View, rect: egui::Rect, p: Pos2, zoom: f64) {
@@ -754,19 +772,19 @@ fn marquee(p: &egui::Painter, r: egui::Rect) {
/// a zoom factor (about the pointer) and a scroll (screen points the content moves). The wheel scrolls, Cmd- and Alt-wheel (Option
/// on the Mac) zoom. With General › Zoom with Mouse Wheel (`wheel_zooms`) the wheel and Alt-wheel
/// zoom, Shift-wheel scrolls up and down and Cmd/Ctrl-wheel sideways. `line` and `page`: points
/// per wheel line and page. `alt_turn`: the last wheel turn was an Alt-wheel one (kept by the
/// caller across frames).
fn wheel(i: &egui::InputState, wheel_zooms: bool, line: f32, page: f32, alt_turn: &mut bool) -> (f64, egui::Vec2) {
/// per wheel line and page. `turn`: kept by the caller across frames. A mouse wheel's notches zoom
/// in a glide over a few frames, as scrolling does (#888); a trackpad's fine steps at once.
fn wheel(i: &egui::InputState, wheel_zooms: bool, line: f32, page: f32, turn: &mut WheelTurn) -> (f64, egui::Vec2) {
// Fingers on a touch screen pan the content with them (a trackpad sends scrolls instead).
let pan = i.multi_touch().map_or(egui::Vec2::ZERO, |t| t.translation_delta);
if !wheel_zooms {
// egui makes Cmd-wheel (and a pinch) its zoom and the rest a scroll it spreads over a few
// frames: the rest of an Alt-wheel turn zooms too, however soon Alt is let go.
if let Some(m) = i.events.iter().rev().find_map(|e| if let egui::Event::MouseWheel { modifiers, .. } = e { Some(modifiers) } else { None }) {
*alt_turn = m.alt && !m.command;
turn.alt = m.alt && !m.command;
}
let (zoom, scroll) = (f64::from(i.zoom_delta()), i.smooth_scroll_delta);
return if *alt_turn && scroll != egui::Vec2::ZERO {
return if turn.alt && scroll != egui::Vec2::ZERO {
(zoom * (f64::from(scroll.x + scroll.y) * WHEEL_ZOOM).exp(), pan)
} else {
(zoom, scroll + pan)
@@ -787,16 +805,23 @@ fn wheel(i: &egui::InputState, wheel_zooms: bool, line: f32, page: f32, alt_turn
scroll.x += d.x + d.y;
} else if modifiers.shift {
scroll.y += d.x + d.y;
} else {
} else if *unit == egui::MouseWheelUnit::Point {
zoom *= (f64::from(d.y) * WHEEL_ZOOM).exp();
scroll.x += d.x;
} else {
turn.glide += f64::from(d.y) * WHEEL_ZOOM;
scroll.x += d.x;
}
}
egui::Event::Zoom(f) => zoom *= f64::from(*f),
_ => {}
}
}
(zoom, scroll)
// This frame's part of the glide: what is left shrinks by e every WHEEL_GLIDE seconds.
let part = if turn.glide.abs() < 1e-3 { 1.0 } else { 1.0 - (-f64::from(i.stable_dt.min(0.1)) / WHEEL_GLIDE).exp() };
let step = turn.glide * part;
turn.glide -= step;
(zoom * step.exp(), scroll)
}
/// Enable Touch Gestures: this frame's finger contacts on the canvas ([`crate::touch`]). A second
@@ -1670,8 +1695,8 @@ fn selection_overlay(app: &mut VectorcraftApp, p: &egui::Painter, xf: &Xf) {
app.session.prefs.hide_corner_widget_above,
)
{
let color = c32(st.doc.layer_color(w.id));
for sp in w.visible().map(|q| xf.to_screen(q)) {
for (id, q) in w.visible_on() {
let (sp, color) = (xf.to_screen(q), c32(st.doc.layer_color(id)));
p.circle_filled(sp, 3.0, Color32::WHITE);
p.circle_stroke(sp, 3.0, Stroke::new(1.0, color));
p.circle_filled(sp, 1.0, color);
@@ -1718,6 +1743,22 @@ fn ime_output(app: &mut VectorcraftApp, ctx: &egui::Context, xf: &Xf) {
});
}
/// The width an artboard needs on screen for its name to show (px): narrower, the name is left
/// out rather than spilling over its neighbours.
const MIN_LABELLED_ARTBOARD: f32 = 24.0;
/// Artboard `name` above the top-left corner of its on-screen rect `r`, no wider than the
/// artboard: a longer name ends in "…", and an artboard too narrow shows none (#949).
fn artboard_label(painter: &egui::Painter, r: egui::Rect, name: &str, color: Color32) {
if r.width() < MIN_LABELLED_ARTBOARD {
return;
}
let mut job = egui::text::LayoutJob::simple_singleline(name.to_owned(), egui::FontId::proportional(11.0), color);
job.wrap = egui::text::TextWrapping { max_width: r.width(), max_rows: 1, break_anywhere: true, overflow_character: Some('…') };
let galley = painter.layout_job(job);
painter.galley(r.left_top() - vec2(0.0, 4.0 + galley.size().y), galley, color);
}
/// Is overlay label `text` the Artboard tool's "01 - <artboard name>"? It holds a name, so it is
/// shown as it is; the tools' other labels ("anchor", "path", Puppet Warp's warning) are ours.
fn names_an_artboard(text: &str) -> bool {
@@ -2188,6 +2229,31 @@ mod tests {
assert!(!names_an_artboard("anchor") && !names_an_artboard("1 - x") && !names_an_artboard("Off the mesh - move closer"));
}
/// #949: an artboard's name stays within the artboard's width on screen, ending in "…" when
/// longer, and an artboard too narrow shows none.
#[test]
fn artboard_labels_fit_their_artboard() {
let ctx = egui::Context::default();
let drawn = |width: f32| {
let r = egui::Rect::from_min_size(pos2(50.0, 50.0), vec2(width, 40.0));
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| artboard_label(ui.painter(), r, "01 - A long artboard name", Color32::WHITE));
out.textures_delta.clear();
out.shapes
.into_iter()
.filter_map(|c| match c.shape {
egui::Shape::Text(t) => Some((t.galley.text().to_string(), t.galley.size().x)),
_ => None,
})
.collect::<Vec<_>>()
};
let full = drawn(400.0);
assert_eq!(full.first().map(|(s, _)| s.as_str()), Some("01 - A long artboard name"));
// Laid out no wider than the artboard (the galley keeps the whole text; it draws "…").
let (_, w) = drawn(30.0).first().cloned().unwrap();
assert!(full[0].1 > 30.0 && w <= 30.0 + 0.5, "{full:?} {w}");
assert!(drawn(10.0).is_empty());
}
fn middle(pos: Pos2, pressed: bool) -> egui::Event {
egui::Event::PointerButton { pos, button: egui::PointerButton::Middle, pressed, modifiers: Default::default() }
}
@@ -2868,6 +2934,43 @@ mod tests {
assert!((zoom - 1.0).abs() < 1e-9 && scroll.x < -1.0 && scroll.y == 0.0, "on: Cmd-wheel scrolls sideways ({zoom}, {scroll:?})");
}
/// #888: with Zoom with Mouse Wheel, a wheel notch's zoom glides in over a few frames, as a
/// scroll does, instead of jumping on the frame it arrives; it ends at the same zoom.
#[test]
fn wheel_notches_zoom_in_a_glide() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
app.session.execute("prefs.set", &json!({"key": "zoomWithMouseWheel", "value": true})).unwrap();
let ctx = egui::Context::default();
let run = |app: &mut VectorcraftApp| {
let mut raw = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), ..Default::default() };
app.raw_input_hook(&mut raw);
let mut out = ctx.run_ui(raw, |ui| show(app, ui));
out.textures_delta.clear();
};
run(&mut app);
let at = app.canvas_rect.unwrap().center();
let before = app.view().unwrap().zoom;
crate::tests_synthetic::control(&mut app, &ctx, "ui.wheel", json!({"x": at.x, "y": at.y, "dy": 1}));
// The first frame the zoom moves in (the event may land a frame later).
let mut first = 1.0;
for _ in 0..3 {
run(&mut app);
first = app.view().unwrap().zoom / before;
if first != 1.0 {
break;
}
}
for _ in 0..60 {
run(&mut app);
}
let done = app.view().unwrap().zoom / before;
assert!(done > 1.01, "the notch zooms in ({done})");
assert!(first > 1.0 && first < done, "the first frame takes only part of it ({first} of {done})");
run(&mut app);
assert_eq!(app.view().unwrap().zoom / before, done, "and it stops");
}
/// Two fingers dragged together on a touch screen pan the canvas with them, under either
/// Zoom with Mouse Wheel setting, and draw nothing (#449).
#[test]
@@ -2898,6 +3001,21 @@ mod tests {
}
}
/// General › Display Print Size at 100% Zoom (#394): `view.actualSize` is one document point per
/// screen point with it off, and 96 screen points per document inch with it on.
#[test]
fn view_actual_size_follows_display_print_size() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.session.execute("file.new", &json!({"width": 200, "height": 100})).unwrap();
app.canvas_rect = Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(460.0, 260.0)));
app.run("view.setZoom", json!({"zoom": 250})).unwrap();
app.run("view.actualSize", json!({})).unwrap();
assert!((app.view().unwrap().zoom - 1.0).abs() < 1e-12, "off by default: 100% is 1:1");
app.session.execute("prefs.set", &json!({"key": "displayPrintSize", "value": true})).unwrap();
app.run("view.actualSize", json!({})).unwrap();
assert!((app.view().unwrap().zoom - 96.0 / 72.0).abs() < 1e-12, "an inch is 96 screen points");
}
/// Performance › Animated Zoom (#394): the Zoom tool dragged sideways zooms about where it was
/// pressed (right in, left out), held still it zooms on, and a click still steps. Off, or with
/// GPU Performance off, a drag zooms to the area dragged across.
+39 -10
View File
@@ -14,8 +14,10 @@
//! real egui pointer input in screen points (reaches every widget: panels, flyouts, dialogs)
//! - `ui.set {brightness?, panel?, dockTab?, rulers?, outline?, …}`
//! - `ui.dialog.set {field, value}` / `ui.dialog.confirm` / `ui.dialog.cancel`
//! - `ui.resize {width, height}`, `ui.focus`, `ui.screenshot {path?}`
//! - `ui.render {path?, scale?}`: render the active artboard headlessly (PNG)
//! - `ui.resize {width, height}`, `ui.focus`, `ui.screenshot {path?, data?}` (`data: true`: the PNG
//! comes back as `pngBase64` as well)
//! - `ui.render {path?, scale?, data?}`: render the active artboard headlessly (PNG; without a path,
//! or with `data: true`, as `pngBase64`)
//! - `app.open {path}` (any readable format) / `app.save {path?, svg?: {…SVG options}}` / `app.quit`
//! (`file.close`, `file.closeAll` and `app.quit` first open a `saveChanges` dialog for each
//! modified document: `ui.dialog.confirm` saves, set `discard: true` then confirm to discard)
@@ -47,7 +49,12 @@ impl ControlRequest {
pub enum Outcome {
Done(Value),
Screenshot { path: Option<String> },
/// A window capture, answered once a frame was presented: written to `path`, the PNG sent
/// back too with `data`.
Screenshot {
path: Option<String>,
data: bool,
},
}
fn ok(v: Value) -> Outcome {
@@ -301,11 +308,15 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlReques
ok(Value::Null)
}
"ui.screenshot" => {
// Refused now rather than once the frame comes (it is checked again then).
if let Some(Err(e)) = s("path").map(vectorcraft_engine::file_access::check_write) {
return err(e);
}
if p.get("focus").and_then(Value::as_bool).unwrap_or(true) {
ctx.send_viewport_cmd(egui::ViewportCommand::Focus);
}
ctx.request_repaint();
Outcome::Screenshot { path: s("path").map(str::to_string) }
Outcome::Screenshot { path: s("path").map(str::to_string), data: p.get("data").and_then(Value::as_bool) == Some(true) }
}
"ui.render" => {
let Some(st) = app.session.active() else { return err("no document") };
@@ -320,12 +331,16 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlReques
Ok(png) => png,
Err(e) => return err(e),
};
let data = p.get("data").and_then(Value::as_bool) == Some(true);
match s("path") {
Some(path) => match app.services.write.as_mut() {
Some(w) => wrap(w(path, &png).map(|_| json!({"path": path, "width": img.width, "height": img.height}))),
Some(w) => wrap(
w(path, &png)
.map(|_| with_data(json!({"path": path, "width": img.width, "height": img.height}), data.then_some(png.as_slice()))),
),
None => err("no writer"),
},
None => ok(json!({"width": img.width, "height": img.height, "pngBase64": vectorcraft_format::base64_encode(&png)})),
None => ok(with_data(json!({"width": img.width, "height": img.height}), Some(&png))),
}
}
"app.open" => wrap(app.run("file.open", json!({"path": s("path")}))),
@@ -342,20 +357,34 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlReques
}
}
pub fn save_screenshot(app: &mut VectorcraftApp, image: &egui::ColorImage, path: Option<&str>) -> Value {
/// `result` with the PNG as `pngBase64` when there is one.
fn with_data(mut result: Value, png: Option<&[u8]>) -> Value {
if let Some(png) = png {
result["pngBase64"] = json!(vectorcraft_format::base64_encode(png));
}
result
}
/// The reply to `ui.screenshot`: the window capture `image` written to `path`, and sent back as
/// `pngBase64` with `data`.
pub fn save_screenshot(app: &mut VectorcraftApp, image: &egui::ColorImage, path: Option<&str>, data: bool) -> Value {
let [w, h] = image.size;
let Some(path) = path else {
if path.is_none() && !data {
return json!({"ok": true, "result": {"width": w, "height": h}});
};
}
let rgba: Vec<u8> = image.pixels.iter().flat_map(|c| c.to_array()).collect();
let img = vectorcraft_render::Rendered { width: w as u32, height: h as u32, pixels: rgba };
let png = match img.to_png() {
Ok(png) => png,
Err(e) => return json!({"ok": false, "error": e}),
};
let data = data.then_some(png.as_slice());
let Some(path) = path else {
return json!({"ok": true, "result": with_data(json!({"width": w, "height": h}), data)});
};
match app.services.write.as_mut() {
Some(wr) => match wr(path, &png) {
Ok(()) => json!({"ok": true, "result": {"path": path, "width": w, "height": h}}),
Ok(()) => json!({"ok": true, "result": with_data(json!({"path": path, "width": w, "height": h}), data)}),
Err(e) => json!({"ok": false, "error": e}),
},
None => json!({"ok": false, "error": "no writer configured"}),
+16 -5
View File
@@ -7,6 +7,8 @@ use std::cmp::Ordering;
use egui::RichText;
use crate::VectorcraftApp;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Contributor {
pub login: &'static str,
@@ -214,7 +216,7 @@ impl Default for View {
}
/// About ▸ Contributors: a name toggle, a sort, and the list as a grab bag or a table.
pub fn contributors_ui(ui: &mut egui::Ui) {
pub fn contributors_ui(app: &mut VectorcraftApp, ui: &mut egui::Ui) {
let id = egui::Id::new("credits_view");
let mut v = ui.data_mut(|d| d.get_temp::<View>(id)).unwrap_or_default();
ui.horizontal_wrapped(|ui| {
@@ -254,14 +256,14 @@ pub fn contributors_ui(ui: &mut egui::Ui) {
if list.is_empty() {
ui.label(tl!("No contributor data was built into this copy."));
} else if v.table {
table(ui, &list, &mut v);
table(app, ui, &list, &mut v);
} else {
ui.horizontal_wrapped(|ui| {
for (i, c) in list.iter().enumerate() {
if i > 0 {
ui.label(RichText::new("·").weak());
}
ui.hyperlink_to(c.name(v.names), format!("https://github.com/{}", c.login)).on_hover_text(c.summary());
link(app, ui, c, v.names);
}
});
}
@@ -269,7 +271,16 @@ pub fn contributors_ui(ui: &mut egui::Ui) {
ui.data_mut(|d| d.insert_temp(id, v));
}
fn table(ui: &mut egui::Ui, list: &[&Contributor], v: &mut View) {
fn link(app: &mut VectorcraftApp, ui: &mut egui::Ui, c: &Contributor, names: NameMode) -> egui::Response {
let url = format!("https://github.com/{}", c.login);
let r = ui.add(egui::Link::new(c.name(names))).on_hover_text(c.summary());
if r.clicked() {
app.open_url(&url);
}
r
}
fn table(app: &mut VectorcraftApp, ui: &mut egui::Ui, list: &[&Contributor], v: &mut View) {
egui::Grid::new("credits_table").striped(true).num_columns(SortKey::ALL.len()).show(ui, |ui| {
for k in SortKey::ALL {
let arrow = if v.key == k { if v.ascending { " ▲" } else { " ▼" } } else { "" };
@@ -284,7 +295,7 @@ fn table(ui: &mut egui::Ui, list: &[&Contributor], v: &mut View) {
}
ui.end_row();
for c in list {
ui.hyperlink_to(c.name(v.names), format!("https://github.com/{}", c.login)).on_hover_text(c.summary());
link(app, ui, c, v.names);
ui.label(group(c.prs));
ui.label(group(c.commits));
ui.label(group(c.lines_added));
+1 -1
View File
@@ -33,7 +33,7 @@ pub(super) fn show(app: &mut VectorcraftApp, ctx: &egui::Context) {
match tab {
1 => {
ui.set_width(660.0);
crate::credits::contributors_ui(ui);
crate::credits::contributors_ui(app, ui);
}
2 => {
ui.set_width(660.0);
+22 -2
View File
@@ -1,5 +1,5 @@
//! Artboard Options (double-click with the Artboard tool): the artboard's fields, sizes parsed
//! with units.
//! Artboard Options (double-click with the Artboard tool, or double-click the tool): the
//! artboard's fields, sizes parsed with units.
use serde_json::{Value, json};
@@ -9,6 +9,25 @@ use crate::state::Dialog;
pub(super) const SPEC: DialogSpec = DialogSpec { heading: |_| tl!("Artboard Options").into(), confirm, ..DialogSpec::FORM };
/// The dialog kind of Artboard Options.
pub const KIND: &str = "artboardOptions";
/// Open Artboard Options for the active artboard: the Artboard tool's while it is in use, else the
/// window's.
pub fn open(app: &mut VectorcraftApp) -> Result<Value, String> {
let index = if app.session.tool_id() == "artboard" {
app.session.tool_options()["active"].as_u64().and_then(|i| usize::try_from(i).ok()).unwrap_or(0)
} else {
app.view().map_or(0, |v| v.artboard)
};
let st = app.session.active().ok_or("no document")?;
let a = st.doc.artboards.get(index).ok_or("no artboard")?;
let r = a.rect;
let fields = json!({"index": index, "name": a.name, "x": r.x0, "y": r.y0, "width": r.width(), "height": r.height()});
app.ui.dialog = Some(Dialog::new(KIND, fields));
Ok(json!({ "dialog": KIND }))
}
fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
let mut p = Value::Object(d.fields.clone());
for k in ["x", "y", "width", "height"] {
@@ -16,5 +35,6 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
p[k] = json!(d.f64(k, 0.0));
}
}
p["moveArt"] = json!(crate::panels::artboards::move_art(app));
run_and_close(app, "artboard.setProps", p)
}
@@ -0,0 +1,413 @@
//! Scatter, Art and Pattern Brush Options: the [`super::brush_options`] of the brushes made of
//! art. Each has the brush's own values, Colorization (Method, and Key Color for Hue Shift, picked
//! from the art's colours) and a live preview of a stroke.
//!
//! Fields (besides `__brush`, `__type` and `name`): `__def` (the brush definition, for the preview),
//! `__colors` (the art's colours, most used first), `colorization` (`none` | `tints` |
//! `tintsAndShades` | `hueShift`), `keyColor` (a colour).
//! Scatter: per value (`size`, `spacing`, `scatter` %, `rotation` °) `…Min`, `…Max` and `…Mode`
//! (`fixed` | `random` | `pressure`), `rotationRelativeTo` (`page` | `path`).
//! Art: `width` (%), `scaleMode` (`proportional` | `stretch` | `betweenGuides`), `guideStart` and
//! `guideEnd` (% of the art's length), `direction` (`leftToRight` …), `flipAlong`, `flipAcross`.
//! Pattern: `scale`, `spacing` (%), `fit` (`stretch` | `addSpace` | `approximate`), `flipAlong`,
//! `flipAcross`, `__tiles` (slot → preview key, for the tiles the brush has).
use std::hash::{Hash, Hasher};
use egui::{Color32, Sense, Stroke, pos2, vec2};
use serde_json::{Map, Value, json};
use vectorcraft_brush::{ArtBrush, ArtScale, Brush, BrushKind, Colorization, Direction, PatternBrush, PatternFit, Scatter, Variation};
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::{Document, Node};
use super::brush_options::{clamped, mode_key, stroke_preview};
use crate::state::Dialog;
use crate::theme::Tokens;
use crate::widgets;
/// Scatter values: (field, suffix), in [`Scatter::RANGES`] order.
const SCATTER: [(&str, &str); 4] = [("size", "%"), ("spacing", "%"), ("scatter", "%"), ("rotation", "°")];
/// Art brush directions, in the order of their arrows (→ ← ↓ ↑).
const DIRECTIONS: [Direction; 4] = [Direction::LeftToRight, Direction::RightToLeft, Direction::TopToBottom, Direction::BottomToTop];
/// Pattern brush tiles: (field of the definition, slot), in the dialog's order.
const TILES: [(&str, &str); 5] =
[("outer_corner", "outerCorner"), ("side", "side"), ("inner_corner", "innerCorner"), ("start", "start"), ("end", "end")];
/// The most key colours offered.
const MAX_COLORS: usize = 16;
/// Width of the label column.
const LABEL_W: f32 = 132.0;
/// Width of the stroke preview.
const PREVIEW_W: f32 = 420.0;
pub(super) fn heading(ty: &str) -> &'static str {
match ty {
"scatter" => tl!("Scatter Brush Options"),
"art" => tl!("Art Brush Options"),
_ => tl!("Pattern Brush Options"),
}
}
fn min_key(key: &str) -> String {
format!("{key}Min")
}
fn max_key(key: &str) -> String {
format!("{key}Max")
}
/// A definition id of a serde enum (`leftToRight`, `addSpace` …).
fn id_of<T: serde::Serialize>(v: &T) -> String {
serde_json::to_value(v).ok().and_then(|v| v.as_str().map(str::to_string)).unwrap_or_default()
}
/// The value of the serde enum `T` that field `key` names, else `T`'s default.
fn parse_field<T: serde::de::DeserializeOwned + Default>(d: &Dialog, key: &str) -> T {
serde_json::from_value(json!(d.str(key))).unwrap_or_default()
}
// ---------- fields ----------
/// The fields of brush `b` (a scatter, art or pattern brush) whose definition is `def`.
pub(super) fn fields(b: &Brush, def: Value, f: &mut Map<String, Value>) {
match &b.kind {
BrushKind::Scatter(s) => {
for ((key, _), ((lo, hi), mode)) in SCATTER.iter().zip(s.values().into_iter().zip(s.modes())) {
f.insert(min_key(key), json!(lo));
f.insert(max_key(key), json!(hi));
f.insert(mode_key(key), json!(mode.id()));
}
f.insert("rotationRelativeTo".into(), json!(if s.rotation_relative_to_path { "path" } else { "page" }));
}
BrushKind::Art(a) => {
let (mode, start, end) = match a.scale {
ArtScale::Proportional => ("proportional", 0.0, 1.0),
ArtScale::Stretch => ("stretch", 0.0, 1.0),
ArtScale::BetweenGuides { start, end } => ("betweenGuides", start, end),
};
f.insert("width".into(), json!(a.width));
f.insert("scaleMode".into(), json!(mode));
f.insert("guideStart".into(), json!(start * 100.0));
f.insert("guideEnd".into(), json!(end * 100.0));
f.insert("direction".into(), json!(id_of(&a.direction)));
f.insert("flipAlong".into(), json!(a.flip_along));
f.insert("flipAcross".into(), json!(a.flip_across));
}
BrushKind::Pattern(p) => {
f.insert("scale".into(), json!(p.scale));
f.insert("spacing".into(), json!(p.spacing));
f.insert("fit".into(), json!(id_of(&p.fit)));
f.insert("flipAlong".into(), json!(p.flip_along));
f.insert("flipAcross".into(), json!(p.flip_across));
// A preview key per tile the brush has: its art's hash, so a changed tile redraws.
let tiles: Map<String, Value> = TILES
.iter()
.filter_map(|(field, slot)| {
let art = def.get(*field).filter(|v| !v.is_null())?;
let mut h = std::collections::hash_map::DefaultHasher::new();
art.to_string().hash(&mut h);
Some(((*slot).to_string(), json!(format!("brush-tile:{:x}", h.finish()))))
})
.collect();
f.insert("__tiles".into(), Value::Object(tiles));
}
BrushKind::Calligraphic(_) | BrushKind::Bristle(_) => {}
}
let colors = vectorcraft_brush::art_colors(b.art());
let colorization = b.kind.colorization();
let key = match colorization {
Colorization::HueShift { key } => key,
_ => colors.first().copied().unwrap_or(Color::BLACK),
};
f.insert("colorization".into(), json!(colorization.id()));
f.insert("keyColor".into(), serde_json::to_value(key).unwrap_or_default());
f.insert("__colors".into(), serde_json::to_value(colors.into_iter().take(MAX_COLORS).collect::<Vec<_>>()).unwrap_or_default());
f.insert("__def".into(), def);
}
fn key_color(d: &Dialog) -> Color {
d.fields.get("keyColor").and_then(|v| serde_json::from_value(v.clone()).ok()).unwrap_or(Color::BLACK)
}
fn colorization(d: &Dialog) -> Colorization {
Colorization::parse(&d.str("colorization"), key_color(d)).unwrap_or_default()
}
/// How scatter value `key` varies.
fn scatter_mode(d: &Dialog, key: &str) -> Variation {
Variation::parse(&d.str(&mode_key(key))).unwrap_or_default()
}
/// `brush.options` params from the fields.
pub(super) fn params(d: &Dialog) -> Value {
let mut p = match d.str("__type").as_str() {
"scatter" => {
let mut p = json!({ "rotation_relative_to_path": d.str("rotationRelativeTo") == "path" });
for ((key, _), range) in SCATTER.iter().zip(Scatter::RANGES) {
p[*key] = json!([clamped(d, &min_key(key), range), clamped(d, &max_key(key), range)]);
}
p["modes"] = json!(SCATTER.map(|(key, _)| scatter_mode(d, key).id()));
p
}
"art" => {
let scale = match d.str("scaleMode").as_str() {
"proportional" => ArtScale::Proportional,
"betweenGuides" => ArtScale::BetweenGuides {
start: clamped(d, "guideStart", (0.0, 100.0)) / 100.0,
end: clamped(d, "guideEnd", (0.0, 100.0)) / 100.0,
},
_ => ArtScale::Stretch,
};
json!({
"width": clamped(d, "width", ArtBrush::WIDTH_RANGE),
"scale": serde_json::to_value(scale).unwrap_or_default(),
"direction": id_of(&parse_field::<Direction>(d, "direction")),
"flip_along": d.bool("flipAlong"),
"flip_across": d.bool("flipAcross"),
})
}
_ => json!({
"scale": clamped(d, "scale", PatternBrush::SCALE_RANGE),
"spacing": clamped(d, "spacing", PatternBrush::SPACING_RANGE),
"fit": id_of(&parse_field::<PatternFit>(d, "fit")),
"flip_along": d.bool("flipAlong"),
"flip_across": d.bool("flipAcross"),
}),
};
p["colorization"] = serde_json::to_value(colorization(d)).unwrap_or_default();
p
}
// ---------- body ----------
pub(super) fn body(ui: &mut egui::Ui, d: &mut Dialog) {
match d.str("__type").as_str() {
"scatter" => scatter_body(ui, d),
"art" => art_body(ui, d),
_ => pattern_body(ui, d),
}
ui.add_space(6.0);
colorization_body(ui, d);
ui.add_space(10.0);
// The brush as OK would make it, on a sample stroke.
let mut def = d.fields.get("__def").cloned().unwrap_or_default();
if let (Some(o), Value::Object(p)) = (def.as_object_mut(), params(d)) {
o.extend(p);
}
let ty = d.str("__type");
stroke_preview(ui, &ty, def, vec2(PREVIEW_W, 64.0));
}
/// A whole-number field (`suffix`: % or °) bound to `d.fields[key]`, kept within `range`.
fn number(ui: &mut egui::Ui, d: &mut Dialog, key: &str, range: (f64, f64), suffix: &str, enabled: bool) {
ui.add_enabled_ui(enabled, |ui| {
if let Some(v) = widgets::range_field(ui, ("brush-art-value", key), clamped(d, key, range), range.0..=range.1, suffix, 0, 72.0) {
d.fields.insert(key.into(), json!(v));
}
});
}
/// A row of radio buttons bound to the string field `key`: (value, label).
fn radios(ui: &mut egui::Ui, d: &mut Dialog, key: &str, options: &[(&str, &str)]) {
let cur = d.str(key);
for (value, label) in options {
if widgets::radio(ui, label, cur == *value, true) {
d.fields.insert(key.into(), json!(value));
}
}
}
fn flips(ui: &mut egui::Ui, d: &mut Dialog) {
widgets::label_row(ui, tl!("Flip:"), LABEL_W, |ui| {
super::form::check(ui, d, "flipAlong", tl!("Flip Along"));
ui.add_space(12.0);
super::form::check(ui, d, "flipAcross", tl!("Flip Across"));
});
}
fn scatter_body(ui: &mut egui::Ui, d: &mut Dialog) {
let labels = [tl!("Size:"), tl!("Spacing:"), tl!("Scatter:"), tl!("Rotation:")];
let modes = Variation::ALL.map(Variation::label);
egui::Grid::new("brush-options-scatter").num_columns(4).spacing([8.0, 8.0]).show(ui, |ui| {
for (((key, suffix), range), text) in SCATTER.into_iter().zip(Scatter::RANGES).zip(labels) {
super::swatch_options::label(ui, text);
let mode = scatter_mode(d, key);
for (field, enabled) in [(min_key(key), true), (max_key(key), mode != Variation::Fixed)] {
ui.add_enabled_ui(enabled, |ui| {
if let Some(v) =
widgets::range_field(ui, ("brush-scatter", field.as_str()), clamped(d, &field, range), range.0..=range.1, suffix, 0, 72.0)
{
d.fields.insert(field.clone(), json!(v));
}
});
}
if let Some(m) = widgets::dropdown(ui, ("brush-scatter-mode", key), mode.label(), &modes, 104.0).and_then(|i| Variation::ALL.get(i)) {
d.fields.insert(mode_key(key), json!(m.id()));
}
ui.end_row();
}
});
ui.add_space(4.0);
let relative = [("page", tl!("Page")), ("path", tl!("Path"))];
super::form::choice(ui, d, "rotationRelativeTo", tl!("Rotation relative to:"), (LABEL_W, 104.0), &relative);
}
fn art_body(ui: &mut egui::Ui, d: &mut Dialog) {
widgets::label_row(ui, tl!("Width:"), LABEL_W, |ui| number(ui, d, "width", ArtBrush::WIDTH_RANGE, "%", true));
ui.add_space(4.0);
widgets::section_header(ui, tl!("Brush Scale Options"));
let scales = [
("proportional", tl!("Scale Proportionately")),
("stretch", tl!("Stretch to Fit Stroke Length")),
("betweenGuides", tl!("Stretch Between Guides")),
];
radios(ui, d, "scaleMode", &scales);
let guides = d.str("scaleMode") == "betweenGuides";
ui.horizontal(|ui| {
ui.add_space(18.0);
widgets::label_row(ui, tl!("Start:"), 40.0, |ui| number(ui, d, "guideStart", (0.0, 100.0), "%", guides));
ui.add_space(8.0);
widgets::label_row(ui, tl!("End:"), 34.0, |ui| number(ui, d, "guideEnd", (0.0, 100.0), "%", guides));
});
ui.add_space(6.0);
widgets::label_row(ui, tl!("Direction:"), LABEL_W, |ui| {
let tips = [tl!("Left to Right"), tl!("Right to Left"), tl!("Top to Bottom"), tl!("Bottom to Top")];
let cur = parse_field::<Direction>(d, "direction");
for (dir, tip) in DIRECTIONS.into_iter().zip(tips) {
if arrow_button(ui, dir, dir == cur, tip) {
d.fields.insert("direction".into(), json!(id_of(&dir)));
}
}
});
flips(ui, d);
}
/// A toggle button showing an arrow pointing the way `dir` runs. Whether it was clicked.
fn arrow_button(ui: &mut egui::Ui, dir: Direction, selected: bool, tip: &str) -> bool {
let t = Tokens::get(ui.ctx());
let (r, resp) = ui.allocate_exact_size(vec2(26.0, 24.0), Sense::click());
let p = ui.painter();
if selected {
p.rect_filled(r, 3.0, t.row_selected);
} else if resp.hovered() {
p.rect_filled(r, 3.0, t.hover);
}
p.rect_stroke(r, 3.0, Stroke::new(1.0, if selected { t.accent } else { t.input_border }), egui::StrokeKind::Inside);
let u = match dir {
Direction::LeftToRight => vec2(1.0, 0.0),
Direction::RightToLeft => vec2(-1.0, 0.0),
Direction::TopToBottom => vec2(0.0, 1.0),
Direction::BottomToTop => vec2(0.0, -1.0),
};
let (c, n) = (r.center(), vec2(-u.y, u.x));
let (tail, tip_at) = (c - u * 7.0, c + u * 7.0);
let stroke = Stroke::new(1.5, if selected { t.text_strong } else { t.text });
p.line_segment([tail, tip_at], stroke);
p.line_segment([tip_at, tip_at - u * 4.0 + n * 4.0], stroke);
p.line_segment([tip_at, tip_at - u * 4.0 - n * 4.0], stroke);
resp.on_hover_text(tip).clicked()
}
fn pattern_body(ui: &mut egui::Ui, d: &mut Dialog) {
widgets::label_row(ui, tl!("Scale:"), LABEL_W, |ui| number(ui, d, "scale", PatternBrush::SCALE_RANGE, "%", true));
widgets::label_row(ui, tl!("Spacing:"), LABEL_W, |ui| number(ui, d, "spacing", PatternBrush::SPACING_RANGE, "%", true));
ui.add_space(6.0);
tiles(ui, d);
ui.add_space(6.0);
flips(ui, d);
widgets::section_header(ui, tl!("Fit"));
let fits = [("stretch", tl!("Stretch to fit")), ("addSpace", tl!("Add space to fit")), ("approximate", tl!("Approximate path"))];
radios(ui, d, "fit", &fits);
}
/// The pattern's five tiles, each with its art (or None when the brush has no such tile).
fn tiles(ui: &mut egui::Ui, d: &Dialog) {
let t = Tokens::get(ui.ctx());
let names = [tl!("Outer Corner Tile"), tl!("Side Tile"), tl!("Inner Corner Tile"), tl!("Start Tile"), tl!("End Tile")];
let keys = d.fields.get("__tiles");
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 6.0;
for ((field, slot), name) in TILES.into_iter().zip(names) {
ui.vertical(|ui| {
let (r, resp) = ui.allocate_exact_size(vec2(76.0, 44.0), Sense::hover());
ui.painter().rect_filled(r, 0.0, Color32::WHITE);
let key = keys.and_then(|k| k.get(slot)).and_then(Value::as_str);
let tex = key.and_then(|key| {
widgets::doc_preview(ui, key, r.size(), |w, h| {
let art: Node = serde_json::from_value(d.fields.get("__def")?.get(field)?.clone()).ok()?;
tile_doc(&art, w, h)
})
});
match tex {
Some(tex) => {
ui.painter().image(tex.id(), r, egui::Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
}
None => {
ui.painter().text(
r.center(),
egui::Align2::CENTER_CENTER,
tl!("None"),
egui::FontId::proportional(11.5),
Color32::from_gray(140),
);
}
}
ui.painter().rect_stroke(r, 0.0, Stroke::new(1.0, t.input_border), egui::StrokeKind::Inside);
resp.on_hover_text(name);
ui.add_sized(vec2(76.0, 14.0), egui::Label::new(egui::RichText::new(name).size(10.5).color(t.text_dim)).truncate());
});
}
});
}
/// A document of `w` × `h` showing `art` scaled to fit, centred.
fn tile_doc(art: &Node, w: f64, h: f64) -> Option<Document> {
let b = art.visual_bounds().or_else(|| art.geometric_bounds())?;
let k = ((w - 8.0) / b.width().max(1e-6)).min((h - 8.0) / b.height().max(1e-6));
if !k.is_finite() || k <= 0.0 {
return None;
}
let mut doc = Document::new(w, h);
let mut n = doc.reid(art);
let xf = vectorcraft_geom::Affine::translate((w / 2.0, h / 2.0))
* vectorcraft_geom::Affine::scale(k)
* vectorcraft_geom::Affine::translate(-b.center().to_vec2());
n.transform(xf, true);
let l = doc.layers.first()?.id;
doc.insert(Some(l), 0, n).ok()?;
Some(doc)
}
fn colorization_body(ui: &mut egui::Ui, d: &mut Dialog) {
widgets::section_header(ui, tl!("Colorization"));
let methods = [tl!("None"), tl!("Tints"), tl!("Tints and Shades"), tl!("Hue Shift")];
let cur = d.str("colorization");
let at = Colorization::IDS.iter().position(|id| *id == cur).unwrap_or(0);
widgets::label_row(ui, tl!("Method:"), LABEL_W, |ui| {
if let Some(id) = widgets::dropdown_names(ui, "brush-colorization", methods.get(at).copied().unwrap_or_default(), &methods, 150.0)
.and_then(|i| Colorization::IDS.get(i))
{
d.fields.insert("colorization".into(), json!(id));
}
});
// Hue Shift's key colour: the art colour that becomes the stroke colour.
let colors: Vec<Color> = d.fields.get("__colors").and_then(|v| serde_json::from_value(v.clone()).ok()).unwrap_or_default();
let hue_shift = cur == "hueShift";
let key = key_color(d);
widgets::label_row(ui, tl!("Key Color:"), LABEL_W, |ui| {
ui.add_enabled_ui(hue_shift, |ui| {
ui.spacing_mut().item_spacing.x = 3.0;
for c in colors {
let (r, resp) = ui.allocate_exact_size(vec2(18.0, 18.0), Sense::click());
widgets::swatch_tile(ui, r, &Paint::solid(c), hue_shift && c == key, resp.hovered());
if resp.on_hover_text(c.to_hex()).clicked() {
d.fields.insert("keyColor".into(), serde_json::to_value(c).unwrap_or_default());
}
}
});
});
}
+496
View File
@@ -0,0 +1,496 @@
//! Brush Options (double-click a brush in the Brushes panel, or the panel menu's Brush Options…).
//! Calligraphic Brush Options: Name, then Angle, Roundness and Size, each Fixed, Random or
//! Pressure with its Variation, over live previews of the nib and of a stroke (light, heavy, light
//! pressure). Bristle Brush Options: Name, Shape and the bristle sliders. Scatter, Art and Pattern
//! Brush Options: see [`super::brush_art_options`]. OK runs `brush.options` (strokes painted with
//! the brush update); a name another brush has keeps the dialog open.
//!
//! Fields: `__brush` (the brush's name), `__type` (`calligraphic` | `bristle` | `scatter` | `art` |
//! `pattern`), `name`.
//! Calligraphic: `angle` (−180..180°), `roundness` (0..100 %), `size` (0..1296 pt), and per value
//! `angleMode`, `roundnessMode`, `sizeMode` (`fixed` | `random` | `pressure`) and
//! `angleVariation`, `roundnessVariation`, `sizeVariation`. Bristle: `shape` (`roundPoint`…),
//! `size` (pt), `length` (25..300 %), `density`, `thickness`, `opacity`, `stiffness` (1..100 %).
use egui::{Color32, Sense, Stroke, pos2, vec2};
use serde_json::{Map, Value, json};
use vectorcraft_brush::{Bristle, Brush, BrushKind, Calligraphic, Variation};
use super::swatch_options::{grid, label};
use super::{DialogSpec, brush_art_options, form};
use crate::state::Dialog;
use crate::theme::Tokens;
use crate::{VectorcraftApp, widgets};
/// The dialog kind of Brush Options.
pub const KIND: &str = "brushOptions";
pub(super) const SPEC: DialogSpec = DialogSpec { heading, body, confirm, min_width: 440.0, ..DialogSpec::FORM };
/// Calligraphic values: (field, label, suffix, decimals), in [`Calligraphic::RANGES`] order.
const CALLI: [(&str, &str, &str, usize); 3] = [("angle", "Angle:", "°", 0), ("roundness", "Roundness:", "%", 0), ("size", "Size:", " pt", 1)];
/// Bristle sliders: (field, label, range), in percent.
const BRISTLE: [(&str, &str, (f64, f64)); 5] = [
("length", "Bristle Length:", Bristle::LENGTH_RANGE),
("density", "Bristle Density:", Bristle::PERCENT_RANGE),
("thickness", "Bristle Thickness:", Bristle::PERCENT_RANGE),
("opacity", "Paint Opacity:", Bristle::PERCENT_RANGE),
("stiffness", "Stiffness:", Bristle::PERCENT_RANGE),
];
/// Bristle tip shapes: (value, label).
const SHAPES: [(&str, &str); 10] = [
("roundPoint", "Round Point"),
("flatPoint", "Flat Point"),
("roundBlunt", "Round Blunt"),
("flatBlunt", "Flat Blunt"),
("roundCurve", "Round Curve"),
("flatCurve", "Flat Curve"),
("roundAngle", "Round Angle"),
("flatAngle", "Flat Angle"),
("roundFan", "Round Fan"),
("flatFan", "Flat Fan"),
];
/// Width of the bristle dialog's label column.
const LABEL_W: f32 = 112.0;
/// Widths of the nib preview and of the whole preview row.
const NIB_W: f32 = 120.0;
const STROKE_W: f32 = 400.0;
fn heading(d: &Dialog) -> String {
match d.str("__type").as_str() {
"bristle" => tl!("Bristle Brush Options").into(),
"calligraphic" => tl!("Calligraphic Brush Options").into(),
ty => brush_art_options::heading(ty).into(),
}
}
pub(super) fn mode_key(key: &str) -> String {
format!("{key}Mode")
}
fn variation_key(key: &str) -> String {
format!("{key}Variation")
}
/// Open the options of brush `name` (default: the selected path's brush, else the current one).
pub fn open(app: &mut VectorcraftApp, name: Option<&str>) -> Result<Value, String> {
let name = match name {
Some(n) => n.to_string(),
None => target(app).ok_or("no brush selected")?,
};
let def = app.run("brush.get", json!({ "name": name }))?;
let b: Brush = serde_json::from_value(def.clone()).map_err(|e| e.to_string())?;
let mut f = Map::new();
f.insert("__brush".into(), json!(b.name));
f.insert("name".into(), json!(b.name));
f.insert("__type".into(), json!(b.kind.type_id()));
match &b.kind {
BrushKind::Calligraphic(c) => {
let values = [c.angle, c.roundness, c.size];
for (i, (key, ..)) in CALLI.iter().enumerate() {
let (Some(v), Some(var), Some(mode)) = (values.get(i), c.variation.get(i), c.modes().get(i).copied()) else { continue };
f.insert((*key).into(), json!(v));
f.insert(variation_key(key), json!(var));
f.insert(mode_key(key), json!(mode.id()));
}
}
BrushKind::Bristle(b) => {
let v = serde_json::to_value(b).map_err(|e| e.to_string())?;
for key in ["shape", "size"].into_iter().chain(BRISTLE.map(|r| r.0)) {
f.insert(key.into(), v.get(key).cloned().unwrap_or(Value::Null));
}
}
// The definition goes along, for the preview (and the art it carries).
BrushKind::Scatter(_) | BrushKind::Art(_) | BrushKind::Pattern(_) => brush_art_options::fields(&b, def, &mut f),
}
app.ui.dialog = Some(Dialog { kind: KIND.into(), fields: f });
Ok(Value::Null)
}
/// Can Brush Options open without a name (the selected path's brush, else the current one, exists)?
pub fn available(app: &VectorcraftApp) -> bool {
let Some(st) = app.session.active() else { return false };
target(app).is_some_and(|n| vectorcraft_brush::find(&st.doc, &n).is_some())
}
/// The brush Brush Options opens on without a name: the selected path's brush, else the current one.
fn target(app: &VectorcraftApp) -> Option<String> {
let st = app.session.active()?;
let selected = st.selection.subjects().first().and_then(|id| st.doc.node(*id)).and_then(|n| n.appearance.stroke()?.brush.clone());
selected.or_else(|| vectorcraft_brush::current(&st.doc))
}
/// The value of field `key` within `range`.
pub(super) fn clamped(d: &Dialog, key: &str, (lo, hi): (f64, f64)) -> f64 {
d.f64(key, lo).clamp(lo, hi)
}
/// The Calligraphic brush the fields describe.
fn calligraphic(d: &Dialog) -> Calligraphic {
let value = |i: usize| CALLI.get(i).zip(Calligraphic::RANGES.get(i)).map_or(0.0, |((key, ..), r)| clamped(d, key, *r));
let variation =
|i: usize| CALLI.get(i).zip(Calligraphic::MAX_VARIATION.get(i)).map_or(0.0, |((key, ..), max)| clamped(d, &variation_key(key), (0.0, *max)));
let mode = |i: usize| CALLI.get(i).and_then(|(key, ..)| Variation::parse(&d.str(&mode_key(key)))).unwrap_or_default();
Calligraphic { angle: value(0), roundness: value(1), size: value(2), variation: [0, 1, 2].map(variation), modes: Some([0, 1, 2].map(mode)) }
}
/// `brush.options` params from the fields.
fn params(d: &Dialog) -> Value {
match d.str("__type").as_str() {
"bristle" => {
let mut p = json!({ "shape": d.str("shape"), "size": clamped(d, "size", Bristle::SIZE_RANGE) });
for (key, _, range) in BRISTLE {
p[key] = json!(clamped(d, key, range));
}
p
}
"calligraphic" => serde_json::to_value(calligraphic(d)).unwrap_or_default(),
_ => brush_art_options::params(d),
}
}
fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
grid(ui, |ui| {
label(ui, tl!("Name:"));
form::text(ui, d, "name", 240.0);
ui.end_row();
});
ui.add_space(10.0);
match d.str("__type").as_str() {
"bristle" => bristle_body(app, ui, d),
"calligraphic" => calligraphic_body(ui, d),
_ => brush_art_options::body(ui, d),
}
false
}
fn calligraphic_body(ui: &mut egui::Ui, d: &mut Dialog) {
let c = calligraphic(d);
ui.horizontal(|ui| {
nib_preview(ui, &c, vec2(NIB_W, 84.0));
ui.add_space(8.0);
stroke_preview(ui, "calligraphic", serde_json::to_value(&c).unwrap_or_default(), vec2(STROKE_W - NIB_W - 8.0, 84.0));
});
ui.add_space(12.0);
egui::Grid::new("brush-options-calli").num_columns(5).spacing([8.0, 8.0]).show(ui, |ui| {
for (i, (key, text, suffix, decimals)) in CALLI.into_iter().enumerate() {
let (Some(&range), Some(&max)) = (Calligraphic::RANGES.get(i), Calligraphic::MAX_VARIATION.get(i)) else { continue };
label(ui, tl!(text));
if let Some(v) = widgets::range_field(ui, ("brush-value", key), clamped(d, key, range), range.0..=range.1, suffix, decimals, 64.0) {
d.fields.insert(key.into(), json!(v));
}
let mode = Variation::parse(&d.str(&mode_key(key))).unwrap_or_default();
let labels = Variation::ALL.map(Variation::label);
if let Some(m) = widgets::dropdown(ui, ("brush-mode", key), mode.label(), &labels, 104.0).and_then(|i| Variation::ALL.get(i)) {
d.fields.insert(mode_key(key), json!(m.id()));
}
label(ui, tl!("Variation:"));
let vkey = variation_key(key);
ui.add_enabled_ui(mode != Variation::Fixed, |ui| {
if let Some(v) = widgets::range_field(ui, ("brush-var", key), clamped(d, &vkey, (0.0, max)), 0.0..=max, suffix, decimals, 64.0) {
d.fields.insert(vkey.clone(), json!(v));
}
});
ui.end_row();
}
});
}
fn bristle_body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) {
form::choice(ui, d, "shape", tl!("Shape:"), (LABEL_W, 150.0), &SHAPES);
widgets::label_row(ui, tl!("Size:"), LABEL_W, |ui| {
form::length(ui, d, "size", app.session.general_unit(), 80.0);
});
let rail = Tokens::get(ui.ctx()).input_border;
for (key, text, (lo, hi)) in BRISTLE {
widgets::label_row(ui, tl!(text), LABEL_W, |ui| form::slider_field(ui, d, key, lo..=hi, "%", &|_| rail));
}
ui.add_space(8.0);
let p = params(d);
stroke_preview(ui, "bristle", p, vec2(STROKE_W, 64.0));
}
/// A stroke painted with the brush the fields describe (type `ty`, definition fields `def`).
pub(super) fn stroke_preview(ui: &mut egui::Ui, ty: &str, mut def: Value, size: egui::Vec2) {
def["name"] = json!("preview");
def["type"] = json!(ty);
let (r, _) = ui.allocate_exact_size(size, Sense::hover());
crate::panels::brushes::chip(ui, r, &def);
ui.painter().rect_stroke(r, 0.0, Stroke::new(1.0, Tokens::get(ui.ctx()).input_border), egui::StrokeKind::Inside);
}
/// The nib of `c` at its values (black), and — for values that vary — at their lightest and
/// heaviest (grey), on white like the brush previews, its largest nib filling the box.
fn nib_preview(ui: &mut egui::Ui, c: &Calligraphic, size: egui::Vec2) {
let t = Tokens::get(ui.ctx());
let (r, _) = ui.allocate_exact_size(size, Sense::hover());
let p = ui.painter_at(r);
p.rect_filled(r, 0.0, Color32::WHITE);
let guide = Stroke::new(1.0, Color32::from_gray(220));
p.line_segment([pos2(r.left(), r.center().y), pos2(r.right(), r.center().y)], guide);
p.line_segment([pos2(r.center().x, r.top()), pos2(r.center().x, r.bottom())], guide);
let values = [c.angle, c.roundness, c.size];
let at = |sign: f64| -> [f64; 3] {
let mut v = values;
for (i, (x, mode)) in v.iter_mut().zip(c.modes()).enumerate() {
let (Some(&var), Some(&(lo, hi))) = (c.variation.get(i), Calligraphic::RANGES.get(i)) else { continue };
if mode != Variation::Fixed {
*x = (*x + sign * var).clamp(lo, hi);
}
}
v
};
let nibs = [at(-1.0), at(1.0), values];
let largest = nibs.iter().map(|n| n[2]).fold(0.0, f64::max).max(1e-6);
let scale = (r.height().min(r.width()) - 12.0) / largest as f32;
for (k, [angle, round, size]) in nibs.into_iter().enumerate() {
let fill = if k < 2 { Color32::from_gray(200) } else { Color32::BLACK };
let (a, b) = ((size as f32 * scale / 2.0).max(0.5), (size * round.clamp(1.0, 100.0) / 100.0) as f32 * scale / 2.0);
let th = (angle as f32).to_radians();
let (u, v) = (vec2(th.cos(), -th.sin()), vec2(th.sin(), th.cos()));
let pts: Vec<egui::Pos2> = (0..48)
.map(|i| {
let s = i as f32 / 48.0 * std::f32::consts::TAU;
r.center() + u * (a * s.cos()) + v * (b.max(0.5) * s.sin())
})
.collect();
p.add(egui::Shape::convex_polygon(pts, fill, Stroke::NONE));
}
p.rect_stroke(r, 0.0, Stroke::new(1.0, t.input_border), egui::StrokeKind::Inside);
}
/// OK: edit the brush (and rename it). A name another brush has keeps the dialog open.
fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
let name = d.str("name").trim().to_string();
let r = app.run("brush.options", json!({ "name": d.str("__brush"), "newName": name, "params": params(d) }));
if r.is_ok() {
app.ui.dialog = None;
}
r
}
#[cfg(test)]
mod tests {
use super::*;
use vectorcraft_engine::Session;
fn app() -> VectorcraftApp {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 300, "height": 200})).unwrap();
app
}
fn set(app: &mut VectorcraftApp, fields: Value) {
let d = app.ui.dialog.as_mut().unwrap();
for (k, v) in fields.as_object().unwrap() {
d.fields.insert(k.clone(), v.clone());
}
}
fn shown(app: &mut VectorcraftApp) -> String {
crate::tests_labels::painted_text(app, |app, ui| super::super::show(app, ui.ctx()))
}
#[test]
fn calligraphic_options_show_each_value_with_its_variation_and_ok_applies_them() {
let mut app = app();
app.run("ui.brushOptions", json!({"name": "6 pt. Flat"})).unwrap();
let text = shown(&mut app);
for label in ["Calligraphic Brush Options", "Name:", "Angle:", "Roundness:", "Size:", "Variation:", "Fixed", "OK", "Cancel"] {
assert!(text.contains(label), "{label} in {text}");
}
let d = app.ui.dialog.as_ref().unwrap();
assert_eq!((d.f64("angle", 0.0), d.f64("roundness", 0.0), d.f64("size", 0.0)), (45.0, 15.0, 6.0));
// Out-of-range values are kept within their ranges.
set(&mut app, json!({"angleMode": "random", "angleVariation": 500, "roundness": 140, "sizeMode": "pressure", "sizeVariation": 4}));
assert!(shown(&mut app).contains("Pressure"));
super::super::confirm(&mut app).unwrap();
let def = app.run("brush.get", json!({"name": "6 pt. Flat"})).unwrap();
assert_eq!(def["modes"], json!(["random", "fixed", "pressure"]));
assert_eq!((def["variation"].clone(), def["roundness"].clone()), (json!([180.0, 0.0, 4.0]), json!(100.0)));
// Opened again, it shows what was set.
app.run("ui.brushOptions", json!({"name": "6 pt. Flat"})).unwrap();
let d = app.ui.dialog.as_ref().unwrap();
assert_eq!((d.str("angleMode"), d.f64("sizeVariation", 0.0)), ("random".into(), 4.0));
}
#[test]
fn a_taken_name_keeps_the_dialog_open() {
let mut app = app();
app.run("ui.brushOptions", json!({"name": "6 pt. Flat"})).unwrap();
set(&mut app, json!({"name": "3 pt. Round"}));
assert!(super::super::confirm(&mut app).is_err());
assert!(app.ui.dialog.is_some());
}
#[test]
fn bristle_options_and_an_unknown_brush() {
let mut app = app();
app.run("ui.brushOptions", json!({"name": "Bristle Round"})).unwrap();
let text = shown(&mut app);
for label in ["Bristle Brush Options", "Shape:", "Bristle Length:", "Stiffness:", "Paint Opacity:"] {
assert!(text.contains(label), "{label} in {text}");
}
set(&mut app, json!({"shape": "flatFan", "density": 0, "length": 120}));
super::super::confirm(&mut app).unwrap();
let def = app.run("brush.get", json!({"name": "Bristle Round"})).unwrap();
assert_eq!((def["shape"].clone(), def["density"].clone(), def["length"].clone()), (json!("flatFan"), json!(1.0), json!(120.0)));
// No such brush: an error, nothing opens.
app.ui.dialog = None;
assert!(app.run("ui.brushOptions", json!({"name": "Nope"})).is_err());
assert!(app.ui.dialog.is_none());
}
/// A path painted with brush `name`, and the height of the art its stroke draws.
fn brushed(app: &mut VectorcraftApp, name: &str) -> (vectorcraft_doc::NodeId, f64) {
let id = app.run("shape.line", json!({"x1": 20, "y1": 100, "x2": 280, "y2": 100})).unwrap()["id"].as_u64().unwrap();
let id = vectorcraft_doc::NodeId(id);
app.run("brush.apply", json!({ "name": name, "ids": [id.0] })).unwrap();
(id, drawn_height(app, id))
}
fn drawn_height(app: &VectorcraftApp, id: vectorcraft_doc::NodeId) -> f64 {
let st = app.session.active().unwrap();
let pieces = vectorcraft_brush::node_pieces(&st.doc, st.doc.node(id).unwrap()).unwrap();
vectorcraft_brush::pieces_bounds(&pieces).unwrap().height()
}
#[test]
fn scatter_options_show_each_value_with_its_mode_and_ok_applies_them() {
let mut app = app();
let (id, before) = brushed(&mut app, "Confetti");
app.run("ui.brushOptions", json!({"name": "Confetti"})).unwrap();
let text = shown(&mut app);
for label in [
"Scatter Brush Options",
"Name:",
"Size:",
"Spacing:",
"Scatter:",
"Rotation:",
"Random",
"Rotation relative to:",
"Page",
"Colorization",
"Method:",
"Tints",
"Key Color:",
] {
assert!(text.contains(label), "{label} in {text}");
}
let d = app.ui.dialog.as_ref().unwrap();
assert_eq!((d.f64("sizeMin", 0.0), d.f64("sizeMax", 0.0), d.str("sizeMode")), (60.0, 140.0, "random".into()));
assert_eq!((d.str("rotationRelativeTo"), d.str("colorization")), ("page".into(), "tints".into()));
// Values are kept within their ranges (rotation here); Fixed takes the first value.
set(
&mut app,
json!({"sizeMode": "fixed", "sizeMin": 5000, "spacingMode": "pressure", "rotationMin": -400, "rotationRelativeTo": "path", "colorization": "hueShift"}),
);
assert!(shown(&mut app).contains("Hue Shift"));
super::super::confirm(&mut app).unwrap();
assert!(app.ui.dialog.is_none());
let def = app.run("brush.get", json!({"name": "Confetti"})).unwrap();
assert_eq!(
(&def["size"], &def["rotation"][0], &def["modes"]),
(&json!([5000.0, 140.0]), &json!(-180.0), &json!(["fixed", "pressure", "random", "random"]))
);
assert_eq!((&def["rotation_relative_to_path"], &def["colorization"]["method"]), (&json!(true), &json!("hueShift")));
assert!(def["colorization"]["key"].is_object(), "the art's colour as the key: {def}");
assert!(drawn_height(&app, id) > before, "the stroke redraws with the bigger confetti");
// Opened again, it shows what was set.
app.run("ui.brushOptions", json!({"name": "Confetti"})).unwrap();
let d = app.ui.dialog.as_ref().unwrap();
assert_eq!((d.str("sizeMode"), d.str("spacingMode"), d.str("colorization")), ("fixed".into(), "pressure".into(), "hueShift".into()));
}
#[test]
fn art_options_set_width_scale_direction_flips_and_colorization() {
let mut app = app();
let (id, before) = brushed(&mut app, "Arrow");
app.run("ui.brushOptions", json!({"name": "Arrow"})).unwrap();
let text = shown(&mut app);
for label in [
"Art Brush Options",
"Width:",
"Brush Scale Options",
"Scale Proportionately",
"Stretch to Fit Stroke Length",
"Stretch Between Guides",
"Start:",
"End:",
"Direction:",
"Flip Along",
"Flip Across",
"Colorization",
] {
assert!(text.contains(label), "{label} in {text}");
}
let d = app.ui.dialog.as_ref().unwrap();
assert_eq!(
(d.f64("width", 0.0), d.str("scaleMode"), d.f64("guideEnd", 0.0), d.str("direction")),
(100.0, "betweenGuides".into(), 78.0, "leftToRight".into())
);
set(
&mut app,
json!({"width": 250, "guideStart": 10, "guideEnd": 140, "direction": "bottomToTop", "flipAcross": true, "colorization": "none"}),
);
super::super::confirm(&mut app).unwrap();
let def = app.run("brush.get", json!({"name": "Arrow"})).unwrap();
assert_eq!(
(&def["width"], &def["scale"], &def["direction"]),
(&json!(250.0), &json!({"mode": "betweenGuides", "start": 0.1, "end": 1.0}), &json!("bottomToTop"))
);
assert_eq!((&def["flip_across"], &def["flip_along"], &def["colorization"]), (&json!(true), &json!(false), &json!({"method": "none"})));
assert!(drawn_height(&app, id) != before, "the stroke redraws");
// Stretch to Fit drops the guides.
app.run("ui.brushOptions", json!({"name": "Arrow"})).unwrap();
set(&mut app, json!({"scaleMode": "stretch"}));
super::super::confirm(&mut app).unwrap();
assert_eq!(app.run("brush.get", json!({"name": "Arrow"})).unwrap()["scale"], json!({"mode": "stretch"}));
}
#[test]
fn pattern_options_show_the_tiles_and_set_scale_spacing_fit() {
let mut app = app();
app.run("ui.brushOptions", json!({"name": "Chain"})).unwrap();
let text = shown(&mut app);
for label in [
"Pattern Brush Options",
"Scale:",
"Spacing:",
"Outer Corner Tile",
"Side Tile",
"Inner Corner Tile",
"Start Tile",
"End Tile",
"None",
"Fit",
"Stretch to fit",
"Add space to fit",
"Approximate path",
"Flip Along",
] {
assert!(text.contains(label), "{label} in {text}");
}
let d = app.ui.dialog.as_ref().unwrap();
let mut tiles: Vec<&String> = d.fields["__tiles"].as_object().unwrap().keys().collect();
tiles.sort();
assert_eq!(tiles, ["innerCorner", "outerCorner", "side"], "Chain has side and corner tiles, no start or end");
// Diamonds (straight-edged tiles, so sizes compare exactly).
let (id, before) = brushed(&mut app, "Diamonds");
app.run("ui.brushOptions", json!({"name": "Diamonds"})).unwrap();
assert_eq!((app.ui.dialog.as_ref().unwrap().f64("spacing", 0.0), app.ui.dialog.as_ref().unwrap().str("fit")), (25.0, "addSpace".into()));
set(&mut app, json!({"scale": 200, "spacing": 30, "fit": "approximate", "flipAlong": true}));
super::super::confirm(&mut app).unwrap();
let def = app.run("brush.get", json!({"name": "Diamonds"})).unwrap();
assert_eq!(
(&def["scale"], &def["spacing"], &def["fit"], &def["flip_along"]),
(&json!(200.0), &json!(30.0), &json!("approximate"), &json!(true))
);
assert!((drawn_height(&app, id) / before - 2.0).abs() < 1e-6, "tiles twice the size");
}
}
+5 -2
View File
@@ -1,5 +1,5 @@
//! The generic parameter dialog (`ui.paramDialog`): edits a command's parameters (`__command`,
//! headed `__label`) and runs it on OK.
//! headed `__label`) and runs it on OK, with the fixed ones in `__params` (not shown) added.
use serde_json::Value;
@@ -60,7 +60,10 @@ const VERTICAL_ALIGN: form::Choices = &[("Top", "top"), ("Center", "center"), ("
/// Closes before running, so a dialog the command opens stays open.
fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
let cmd = d.str("__command");
let params = form::params(d);
let mut params = form::params(d);
if let (Some(o), Some(Value::Object(fixed))) = (params.as_object_mut(), d.fields.get("__params")) {
o.extend(fixed.clone());
}
app.ui.dialog = None;
// A tool's click-to-size shape (Flare) goes on the active perspective plane while the grid shows.
let at = |x: &str, y: &str| Some(vectorcraft_geom::Point::new(params.get(x)?.as_f64()?, params.get(y)?.as_f64()?));
+10 -6
View File
@@ -63,12 +63,7 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
label(ui, tl!("Corner:"));
ui.vertical(|ui| {
for k in CornerKind::ALL {
let name = match k {
CornerKind::Round => tl!("Round"),
CornerKind::InvertedRound => tl!("Inverted Round"),
CornerKind::Chamfer => tl!("Chamfer"),
};
if widgets::radio(ui, name, Some(k) == current, true) {
if widgets::radio(ui, kind_label(k), Some(k) == current, true) {
d.fields.insert("kind".into(), json!(k));
}
}
@@ -81,6 +76,15 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
false
}
/// A corner kind's name, in the UI language.
pub(crate) fn kind_label(k: CornerKind) -> &'static str {
match k {
CornerKind::Round => tl!("Round"),
CornerKind::InvertedRound => tl!("Inverted Round"),
CornerKind::Chamfer => tl!("Chamfer"),
}
}
fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
let mut p = json!({"id": d.fields.get("id"), "corners": d.fields.get("corners")});
if let Some(k) = d.fields.get("kind") {
+40 -3
View File
@@ -11,7 +11,7 @@ use serde_json::{Map, Value, json};
use vectorcraft_color::Color;
use super::{DialogSpec, form};
use super::{DialogSpec, form, transform_each};
use crate::panels::c32;
use crate::state::Dialog;
use crate::theme::Tokens;
@@ -131,6 +131,7 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
let changed = match vectorcraft_plugins::effect::installed(&id) {
Some(plugin) => form::schema_fields(ui, d, &plugin.manifest().params),
None if vectorcraft_effects::is_adjustment(&id) => adjust_fields(ui, d),
None if id == TRANSFORM => transform_fields(ui, d, app.session.general_unit()),
None => {
let doc = vectorcraft_effects::effect_info(&id).map(|e| e.params).unwrap_or_default();
let unit = app.session.general_unit();
@@ -138,8 +139,8 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
}
};
ui.add_space(6.0);
let mut pv = d.bool("preview");
let pv_changed = ui.checkbox(&mut pv, tl!("Preview")).changed();
let pv_changed = widgets::check(ui, tl!("Preview"), d.bool("preview"), true);
let pv = d.bool("preview") != pv_changed;
d.fields.insert("preview".into(), json!(pv));
if pv && (changed || pv_changed || !app.session.in_interaction()) {
let label = d.str("__label");
@@ -158,6 +159,12 @@ fn choices(effect: &str, key: &str) -> Option<form::Choices> {
("blur.radial", "method") => Some(&[("Spin", "spin"), ("Zoom", "zoom")]),
("blur.radial", "quality") => Some(&[("Draft", "draft"), ("Good", "good"), ("Best", "best")]),
("blur.smart", "quality") => Some(&[("Low", "low"), ("Medium", "medium"), ("High", "high")]),
("pixelate.mezzotint", "type") => Some(&vectorcraft_effects::pixel::MEZZOTINT_TYPES),
("texture.grain", "grainType") => Some(&vectorcraft_effects::pixel::GRAIN_TYPES),
("texture.texturizer", "texture") => Some(&vectorcraft_effects::pixel::TEXTURES),
("texture.texturizer", "lightDirection") => Some(&vectorcraft_effects::pixel::LIGHT_DIRECTIONS),
("video.deinterlace", "eliminate") => Some(&[("Odd Fields", "odd"), ("Even Fields", "even")]),
("video.deinterlace", "create") => Some(&[("Duplication", "duplication"), ("Interpolation", "interpolation")]),
("path.offsetPath", "joins") => Some(&[("Miter", "miter"), ("Round", "round"), ("Bevel", "bevel")]),
("distort.roughen" | "distort.zigZag", "points") => Some(&[("Smooth", "smooth"), ("Corner", "corner")]),
_ => None,
@@ -261,12 +268,42 @@ fn adjust_fields(ui: &mut egui::Ui, d: &mut Dialog) -> bool {
form::check(ui, d, key, label);
}
}
values_changed(d)
}
/// Do the dialog's values differ from those last previewed? (Remembers them for the next frame.)
fn values_changed(d: &mut Dialog) -> bool {
let params = form::params(d);
let changed = d.fields.get(form::PREVIEWED) != Some(&params);
d.fields.insert(form::PREVIEWED.into(), params);
changed
}
/// The Transform effect (Effect › Distort & Transform › Transform…).
const TRANSFORM: &str = "distort.transform";
/// The Transform effect's dialog: Transform Each's Scale and Move sliders and Rotate dial, then
/// Copies, the reflections, the reference point and Random. Returns whether the values differ from
/// those last previewed.
fn transform_fields(ui: &mut egui::Ui, d: &mut Dialog, unit: vectorcraft_doc::Unit) -> bool {
ui.horizontal_top(|ui| {
ui.vertical(|ui| transform_each::sections(ui, d, unit));
ui.add_space(16.0);
ui.vertical(|ui| {
widgets::subheader(ui, tl!("Options"));
ui.add_space(4.0);
ui.horizontal(|ui| {
widgets::dim_label(ui, tl!("Copies:"));
if let Some(n) = widgets::range_field(ui, "fx-copies", d.f64("copies", 0.0), 0.0..=1000.0, "", 0, 50.0) {
d.fields.insert("copies".into(), json!(n));
}
});
transform_each::reflect_options(ui, d);
});
});
values_changed(d)
}
/// Curves' graph of its `points` (input across, output up, 0..255): drag a point to move it
/// between its neighbours, press on the graph away from the points to add one there, drag one
/// off the graph to remove it (two always stay).
Loaded 100 of 144 files, more files were not shown because too many files have changed in this diff. Show more