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
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
65 changed files with 4948 additions and 496 deletions

No files matched your search

Generated
+1
View File
@@ -4499,6 +4499,7 @@ dependencies = [
"vectorcraft-effects",
"vectorcraft-geom",
"vectorcraft-metafile",
"vectorcraft-pdf",
"vectorcraft-render",
"vectorcraft-testkit",
"vectorcraft-text",
+2 -2
View File
@@ -201,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
+19 -15
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,7 +35,7 @@ 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 (~10 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 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 |
@@ -53,13 +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 45 filters (Artistic, Brush Strokes, Distort,
Sketch, Stylize, Texture) 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), Pixelate › Color Halftone, Crystallize, Mezzotint and Pointillize, Sharpen › Unsharp Mask,
Stylize › Glowing Edges and Video › De-Interlace and NTSC Colors; each new filter is now one function, a catalogue row, menu and translations. Parallelizes
well across agents. 23–36 h.
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.
@@ -68,10 +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 (Scatter, Art and Pattern Brush Options; Calligraphic
and Bristle are done, Calligraphic with pen pressure); 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.
@@ -138,7 +142,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
- **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. 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 Design, Column and Marker artwork remain open.
- **Graphs:** Column, Stacked Column, Bar, Stacked Bar, Line, Area, Scatter, Pie and Radar graph tools with Graph Data and Graph Type. A series starts in greyscale; Group Selection's fill and stroke are kept separately on the graph and reapplied when its data, type or size changes, and through save, open and undo. Graph Type's Override Calculated Values (both `axisMin` and `axisMax`) uses exactly that value-axis range, split into the tick count (5 when automatic). Line graphs take Edge-to-Edge Lines (off by default: the points sit at the centres of their categories; graphs saved before the option keep their edge-to-edge lines), and radar graphs follow Mark Data Points and Connect Data Points. Blank cells (an empty CSV cell or `null`) are values left out, not zeros: columns, bars and stacked segments skip them, lines and scatter lines break around them and the value axis ignores them, while area, pie and radar draw them as 0; a number in straight quotes is a label, and the data window writes it back quoted. Graph 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.
@@ -160,7 +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. Still to come: Scatter, Art and Pattern Brush Options, Stylus Wheel, Tilt, Bearing and Rotation, and pen pressure on macOS.
- **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.
@@ -220,10 +224,10 @@ 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 | 89% | Scatter, Art and Pattern Brush Options (Calligraphic and Bristle 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) | 10–18 |
| 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, the Transform effect's Transform Objects/Patterns and Scale Strokes & Effects options | 6–10 |
| Raster effects (Effect Gallery, Document Raster Effects Settings) | 4 | 29% | ~45 Photoshop-style filters (Artistic, Brush Strokes, Distort, Sketch, Stylize, Texture), 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, Glowing Edges, Pixelate (Color Halftone, Crystallize, Mezzotint, Pointillize) and Video (De-Interlace, NTSC Colors) are done | 23–36 |
| 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 |
@@ -237,7 +241,7 @@ about 1–1.5 agent-hours each, so the other rows (estimated on the older scale)
| 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 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 | 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 |
| 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 |
+3
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
+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()
}
+2
View File
@@ -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 {
+167 -10
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)]
@@ -181,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 {
@@ -193,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")]
@@ -236,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 {
@@ -286,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 {
@@ -340,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.
@@ -356,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.
@@ -404,15 +566,10 @@ pub fn stroke_pieces(brush: &Brush, bp: &BezPath, stroke: &StrokeLayer) -> Vec<N
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);
}
+74
View File
@@ -447,3 +447,77 @@ fn junk_pressure_brushes_draw_finite_art() {
}
}
}
/// 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.
+46 -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")]
@@ -93,6 +100,10 @@ pub struct GraphSpec {
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).
@@ -103,14 +114,46 @@ 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 {
@@ -120,6 +163,7 @@ impl Default for GraphSpec {
series: vec![],
categories: vec![],
rows: vec![vec![1.0]],
blanks: vec![],
column_width: 90.0,
cluster_width: 80.0,
legend: true,
@@ -128,6 +172,7 @@ impl Default for GraphSpec {
axis_max: None,
mark_points: true,
connect_points: true,
edge_to_edge: false,
placed: None,
}
}
+1 -1
View File
@@ -65,7 +65,7 @@ pub use appearance::{
};
pub use assets::ExportAsset;
pub use corners::LiveCorners;
pub use graph::{GraphKind, GraphSpec, SeriesPaint};
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};
+47
View File
@@ -130,6 +130,10 @@ fn lengths_of(id: &str) -> Lengths {
"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(),
}
}
@@ -144,6 +148,7 @@ 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"];
@@ -332,6 +337,48 @@ pub fn effect_catalog() -> Vec<EffectInfo> {
"{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…",
+95 -6
View File
@@ -1,6 +1,7 @@
//! Photoshop-style raster effects (Effect › Blur › Radial Blur and Smart Blur, Pixelate › Color
//! Halftone, Crystallize, Mezzotint and Pointillize, Sharpen › Unsharp Mask, Stylize › Glowing
//! Edges, and Video › De-Interlace and NTSC Colors): filters over premultiplied RGBA8 pixels.
//! 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
@@ -13,17 +14,19 @@ 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; 10] = [
pub const PIXEL_EFFECTS: [&str; 16] = [
"blur.radial",
"blur.smart",
"pixelate.colorHalftone",
@@ -32,6 +35,12 @@ pub const PIXEL_EFFECTS: [&str; 10] = [
"pixelate.pointillize",
"sharpen.unsharpMask",
"stylize.glowingEdges",
"texture.craquelure",
"texture.grain",
"texture.mosaicTiles",
"texture.patchwork",
"texture.stainedGlass",
"texture.texturizer",
"video.deinterlace",
"video.ntscColors",
];
@@ -62,6 +71,24 @@ pub enum PixelFx {
/// 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 },
@@ -100,6 +127,9 @@ pub struct PixelSpace {
/// 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.
@@ -137,6 +167,36 @@ pub(crate) fn parse(id: &str, p: &Value) -> Option<PixelFx> {
"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"),
@@ -159,9 +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::DeInterlace { .. } | PixelFx::NtscColors => 0.0,
// A crystal's or dot's point inside the object reaches out by up to its cell.
PixelFx::Crystallize { cell } | PixelFx::Pointillize { cell } => 1.5 * cell,
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),
}
}
@@ -179,6 +248,12 @@ impl PixelFx {
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),
@@ -199,12 +274,26 @@ impl PixelFx {
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>();
+66 -49
View File
@@ -8,7 +8,7 @@ use std::sync::OnceLock;
use vectorcraft_geom::{Affine, Point};
use super::{Channels, PixelSpace, gaussian, to16};
use super::{Channels, PixelSpace, Px16, gaussian, pick, to16};
use crate::util::noise;
/// Mezzotint's patterns, in its Type menu's order: (label, parameter value).
@@ -62,7 +62,7 @@ impl Mezzotint {
/// The pattern of Type `value` (a [`MEZZOTINT_TYPES`] value, any case); Fine Dots otherwise.
pub fn parse(value: &str) -> Self {
MEZZOTINT_TYPES.iter().zip(MEZZOTINT_KINDS).find(|((_, v), _)| v.eq_ignore_ascii_case(value)).map_or(Self::FINE_DOTS, |(_, kind)| kind)
pick(&MEZZOTINT_TYPES, &MEZZOTINT_KINDS, value, Self::FINE_DOTS)
}
}
@@ -70,48 +70,48 @@ impl Mezzotint {
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.
fn rand(salt: u64, i: i64, j: i64, k: u64) -> f64 {
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).
fn cell(v: f64) -> Option<i64> {
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.
struct Place {
pub(super) struct Place {
/// Pixel coordinates → document coordinates relative to the centre.
to_rel: Affine,
pub(super) to_rel: Affine,
/// The other way.
to_px: Affine,
pub(super) to_px: Affine,
/// A pixel's size in document units.
px: f64,
pub(super) px: f64,
w: usize,
h: usize,
}
impl Place {
fn new(space: &PixelSpace, w: usize, h: usize) -> Option<Self> {
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`).
fn at(&self, x: usize, y: usize) -> Point {
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.
fn index(&self, p: Point) -> Option<usize> {
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.
fn each(&self, px: &mut [[u8; 4]], mut f: impl FnMut(usize, usize, &mut [u8; 4])) {
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);
@@ -122,7 +122,7 @@ impl Place {
/// 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.
const AROUND: [(i64, i64); 25] = [
pub(super) const AROUND: [(i64, i64); 25] = [
(0, 0),
(-1, 0),
(1, 0),
@@ -152,18 +152,18 @@ const AROUND: [(i64, i64); 25] = [
/// What the cells [`AROUND`] one cell hold, made when first asked for and kept while consecutive
/// pixels stay in that cell.
struct Around<T> {
pub(super) struct Around<T> {
cell: Option<(i64, i64)>,
items: [Option<T>; 25],
}
impl<T: Copy> Around<T> {
fn new() -> Self {
pub(super) fn new() -> Self {
Self { cell: None, items: [None; 25] }
}
/// Item `k` of [`AROUND`] cell (`i`, `j`): `make(cell i', j')`.
fn get(&mut self, (i, j): (i64, i64), k: usize, make: impl FnOnce(i64, i64) -> T) -> Option<T> {
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];
@@ -175,10 +175,26 @@ impl<T: Copy> Around<T> {
}
/// The random point of cell (`i`, `j`) of pattern `salt`, in cells.
fn point_in(salt: u64, i: i64, j: i64) -> Point {
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) {
@@ -188,8 +204,6 @@ pub(super) fn crystallize(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelS
}
let src = px.to_vec();
let mut around = Around::new();
// How far cell `c` lies from coordinate `t` (in cells) along one axis.
let gap = |c: i64, t: f64| (c as f64 - t).max(t - c as f64 - 1.0).max(0.0);
place.each(px, |x, y, out| {
let q = place.at(x, y);
let (u, v) = (q.x / cell_size, q.y / cell_size);
@@ -215,6 +229,34 @@ pub(super) fn crystallize(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelS
});
}
/// 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)]
@@ -237,20 +279,10 @@ pub(super) fn pointillize(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelS
}
// 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 alpha: Vec<u8> = px.iter().map(|p| p[3]).collect();
let mut soft = to16(px);
gaussian(&mut soft, w, cell_size / place.px / 4.0);
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 k = place.index(Point::new(at.x * cell_size, at.y * cell_size));
let a = k.and_then(|k| alpha.get(k)).map_or(0.0, |a| f32::from(*a) / 255.0);
let colour = match k.and_then(|k| soft.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],
};
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();
@@ -499,19 +531,6 @@ pub(super) fn mezzotint(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpa
/// 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 smooth = |t: f64| t * t * (3.0 - 2.0 * t);
// Random values at whole coordinates, blended smoothly between them along x (and y).
let blend = |x: f64, y: f64, along_y: bool| {
let (Some(i), Some(j)) = (cell(x), cell(y)) else { return 0.5 };
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;
if !along_y {
return top;
}
let bottom = at(0, 1) + (at(1, 1) - at(0, 1)) * fx;
top + (bottom - top) * fy
};
let grain = |size: f64| match (cell(q.x / size), cell(q.y / size)) {
(Some(i), Some(j)) => rand(salt, i, j, 1),
_ => 0.5,
@@ -520,12 +539,10 @@ fn threshold(kind: Mezzotint, salt: u64, q: Point) -> f64 {
Mezzotint::Dots { size, smooth: false, .. } => grain(f64::from(size)),
Mezzotint::Dots { size, grainy, .. } => {
let s = f64::from(size);
let blob = blend(q.x / s, q.y / s, true);
let blob = value_noise(salt, q.x / s, q.y / s);
if grainy { 0.5 * (blob + grain(1.0)) } else { blob }
}
Mezzotint::Lines { length, width } => {
let row = (q.y / f64::from(width)).floor();
blend(q.x / f64::from(length), row, false)
}
// Blended along x only: whole rows.
Mezzotint::Lines { length, width } => value_noise(salt, q.x / f64::from(length), (q.y / f64::from(width)).floor()),
}
}
+306 -5
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), channels: Channels::Rgb, line: 1.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,8 +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, channels: Channels::Rgb, line: 1.0 };
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()
};
@@ -254,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), channels: Channels::Rgb, line: 1.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"})),
@@ -268,6 +282,14 @@ fn pixel_timings() {
("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();
@@ -525,3 +547,282 @@ fn ntsc_colors_tame_only_colours_too_strong_for_the_signal() {
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);
});
}
+10 -4
View File
@@ -63,7 +63,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Brush Options…",
[],
None,
"{name, params?: {…fields to change (brush.get shows them; 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)}, 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)?;
+3 -1
View File
@@ -652,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)))
+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");
}
+362 -69
View File
@@ -23,7 +23,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Create Graph",
[],
None,
"{type: column|stackedColumn|bar|stackedBar|line|area|scatter|pie|radar, x, y, width, height, series?: [..], categories?: [..], rows?: [[..]], csv?} → {id}",
"{type: column|stackedColumn|bar|stackedBar|line|area|scatter|pie|radar, x, y, width, height, series?: [..], categories?: [..], rows?: [[number|null]], csv?} → {id}; an empty CSV cell or a null is a blank value, a number in straight quotes is a label",
has_doc,
create
),
@@ -32,7 +32,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Data…",
["Object", "Graph"],
None,
"{id?, series?, categories?, rows?, csv?: first row = series labels (first cell empty), then one row per category: label, values…} replace the graph's data; no data → the current {csv, series, categories, rows}",
"{id?, series?, categories?, rows?, csv?: first row = series labels (first cell empty), then one row per category: label, values…; an empty cell or a null is a blank value, a quoted number a label} replace the graph's data; no data → the current {csv, series, categories, rows}",
has_selection,
set_data
),
@@ -41,7 +41,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Type…",
["Object", "Graph"],
None,
"{id?, type?, columnWidth?: %, clusterWidth?: %, legend?: bool, markPoints?: bool, connectPoints?: bool, ticks?: n, axisMin?, axisMax?} change the graph type and options; no options → the current ones",
"{id?, type?, columnWidth?: %, clusterWidth?: %, legend?: bool, markPoints?: bool, connectPoints?: bool, edgeToEdge?: bool (line graphs: true runs the lines across the whole plot, false puts the points at the centres of their categories), ticks?: n, axisMin?, axisMax?} change the graph type and options (axisMin and axisMax together override the calculated value axis: exactly that range in `ticks` divisions, 5 when 0); no options → the current ones",
has_selection,
set_type
),
@@ -50,46 +50,83 @@ pub fn specs() -> Vec<CommandSpec> {
// ---------- data ----------
fn parse_csv(csv: &str) -> (Vec<String>, Vec<String>, Vec<Vec<f64>>) {
let rows: Vec<Vec<String>> = csv
/// One CSV cell: its text, and whether it was in straight quotes (a quoted number is a label, not a value).
struct Cell {
text: String,
quoted: bool,
}
impl Cell {
fn number(&self) -> Option<f64> {
if self.quoted { None } else { self.text.parse::<f64>().ok().filter(|v| v.is_finite()) }
}
}
fn parse_csv(csv: &str) -> (Vec<String>, Vec<String>, Vec<Vec<Option<f64>>>) {
let rows: Vec<Vec<Cell>> = csv
.lines()
.map(|l| l.trim_end_matches('\r'))
.filter(|l| !l.trim().is_empty())
.map(|l| {
let sep = if l.contains('\t') { '\t' } else { ',' };
l.split(sep).map(|c| c.trim().trim_matches('"').to_string()).collect()
l.split(sep)
.map(|c| {
let c = c.trim();
let quoted = c.len() >= 2 && c.starts_with('"') && c.ends_with('"');
Cell { text: c.trim_matches('"').to_string(), quoted }
})
.collect()
})
.collect();
let Some(first) = rows.first() else { return (vec![], vec![], vec![]) };
// A header row has a non-numeric cell after the first one (or an empty first cell).
let header = first.first().is_some_and(|c| c.is_empty()) || first.iter().skip(1).any(|c| c.parse::<f64>().is_err());
let series = if header { first.iter().skip(1).cloned().collect() } else { vec![] };
// A label is quoted or doesn't read as a number; an empty unquoted cell is a blank value.
let label = |c: &Cell| c.quoted || (c.number().is_none() && !c.text.is_empty());
// A header row has an empty first cell or a label after it, or is a label followed only by empty cells (`Year,,`).
// All-empty series names mean no legend.
let empty = |c: &Cell| c.text.is_empty() && !c.quoted;
let header = first.first().is_some_and(empty)
|| first.iter().skip(1).any(label)
|| (first.len() > 1 && first.first().is_some_and(label) && first.iter().skip(1).all(empty));
let mut series: Vec<String> = if header { first.iter().skip(1).map(|c| c.text.clone()).collect() } else { vec![] };
if series.iter().all(String::is_empty) {
series.clear();
}
let data = rows.get(usize::from(header)..).unwrap_or_default();
// The first column holds the category labels if any row starts with one; then it does for every row, so a
// row with an empty label doesn't shift its values one place left.
let labelled = data.iter().any(|r| r.first().is_some_and(label));
let mut categories = vec![];
let mut values = vec![];
for r in rows.iter().skip(header as usize) {
let labelled = r.first().is_some_and(|c| c.parse::<f64>().is_err());
categories.push(if labelled { r[0].clone() } else { String::new() });
values.push(r.iter().skip(labelled as usize).map(|c| c.parse::<f64>().unwrap_or(0.0)).collect());
}
if categories.iter().all(String::is_empty) {
categories.clear();
for r in data {
if labelled {
categories.push(r.first().map(|c| c.text.clone()).unwrap_or_default());
}
values.push(r.iter().skip(labelled as usize).map(Cell::number).collect());
}
(series, categories, values)
}
fn to_csv(g: &GraphSpec) -> String {
let mut out = String::new();
if !g.series.is_empty() {
out.push(',');
out.push_str(&g.series.join(","));
out.push('\n');
}
for (i, r) in g.rows.iter().enumerate() {
if let Some(c) = g.categories.get(i) {
out.push_str(c);
// Always a header row (empty first cell), so a first data row starting with a blank isn't read as one.
let cells = g.cells();
let width = cells.iter().map(Vec::len).max().unwrap_or(0).max(g.series.len());
let mut out = String::from(",");
out.push_str(&(0..width).map(|s| g.series.get(s).cloned().unwrap_or_default()).collect::<Vec<_>>().join(","));
out.push('\n');
let labelled = !g.categories.is_empty();
for (i, r) in cells.iter().enumerate() {
if labelled {
// An empty label, one that reads as a number or one with spaces around it goes in quotes, so it reads back
// as the same label.
let c = g.categories.get(i).map(String::as_str).unwrap_or_default();
if c.trim().is_empty() || c.trim().parse::<f64>().is_ok() || c != c.trim() {
out.push_str(&format!("\"{c}\""));
} else {
out.push_str(c);
}
out.push(',');
}
out.push_str(&r.iter().map(|v| fmt_value(*v)).collect::<Vec<_>>().join(","));
out.push_str(&r.iter().map(|v| v.map(fmt_value).unwrap_or_default()).collect::<Vec<_>>().join(","));
out.push('\n');
}
out
@@ -101,7 +138,7 @@ fn apply_data(g: &mut GraphSpec, p: &Value) -> bool {
let (s, c, r) = parse_csv(csv);
g.series = s;
g.categories = c;
g.rows = r;
g.set_cells(r);
changed = true;
}
let strings =
@@ -115,10 +152,24 @@ fn apply_data(g: &mut GraphSpec, p: &Value) -> bool {
changed = true;
}
if let Some(rows) = p.get("rows").and_then(Value::as_array) {
g.rows = rows.iter().map(|r| r.as_array().map(|a| a.iter().map(|v| v.as_f64().unwrap_or(0.0)).collect()).unwrap_or_default()).collect();
// `null` (or anything that isn't a finite number) is a blank cell.
g.set_cells(
rows.iter().map(|r| r.as_array().map(|a| a.iter().map(|v| v.as_f64().filter(|v| v.is_finite())).collect()).unwrap_or_default()).collect(),
);
changed = true;
}
g.rows.retain(|r| !r.is_empty());
// Drop rows with neither a value nor a label; a label-only row stays, its values blank.
let mut cells = g.cells();
let keep: Vec<bool> = (0..cells.len())
.map(|c| cells.get(c).is_some_and(|r| r.iter().any(Option::is_some)) || g.categories.get(c).is_some_and(|l| !l.is_empty()))
.collect();
if keep.contains(&false) || cells.len() < g.rows.len() {
let mut k = keep.iter();
cells.retain(|_| k.next().copied().unwrap_or(false));
let mut k = keep.iter();
g.categories.retain(|_| k.next().copied().unwrap_or(true));
g.set_cells(cells);
}
changed
}
@@ -166,6 +217,37 @@ fn nice_axis(lo: f64, hi: f64, ticks: usize) -> (f64, f64, f64) {
((lo / step).floor() * step, (hi / step).ceil() * step, step)
}
/// The value axis: with both `axis_min` and `axis_max` set (Graph Type › Tick Values › Override Calculated Values),
/// exactly that range split into `ticks` divisions (5 when automatic); otherwise a nice axis around the data, using
/// whichever bound was given.
fn value_axis(g: &GraphSpec, lo: f64, hi: f64) -> (f64, f64, f64) {
match (g.axis_min, g.axis_max) {
(Some(min), Some(max)) if max > min && (max - min).is_finite() => {
let n = if g.ticks == 0 { 5 } else { g.ticks.clamp(1, 100) } as f64;
(min, max, (max - min) / n)
}
_ => {
// An override too wide for f64 (or NaN) gives a non-finite axis, which would reach `clamp` with NaN bounds
// and panic: fall back to the data, then to 0..1.
let finite = |(a, b, s): (f64, f64, f64)| a.is_finite() && b.is_finite() && s.is_finite() && b > a && s > 0.0;
[nice_axis(g.axis_min.unwrap_or(lo), g.axis_max.unwrap_or(hi), g.ticks), nice_axis(lo, hi, g.ticks)]
.into_iter()
.find(|&a| finite(a))
.unwrap_or((0.0, 1.0, 0.2))
}
}
}
/// The tick values from `lo` to `hi` every `step`, both ends included: counted, not accumulated, so the last tick
/// lands on `hi` and a step too small for the range (or not finite) can't loop forever. At most 1000 ticks.
fn tick_values(lo: f64, hi: f64, step: f64) -> Vec<f64> {
let n = ((hi - lo) / step).round();
if !(lo.is_finite() && step.is_finite() && step > 0.0 && n.is_finite() && n >= 0.0) {
return vec![];
}
(0..=(n as usize).min(1000)).map(|i| lo + i as f64 * step).collect()
}
struct Gen<'a> {
d: &'a mut Document,
out: Vec<Arc<Node>>,
@@ -203,6 +285,11 @@ impl Gen<'_> {
}
}
/// The stretches of two or more points between blank cells, which a line connects.
fn runs(pts: &[Option<Point>]) -> Vec<Vec<Point>> {
pts.split(Option::is_none).map(|r| r.iter().flatten().copied().collect::<Vec<_>>()).filter(|r| r.len() > 1).collect()
}
fn polyline(pts: &[Point], closed: bool) -> PathData {
let mut bp = BezPath::new();
for (i, p) in pts.iter().enumerate() {
@@ -231,7 +318,11 @@ fn generate(d: &mut Document, g: &GraphSpec) -> Vec<Arc<Node>> {
let ncat = g.rows.len().max(1);
let series_label = |i: usize| g.series.get(i).cloned().unwrap_or_default();
let mut series: Vec<Vec<Arc<Node>>> = vec![vec![]; nser];
let val = |c: usize, s: usize| g.rows.get(c).and_then(|r| r.get(s)).copied().unwrap_or(0.0);
// A blank cell (no value) is `None`: columns, bars and stacked segments leave it out, lines and scatter points
// break around it and the value axis ignores it. Area, pie and radar draw it as 0, which is what it adds to them.
let cells = g.cells();
let cell = |c: usize, s: usize| cells.get(c).and_then(|r| r.get(s)).copied().flatten();
let val = |c: usize, s: usize| cell(c, s).unwrap_or(0.0);
let stacked = matches!(g.kind, GraphKind::StackedColumn | GraphKind::StackedBar | GraphKind::Area);
let horizontal = matches!(g.kind, GraphKind::Bar | GraphKind::StackedBar);
@@ -270,8 +361,7 @@ fn generate(d: &mut Document, g: &GraphSpec) -> Vec<Arc<Node>> {
GraphKind::Radar => {
let centre = r.center();
let rad = (r.width().min(r.height()) / 2.0).max(1.0);
let hi = g.axis_max.unwrap_or_else(|| g.rows.iter().flatten().copied().fold(0.0, f64::max));
let (lo, hi, step) = nice_axis(g.axis_min.unwrap_or(0.0), hi, g.ticks);
let (lo, hi, step) = value_axis(g, 0.0, g.rows.iter().flatten().copied().fold(0.0, f64::max));
let at = |c: usize, v: f64| {
let a = -std::f64::consts::FRAC_PI_2 + std::f64::consts::TAU * c as f64 / ncat as f64;
centre + vectorcraft_geom::Vec2::new(a.cos(), a.sin()) * (rad * ((v - lo) / (hi - lo)).clamp(0.0, 1.0))
@@ -284,21 +374,23 @@ fn generate(d: &mut Document, g: &GraphSpec) -> Vec<Arc<Node>> {
axes.push(b.text(Point::new(p.x, p.y + LABEL_SIZE * 0.35), cat, Justify::Center));
}
}
let mut v = lo + step;
while v <= hi + step * 1e-6 {
for v in tick_values(lo, hi, step).into_iter().skip(1) {
let ring: Vec<Point> = (0..ncat).map(|c| at(c, v)).collect();
axes.push(b.path(polyline(&ring, true), Paint::None, Paint::solid(Color::rgb(0.6, 0.6, 0.6)), 0.25));
v += step;
}
let ax = b.group("Axes", axes);
b.out.push(ax);
for (s, items) in series.iter_mut().enumerate() {
let pts: Vec<Point> = (0..ncat).map(|c| at(c, val(c, s))).collect();
let (fill, stroke, w) = mark(s, false, default_series_paint(s), 1.0);
items.push(b.path(polyline(&pts, true), fill, stroke, w));
for p in pts {
let (fill, stroke, w) = mark(s, true, Paint::None, 0.0);
items.push(b.path(marker(p, 4.0), fill, stroke, w));
if g.connect_points && pts.len() > 1 {
let (fill, stroke, w) = mark(s, false, default_series_paint(s), 1.0);
items.push(b.path(polyline(&pts, true), fill, stroke, w));
}
if g.mark_points {
for p in pts {
let (fill, stroke, w) = mark(s, true, Paint::None, 0.0);
items.push(b.path(marker(p, 4.0), fill, stroke, w));
}
}
}
}
@@ -315,19 +407,21 @@ fn generate(d: &mut Document, g: &GraphSpec) -> Vec<Arc<Node>> {
lo = lo.min(neg);
} else if scatter {
for s in (0..nser).step_by(2) {
hi = hi.max(val(c, s));
lo = lo.min(val(c, s));
xlo = xlo.min(val(c, s + 1));
xhi = xhi.max(val(c, s + 1));
if let (Some(y), Some(x)) = (cell(c, s), cell(c, s + 1)) {
hi = hi.max(y);
lo = lo.min(y);
xlo = xlo.min(x);
xhi = xhi.max(x);
}
}
} else {
for s in 0..nser {
hi = hi.max(val(c, s));
lo = lo.min(val(c, s));
for v in (0..nser).filter_map(|s| cell(c, s)) {
hi = hi.max(v);
lo = lo.min(v);
}
}
}
let (lo, hi, step) = nice_axis(g.axis_min.unwrap_or(lo), g.axis_max.unwrap_or(hi), g.ticks);
let (lo, hi, step) = value_axis(g, lo, hi);
// Value → coordinate along the value axis (y for columns/lines, x for bars).
let vpos = |v: f64| {
let t = (v - lo) / (hi - lo);
@@ -335,8 +429,7 @@ fn generate(d: &mut Document, g: &GraphSpec) -> Vec<Arc<Node>> {
};
let mut axes = vec![];
// Value axis with ticks and labels.
let mut v = lo;
while v <= hi + step * 1e-6 {
for v in tick_values(lo, hi, step) {
let q = vpos(v);
if horizontal {
axes.push(b.line(Point::new(q, r.y1), Point::new(q, r.y1 + 4.0)));
@@ -345,7 +438,6 @@ fn generate(d: &mut Document, g: &GraphSpec) -> Vec<Arc<Node>> {
axes.push(b.line(Point::new(r.x0 - 4.0, q), Point::new(r.x0, q)));
axes.push(b.text(Point::new(r.x0 - 6.0, q + LABEL_SIZE * 0.35), &fmt_value(v), Justify::Right));
}
v += step;
}
if horizontal {
axes.push(b.line(Point::new(r.x0, r.y1), Point::new(r.x1, r.y1)));
@@ -358,7 +450,8 @@ fn generate(d: &mut Document, g: &GraphSpec) -> Vec<Arc<Node>> {
let span = if horizontal { r.height() } else { r.width() };
let cat_w = span / ncat as f64;
let cat_start = |c: usize| if horizontal { r.y0 + c as f64 * cat_w } else { r.x0 + c as f64 * cat_w };
let points_mode = matches!(g.kind, GraphKind::Line | GraphKind::Area);
// Area bands always span the plot; line graphs only with Edge-to-Edge Lines.
let points_mode = g.kind == GraphKind::Area || (g.kind == GraphKind::Line && g.edge_to_edge);
let cat_mid = |c: usize| {
if points_mode && ncat > 1 { r.x0 + r.width() * c as f64 / (ncat - 1) as f64 } else { cat_start(c) + cat_w / 2.0 }
};
@@ -384,14 +477,20 @@ fn generate(d: &mut Document, g: &GraphSpec) -> Vec<Arc<Node>> {
x += xs;
}
for (si, s) in (0..nser).step_by(2).enumerate() {
let pts: Vec<Point> =
(0..ncat).map(|c| Point::new(r.x0 + (val(c, s + 1) - xl) / (xh - xl) * r.width(), vpos(val(c, s)))).collect();
if g.connect_points && pts.len() > 1 {
let (fill, stroke, w) = mark(si, false, default_series_paint(si), 1.0);
series[si].push(b.path(polyline(&pts, false), fill, stroke, w));
let pts: Vec<Option<Point>> = (0..ncat)
.map(|c| match (cell(c, s), cell(c, s + 1)) {
(Some(y), Some(x)) => Some(Point::new(r.x0 + (x - xl) / (xh - xl) * r.width(), vpos(y))),
_ => None,
})
.collect();
if g.connect_points {
for run in runs(&pts) {
let (fill, stroke, w) = mark(si, false, default_series_paint(si), 1.0);
series[si].push(b.path(polyline(&run, false), fill, stroke, w));
}
}
if g.mark_points {
for p in &pts {
for p in pts.iter().flatten() {
let (fill, stroke, w) = mark(si, true, Paint::None, 0.0);
series[si].push(b.path(marker(*p, 5.0), fill, stroke, w));
}
@@ -408,8 +507,9 @@ fn generate(d: &mut Document, g: &GraphSpec) -> Vec<Arc<Node>> {
for c in 0..ncat {
let c0 = cat_start(c) + (cat_w - cluster) / 2.0;
for (s, items) in series.iter_mut().enumerate() {
let Some(v) = cell(c, s) else { continue };
let a = c0 + slot * s as f64 + (slot - bar) / 2.0;
let (v0, v1) = (vpos(0.0f64.clamp(lo, hi)), vpos(val(c, s)));
let (v0, v1) = (vpos(0.0f64.clamp(lo, hi)), vpos(v));
let rect = if horizontal {
Rect::new(v0.min(v1), a, v0.max(v1), a + bar)
} else {
@@ -427,7 +527,7 @@ fn generate(d: &mut Document, g: &GraphSpec) -> Vec<Arc<Node>> {
let a = cat_start(c) + (cat_w - bar) / 2.0;
let (mut pos, mut neg) = (0.0, 0.0);
for (s, items) in series.iter_mut().enumerate() {
let v = val(c, s);
let Some(v) = cell(c, s) else { continue };
let base = if v >= 0.0 { &mut pos } else { &mut neg };
let (v0, v1) = (vpos(*base), vpos(*base + v));
*base += v;
@@ -443,13 +543,15 @@ fn generate(d: &mut Document, g: &GraphSpec) -> Vec<Arc<Node>> {
}
GraphKind::Line => {
for (s, items) in series.iter_mut().enumerate() {
let pts: Vec<Point> = (0..ncat).map(|c| Point::new(cat_mid(c), vpos(val(c, s)))).collect();
if g.connect_points && pts.len() > 1 {
let (fill, stroke, w) = mark(s, false, default_series_paint(s), 1.0);
items.push(b.path(polyline(&pts, false), fill, stroke, w));
let pts: Vec<Option<Point>> = (0..ncat).map(|c| cell(c, s).map(|v| Point::new(cat_mid(c), vpos(v)))).collect();
if g.connect_points {
for run in runs(&pts) {
let (fill, stroke, w) = mark(s, false, default_series_paint(s), 1.0);
items.push(b.path(polyline(&run, false), fill, stroke, w));
}
}
if g.mark_points {
for p in &pts {
for p in pts.iter().flatten() {
let (fill, stroke, w) = mark(s, true, Paint::None, 0.0);
items.push(b.path(marker(*p, 5.0), fill, stroke, w));
}
@@ -666,7 +768,8 @@ fn set_data(s: &mut Session, p: &Value) -> Result<Value> {
let id = target(s, p, "graph.setData")?;
let mut spec = spec_of(s, id)?;
if !apply_data(&mut spec, p) {
return Ok(json!({ "csv": to_csv(&spec), "series": spec.series, "categories": spec.categories, "rows": spec.rows }));
// Blank cells come back as null, so the rows can be edited and sent back as they are.
return Ok(json!({ "csv": to_csv(&spec), "series": spec.series, "categories": spec.categories, "rows": spec.cells() }));
}
s.edit("Graph Data", |d, _| regenerate(d, id, spec))?;
Ok(json!({ "id": id.0 }))
@@ -676,11 +779,12 @@ fn set_type(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "graph.setType";
let id = target(s, p, C)?;
let mut spec = spec_of(s, id)?;
let keys = ["type", "columnWidth", "clusterWidth", "legend", "markPoints", "connectPoints", "ticks", "axisMin", "axisMax"];
let keys = ["type", "columnWidth", "clusterWidth", "legend", "markPoints", "connectPoints", "edgeToEdge", "ticks", "axisMin", "axisMax"];
if !keys.iter().any(|k| p.get(*k).is_some()) {
return Ok(json!({
"type": spec.kind.id(), "columnWidth": spec.column_width, "clusterWidth": spec.cluster_width, "legend": spec.legend,
"markPoints": spec.mark_points, "connectPoints": spec.connect_points, "ticks": spec.ticks,
"markPoints": spec.mark_points, "connectPoints": spec.connect_points, "edgeToEdge": spec.edge_to_edge, "ticks": spec.ticks,
"axisMin": spec.axis_min, "axisMax": spec.axis_max,
}));
}
if let Some(t) = str_param(p, "type") {
@@ -691,6 +795,7 @@ fn set_type(s: &mut Session, p: &Value) -> Result<Value> {
spec.legend = bool_or(p, "legend", spec.legend);
spec.mark_points = bool_or(p, "markPoints", spec.mark_points);
spec.connect_points = bool_or(p, "connectPoints", spec.connect_points);
spec.edge_to_edge = bool_or(p, "edgeToEdge", spec.edge_to_edge);
spec.ticks = p.get("ticks").and_then(Value::as_u64).map_or(spec.ticks, |t| t.min(100) as usize);
if let Some(v) = p.get("axisMin") {
spec.axis_min = v.as_f64();
@@ -790,6 +895,89 @@ mod tests {
assert!(matches!(n.kind, NodeKind::Group { .. }));
}
/// The x of every marker of series `index`, left to right.
fn marker_xs(s: &Session, id: NodeId, index: u32) -> Vec<f64> {
let n = s.doc().unwrap().doc.node(id).unwrap().clone();
let mut xs: Vec<f64> = series(&n, index)
.children()
.unwrap()
.iter()
.map(|c| c.geometric_bounds().unwrap())
.filter(|b| (b.width() - 5.0).abs() < 1e-6)
.map(|b| b.center().x)
.collect();
xs.sort_by(f64::total_cmp);
xs
}
#[test]
fn edge_to_edge_lines_off_puts_line_points_at_category_centres() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
// 300 pt wide, three categories: centres at 150, 250, 350 (off by default); edges at 100 and 400.
let id = graph(&mut s, "line");
assert_eq!(marker_xs(&s, id, 0), vec![150.0, 250.0, 350.0]);
assert_eq!(s.execute("graph.setType", &json!({})).unwrap()["edgeToEdge"], json!(false));
s.execute("graph.setType", &json!({"edgeToEdge": true})).unwrap();
assert_eq!(marker_xs(&s, id, 0), vec![100.0, 250.0, 400.0]);
// A graph saved before the option existed keeps its edge-to-edge lines.
let old: GraphSpec = serde_json::from_value(json!({"kind": "line"})).unwrap();
assert!(old.edge_to_edge);
}
#[test]
fn radar_graphs_follow_mark_and_connect_data_points() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
let id = graph(&mut s, "radar");
let parts = |s: &Session| series(&s.doc().unwrap().doc.node(id).unwrap().clone(), 0).children().unwrap().len();
// The ring, three markers and the legend swatch.
assert_eq!(parts(&s), 5);
s.execute("graph.setType", &json!({"markPoints": false})).unwrap();
assert_eq!(parts(&s), 2);
s.execute("graph.setType", &json!({"markPoints": true, "connectPoints": false})).unwrap();
assert_eq!(parts(&s), 4);
}
#[test]
fn an_overridden_value_axis_is_used_exactly() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
let r = s.execute("graph.create", &json!({"type": "column", "x": 20, "y": 20, "width": 170, "height": 74, "series": ["a"], "categories": ["x", "y"], "rows": [[4839], [10488]]})).unwrap();
let id = NodeId(r["id"].as_u64().unwrap());
let tallest = |s: &Session| -> f64 {
let n = s.doc().unwrap().doc.node(id).unwrap().clone();
let bars = series(&n, 0).children().unwrap();
bars.iter().map(|c| c.geometric_bounds().unwrap().height()).fold(0.0, f64::max)
};
// Calculated: a nice axis (0 to 12500 here), so the 10488 column falls short of 10488 / 12000 of the plot.
assert!((tallest(&s) - 74.0 * 10488.0 / 12000.0).abs() > 1.0);
s.execute("graph.setType", &json!({"axisMin": 0, "axisMax": 12000})).unwrap();
assert!((tallest(&s) - 74.0 * 10488.0 / 12000.0).abs() < 1e-6, "{}", tallest(&s));
let o = s.execute("graph.setType", &json!({})).unwrap();
assert_eq!((o["axisMin"].as_f64(), o["axisMax"].as_f64()), (Some(0.0), Some(12000.0)));
// One bound only keeps the calculated (nice) axis around it.
s.execute("graph.setType", &json!({"axisMax": null})).unwrap();
assert!((tallest(&s) - 74.0 * 10488.0 / 12000.0).abs() > 1.0);
}
#[test]
fn tick_values_end_on_the_maximum_and_never_run_away() {
// Counted, not accumulated: an offset range keeps its last tick.
let t = super::tick_values(1e10, 1e10 + 1.0, 0.01);
assert_eq!(t.len(), 101);
assert!((t[100] - (1e10 + 1.0)).abs() < 1e-3);
// A range too wide for f64, or a step too small to move, gives no ticks or a capped list.
assert!(super::tick_values(-1.7e308, 1.7e308, f64::INFINITY).is_empty());
assert!(super::tick_values(1e20, 1e20 + 16384.0, 163.84).len() <= 1001);
assert!(super::tick_values(0.0, 1.0, 0.0).is_empty());
// An override whose span overflows falls back to the calculated axis instead of hanging.
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
graph(&mut s, "column");
s.execute("graph.setType", &json!({"axisMin": -1.7e308, "axisMax": 1.7e308})).unwrap();
}
#[test]
fn group_selection_paint_becomes_the_series_colour_and_survives_regeneration() {
let mut s = Session::new();
@@ -1017,10 +1205,115 @@ mod tests {
let (s, c, r) = super::parse_csv("\t2024\t2025\nQ1\t1\t2\nQ2\t3\t4\n");
assert_eq!(s, vec!["2024", "2025"]);
assert_eq!(c, vec!["Q1", "Q2"]);
assert_eq!(r, vec![vec![1.0, 2.0], vec![3.0, 4.0]]);
assert_eq!(r, vec![vec![Some(1.0), Some(2.0)], vec![Some(3.0), Some(4.0)]]);
let (s, c, r) = super::parse_csv("1,2\n3,4");
assert!(s.is_empty() && c.is_empty());
assert_eq!(r.len(), 2);
assert_eq!(super::nice_axis(0.0, 7.0, 0), (0.0, 8.0, 2.0));
}
#[test]
fn empty_cells_are_blank_and_quoted_numbers_are_labels() {
// Quoted years label the rows; empty and non-numeric cells are blank values, not zeros.
let (s, c, r) = super::parse_csv(",a,b\n\"2023\",1,\n\"2024\",,x\n\"2025\",3,4");
assert_eq!(s, vec!["a", "b"]);
assert_eq!(c, vec!["2023", "2024", "2025"]);
assert_eq!(r, vec![vec![Some(1.0), None], vec![None, None], vec![Some(3.0), Some(4.0)]]);
// Back to CSV: blanks stay empty and numeric labels keep their quotes, so it reads back the same.
let mut g = GraphSpec { series: s, categories: c, ..GraphSpec::default() };
g.set_cells(r);
assert_eq!(g.blanks, vec![[0, 1], [1, 0], [1, 1]]);
let csv = super::to_csv(&g);
assert!(csv.contains("\"2024\",,"), "{csv}");
let (s2, c2, r2) = super::parse_csv(&csv);
assert_eq!((s2, c2, r2), (g.series.clone(), g.categories.clone(), g.cells()));
}
#[test]
fn a_row_with_an_empty_label_keeps_its_values_in_place() {
// The label column is the first column of every row once any row has a label.
let (_, c, r) = super::parse_csv(",a,b\nQ1,1,2\n,3,4");
assert_eq!(c, vec!["Q1", ""]);
assert_eq!(r, vec![vec![Some(1.0), Some(2.0)], vec![Some(3.0), Some(4.0)]]);
// And a data window round trip keeps it (the empty label is written as "").
let mut g = GraphSpec { series: vec!["a".into(), "b".into()], categories: c, ..GraphSpec::default() };
g.set_cells(r);
assert_eq!(super::parse_csv(&super::to_csv(&g)).2, g.cells());
// Without series or labels, a first row starting with a blank is still data after a round trip.
let mut g = GraphSpec::default();
g.set_cells(vec![vec![None, Some(2.0)], vec![Some(3.0), Some(4.0)]]);
let (s, c, r) = super::parse_csv(&super::to_csv(&g));
assert!(s.is_empty() && c.is_empty());
assert_eq!(r, g.cells());
// A label with spaces around a number stays a label, spaces and all.
let mut g = GraphSpec { categories: vec!["2024 ".into()], ..GraphSpec::default() };
g.set_cells(vec![vec![Some(1.0)]]);
assert_eq!(super::parse_csv(&super::to_csv(&g)).1, vec!["2024 "]);
}
#[test]
fn blanks_read_back_as_null_and_year_header_rows_still_read_as_headers() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
let r =
s.execute("graph.create", &json!({"type": "line", "x": 0, "y": 0, "width": 300, "height": 200, "rows": [[1, null], [2, 3]]})).unwrap();
let id = NodeId(r["id"].as_u64().unwrap());
assert_eq!(s.execute("graph.setData", &json!({"id": id.0})).unwrap()["rows"], json!([[1.0, null], [2.0, 3.0]]));
// A label followed by empty cells is a header with no series names, as before blanks existed.
let (s, c, r) = super::parse_csv("Year,,\n2023,1,2");
assert!(s.is_empty());
assert_eq!(c, Vec::<String>::new());
assert_eq!(r, vec![vec![Some(2023.0), Some(1.0), Some(2.0)]]);
}
#[test]
fn a_huge_ragged_grid_is_capped() {
let g = GraphSpec { rows: vec![vec![0.0; vectorcraft_doc::MAX_GRAPH_SERIES + 10]; 3], ..GraphSpec::default() };
assert!(g.cells().iter().all(|r| r.len() == vectorcraft_doc::MAX_GRAPH_SERIES));
}
#[test]
fn a_label_only_row_stays_with_blank_values() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
let r =
s.execute("graph.create", &json!({"type": "column", "x": 0, "y": 0, "width": 300, "height": 200, "csv": ",a\nQ1,1\nQ2\nQ3,3"})).unwrap();
let id = NodeId(r["id"].as_u64().unwrap());
let spec = s.doc().unwrap().doc.node(id).unwrap().graph.clone().unwrap();
assert_eq!(spec.categories, vec!["Q1", "Q2", "Q3"]);
assert_eq!(spec.cells(), vec![vec![Some(1.0)], vec![None], vec![Some(3.0)]]);
}
#[test]
fn blank_cells_leave_out_their_columns_and_break_lines() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
let rows = json!([[3, 1], [null, 2], [5, null], [4, 4]]);
let r = s
.execute("graph.create", &json!({"type": "column", "x": 100, "y": 100, "width": 400, "height": 200, "series": ["a", "b"], "rows": rows}))
.unwrap();
let id = NodeId(r["id"].as_u64().unwrap());
let count = |s: &Session, index: u32| series(&s.doc().unwrap().doc.node(id).unwrap().clone(), index).children().unwrap().len();
// Three columns each (one blank), plus the legend swatch.
assert_eq!((count(&s, 0), count(&s, 1)), (4, 4));
s.execute("graph.setType", &json!({"type": "stackedColumn"})).unwrap();
assert_eq!((count(&s, 0), count(&s, 1)), (4, 4));
// Line: series a is 3, blank, 5, 4. The lone first point has nothing to connect to, so one line (5 to 4), three
// markers and the legend swatch.
s.execute("graph.setType", &json!({"type": "line"})).unwrap();
assert_eq!(count(&s, 0), 1 + 3 + 1);
// Series b is 1, 2, blank, 4: one line (1 to 2), three markers and the swatch.
assert_eq!(count(&s, 1), 1 + 3 + 1);
// A data window round trip keeps the blanks.
let csv = s.execute("graph.setData", &json!({})).unwrap()["csv"].as_str().unwrap().to_string();
assert_eq!(csv.lines().nth(2), Some(",2"), "{csv}");
let spec = s.doc().unwrap().doc.node(id).unwrap().graph.clone().unwrap();
assert_eq!(spec.cells()[1], vec![None, Some(2.0)]);
// In the file a blank is a 0 placeholder plus its place in `blanks`, so a version without blanks reads
// numbers (as zeros); a graph without blanks writes no `blanks` at all.
let v = serde_json::to_value(&spec).unwrap();
assert_eq!(v["rows"][1], json!([0.0, 2.0]));
assert_eq!(v["blanks"], json!([[1, 0], [2, 1]]));
assert!(serde_json::to_value(GraphSpec::default()).unwrap().get("blanks").is_none());
}
}
+414 -26
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)]
@@ -87,7 +132,7 @@ impl Libraries {
})
.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));
}
}
@@ -120,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);
@@ -194,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![] };
@@ -234,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())
}
@@ -260,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> {
@@ -269,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
),
@@ -278,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
),
@@ -336,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
),
]
}
@@ -362,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(),
})
}
@@ -391,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,
}))
}
@@ -457,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
@@ -682,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)
}
+28 -3
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, polygonAngle?: degrees (counterclockwise from its first vertex straight up), polygonRadius?: pt (centre to vertex), sideLength?: pt (sets the radius), makeSidesEqual?: true (a polygon scaled unevenly: drops the uneven scale and shear, keeping its centre, angle and mean radius), pieStart?, pieEnd?: degrees (an ellipse's pie, counterclockwise from 3 o'clock; 0 to 360 is the whole ellipse), invertPie?: true (swaps them: the other part of the ellipse)} (Live Corners on any path: radius and kind set its corners (anchors without handles between two straight sides): `corners` (anchor indices of the path with its corners uncut, counting every subpath's anchors in order: a rectangle's 0 top-left, 1 top-right, 2 bottom-right, 3 bottom-left; a polygon's from the first vertex clockwise), else the corners with a Direct-Selected anchor, else every corner; each radius is drawn no larger than half the corner's shorter side allows; a path that isn't a live shape keeps its uncut outline so its corners stay editable, and is plain again once none is cut; sides: a polygon's, which keep the radius they shared; the polygon params apply in that order: sides, makeSidesEqual, polygonAngle, then sideLength or else polygonRadius)",
"{id?, ids?, radius?: pt, kind?: \"round\"|\"invertedRound\"|\"chamfer\", corners?: [i…], items?: [{id, corners?: [i…]}] (several objects, each with its own corners, in place of ids and corners: the canvas's corner widgets across a selection), sides?: n, polygonAngle?: degrees (counterclockwise from its first vertex straight up), polygonRadius?: pt (centre to vertex), sideLength?: pt (sets the radius), makeSidesEqual?: true (a polygon scaled unevenly: drops the uneven scale and shear, keeping its centre, angle and mean radius), pieStart?, pieEnd?: degrees (an ellipse's pie, counterclockwise from 3 o'clock; 0 to 360 is the whole ellipse), invertPie?: true (swaps them: the other part of the ellipse)} (Live Corners on any path: radius and kind set its corners (anchors 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())
@@ -1154,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();
+71
View File
@@ -297,6 +297,77 @@ fn brush_options_set_variation_modes() {
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)]))]);
}
+19
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();
+20 -4
View File
@@ -1,5 +1,6 @@
//! The Photoshop-style raster effects (Effect › Blur › Radial Blur and Smart Blur, Pixelate ›
//! Color Halftone, Crystallize, Mezzotint and Pointillize, Sharpen › Unsharp Mask): applied and
//! 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.
@@ -21,6 +22,16 @@ const EFFECTS: [(&str, &str); 3] = [
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"}"#)];
@@ -61,7 +72,12 @@ 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"), (VIDEO.as_slice(), "Video"), ([("video.ntscColors", "{}")].as_slice(), "Video")] {
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}");
@@ -86,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.into_iter().chain(PIXELATE).chain(VIDEO) {
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();
@@ -115,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.into_iter().chain(PIXELATE).chain(VIDEO) {
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();
+23
View File
@@ -227,6 +227,20 @@ fn structured_junk() {
"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})),
@@ -282,6 +296,15 @@ fn structured_junk() {
("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"})),
+57 -1
View File
@@ -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();
+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]
+2 -2
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"],
),
+2 -1
View File
@@ -603,7 +603,8 @@ impl Renderer {
};
// 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 space = effects::PixelSpace { to_doc, px, center: content.reach.center(), channels, line: 72.0 / ppi };
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 {
+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.
+44 -9
View File
@@ -367,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 {
@@ -1701,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);
@@ -1749,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 {
@@ -2219,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() }
}
+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);
@@ -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());
}
}
});
});
}
+185 -33
View File
@@ -1,10 +1,12 @@
//! 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. OK runs `brush.options`
//! (strokes painted with the brush update); a name another brush has keeps the dialog open.
//! 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`), `name`.
//! 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`…),
@@ -12,10 +14,10 @@
use egui::{Color32, Sense, Stroke, pos2, vec2};
use serde_json::{Map, Value, json};
use vectorcraft_brush::{Brush, BrushKind, Calligraphic, Variation};
use vectorcraft_brush::{Bristle, Brush, BrushKind, Calligraphic, Variation};
use super::swatch_options::{grid, label};
use super::{DialogSpec, form};
use super::{DialogSpec, brush_art_options, form};
use crate::state::Dialog;
use crate::theme::Tokens;
use crate::{VectorcraftApp, widgets};
@@ -25,19 +27,16 @@ pub const KIND: &str = "brushOptions";
pub(super) const SPEC: DialogSpec = DialogSpec { heading, body, confirm, min_width: 440.0, ..DialogSpec::FORM };
/// The brush types that have an options dialog.
pub const TYPES: [&str; 2] = ["calligraphic", "bristle"];
/// 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, min, max), in percent.
const BRISTLE: [(&str, &str, f64, f64); 5] = [
("length", "Bristle Length:", 25.0, 300.0),
("density", "Bristle Density:", 1.0, 100.0),
("thickness", "Bristle Thickness:", 1.0, 100.0),
("opacity", "Paint Opacity:", 1.0, 100.0),
("stiffness", "Stiffness:", 1.0, 100.0),
/// 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).
@@ -64,11 +63,12 @@ const STROKE_W: f32 = 400.0;
fn heading(d: &Dialog) -> String {
match d.str("__type").as_str() {
"bristle" => tl!("Bristle Brush Options").into(),
_ => tl!("Calligraphic Brush Options").into(),
"calligraphic" => tl!("Calligraphic Brush Options").into(),
ty => brush_art_options::heading(ty).into(),
}
}
fn mode_key(key: &str) -> String {
pub(super) fn mode_key(key: &str) -> String {
format!("{key}Mode")
}
@@ -83,7 +83,7 @@ pub fn open(app: &mut VectorcraftApp, name: Option<&str>) -> Result<Value, Strin
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).map_err(|e| e.to_string())?;
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));
@@ -104,16 +104,17 @@ pub fn open(app: &mut VectorcraftApp, name: Option<&str>) -> Result<Value, Strin
f.insert(key.into(), v.get(key).cloned().unwrap_or(Value::Null));
}
}
_ => return Err("this kind of brush has no options dialog yet".into()),
// 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 (its brush has a type with a dialog)?
/// 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).and_then(|n| vectorcraft_brush::find(&st.doc, &n)).is_some_and(|b| TYPES.contains(&b.kind.type_id()))
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.
@@ -124,7 +125,7 @@ fn target(app: &VectorcraftApp) -> Option<String> {
}
/// The value of field `key` within `range`.
fn clamped(d: &Dialog, key: &str, (lo, hi): (f64, f64)) -> f64 {
pub(super) fn clamped(d: &Dialog, key: &str, (lo, hi): (f64, f64)) -> f64 {
d.f64(key, lo).clamp(lo, hi)
}
@@ -141,13 +142,14 @@ fn calligraphic(d: &Dialog) -> Calligraphic {
fn params(d: &Dialog) -> Value {
match d.str("__type").as_str() {
"bristle" => {
let mut p = json!({ "shape": d.str("shape"), "size": clamped(d, "size", (0.1, 1296.0)) });
for (key, _, lo, hi) in BRISTLE {
p[key] = json!(clamped(d, key, (lo, hi)));
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
}
_ => serde_json::to_value(calligraphic(d)).unwrap_or_default(),
"calligraphic" => serde_json::to_value(calligraphic(d)).unwrap_or_default(),
_ => brush_art_options::params(d),
}
}
@@ -160,7 +162,8 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
ui.add_space(10.0);
match d.str("__type").as_str() {
"bristle" => bristle_body(app, ui, d),
_ => calligraphic_body(ui, d),
"calligraphic" => calligraphic_body(ui, d),
_ => brush_art_options::body(ui, d),
}
false
}
@@ -203,7 +206,7 @@ fn bristle_body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) {
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 {
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);
@@ -212,7 +215,7 @@ fn bristle_body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) {
}
/// A stroke painted with the brush the fields describe (type `ty`, definition fields `def`).
fn stroke_preview(ui: &mut egui::Ui, ty: &str, mut def: Value, size: egui::Vec2) {
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());
@@ -325,7 +328,7 @@ mod tests {
}
#[test]
fn bristle_options_and_brushes_without_a_dialog() {
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);
@@ -336,9 +339,158 @@ mod tests {
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)));
// Art, scatter and pattern brushes have no dialog yet: an error, nothing opens.
assert!(app.run("ui.brushOptions", json!({"name": "Arrow"})).is_err());
assert!(app.ui.dialog.is_none());
// 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()?));
+3
View File
@@ -160,6 +160,9 @@ fn choices(effect: &str, key: &str) -> Option<form::Choices> {
("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")]),
+6 -3
View File
@@ -232,10 +232,13 @@ pub(super) fn param_fields(
continue;
}
if let Some(options) = choices(&k) {
// In the "effect" context: some languages read the plain Soft, Horizontal and
// Vertical (Grain's types) as type settings.
let shown = |label: &'static str| crate::i18n::tr_ctx(crate::i18n::current(), "effect", label);
let cur = v.as_str().unwrap_or_default();
let label = options.iter().find(|(_, value)| *value == cur).map_or(cur, |(l, _)| *l);
let labels: Vec<&str> = options.iter().map(|(l, _)| *l).collect();
if let Some((_, value)) = crate::widgets::dropdown(ui, ("fx-choice", &k), label, &labels, 140.0).and_then(|i| options.get(i)) {
let label = options.iter().find(|(_, value)| *value == cur).map_or(cur, |(l, _)| shown(l));
let labels: Vec<&str> = options.iter().map(|(l, _)| shown(l)).collect();
if let Some((_, value)) = crate::widgets::dropdown_names(ui, ("fx-choice", &k), label, &labels, 140.0).and_then(|i| options.get(i)) {
d.fields.insert(k, json!(value));
changed = true;
}
+1
View File
@@ -10,6 +10,7 @@ mod about;
mod all_tools;
pub mod artboard_options;
pub mod blend_options;
mod brush_art_options;
pub mod brush_options;
pub mod color_balance;
pub mod color_guide_options;
+10
View File
@@ -176,6 +176,12 @@ fn raster_filter_dialogs_preview_and_apply() {
("pixelate.crystallize", "cellSize", "20"),
("pixelate.mezzotint", "type", "longLines"),
("pixelate.pointillize", "cellSize", "12"),
("texture.craquelure", "crackDepth", "9"),
("texture.grain", "grainType", "stippled"),
("texture.mosaicTiles", "tileSize", "20"),
("texture.patchwork", "relief", "20"),
("texture.stainedGlass", "borderThickness", "2"),
("texture.texturizer", "lightDirection", "bottomLeft"),
] {
let mut app = app();
let id = app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 50})).unwrap()["id"].as_u64().unwrap();
@@ -203,6 +209,10 @@ fn raster_filter_dialogs_preview_and_apply() {
assert_eq!(super::form::humanize("maxRadius"), "Max. Radius:");
assert_eq!(super::form::humanize("channel3"), "Channel 3:");
assert_eq!(super::form::humanize("cellSize"), "Cell Size:");
assert_eq!(super::form::humanize("lightDirection"), "Light Direction:");
let doc = vectorcraft_effects::effect_info("texture.texturizer").unwrap().params;
let ranks: Vec<usize> = ["texture", "scaling", "relief", "lightDirection", "invert"].iter().map(|k| super::effect::doc_rank(doc, k)).collect();
assert!(ranks.windows(2).all(|w| w[0] < w[1]) && ranks[4] < usize::MAX, "{ranks:?}");
}
#[test]
+10
View File
@@ -660,3 +660,13 @@
Video Video
De-Interlace… Odstranit prokládání…
NTSC Colors Barvy NTSC
RGB: RGB:
CMYK: CMYK:
CMYK CMYK
Texture Textura
Craquelure… Krakelování…
Grain… Zrno…
Mosaic Tiles… Mozaikové dlaždice…
Patchwork… Patchwork…
Stained Glass… Vitráž…
Texturizer… Texturovač…
Can't render this file because it contains an unexpected character in line 2 and column 73.
+91 -1
View File
@@ -1588,6 +1588,37 @@
New Bristle Brush Nuevo pincel de cerdas
Calligraphic Brush Options Opciones de pincel caligráfico
Bristle Brush Options Opciones de pincel de cerdas
Scatter Brush Options Opciones de pincel de dispersión
Art Brush Options Opciones de pincel de arte
Pattern Brush Options Opciones de pincel de motivo
Scatter: Dispersión:
Rotation: Rotación:
Rotation relative to: Rotación relativa a:
Brush Scale Options Opciones de escala del pincel
Scale Proportionately Escalar proporcionalmente
Stretch to Fit Stroke Length Estirar para ajustar a la longitud del trazo
Stretch Between Guides Estirar entre guías
Start: Inicio:
End: Final:
Left to Right De izquierda a derecha
Right to Left De derecha a izquierda
Top to Bottom De arriba abajo
Bottom to Top De abajo arriba
Flip: Voltear:
Outer Corner Tile Mosaico de esquina exterior
Side Tile Mosaico lateral
Inner Corner Tile Mosaico de esquina interior
Start Tile Mosaico inicial
End Tile Mosaico final
Fit Ajuste
Stretch to fit Estirar para ajustar
Add space to fit Agregar espacio para ajustar
Approximate path Aproximar trazado
Colorization Coloración
Tints Matices
Tints and Shades Matices y sombras
Hue Shift Cambio de tono
Key Color: Color clave:
Roundness: Redondez:
Fixed Fijo
Pressure Presión
@@ -2760,6 +2791,23 @@
Add to Library Añadir a la biblioteca
Delete Library Eliminar biblioteca
Delete Library Item Eliminar elemento de la biblioteca
No results Sin resultados
No items in this library match “{query}”. Ningún elemento de esta biblioteca coincide con “{query}”.
Right-click an item to move it into this group. Haz clic con el botón derecho en un elemento para moverlo a este grupo.
New Group Nuevo grupo
New Group… Nuevo grupo…
Move to Group Mover al grupo
No Group Sin grupo
Rename Group… Cambiar nombre del grupo…
Delete Group Eliminar grupo
Import Library… Importar biblioteca…
Export Library… Exportar biblioteca…
Import Library Importar biblioteca
Export Library Exportar biblioteca
New Library Group Nuevo grupo de biblioteca
Rename Library Group Cambiar nombre del grupo de biblioteca
Delete Library Group Eliminar grupo de biblioteca
Move Library Item to Group Mover elemento de la biblioteca al grupo
Library Biblioteca
Rename Library Cambiar nombre de la biblioteca
Show Library Mostrar biblioteca
@@ -3485,7 +3533,6 @@ axis Both Ambos
@msg no bounds sin límites
@msg no brush named `{name}` no hay ningún pincel llamado `{name}`
@msg no brush selected no hay ningún pincel seleccionado
@msg this kind of brush has no options dialog yet este tipo de pincel aún no tiene un cuadro de opciones
@msg no catalog no hay catálogo
@msg no changes since the last save no hay cambios desde la última vez que se guardó
@msg no clock to keep a heartbeat by no hay reloj para mantener la señal de actividad
@@ -3881,3 +3928,46 @@ axis Both Ambos
Even Fields Campos pares
Duplication Duplicación
Interpolation Interpolación
@msg Imported library {name} Biblioteca {name} importada
RGB: RGB:
CMYK: CMYK:
Texture Textura
Craquelure… Craquelado…
Grain… Granulado…
Mosaic Tiles… Azulejos de mosaico…
Patchwork… Retazos…
Stained Glass… Vidriera…
Texturizer… Texturizador…
Craquelure Craquelado
Grain Granulado
Mosaic Tiles Azulejos de mosaico
Patchwork Retazos
Stained Glass Vidriera
Texturizer Texturizador
Crack Spacing Espaciado de grietas
Crack Depth Profundidad de grietas
Crack Brightness Brillo de grietas
Intensity Intensidad
Grain Type Tipo de granulado
Tile Size Tamaño de azulejo
Grout Width Anchura de la junta
Lighten Grout Aclarar junta
Square Size Tamaño de cuadrado
Relief Relieve
Border Thickness Grosor del borde
Light Intensity Intensidad de luz
Light Direction Dirección de la luz
Sprinkles Espolvoreado
Clumped Agrupado
Contrasty Contrastado
Enlarged Ampliado
Stippled Punteado
Speckle Moteado
Brick Ladrillo
Burlap Arpillera
Canvas Lienzo
Sandstone Arenisca
Bottom Left Abajo a la izquierda
Top Left Arriba a la izquierda
Top Right Arriba a la derecha
Bottom Right Abajo a la derecha
Can't render this file because it contains an unexpected character in line 2 and column 73.
+92 -1
View File
@@ -1585,6 +1585,37 @@
New Bristle Brush Nouvelle forme Soies
Calligraphic Brush Options Options de forme calligraphique
Bristle Brush Options Options de forme Soies
Scatter Brush Options Options de forme de diffusion
Art Brush Options Options de forme artistique
Pattern Brush Options Options de forme de motif
Scatter: Dispersion :
Rotation: Rotation :
Rotation relative to: Rotation par rapport à :
Brush Scale Options Options de mise à l'échelle de la forme
Scale Proportionately Mettre à l'échelle proportionnellement
Stretch to Fit Stroke Length Étirer selon la longueur du contour
Stretch Between Guides Étirer entre les repères
Start: Début :
End: Fin :
Left to Right De gauche à droite
Right to Left De droite à gauche
Top to Bottom De haut en bas
Bottom to Top De bas en haut
Flip: Retourner :
Outer Corner Tile Carreau d'angle extérieur
Side Tile Carreau latéral
Inner Corner Tile Carreau d'angle intérieur
Start Tile Carreau de début
End Tile Carreau de fin
Fit Ajustement
Stretch to fit Étirer pour ajuster
Add space to fit Ajouter de l'espace pour ajuster
Approximate path Tracé approximatif
Colorization Colorisation
Tints Teintes
Tints and Shades Teintes et ombres
Hue Shift Décalage de teinte
Key Color: Couleur clé :
Roundness: Arrondi :
Fixed Fixe
Pressure Pression
@@ -2757,6 +2788,23 @@
Add to Library Ajouter à la bibliothèque
Delete Library Supprimer la bibliothèque
Delete Library Item Supprimer l’élément de la bibliothèque
No results Aucun résultat
No items in this library match “{query}”. Aucun élément de cette bibliothèque ne correspond à « {query} ».
Right-click an item to move it into this group. Cliquez avec le bouton droit sur un élément pour le placer dans ce groupe.
New Group Nouveau groupe
New Group… Nouveau groupe…
Move to Group Déplacer vers le groupe
No Group Aucun groupe
Rename Group… Renommer le groupe…
Delete Group Supprimer le groupe
Import Library… Importer une bibliothèque…
Export Library… Exporter la bibliothèque…
Import Library Importer une bibliothèque
Export Library Exporter la bibliothèque
New Library Group Nouveau groupe de bibliothèque
Rename Library Group Renommer le groupe de bibliothèque
Delete Library Group Supprimer le groupe de bibliothèque
Move Library Item to Group Déplacer l’élément de la bibliothèque vers un groupe
Library Bibliothèque
Rename Library Renommer la bibliothèque
Show Library Afficher la bibliothèque
@@ -3482,7 +3530,6 @@ axis Both Les deux
@msg no bounds aucune limite
@msg no brush named `{name}` aucune forme nommée `{name}`
@msg no brush selected aucune forme sélectionnée
@msg this kind of brush has no options dialog yet ce type de forme n’a pas encore de boîte de dialogue d’options
@msg no catalog aucun catalogue
@msg no changes since the last save aucune modification depuis le dernier enregistrement
@msg no clock to keep a heartbeat by aucune horloge pour entretenir le signal d’activité
@@ -3892,3 +3939,47 @@ axis Both Les deux
Even Fields Trames paires
Duplication Duplication
Interpolation Interpolation
@msg Imported library {name} Bibliothèque {name} importée
RGB: RGB:
CMYK: CMYK:
Texture Texture
Craquelure… Craquelure…
Grain… Grain…
Mosaic Tiles… Carreaux de mosaïque…
Patchwork… Patchwork…
Stained Glass… Vitrail…
Texturizer… Placage de texture…
Craquelure Craquelure
Grain Grain
Mosaic Tiles Carreaux de mosaïque
Patchwork Patchwork
Stained Glass Vitrail
Texturizer Placage de texture
Crack Spacing Espacement des craquelures
Crack Depth Profondeur des craquelures
Crack Brightness Luminosité des craquelures
Intensity Intensité
Grain Type Type de grain
Tile Size Taille des carreaux
Grout Width Largeur des joints
Lighten Grout Éclaircir les joints
Square Size Taille des carrés
Relief Relief
Border Thickness Épaisseur de la bordure
Light Intensity Intensité de la lumière
Light Direction Direction de la lumière
Sprinkles Saupoudré
Clumped Agglutiné
Contrasty Contrasté
Enlarged Agrandi
Stippled Pointillé
Speckle Moucheté
Brick Brique
Burlap Toile de jute
Canvas Toile
Sandstone Grès
Bottom Left En bas à gauche
Top Left En haut à gauche
Top Right En haut à droite
Bottom Right En bas à droite
effect Soft Doux
Can't render this file because it contains an unexpected character in line 2 and column 73.
+93 -1
View File
@@ -1588,6 +1588,37 @@
New Bristle Brush Nuovo pennello a setole
Calligraphic Brush Options Opzioni pennello calligrafico
Bristle Brush Options Opzioni pennello a setole
Scatter Brush Options Opzioni pennello a dispersione
Art Brush Options Opzioni pennello artistico
Pattern Brush Options Opzioni pennello motivo
Scatter: Dispersione:
Rotation: Rotazione:
Rotation relative to: Rotazione relativa a:
Brush Scale Options Opzioni di scala del pennello
Scale Proportionately Ridimensiona proporzionalmente
Stretch to Fit Stroke Length Allunga per adattare alla lunghezza del tratto
Stretch Between Guides Allunga tra le guide
Start: Inizio:
End: Fine:
Left to Right Da sinistra a destra
Right to Left Da destra a sinistra
Top to Bottom Dall'alto in basso
Bottom to Top Dal basso in alto
Flip: Capovolgi:
Outer Corner Tile Tassello angolo esterno
Side Tile Tassello laterale
Inner Corner Tile Tassello angolo interno
Start Tile Tassello iniziale
End Tile Tassello finale
Fit Adattamento
Stretch to fit Allunga per adattare
Add space to fit Aggiungi spazio per adattare
Approximate path Approssima tracciato
Colorization Colorazione
Tints Tinte
Tints and Shades Tinte e ombre
Hue Shift Variazione tonalità
Key Color: Colore chiave:
Roundness: Rotondità:
Fixed Fisso
Pressure Pressione
@@ -2760,6 +2791,23 @@
Add to Library Aggiungi alla libreria
Delete Library Elimina libreria
Delete Library Item Elimina elemento della libreria
No results Nessun risultato
No items in this library match “{query}”. Nessun elemento di questa libreria corrisponde a “{query}”.
Right-click an item to move it into this group. Fai clic con il pulsante destro su un elemento per spostarlo in questo gruppo.
New Group Nuovo gruppo
New Group… Nuovo gruppo…
Move to Group Sposta nel gruppo
No Group Nessun gruppo
Rename Group… Rinomina gruppo…
Delete Group Elimina gruppo
Import Library… Importa libreria…
Export Library… Esporta libreria…
Import Library Importa libreria
Export Library Esporta libreria
New Library Group Nuovo gruppo di libreria
Rename Library Group Rinomina gruppo di libreria
Delete Library Group Elimina gruppo di libreria
Move Library Item to Group Sposta elemento della libreria nel gruppo
Library Libreria
Rename Library Rinomina libreria
Show Library Mostra libreria
@@ -3485,7 +3533,6 @@ axis Both Entrambi
@msg no bounds nessun limite
@msg no brush named `{name}` nessun pennello denominato `{name}`
@msg no brush selected nessun pennello selezionato
@msg this kind of brush has no options dialog yet questo tipo di pennello non ha ancora una finestra di opzioni
@msg no catalog nessun catalogo
@msg no changes since the last save nessuna modifica dall'ultimo salvataggio
@msg no clock to keep a heartbeat by nessun orologio con cui mantenere il segnale di attività
@@ -3881,3 +3928,48 @@ axis Both Entrambi
Even Fields Campi pari
Duplication Duplicazione
Interpolation Interpolazione
@msg Imported library {name} Libreria {name} importata
RGB: RGB:
CMYK: CMYK:
Texture Texture
Craquelure… Craquelé…
Grain… Granulosità…
Mosaic Tiles… Tessere di mosaico…
Patchwork… Patchwork…
Stained Glass… Vetro colorato…
Texturizer… Applica texture…
Craquelure Craquelé
Grain Granulosità
Mosaic Tiles Tessere di mosaico
Patchwork Patchwork
Stained Glass Vetro colorato
Texturizer Applica texture
Crack Spacing Spaziatura crepe
Crack Depth Profondità crepe
Crack Brightness Luminosità crepe
Intensity Intensità
Grain Type Tipo di granulosità
Tile Size Dimensione tessere
Grout Width Larghezza stuccatura
Lighten Grout Schiarisci stuccatura
Square Size Dimensione quadrati
Relief Rilievo
Border Thickness Spessore bordo
Light Intensity Intensità luce
Light Direction Direzione luce
Sprinkles Spruzzato
Clumped Raggruppato
Contrasty Contrastato
Enlarged Ingrandito
Stippled Punteggiato
Speckle Maculato
Brick Mattone
Burlap Tela di iuta
Canvas Tela
Sandstone Arenaria
Bottom Left In basso a sinistra
Top Left In alto a sinistra
Top Right In alto a destra
Bottom Right In basso a destra
effect Soft Morbido
effect Regular Normale
Can't render this file because it contains an unexpected character in line 2 and column 73.
+94 -1
View File
@@ -1681,6 +1681,37 @@
New Bristle Brush 新規剛毛ブラシ
Calligraphic Brush Options 絵筆ブラシオプション
Bristle Brush Options 剛毛ブラシオプション
Scatter Brush Options 散布ブラシオプション
Art Brush Options アートブラシオプション
Pattern Brush Options パターンブラシオプション
Scatter: 散布:
Rotation: 回転:
Rotation relative to: 回転の基準:
Brush Scale Options ブラシ伸縮オプション
Scale Proportionately 縦横比を保持して拡大・縮小
Stretch to Fit Stroke Length ストロークの長さに合わせて伸縮
Stretch Between Guides ガイド間で伸縮
Start: 始点:
End: 終点:
Left to Right 左から右
Right to Left 右から左
Top to Bottom 上から下
Bottom to Top 下から上
Flip: 反転:
Outer Corner Tile 外角タイル
Side Tile サイドタイル
Inner Corner Tile 内角タイル
Start Tile 始点タイル
End Tile 終点タイル
Fit 調整
Stretch to fit 伸縮して合わせる
Add space to fit 間隔を空けて合わせる
Approximate path パスに近似
Colorization 着色
Tints 淡彩
Tints and Shades 淡彩と陰影
Hue Shift 色相シフト
Key Color: キーカラー:
Roundness: 真円率:
Fixed 固定
Pressure 筆圧
@@ -2496,6 +2527,23 @@
Add to Library ライブラリに追加
Delete Library ライブラリを削除
Delete Library Item ライブラリ項目を削除
No results 結果はありません
No items in this library match “{query}”. このライブラリには「{query}」に一致する項目がありません。
Right-click an item to move it into this group. 項目を右クリックすると、このグループに移動できます。
New Group 新規グループ
New Group… 新規グループ…
Move to Group グループに移動
No Group グループなし
Rename Group… グループ名を変更…
Delete Group グループを削除
Import Library… ライブラリを読み込み…
Export Library… ライブラリを書き出し…
Import Library ライブラリを読み込み
Export Library ライブラリを書き出し
New Library Group 新規ライブラリグループ
Rename Library Group ライブラリグループ名を変更
Delete Library Group ライブラリグループを削除
Move Library Item to Group ライブラリ項目をグループに移動
Library ライブラリ
Rename Library ライブラリ名を変更
Show Library ライブラリを表示
@@ -3300,7 +3348,6 @@ theme Appearance 外観
@msg Expand: no brush strokes selected 拡張: ブラシストロークが選択されていません
@msg no brush named `{name}` `{name}` という名前のブラシが見つかりません
@msg no brush selected ブラシが選択されていません
@msg this kind of brush has no options dialog yet この種類のブラシにはまだオプションダイアログがありません
@msg no symbol instances selected シンボルインスタンスが選択されていません
@msg select a symbol instance シンボルインスタンスを選択してください
@msg the art has no bounds アートに境界がありません
@@ -3873,3 +3920,49 @@ theme Appearance 外観
Even Fields 偶数フィールド
Duplication 複製
Interpolation 補間
@msg Imported library {name} ライブラリ {name} を読み込みました
RGB: RGB:
CMYK: CMYK:
Texture テクスチャ
Craquelure… クラッキング…
Grain… 粒状…
Mosaic Tiles… モザイクタイル…
Patchwork… パッチワーク…
Stained Glass… ステンドグラス…
Texturizer… テクスチャライザー…
Craquelure クラッキング
Grain 粒状
Mosaic Tiles モザイクタイル
Patchwork パッチワーク
Stained Glass ステンドグラス
Texturizer テクスチャライザー
Crack Spacing 溝の間隔
Crack Depth 溝の深さ
Crack Brightness 溝の明るさ
Intensity 強さ
Grain Type 粒子の種類
Tile Size タイルのサイズ
Grout Width 目地の幅
Lighten Grout 目地を明るく
Square Size 正方形のサイズ
Relief レリーフ
Border Thickness 境界線の太さ
Light Intensity 光の強さ
Light Direction 光源の方向
Sprinkles 散布
Clumped 集合
Contrasty 高コントラスト
Enlarged 拡大
Stippled スティップル
Speckle 斑点
Brick レンガ
Burlap 麻布
Canvas カンバス
Sandstone 砂岩
Bottom Left 左下
Top Left 左上
Top Right 右上
Bottom Right 右下
effect Soft ソフト
effect Horizontal 水平
effect Vertical 垂直
Can't render this file because it contains an unexpected character in line 2 and column 73.
+12
View File
@@ -254,6 +254,13 @@ pub fn lang_from_tag(tag: &str) -> Option<Lang> {
cands.iter().find_map(|c| Lang::from_code(c))
}
/// True when `tag` names the generic `C` / `POSIX` locale (or a variant like `C.UTF-8`), which on
/// macOS means "no particular language" rather than English: the system language list decides.
fn is_c_locale(tag: &str) -> bool {
let base = tag.split(['.', '@']).next().unwrap_or("").to_ascii_lowercase();
matches!(base.as_str(), "c" | "posix")
}
/// Work out the system language on a background thread, so the first frame doesn't wait for the
/// locale probe (which runs `reg.exe` on Windows and `defaults` on macOS). Call it at startup; a
/// frame that needs the language before the probe finishes waits for it. A no-op on wasm.
@@ -295,6 +302,11 @@ fn detect_system_lang() -> Lang {
// variables. `LANGUAGE` is a colon-separated priority list.
for var in ["VECTORCRAFT_LOCALE", "LC_ALL", "LC_MESSAGES", "LANG", "LANGUAGE"] {
let Some(v) = std::env::var(var).ok().filter(|v| !v.is_empty()) else { continue };
// `C` / `POSIX` (and variants like `C.UTF-8`, `POSIX.UTF-8`) mean "no particular locale":
// don't force English, fall through to the OS preferred-languages list below.
if v.split(':').all(is_c_locale) {
continue;
}
if let Some(l) = v.split(':').find_map(lang_from_tag) {
return l;
}
+9
View File
@@ -2721,3 +2721,12 @@
Video Vídeo
De-Interlace… Desentrelaçar…
NTSC Colors Cores NTSC
RGB: RGB:
CMYK: CMYK:
Texture Textura
Craquelure… Craquelê…
Grain… Granulação…
Mosaic Tiles… Ladrilhos de mosaico…
Patchwork… Colcha de retalhos…
Stained Glass… Vitral…
Texturizer… Texturizador…
Can't render this file because it contains an unexpected character in line 6 and column 47.
+91 -1
View File
@@ -1584,6 +1584,37 @@
New Bristle Brush Новая щетинная кисть
Calligraphic Brush Options Параметры каллиграфической кисти
Bristle Brush Options Параметры щетинной кисти
Scatter Brush Options Параметры кисти-распыления
Art Brush Options Параметры художественной кисти
Pattern Brush Options Параметры узорной кисти
Scatter: Рассеивание:
Rotation: Поворот:
Rotation relative to: Поворот относительно:
Brush Scale Options Параметры масштабирования кисти
Scale Proportionately Масштабировать пропорционально
Stretch to Fit Stroke Length Растянуть по длине обводки
Stretch Between Guides Растянуть между направляющими
Start: Начало:
End: Конец:
Left to Right Слева направо
Right to Left Справа налево
Top to Bottom Сверху вниз
Bottom to Top Снизу вверх
Flip: Отразить:
Outer Corner Tile Фрагмент внешнего угла
Side Tile Боковой фрагмент
Inner Corner Tile Фрагмент внутреннего угла
Start Tile Начальный фрагмент
End Tile Конечный фрагмент
Fit Подгонка
Stretch to fit Растянуть для подгонки
Add space to fit Добавить интервалы для подгонки
Approximate path Приблизить к контуру
Colorization Перекрашивание
Tints Оттенки
Tints and Shades Оттенки и тени
Hue Shift Сдвиг тона
Key Color: Ключевой цвет:
Roundness: Округлость:
Fixed Фиксированный
Pressure Нажим
@@ -2756,6 +2787,23 @@
Add to Library Добавить в библиотеку
Delete Library Удалить библиотеку
Delete Library Item Удалить элемент библиотеки
No results Ничего не найдено
No items in this library match “{query}”. В этой библиотеке нет элементов, соответствующих «{query}».
Right-click an item to move it into this group. Щёлкните элемент правой кнопкой мыши, чтобы переместить его в эту группу.
New Group Новая группа
New Group… Новая группа…
Move to Group Переместить в группу
No Group Без группы
Rename Group… Переименовать группу…
Delete Group Удалить группу
Import Library… Импортировать библиотеку…
Export Library… Экспортировать библиотеку…
Import Library Импортировать библиотеку
Export Library Экспортировать библиотеку
New Library Group Новая группа библиотеки
Rename Library Group Переименовать группу библиотеки
Delete Library Group Удалить группу библиотеки
Move Library Item to Group Переместить элемент библиотеки в группу
Library Библиотека
Rename Library Переименовать библиотеку
Show Library Показать библиотеку
@@ -3481,7 +3529,6 @@ axis Both Оба
@msg no bounds нет границ
@msg no brush named `{name}` нет кисти с именем `{name}`
@msg no brush selected кисть не выбрана
@msg this kind of brush has no options dialog yet у кисти этого типа пока нет окна параметров
@msg no catalog нет каталога
@msg no changes since the last save нет изменений с момента последнего сохранения
@msg no clock to keep a heartbeat by нет часов для поддержания сигнала
@@ -3879,3 +3926,46 @@ axis Both Оба
Even Fields Чётные поля
Duplication Дублированием
Interpolation Интерполяцией
@msg Imported library {name} Импортирована библиотека {name}
RGB: RGB:
CMYK: CMYK:
Texture Текстура
Craquelure… Кракелюры…
Grain… Зерно…
Mosaic Tiles… Мозаичные фрагменты…
Patchwork… Цветная плитка…
Stained Glass… Витраж…
Texturizer… Текстуризатор…
Craquelure Кракелюры
Grain Зерно
Mosaic Tiles Мозаичные фрагменты
Patchwork Цветная плитка
Stained Glass Витраж
Texturizer Текстуризатор
Crack Spacing Интервал трещин
Crack Depth Глубина трещин
Crack Brightness Яркость трещин
Intensity Интенсивность
Grain Type Тип зерна
Tile Size Размер фрагментов
Grout Width Ширина швов
Lighten Grout Осветление швов
Square Size Размер квадратов
Relief Рельеф
Border Thickness Толщина границ
Light Intensity Интенсивность света
Light Direction Направление света
Sprinkles Брызги
Clumped Комки
Contrasty Контрастное
Enlarged Укрупнённое
Stippled Пунктир
Speckle Крапинки
Brick Кирпич
Burlap Мешковина
Canvas Холст
Sandstone Песчаник
Bottom Left Снизу слева
Top Left Сверху слева
Top Right Сверху справа
Bottom Right Снизу справа
Can't render this file because it contains an unexpected character in line 2 and column 73.
+91 -1
View File
@@ -1580,6 +1580,37 @@
New Bristle Brush Новий щетинний пензель
Calligraphic Brush Options Параметри каліграфічного пензля
Bristle Brush Options Параметри щетинного пензля
Scatter Brush Options Параметри пензля-розпорошення
Art Brush Options Параметри художнього пензля
Pattern Brush Options Параметри візерункового пензля
Scatter: Розсіювання:
Rotation: Поворот:
Rotation relative to: Поворот відносно:
Brush Scale Options Параметри масштабування пензля
Scale Proportionately Масштабувати пропорційно
Stretch to Fit Stroke Length Розтягнути за довжиною обведення
Stretch Between Guides Розтягнути між напрямними
Start: Початок:
End: Кінець:
Left to Right Зліва направо
Right to Left Справа наліво
Top to Bottom Згори вниз
Bottom to Top Знизу вгору
Flip: Відобразити:
Outer Corner Tile Фрагмент зовнішнього кута
Side Tile Бічний фрагмент
Inner Corner Tile Фрагмент внутрішнього кута
Start Tile Початковий фрагмент
End Tile Кінцевий фрагмент
Fit Припасування
Stretch to fit Розтягнути для припасування
Add space to fit Додати інтервали для припасування
Approximate path Наблизити до контуру
Colorization Перефарбування
Tints Відтінки
Tints and Shades Відтінки й тіні
Hue Shift Зсув тону
Key Color: Ключовий колір:
Roundness: Округлість:
Fixed Фіксований
Pressure Натиск
@@ -2752,6 +2783,23 @@
Add to Library Додати до бібліотеки
Delete Library Видалити бібліотеку
Delete Library Item Видалити елемент бібліотеки
No results Нічого не знайдено
No items in this library match “{query}”. У цій бібліотеці немає елементів, що відповідають «{query}».
Right-click an item to move it into this group. Клацніть елемент правою кнопкою миші, щоб перемістити його в цю групу.
New Group Нова група
New Group… Нова група…
Move to Group Перемістити в групу
No Group Без групи
Rename Group… Перейменувати групу…
Delete Group Видалити групу
Import Library… Імпортувати бібліотеку…
Export Library… Експортувати бібліотеку…
Import Library Імпортувати бібліотеку
Export Library Експортувати бібліотеку
New Library Group Нова група бібліотеки
Rename Library Group Перейменувати групу бібліотеки
Delete Library Group Видалити групу бібліотеки
Move Library Item to Group Перемістити елемент бібліотеки в групу
Library Бібліотека
Rename Library Перейменувати бібліотеку
Show Library Показати бібліотеку
@@ -3468,7 +3516,6 @@ axis Both Обидві
@msg no bounds немає кордонів
@msg no brush named `{name}` немає пензля з ім’ям `{name}`
@msg no brush selected пензель не вибрано
@msg this kind of brush has no options dialog yet пензель цього типу ще не має вікна параметрів
@msg no catalog немає каталогу
@msg no changes since the last save немає змін з моменту останнього збереження
@msg no clock to keep a heartbeat by немає годинника для підтримки сигналу
@@ -3864,3 +3911,46 @@ axis Both Обидві
Even Fields Парні поля
Duplication Дублюванням
Interpolation Інтерполяцією
@msg Imported library {name} Імпортовано бібліотеку {name}
RGB: RGB:
CMYK: CMYK:
Texture Текстура
Craquelure… Кракелюри…
Grain… Зерно…
Mosaic Tiles… Мозаїчні фрагменти…
Patchwork… Клаптикова плитка…
Stained Glass… Вітраж…
Texturizer… Текстуризатор…
Craquelure Кракелюри
Grain Зерно
Mosaic Tiles Мозаїчні фрагменти
Patchwork Клаптикова плитка
Stained Glass Вітраж
Texturizer Текстуризатор
Crack Spacing Інтервал тріщин
Crack Depth Глибина тріщин
Crack Brightness Яскравість тріщин
Intensity Інтенсивність
Grain Type Тип зерна
Tile Size Розмір фрагментів
Grout Width Ширина швів
Lighten Grout Освітлення швів
Square Size Розмір квадратів
Relief Рельєф
Border Thickness Товщина меж
Light Intensity Інтенсивність світла
Light Direction Напрямок світла
Sprinkles Бризки
Clumped Грудки
Contrasty Контрастне
Enlarged Збільшене
Stippled Пунктир
Speckle Цятки
Brick Цегла
Burlap Мішковина
Canvas Полотно
Sandstone Пісковик
Bottom Left Знизу ліворуч
Top Left Вгорі ліворуч
Top Right Вгорі праворуч
Bottom Right Знизу праворуч
Can't render this file because it contains an unexpected character in line 659 and column 40.
+88
View File
@@ -1519,6 +1519,34 @@
New Bristle Brush 新建鬃毛画笔
Calligraphic Brush Options 书法画笔选项
Bristle Brush Options 鬃毛画笔选项
Scatter Brush Options 散布画笔选项
Art Brush Options 艺术画笔选项
Pattern Brush Options 图案画笔选项
Scatter: 散布:
Rotation: 旋转:
Rotation relative to: 旋转相对于:
Brush Scale Options 画笔缩放选项
Scale Proportionately 等比缩放
Stretch to Fit Stroke Length 伸展以适合描边长度
Stretch Between Guides 在参考线之间伸展
Start: 起点:
End: 终点:
Left to Right 从左到右
Right to Left 从右到左
Top to Bottom 从上到下
Bottom to Top 从下到上
Flip: 翻转:
Outer Corner Tile 外角拼贴
Side Tile 边线拼贴
Inner Corner Tile 内角拼贴
Start Tile 起点拼贴
End Tile 终点拼贴
Fit 适合
Stretch to fit 伸展以适合
Add space to fit 添加间距以适合
Approximate path 近似路径
Colorization 着色
Key Color: 主色:
Roundness: 圆度:
Fixed 固定
Pressure 压力
@@ -2652,6 +2680,23 @@
Add to Library 添加到库
Delete Library 删除库
Delete Library Item 删除库项目
No results 无结果
No items in this library match “{query}”. 此库中没有与“{query}”匹配的项目。
Right-click an item to move it into this group. 右键单击项目可将其移到此组。
New Group 新建组
New Group… 新建组…
Move to Group 移到组
No Group 无组
Rename Group… 重命名组…
Delete Group 删除组
Import Library… 导入库…
Export Library… 导出库…
Import Library 导入库
Export Library 导出库
New Library Group 新建库组
Rename Library Group 重命名库组
Delete Library Group 删除库组
Move Library Item to Group 将库项目移到组
Library 库
Rename Library 重命名库
Show Library 显示库
@@ -3514,3 +3559,46 @@ axis Both 两者
Even Fields 偶数场
Duplication 复制
Interpolation 插值
Texture 纹理
Craquelure… 龟裂缝…
Grain… 颗粒…
Mosaic Tiles… 马赛克拼贴…
Patchwork… 拼缀图…
Stained Glass… 染色玻璃…
Texturizer… 纹理化…
Craquelure 龟裂缝
Grain 颗粒
Mosaic Tiles 马赛克拼贴
Patchwork 拼缀图
Stained Glass 染色玻璃
Texturizer 纹理化
Crack Spacing 裂缝间距
Crack Depth 裂缝深度
Crack Brightness 裂缝亮度
Intensity 强度
Grain Type 颗粒类型
Tile Size 拼贴大小
Grout Width 缝隙宽度
Lighten Grout 加亮缝隙
Square Size 方形大小
Relief 凸现
Border Thickness 边框粗细
Light Intensity 光照强度
Light Direction 光照方向
Sprinkles 喷洒
Clumped 结块
Contrasty 强反差
Enlarged 扩大
Stippled 点刻
Speckle 斑点
Brick 砖形
Burlap 粗麻布
Canvas 画布
Sandstone 砂岩
Bottom Left 左下
Top Left 左上
Top Right 右上
Bottom Right 右下
effect Soft 柔和
effect Horizontal 水平
effect Vertical 垂直
Can't render this file because it contains an unexpected character in line 2 and column 73.
+91
View File
@@ -1563,6 +1563,37 @@
New Bristle Brush 新增鬃毛筆刷
Calligraphic Brush Options 書法筆刷選項
Bristle Brush Options 鬃毛筆刷選項
Scatter Brush Options 散佈筆刷選項
Art Brush Options 藝術筆刷選項
Pattern Brush Options 圖樣筆刷選項
Scatter: 散佈:
Rotation: 旋轉:
Rotation relative to: 旋轉相對於:
Brush Scale Options 筆刷縮放選項
Scale Proportionately 等比例縮放
Stretch to Fit Stroke Length 延伸以符合筆畫長度
Stretch Between Guides 在參考線之間延伸
Start: 起點:
End: 終點:
Left to Right 由左至右
Right to Left 由右至左
Top to Bottom 由上而下
Bottom to Top 由下而上
Flip: 翻轉:
Outer Corner Tile 外角拼貼
Side Tile 邊線拼貼
Inner Corner Tile 內角拼貼
Start Tile 起點拼貼
End Tile 終點拼貼
Fit 符合
Stretch to fit 延伸以符合
Add space to fit 增加間距以符合
Approximate path 近似路徑
Colorization 上色
Tints 淺色
Tints and Shades 淺色與暗色
Hue Shift 色相偏移
Key Color: 主要顏色:
Roundness: 圓度:
Fixed 固定
Pressure 壓力
@@ -2738,6 +2769,23 @@
Add to Library 加入資料庫
Delete Library 刪除資料庫
Delete Library Item 刪除資料庫項目
No results 沒有結果
No items in this library match “{query}”. 此資料庫中沒有符合「{query}」的項目。
Right-click an item to move it into this group. 在項目上按一下右鍵,即可將其移至此群組。
New Group 新增群組
New Group… 新增群組…
Move to Group 移至群組
No Group 無群組
Rename Group… 重新命名群組…
Delete Group 刪除群組
Import Library… 匯入資料庫…
Export Library… 匯出資料庫…
Import Library 匯入資料庫
Export Library 匯出資料庫
New Library Group 新增資料庫群組
Rename Library Group 重新命名資料庫群組
Delete Library Group 刪除資料庫群組
Move Library Item to Group 將資料庫項目移至群組
Library 資料庫
Rename Library 重新命名資料庫
Show Library 顯示資料庫
@@ -3292,3 +3340,46 @@ axis Both 兩者
Even Fields 偶數圖場
Duplication 複製
Interpolation 內插
RGB: RGB:
CMYK: CMYK:
Texture 紋理
Craquelure… 龜裂紋…
Grain… 顆粒…
Mosaic Tiles… 馬賽克拼貼…
Patchwork… 拼綴圖…
Stained Glass… 彩色玻璃…
Texturizer… 紋理化…
Craquelure 龜裂紋
Grain 顆粒
Mosaic Tiles 馬賽克拼貼
Patchwork 拼綴圖
Stained Glass 彩色玻璃
Texturizer 紋理化
Crack Spacing 裂紋間距
Crack Depth 裂紋深度
Crack Brightness 裂紋亮度
Intensity 強度
Grain Type 顆粒類型
Tile Size 拼貼大小
Grout Width 縫隙寬度
Lighten Grout 加亮縫隙
Square Size 方塊大小
Relief 浮雕
Border Thickness 邊框粗細
Light Intensity 光源強度
Light Direction 光源方向
Sprinkles 噴灑
Clumped 結塊
Contrasty 高反差
Enlarged 放大
Stippled 點刻
Speckle 斑點
Brick 磚塊
Burlap 粗麻布
Canvas 畫布
Sandstone 砂岩
Bottom Left 左下
Top Left 左上
Top Right 右上
Bottom Right 右下
effect Soft 柔和
Can't render this file because it contains an unexpected character in line 6 and column 47.
+5 -2
View File
@@ -15,8 +15,8 @@ use crate::{FilePick, Services, VectorcraftApp, dialogs};
const TEMPLATE_EXTS: &[&str] = &["vctemplate", "ait", "vectorcraft", "drawcraft"];
/// Open bytes of any readable format as a new document (templates open untitled); swatch and
/// graphic style library files open in the library panel and flattener, PDF, print and perspective
/// grid presets files are imported. → `document.open`'s result for a document opened now (its
/// graphic style library files open in the library panel, Libraries panel files are added to it,
/// and flattener, PDF, print and perspective grid presets files are imported. → `document.open`'s result for a document opened now (its
/// `warnings` say what didn't come in as it was), else null.
pub fn open_bytes(app: &mut VectorcraftApp, name: &str, bytes: &[u8], path: Option<String>) -> Result<Value, String> {
let ext = fileio::extension(name);
@@ -39,6 +39,9 @@ pub fn open_bytes(app: &mut VectorcraftApp, name: &str, bytes: &[u8], path: Opti
app.status(format!("Imported {what}: {}", names.join(", ")));
return Ok(Value::Null);
}
if vectorcraft_engine::cmd::library::LIBRARY_EXTS.contains(&ext.as_str()) {
return crate::panels::libraries::import(app, json!({"data": String::from_utf8_lossy(bytes)})).map(|_| Value::Null);
}
let swatches = vectorcraft_engine::cmd::swatchlib::LIBRARY_EXTS.contains(&ext.as_str());
if swatches || ext == vectorcraft_doc::style_libs::STYLES_EXT {
let p = match path {
+8 -3
View File
@@ -346,7 +346,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
"ui.brushOptions",
"Brush Options…",
"",
"{name?} (default: the selected path's brush, else the current brush) open its Brush Options, what double-clicking a brush in the Brushes panel opens: Calligraphic (dialog `brushOptions`, fields name, angle −180..180, roundness 0..100, size 0..1296 pt, angleMode/roundnessMode/sizeMode: fixed|random|pressure, angleVariation/roundnessVariation/sizeVariation) or Bristle (fields name, shape, size, length, density, thickness, opacity, stiffness); OK runs brush.options. Other brush types: error",
"{name?} (default: the selected path's brush, else the current brush) open its Brush Options, what double-clicking a brush in the Brushes panel opens (dialog `brushOptions`): Calligraphic (fields name, angle −180..180, roundness 0..100, size 0..1296 pt, angleMode/roundnessMode/sizeMode: fixed|random|pressure, angleVariation/roundnessVariation/sizeVariation), Bristle (fields name, shape, size, length, density, thickness, opacity, stiffness), Scatter (fields sizeMin/sizeMax/sizeMode, spacingMin/spacingMax/spacingMode, scatterMin/scatterMax/scatterMode, rotationMin/rotationMax/rotationMode with modes fixed|random|pressure, rotationRelativeTo: page|path), Art (fields width %, scaleMode: proportional|stretch|betweenGuides, guideStart/guideEnd % of the art length, direction: leftToRight|rightToLeft|topToBottom|bottomToTop, flipAlong, flipAcross) or Pattern (fields scale %, spacing %, fit: stretch|addSpace|approximate, flipAlong, flipAcross); Scatter, Art and Pattern also colorization: none|tints|tintsAndShades|hueShift and keyColor; OK runs brush.options",
),
(
"ui.graphicStyleOptions",
@@ -3305,8 +3305,13 @@ fn font_items(english_names: bool) -> Vec<Item> {
/// The raster effects' submenus of the Effect menu (the Photoshop-style effects, below the
/// vector effects): (submenu, the catalogue's menu path of its effects).
const RASTER_MENUS: [(&str, &[&str]); 4] =
[("Blur", &["Effect", "Blur"]), ("Pixelate", &["Effect", "Pixelate"]), ("Sharpen", &["Effect", "Sharpen"]), ("Video", &["Effect", "Video"])];
const RASTER_MENUS: [(&str, &[&str]); 5] = [
("Blur", &["Effect", "Blur"]),
("Pixelate", &["Effect", "Pixelate"]),
("Sharpen", &["Effect", "Sharpen"]),
("Texture", &["Effect", "Texture"]),
("Video", &["Effect", "Video"]),
];
/// The Effect menu, built from the effects catalogue (vector effects), plus raster effects.
fn effect_menu() -> Vec<Item> {
+1 -2
View File
@@ -89,7 +89,6 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
if group.is_empty() {
continue;
}
let has_options = brush_options::TYPES.contains(&ty);
let row = |ui: &mut Ui, name: &str, size: egui::Vec2, label: bool| -> egui::Response {
let (r, resp) = ui.allocate_exact_size(size, Sense::click_and_drag());
// Dragged onto a path, the brush is applied to it.
@@ -121,7 +120,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
};
// A click applies the brush; the second click of a double-click opens its options.
let mut pick = |resp: egui::Response, name: &String| {
if resp.double_clicked() && has_options {
if resp.double_clicked() {
options = Some(name.clone());
} else if resp.clicked() {
clicked = Some(name.clone());
+405 -122
View File
@@ -4,16 +4,20 @@
//! graphic, fill or stroke colour or text styles. A colour click paints the selection through the
//! active Fill/Stroke proxy, a style click applies it to the selected type, and a graphic is
//! dragged onto the canvas (or double-clicked) to place a copy. Libraries are kept on this machine.
//!
//! The search field filters the items by name; items can be moved into user-named groups, shown as
//! collapsible sections after the ungrouped items; and the panel menu exports the library to a
//! `.vclibrary` file and imports one as a new library (#926).
use std::collections::HashMap;
use egui::{Rect, Sense, Stroke, StrokeKind, Ui, vec2};
use serde_json::{Value, json};
use vectorcraft_color::Paint;
use vectorcraft_engine::cmd::library::Library;
use vectorcraft_engine::cmd::library::{ItemKind, LIBRARY_EXT, LIBRARY_EXTS, Library, LibraryGraphic};
use crate::theme::Tokens;
use crate::widgets::{self, PanelDrag, menu_item};
use crate::widgets::{self, PanelDrag, menu_item, menu_item_name};
use crate::{VectorcraftApp, icons};
/// The size of a graphic's tile.
@@ -21,20 +25,28 @@ const TILE: f32 = 56.0;
/// The size of a colour chip.
const CHIP: f32 = 22.0;
/// The current library: its id and a copy of it (the panel acts on it after reading).
/// The current library: its id and a copy of it (the panel menu acts on it after reading).
fn current(app: &VectorcraftApp) -> Option<(String, Library)> {
let libs = &app.session.libraries;
let id = libs.current()?;
Some((id.to_string(), libs.get(id)?.clone()))
}
/// Run `cmd`, reporting a failure in the status bar.
fn run(app: &mut VectorcraftApp, cmd: &str, p: Value) {
if let Err(e) = app.run(cmd, p) {
/// Report a failure in the status bar (a file dialog closed or still open says nothing).
fn report<T>(app: &mut VectorcraftApp, r: Result<T, String>) {
if let Err(e) = r
&& e != "cancelled"
{
app.status(e);
}
}
/// Run `cmd`, reporting a failure in the status bar.
fn run(app: &mut VectorcraftApp, cmd: &str, p: Value) {
let r = app.run(cmd, p);
report(app, r);
}
/// The name dialog for a new library, or for renaming the current one.
fn ask_name(app: &mut VectorcraftApp, rename: Option<&str>) {
let (cmd, label, name) = match rename {
@@ -44,9 +56,46 @@ fn ask_name(app: &mut VectorcraftApp, rename: Option<&str>) {
app.ui.dialog = Some(crate::state::Dialog::new("command", json!({ "__command": cmd, "__label": label, "name": name })));
}
/// The name dialog for a new group of `library` (`items`: `[{kind, item}]` moved into it), or for
/// renaming group `rename`.
fn ask_group_name(app: &mut VectorcraftApp, library: &str, rename: Option<&str>, items: Value) {
let (cmd, label, name, fixed) = match rename {
Some(g) => ("library.renameGroup", "Rename Library Group", g, json!({ "library": library, "group": g })),
None => ("library.createGroup", "New Library Group", "Group", json!({ "library": library, "items": items })),
};
let d = json!({ "__command": cmd, "__label": label, "name": name, "__params": fixed });
app.ui.dialog = Some(crate::state::Dialog::new("command", d));
}
/// What a click in the panel asks for, done once the list is drawn.
enum Act {
Run(&'static str, Value),
/// The New Group dialog; the items (`[{kind, item}]`) go into the new group.
NewGroup(Value),
RenameGroup(String),
}
/// What the item list draws from.
struct Items<'a> {
library: &'a str,
lib: &'a Library,
/// The group of each grouped item ([`Library::group_index`]).
groups: HashMap<(ItemKind, &'a str), usize>,
fill_active: bool,
}
impl Items<'_> {
fn group_of(&self, kind: ItemKind, key: &str) -> Option<usize> {
self.groups.get(&(kind, key)).copied()
}
}
pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
let Some((lib_id, lib)) = current(app) else {
let fill_active = app.session.fill_active;
let has_doc = app.session.active().is_some();
let libs = &app.session.libraries;
let Some((lib_id, lib)) = libs.current().and_then(|id| Some((id.to_string(), libs.get(id)?))) else {
ui.add_space(16.0);
ui.vertical_centered(|ui| {
icons::icon(ui, "library", 40.0, t.text_dim);
@@ -60,18 +109,27 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
});
return;
};
let mut act: Option<Act> = None;
// The library picker.
let libs: Vec<(String, String)> = app.session.libraries.all().iter().map(|(i, l)| (i.clone(), l.name.clone())).collect();
let names: Vec<&str> = libs.iter().map(|(_, n)| n.as_str()).collect();
if let Some(i) = widgets::dropdown_names(ui, "lib-picker", &lib.name, &names, ui.available_width())
&& let Some((id, _)) = libs.get(i)
{
run(app, "library.setCurrent", json!({ "library": id }));
let names: Vec<&str> = libs.all().iter().map(|(_, l)| l.name.as_str()).collect();
if let Some((id, _)) = widgets::dropdown_names(ui, "lib-picker", &lib.name, &names, ui.available_width()).and_then(|i| libs.all().get(i)) {
act = Some(Act::Run("library.setCurrent", json!({ "library": id })));
}
ui.add_space(4.0);
let empty = lib.items().next().is_none() && lib.groups.is_empty();
let query = if empty { String::new() } else { widgets::search_field(ui, egui::Id::new("library-search"), tl!("Search")) };
let query = query.trim().to_lowercase();
let searching = !query.is_empty();
let shown = |name: &str| !searching || name.to_lowercase().contains(&query);
let items = Items { library: &lib_id, lib, groups: lib.group_index(), fill_active };
// The items shown in each section: the ungrouped ones first, then each group's.
let mut counts = vec![0usize; lib.groups.len() + 1];
for (kind, key, _) in lib.items().filter(|(_, _, name)| shown(name)) {
if let Some(n) = counts.get_mut(items.group_of(kind, key).map_or(0, |i| i + 1)) {
*n += 1;
}
}
ui.add_space(4.0);
let empty = lib.colors.is_empty() && lib.char_styles.is_empty() && lib.para_styles.is_empty() && lib.graphics.is_empty();
let mut act: Option<(&'static str, Value)> = None;
let fill_active = app.session.fill_active;
let list_rect = widgets::list_box(ui, |ui| {
ui.set_min_height(140.0);
ui.set_width(ui.available_width());
@@ -84,101 +142,53 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
);
return ui.min_rect();
}
if searching && counts.iter().all(|n| *n == 0) {
let body = crate::i18n::fmt(tl!("No items in this library match “{query}”."), &[("query", &query)]);
super::empty_state(ui, "search", tl!("No results"), &body);
return ui.min_rect();
}
egui::Frame::NONE.inner_margin(egui::Margin::same(6)).show(ui, |ui| {
if !lib.colors.is_empty() {
heading(ui, tl!("Colors"));
ui.horizontal_wrapped(|ui| {
ui.spacing_mut().item_spacing = vec2(4.0, 4.0);
for c in &lib.colors {
let (r, resp) = ui.allocate_exact_size(vec2(CHIP, CHIP), Sense::click_and_drag());
let paint = Paint::solid(c.color);
widgets::swatch_tile(ui, r, &paint, false, false);
ui.painter().rect_stroke(r, 0.0, Stroke::new(1.0, t.border), StrokeKind::Inside);
widgets::drag_source(ui, &resp, || PanelDrag::Paint {
paint: paint.clone(),
params: json!({ "color": c.color }),
rows: None,
});
let resp = resp.on_hover_text(&c.name);
if resp.clicked() {
act = Some((
"library.use",
json!({ "kind": "fillColor", "item": c.name, "to": if fill_active { "fill" } else { "stroke" } }),
));
}
resp.context_menu(|ui| {
if menu_item(ui, "Delete", true, false) {
act = Some(("library.removeItem", json!({ "kind": "fillColor", "item": c.name })));
ui.close();
}
});
}
});
ui.add_space(6.0);
}
for (para, list, title) in [(false, &lib.char_styles, tl!("Character Styles")), (true, &lib.para_styles, tl!("Paragraph Styles"))] {
if list.is_empty() {
items.show(ui, &mut act, |kind, key, name| items.group_of(kind, key).is_none() && shown(name));
for (i, g) in lib.groups.iter().enumerate() {
let n = counts.get(i + 1).copied().unwrap_or_default();
if searching && n == 0 {
continue;
}
heading(ui, title);
let kind = if para { "paraStyle" } else { "charStyle" };
for st in list {
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 22.0), Sense::click());
if resp.hovered() {
ui.painter().rect_filled(r, 0.0, t.hover);
}
icons::paint(
ui,
if para { "pilcrow" } else { "type" },
Rect::from_center_size(r.left_center() + vec2(10.0, 0.0), vec2(14.0, 14.0)),
t.icon,
);
ui.painter().text(
r.left_center() + vec2(24.0, 0.0),
egui::Align2::LEFT_CENTER,
&st.name,
egui::FontId::proportional(12.5),
t.text,
);
let resp = resp.on_hover_text(tl!("Click to apply to the selected type"));
if resp.clicked() {
act = Some(("library.use", json!({ "kind": kind, "item": st.name })));
}
resp.context_menu(|ui| {
if menu_item(ui, "Delete", true, false) {
act = Some(("library.removeItem", json!({ "kind": kind, "item": st.name })));
ui.close();
}
});
// Open unless closed; open while searching, to show what matched.
let key = format!("library-group-closed:{lib_id}:{}", g.name);
let closed: bool = super::pstate(ui.ctx(), &key);
let open = searching || !closed;
let header = widgets::section_toggle_name(ui, &g.name, open);
ui.painter().text(
header.rect.right_center() - vec2(6.0, 0.0),
egui::Align2::RIGHT_CENTER,
n.to_string(),
egui::FontId::proportional(11.5),
t.text_dim,
);
if header.clicked() && !searching {
super::set_pstate(ui.ctx(), &key, !closed);
}
ui.add_space(6.0);
}
if !lib.graphics.is_empty() {
heading(ui, tl!("Graphics"));
ui.horizontal_wrapped(|ui| {
ui.spacing_mut().item_spacing = vec2(4.0, 4.0);
for g in &lib.graphics {
let (r, resp) = ui.allocate_exact_size(vec2(TILE, TILE), Sense::click_and_drag());
tile(ui, r, &lib_id, g);
ui.painter().rect_stroke(r, 0.0, Stroke::new(1.0, if resp.hovered() { t.accent } else { t.border }), StrokeKind::Inside);
// Dragged onto the canvas, a copy is placed where it is dropped.
widgets::drag_source(ui, &resp, || PanelDrag::LibraryGraphic { library: lib_id.clone(), item: g.id.clone() });
let resp = resp.on_hover_text(&g.name);
if resp.double_clicked() {
act = Some(("library.use", json!({ "kind": "graphic", "item": g.id })));
}
resp.context_menu(|ui| {
if menu_item(ui, "Place", true, false) {
act = Some(("library.use", json!({ "kind": "graphic", "item": g.id })));
ui.close();
}
if menu_item(ui, "Delete", true, false) {
act = Some(("library.removeItem", json!({ "kind": "graphic", "item": g.id })));
ui.close();
}
});
header.context_menu(|ui| {
if menu_item(ui, "Rename Group…", true, false) {
act = Some(Act::RenameGroup(g.name.clone()));
ui.close();
}
if menu_item(ui, "Delete Group", true, false) {
act = Some(Act::Run("library.deleteGroup", json!({ "group": g.name })));
ui.close();
}
});
if !open {
continue;
}
ui.add_space(2.0);
if n == 0 {
ui.label(egui::RichText::new(tl!("Right-click an item to move it into this group.")).size(11.5).color(t.text_dim));
ui.add_space(6.0);
} else {
items.show(ui, &mut act, |kind, key, name| items.group_of(kind, key) == Some(i) && shown(name));
}
}
});
ui.min_rect()
@@ -186,7 +196,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
// Art dragged off the canvas, or a swatch, dropped on the list is added to the library.
let zone = ui.interact(list_rect, ui.id().with("libraries-drop"), Sense::hover());
if let Some(ids) = widgets::art_drop(ui, &zone) {
act = Some(("library.add", json!({ "kind": "graphic", "ids": ids })));
act = Some(Act::Run("library.add", json!({ "kind": "graphic", "ids": ids })));
}
if let Some(drag) = zone.dnd_hover_payload::<PanelDrag>()
&& let PanelDrag::Paint { paint: Paint::Solid { color, .. }, .. } = &*drag
@@ -194,21 +204,13 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
ui.painter().rect_stroke(zone.rect, 0.0, Stroke::new(1.5, t.accent), StrokeKind::Inside);
let color = *color;
if zone.dnd_release_payload::<PanelDrag>().is_some() {
act = Some(("library.add", json!({ "kind": "fillColor", "color": color })));
act = Some(Act::Run("library.add", json!({ "kind": "fillColor", "color": color })));
}
}
if let Some((cmd, mut p)) = act {
p["library"] = json!(lib_id);
if cmd == "library.use" && p["kind"] == "graphic" {
// Placed at the centre of the view.
if let Some(v) = app.view() {
p["center"] = json!([v.center.x, v.center.y]);
}
}
run(app, cmd, p);
}
let has_doc = app.session.active().is_some();
widgets::bottom_bar(ui, |ui| {
if widgets::icon_button(ui, "group", tl!("New Group"), false, 24.0).clicked() {
act = Some(Act::NewGroup(json!([])));
}
let add = widgets::icon_button_enabled(ui, "plus", tl!("Add Content"), false, has_doc, 24.0);
egui::Popup::menu(&add).show(|ui| {
for (label, kind) in [
@@ -219,12 +221,154 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
("Paragraph Style", "paraStyle"),
] {
if menu_item(ui, label, true, false) {
run(app, "library.add", json!({ "library": lib_id, "kind": kind }));
act = Some(Act::Run("library.add", json!({ "kind": kind })));
ui.close();
}
}
});
});
match act {
Some(Act::Run(cmd, mut p)) => {
if cmd != "library.setCurrent" {
p["library"] = json!(lib_id);
}
if cmd == "library.use" && p["kind"] == "graphic" {
// Placed at the centre of the view.
if let Some(v) = app.view() {
p["center"] = json!([v.center.x, v.center.y]);
}
}
run(app, cmd, p);
}
Some(Act::NewGroup(items)) => ask_group_name(app, &lib_id, None, items),
Some(Act::RenameGroup(g)) => ask_group_name(app, &lib_id, Some(&g), Value::Null),
None => {}
}
}
impl Items<'_> {
/// The colours, character and paragraph styles and graphics `keep` keeps (given the kind, key
/// and name of each), each kind under its heading.
fn show(&self, ui: &mut Ui, act: &mut Option<Act>, keep: impl Fn(ItemKind, &str, &str) -> bool) {
let t = Tokens::get(ui.ctx());
let lib = self.lib;
let colors: Vec<_> = lib.colors.iter().filter(|c| keep(ItemKind::Color, &c.name, &c.name)).collect();
if !colors.is_empty() {
heading(ui, tl!("Colors"));
ui.horizontal_wrapped(|ui| {
ui.spacing_mut().item_spacing = vec2(4.0, 4.0);
for c in colors {
let (r, resp) = ui.allocate_exact_size(vec2(CHIP, CHIP), Sense::click_and_drag());
let paint = Paint::solid(c.color);
widgets::swatch_tile(ui, r, &paint, false, false);
ui.painter().rect_stroke(r, 0.0, Stroke::new(1.0, t.border), StrokeKind::Inside);
widgets::drag_source(ui, &resp, || PanelDrag::Paint { paint: paint.clone(), params: json!({ "color": c.color }), rows: None });
let resp = resp.on_hover_text(&c.name);
if resp.clicked() {
let to = if self.fill_active { "fill" } else { "stroke" };
*act = Some(Act::Run("library.use", json!({ "kind": "fillColor", "item": c.name, "to": to })));
}
resp.context_menu(|ui| self.item_menu(ui, act, ItemKind::Color, &c.name));
}
});
ui.add_space(6.0);
}
for (kind, list, title) in
[(ItemKind::CharStyle, &lib.char_styles, tl!("Character Styles")), (ItemKind::ParaStyle, &lib.para_styles, tl!("Paragraph Styles"))]
{
let list: Vec<_> = list.iter().filter(|s| keep(kind, &s.name, &s.name)).collect();
if list.is_empty() {
continue;
}
heading(ui, title);
for st in list {
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 22.0), Sense::click());
if resp.hovered() {
ui.painter().rect_filled(r, 0.0, t.hover);
}
let icon = if kind == ItemKind::ParaStyle { "pilcrow" } else { "type" };
icons::paint(ui, icon, Rect::from_center_size(r.left_center() + vec2(10.0, 0.0), vec2(14.0, 14.0)), t.icon);
ui.painter().text(r.left_center() + vec2(24.0, 0.0), egui::Align2::LEFT_CENTER, &st.name, egui::FontId::proportional(12.5), t.text);
let resp = resp.on_hover_text(tl!("Click to apply to the selected type"));
if resp.clicked() {
*act = Some(Act::Run("library.use", json!({ "kind": kind_param(kind), "item": st.name })));
}
resp.context_menu(|ui| self.item_menu(ui, act, kind, &st.name));
}
ui.add_space(6.0);
}
let graphics: Vec<_> = lib.graphics.iter().filter(|g| keep(ItemKind::Graphic, &g.id, &g.name)).collect();
if !graphics.is_empty() {
heading(ui, tl!("Graphics"));
ui.horizontal_wrapped(|ui| {
ui.spacing_mut().item_spacing = vec2(4.0, 4.0);
for g in graphics {
let (r, resp) = ui.allocate_exact_size(vec2(TILE, TILE), Sense::click_and_drag());
tile(ui, r, self.library, g);
ui.painter().rect_stroke(r, 0.0, Stroke::new(1.0, if resp.hovered() { t.accent } else { t.border }), StrokeKind::Inside);
// Dragged onto the canvas, a copy is placed where it is dropped.
widgets::drag_source(ui, &resp, || PanelDrag::LibraryGraphic { library: self.library.to_string(), item: g.id.clone() });
let resp = resp.on_hover_text(&g.name);
let place = || Act::Run("library.use", json!({ "kind": "graphic", "item": g.id }));
if resp.double_clicked() {
*act = Some(place());
}
resp.context_menu(|ui| {
if menu_item(ui, "Place", true, false) {
*act = Some(place());
ui.close();
}
self.item_menu(ui, act, ItemKind::Graphic, &g.id);
});
}
});
ui.add_space(6.0);
}
}
/// An item's context menu entries: Move to Group (No Group, the library's groups, New
/// Group…) and Delete.
fn item_menu(&self, ui: &mut Ui, act: &mut Option<Act>, kind: ItemKind, key: &str) {
let at = self.group_of(kind, key);
let item = json!({ "kind": kind_param(kind), "item": key });
ui.menu_button(format!(" {}", tl!("Move to Group")), |ui| {
if menu_item(ui, "No Group", true, at.is_none()) {
if at.is_some() {
*act = Some(Act::Run("library.moveItem", item.clone()));
}
ui.close();
}
for (i, g) in self.lib.groups.iter().enumerate() {
if menu_item_name(ui, &g.name, true, at == Some(i)) {
if at != Some(i) {
let mut p = item.clone();
p["group"] = json!(g.name);
*act = Some(Act::Run("library.moveItem", p));
}
ui.close();
}
}
ui.separator();
if menu_item(ui, "New Group…", true, false) {
*act = Some(Act::NewGroup(json!([item])));
ui.close();
}
});
if menu_item(ui, "Delete", true, false) {
*act = Some(Act::Run("library.removeItem", item));
ui.close();
}
}
}
/// `kind` as the `library.*` commands' `kind` param (a colour as a fill colour).
fn kind_param(kind: ItemKind) -> &'static str {
match kind {
ItemKind::Graphic => "graphic",
ItemKind::CharStyle => "charStyle",
ItemKind::ParaStyle => "paraStyle",
ItemKind::Color | ItemKind::Unknown => "fillColor",
}
}
/// A section heading in the list.
@@ -235,7 +379,7 @@ fn heading(ui: &mut Ui, text: &str) {
}
/// Graphic `g`'s thumbnail on white in `r`.
fn tile(ui: &Ui, r: Rect, library: &str, g: &vectorcraft_engine::cmd::library::LibraryGraphic) {
fn tile(ui: &Ui, r: Rect, library: &str, g: &LibraryGraphic) {
ui.painter().rect_filled(r, 0.0, egui::Color32::WHITE);
if let Some(tex) = thumbnail(ui.ctx(), library, g) {
let size = tex.size_vec2();
@@ -246,7 +390,7 @@ fn tile(ui: &Ui, r: Rect, library: &str, g: &vectorcraft_engine::cmd::library::L
}
/// The texture of graphic `g`'s PNG thumbnail, decoded once.
fn thumbnail(ctx: &egui::Context, library: &str, g: &vectorcraft_engine::cmd::library::LibraryGraphic) -> Option<egui::TextureHandle> {
fn thumbnail(ctx: &egui::Context, library: &str, g: &LibraryGraphic) -> Option<egui::TextureHandle> {
thread_local! {
static CACHE: crate::graphics::TexCache<HashMap<(String, String, usize), Option<egui::TextureHandle>>> = crate::graphics::TexCache::default();
}
@@ -278,7 +422,37 @@ pub(crate) fn chip(app: &VectorcraftApp, ui: &Ui, r: Rect, library: &str, item:
}
}
/// The panel's ≡ menu: Create New Library, Rename Library…, Delete Library.
/// Import a library file (`library.import`'s params), saying so in the status bar.
pub(crate) fn import(app: &mut VectorcraftApp, p: Value) -> Result<Value, String> {
let r = app.run("library.import", p)?;
let name = r["name"].as_str().unwrap_or_default().to_string();
app.status(format!("Imported library {name}"));
Ok(r)
}
/// Import Library…: a picked `.vclibrary` file becomes a new library (on the web the picked file
/// arrives later through [`crate::io::open_bytes`]).
fn import_picked(app: &mut VectorcraftApp) {
if let Some(f) = app.services.open_async.as_mut() {
f();
return;
}
let pick = crate::FilePick { filters: vec![("Libraries", LIBRARY_EXTS)], ..Default::default() };
if let Some(path) = crate::picks::open(app, &pick) {
let r = import(app, json!({ "path": path }));
report(app, r);
}
}
/// Export Library…: library `id` written to a picked file named after it (the web downloads it).
fn export_picked(app: &mut VectorcraftApp, id: &str, name: &str) {
let file = format!("{}.{LIBRARY_EXT}", vectorcraft_engine::cmd::library::file_stem_for(name));
let r = crate::io::save_command_output_named(app, "library.export", &file, json!({ "library": id }));
report(app, r);
}
/// The panel's ≡ menu: Create New Library, Rename Library…, Delete Library, New Group…, Import
/// Library…, Export Library….
pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) {
let cur = current(app);
if menu_item(ui, "Create New Library…", true, false) {
@@ -304,6 +478,24 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) {
);
ui.close();
}
ui.separator();
if menu_item(ui, "New Group…", cur.is_some(), false)
&& let Some((id, _)) = &cur
{
ask_group_name(app, id, None, json!([]));
ui.close();
}
ui.separator();
if menu_item(ui, "Import Library…", true, false) {
crate::picks::button(app, import_picked);
ui.close();
}
if menu_item(ui, "Export Library…", cur.is_some(), false)
&& let Some((id, lib)) = cur
{
crate::picks::button(app, move |app| export_picked(app, &id, &lib.name));
ui.close();
}
}
#[cfg(test)]
@@ -315,6 +507,7 @@ mod tests {
fn frame(app: &mut VectorcraftApp) {
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| {
show(app, ui);
menu(app, ui);
@@ -345,4 +538,94 @@ mod tests {
let ctx = egui::Context::default();
assert!(thumbnail(&ctx, "Brand", &lib.graphics[0]).is_some());
}
/// The panel's text: every label it shows in one frame (`query` typed in the search field).
fn texts(app: &mut VectorcraftApp, query: &str) -> Vec<String> {
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
ctx.data_mut(|d| d.insert_temp(egui::Id::new("library-search"), query.to_string()));
let mut shapes = vec![];
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| show(app, ui));
out.textures_delta.clear();
shapes.append(&mut out.shapes);
let mut texts = vec![];
for s in shapes {
if let egui::epaint::Shape::Text(t) = s.shape {
texts.push(t.galley.text().to_string());
}
}
texts
}
/// The search field filters items by name, ignoring case; groups show after the ungrouped
/// items, and a search nothing matches says so (#926).
#[test]
fn the_panel_searches_and_shows_groups() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 200, "height": 200})).unwrap();
app.run("library.create", json!({"name": "Brand"})).unwrap();
for (name, color) in [("Ocean Blue", "#0044aa"), ("Sun Yellow", "#ffcc00")] {
app.run("library.add", json!({"kind": "fillColor", "color": color, "name": name})).unwrap();
}
let t = app.run("text.create", json!({"x": 10, "y": 80, "text": "Type"})).unwrap()["id"].clone();
app.run("select.set", json!({"ids": [t]})).unwrap();
app.run("library.add", json!({"kind": "charStyle", "name": "Headline"})).unwrap();
app.run("library.createGroup", json!({"name": "Warm", "items": [{"kind": "fillColor", "item": "Sun Yellow"}]})).unwrap();
let all = texts(&mut app, "");
let at = |s: &str| all.iter().position(|t| t == s);
assert!(at("Headline").is_some() && at("Warm").is_some(), "{all:?}");
assert!(at("Headline") < at("Warm"), "groups come after the ungrouped items: {all:?}");
// "BLUE" finds the blue swatch only: the style and the group without a match are hidden.
let found = texts(&mut app, "BLUE");
assert!(!found.iter().any(|t| t == "Headline" || t == "Warm"), "{found:?}");
let found = texts(&mut app, "yellow");
assert!(found.iter().any(|t| t == "Warm") && !found.iter().any(|t| t == "Headline"), "{found:?}");
let none = texts(&mut app, "zzz");
assert!(none.iter().any(|t| t == "No results"), "{none:?}");
}
/// New Group… and Rename Group… ask for a name and act on the library and group they came
/// from; the dialog shows only the name.
#[test]
fn group_dialogs_run_with_their_library_and_group() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 200, "height": 200})).unwrap();
let lib = app.run("library.create", json!({"name": "Brand"})).unwrap()["id"].as_str().unwrap().to_string();
app.run("library.add", json!({"kind": "fillColor", "color": "#0044aa", "name": "Blue"})).unwrap();
ask_group_name(&mut app, &lib, None, json!([{"kind": "fillColor", "item": "Blue"}]));
app.ui.dialog.as_mut().unwrap().fields.insert("name".into(), json!("Cool"));
crate::dialogs::confirm(&mut app).unwrap();
ask_group_name(&mut app, &lib, Some("Cool"), Value::Null);
app.ui.dialog.as_mut().unwrap().fields.insert("name".into(), json!("Cold"));
crate::dialogs::confirm(&mut app).unwrap();
let got = app.run("library.get", json!({})).unwrap();
assert_eq!(got["groups"], json!([{"name": "Cold", "items": [{"kind": "color", "item": "Blue"}]}]));
}
/// Export Library… writes the library's file and Import Library… adds it back as a new library.
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn libraries_are_exported_and_imported_from_the_panel_menu() {
let dir = vectorcraft_testkit::temp_dir("libraries-panel");
let path = dir.join("Brand.vclibrary").to_string_lossy().to_string();
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 200, "height": 200})).unwrap();
app.run("library.create", json!({"name": "Brand"})).unwrap();
app.run("library.add", json!({"kind": "fillColor", "color": "#0044aa", "name": "Blue"})).unwrap();
let picked = path.clone();
app.services.pick_save = Some(Box::new(move |pick: &crate::FilePick| {
assert_eq!(pick.name, "Brand.vclibrary", "the library's name suggested");
Some(picked.clone())
}));
app.services.write = Some(Box::new(|p: &str, bytes: &[u8]| std::fs::write(p, bytes).map_err(|e| e.to_string())));
export_picked(&mut app, "Brand", "Brand");
assert!(std::path::Path::new(&path).exists());
let picked = path.clone();
app.services.pick_open = Some(Box::new(move |_: &crate::FilePick| Some(picked.clone())));
import_picked(&mut app);
let list = app.run("library.list", json!({})).unwrap();
let names: Vec<&str> = list["libraries"].as_array().unwrap().iter().map(|l| l["name"].as_str().unwrap()).collect();
assert_eq!(names, ["Brand", "Brand (2)"]);
assert_eq!(app.ui.status, "Imported library Brand (2)");
}
}
+1 -1
View File
@@ -424,7 +424,7 @@ pub(crate) fn label_or_name(s: &str, built_in: bool) -> &str {
/// command feeds); returns the one clicked, for the caller to apply.
pub(crate) fn recent_colors_row(app: &VectorcraftApp, ui: &mut Ui) -> Option<Color> {
let t = Tokens::get(ui.ctx());
crate::widgets::subheader(ui, "Recent Colors");
crate::widgets::subheader(ui, tl!("Recent Colors"));
let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 22.0), Sense::hover());
ui.painter().rect_stroke(r, 0.0, egui::Stroke::new(1.0, t.input_border), egui::StrokeKind::Inside);
let mut chosen = None;
+3 -3
View File
@@ -50,7 +50,7 @@ fn plates_section(app: &mut VectorcraftApp, ui: &mut Ui) {
run(app, "view.separationsPreview", json!({"plates": plates.iter().map(|p| p.name.clone()).collect::<Vec<_>>()}));
}
});
ui.label(egui::RichText::new("CMYK").size(12.0).color(if on { t.text } else { t.text_dim }));
ui.label(egui::RichText::new(tl!("CMYK")).size(12.0).color(if on { t.text } else { t.text_dim }));
});
for p in &plates {
ui.horizontal(|ui| {
@@ -86,13 +86,13 @@ fn settings_section(app: &mut VectorcraftApp, ui: &mut Ui) {
let st = cms::active_settings();
widgets::section_header(ui, tl!("Color Settings"));
ui.horizontal(|ui| {
ui.label("RGB:");
ui.label(tl!("RGB:"));
if let Some(n) = profile_dropdown(ui, "cms-rgb", &st.rgb, ProfileKind::Rgb, None) {
run(app, "edit.colorSettings", json!({"rgb": n}));
}
});
ui.horizontal(|ui| {
ui.label("CMYK:");
ui.label(tl!("CMYK:"));
if let Some(n) = profile_dropdown(ui, "cms-cmyk", &st.cmyk, ProfileKind::Cmyk, None) {
run(app, "edit.colorSettings", json!({"cmyk": n}));
}
+15
View File
@@ -78,6 +78,21 @@ fn effect_menu_lists_the_video_filters_last() {
assert!(at("Sharpen") < at("Video"));
}
/// Effect › Texture, between Sharpen and Video: each filter opens its dialog.
#[test]
fn effect_menu_lists_the_texture_filters_between_sharpen_and_video() {
let app = app();
let entries = menus::menu_entries(&app);
let texture: Vec<_> = entries.iter().filter(|e| e.path == ["Effect", "Texture"]).collect();
let labels: Vec<&str> = texture.iter().map(|e| e.label.as_str()).collect();
assert_eq!(labels, ["Craquelure…", "Grain…", "Mosaic Tiles…", "Patchwork…", "Stained Glass…", "Texturizer…"]);
assert!(texture.iter().all(|e| e.command.as_deref() == Some("effect.dialog")));
let raster: Vec<&str> =
entries.iter().filter_map(|e| e.path.get(1).filter(|_| e.path.len() == 2 && e.path[0] == "Effect")).map(String::as_str).collect();
let at = |name: &str| raster.iter().position(|s| *s == name).unwrap();
assert!(at("Sharpen") < at("Texture") && at("Texture") < at("Video"));
}
#[test]
fn library_submenus_are_disabled_placeholders() {
let app = app();
+14 -8
View File
@@ -124,6 +124,12 @@ pub fn section_header(ui: &mut Ui, text: &str) {
/// A collapsible section's header across the panel: a chevron (down while `open`) and `text`;
/// a click anywhere on it toggles the section. Whether it was clicked.
pub fn section_toggle(ui: &mut Ui, text: &str, open: bool) -> bool {
section_toggle_name(ui, tl!(text), open).clicked()
}
/// [`section_toggle`] for a name that is user data (a library group), shown as it is → its
/// response (clicked: toggle; it takes a context menu).
pub fn section_toggle_name(ui: &mut Ui, text: &str, open: bool) -> Response {
let t = Tokens::get(ui.ctx());
let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 24.0), Sense::click());
if resp.hovered() {
@@ -131,9 +137,9 @@ pub fn section_toggle(ui: &mut Ui, text: &str, open: bool) -> bool {
}
let chevron = Rect::from_center_size(pos2(rect.left() + 8.0, rect.center().y), vec2(12.0, 12.0));
icons::paint(ui, if open { "chevron-down" } else { "chevron-right" }, chevron, if resp.hovered() { t.text_strong } else { t.icon });
ui.painter().text(pos2(rect.left() + 18.0, rect.center().y), egui::Align2::LEFT_CENTER, tl!(text), theme::semibold(12.5), t.text);
resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::CollapsingHeader, true, open, tl!(text)));
resp.on_hover_cursor(egui::CursorIcon::PointingHand).clicked()
ui.painter().text(pos2(rect.left() + 18.0, rect.center().y), egui::Align2::LEFT_CENTER, text, theme::semibold(12.5), t.text);
resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::CollapsingHeader, true, open, text));
resp.on_hover_cursor(egui::CursorIcon::PointingHand)
}
/// Full-width 1 px divider with vertical margins.
@@ -592,10 +598,10 @@ pub fn fill_stroke_proxy(ui: &mut Ui, fill: &Paint, stroke: &Paint, mixed: (bool
None
};
ProxyClicks {
fill: fill_resp.on_hover_text("Fill (X), double-click for the Color Picker").clicked(),
stroke: stroke_resp.on_hover_text("Stroke (X), double-click for the Color Picker").clicked(),
swap: swap.on_hover_text("Swap Fill and Stroke (Shift+X)").clicked(),
default: def.on_hover_text("Default Fill and Stroke (D)").clicked(),
fill: fill_resp.on_hover_text(tl!("Fill (X), double-click for the Color Picker")).clicked(),
stroke: stroke_resp.on_hover_text(tl!("Stroke (X), double-click for the Color Picker")).clicked(),
swap: swap.on_hover_text(tl!("Swap Fill and Stroke (Shift+X)")).clicked(),
default: def.on_hover_text(tl!("Default Fill and Stroke (D)")).clicked(),
pick,
}
}
@@ -1662,7 +1668,7 @@ pub fn harmony_wheel(ui: &mut Ui, id: &str, size: f32, colors: &[Color], base: O
ui.add_space(6.0);
ui.horizontal(|ui| {
ui.add_space(((ui.available_width() - size - 76.0) / 2.0).max(0.0));
ui.add_sized([70.0, 22.0], egui::Label::new(egui::RichText::new("Brightness").color(t.text_dim)));
ui.add_sized([70.0, 22.0], egui::Label::new(egui::RichText::new(tl!("Brightness")).color(t.text_dim)));
if let (Some(b), Some([h, s, _])) = (base, base_hsb)
&& let (Some(nv), _) = color_slider(ui, (id, "brightness"), v, size, &|x| crate::panels::c32(&Color::from_hsb(h, s, x)))
{
+27 -8
View File
@@ -282,7 +282,7 @@ objects' fills or strokes differ (`fillMixed` / `strokeMixed`, drawn as a "?" pr
| `apply_effect` | `{effect?, params?, ids?}` | Appends a live effect. Without `effect`, returns the effect catalogue with parameters and defaults. |
| `pathfinder` | `{operation, ids?}` | `unite`, `minusFront`, `intersect`, `exclude`, `divide`, `trim`, `merge`, `crop`, `outline`, `minusBack`. For a live version, apply the `pathfinder.*` effect to a group (`effect.apply` groups several loose selected objects first). |
| `transform` | `{ids?, dx?, dy?, rotate?, scale?, scaleX?, scaleY?, reflect?, shear?, origin?, copy?}` | Runs move, rotate, scale, reflect, shear in that order. With `copy`, the first step duplicates. |
| `create_graph` | `{type?, x, y, width, height, csv? \| series?, categories?, rows?}` | The nine Illustrator graph types: column, stacked column, bar, stacked bar, line, area, scatter, pie and radar. Edit later with `graph.setData` / `graph.setType` via `run_command`. |
| `create_graph` | `{type?, x, y, width, height, csv? \| series?, categories?, rows?}` | The nine Illustrator graph types: column, stacked column, bar, stacked bar, line, area, scatter, pie and radar. An empty CSV cell or a `null` in `rows` is a blank value; a number in straight quotes is a label. Edit later with `graph.setData` / `graph.setType` via `run_command`. |
| `text_wrap` | `{ids?, offset?, invert?, release?}` | Area type below the objects (same layer) flows around them. |
| `undo` / `redo` | `{}` | |
@@ -521,9 +521,10 @@ CMYK too.
PostScript files (`.eps`, and `.ai` files saved in older formats or without PDF compatibility) open through the EPS
reader (see EPS and PostScript import). An `.ai` saved without PDF compatibility (its PDF part is only a placeholder
page) opens from its editing data alone: its type is made from the file's text document where it can be (point type,
area type and type on a path), and what can't be is left out with a warning; so is non-native art (the content of a
placed PDF, which Illustrator shows but doesn't edit and keeps as a PDF inside the editing data); without editing data
it says it can't be opened.
area type and type on a path), and what can't be is left out with a warning. Non-native art (the content of a placed
PDF, which Illustrator shows but doesn't edit and keeps as a PDF inside the editing data) is drawn by the PDF importer,
as a group named "Non-native art" fitted to the object's box; one whose PDF can't be read is left out with a warning.
Without editing data it says it can't be opened.
What a PDF holds comes in as editable art: soft masks become opacity masks (an alpha mask as a white copy of its art;
the backdrop colour gives Clip, an inverting transfer function Invert), transparency groups keep isolation and knockout,
@@ -953,9 +954,26 @@ gives another colour merges into the document's), a colour paints the selection'
to the document (numbered when its name is taken by other attributes) and applied to the selected text, each as one
undo step. `library.removeItem {library?, kind, item}` removes one.
Items can be put in user-named groups, which the panel shows as collapsible sections after the ungrouped items (its
search field filters every item by name; `library.get` lists them all to filter). `library.createGroup {library?,
name?, items?: [{kind, item}]}` makes one (a name another group has, ignoring case, gets a number) with those items
moved into it; `library.renameGroup {library?, group, name}` renames one; `library.deleteGroup {library?, group}`
deletes one, its items staying in the library ungrouped; and `library.moveItem {library?, kind, item, group?}` moves
an item into a group (without `group`, out of its group). An item is in one group at most; `library.get` lists the
groups as `groups: [{name, items: [{kind: "color"|"charStyle"|"paraStyle"|"graphic", item}]}]` (a graphic by its id).
`library.export {library?, path?}` writes a library (its colours, styles, graphics with their art and thumbnails, and
its groups) as a `.vclibrary` file, the JSON the library folder keeps (without `path`: `{data}`, the file's text).
`library.import {path? | data? | dataBase64?, name?}` adds the library of such a file as a new library and makes it
the current one; a name another library has gets a number (`Brand (2)`). Imported files are checked: at most 10,000
items of each kind and 1,000 groups, names clipped and made unique, thumbnails kept only when they are small PNGs, and
groups naming items that aren't there dropped. Files written before groups load as they are.
```json
{"name":"run_command","arguments":{"command":"library.add","params":{"kind":"graphic","name":"Logo"}}}
{"name":"run_command","arguments":{"command":"library.use","params":{"kind":"graphic","item":"Logo","center":[300,200]}}}
{"name":"run_command","arguments":{"command":"library.createGroup","params":{"name":"Logos","items":[{"kind":"graphic","item":"Logo"}]}}}
{"name":"run_command","arguments":{"command":"library.export","params":{"path":"/tmp/Brand.vclibrary"}}}
```
## Strokes on type
@@ -1380,7 +1398,7 @@ size. The journal entry of a scaling command records the `strokes` and `corners`
## Live Corners
`object.setLiveShape {id?, ids?, radius?, kind?, corners?}` sets the corners of any path (one undo step): a live
`object.setLiveShape {id?, ids?, radius?, kind?, corners?, items?}` sets the corners of any path (one undo step): a live
rectangle's or polygon's, a star's, a pen path's. A corner is an anchor without handles between two straight sides
(not an open path's ends, not a smooth anchor, not one the sides run straight on through). `radius` (pt) and `kind`
(`round`, `invertedRound` or `chamfer`) go to the `corners` given, else to the corners holding a Direct-Selected
@@ -1396,9 +1414,10 @@ draws no larger than takes the cut halfway along the corner's shorter side (half
same limit for its four corners), the corners stay circular through uneven scales (the radius scales by the mean scale,
or keeps its size with Scale Corners off), and a rectangle from a file that kept an uneven scale in its transform
(elliptical corners) gets circular ones in document units when its corners are next set. Dragging a corner widget
rounds the corners whose widgets show (every corner, or the Direct-Selected ones), outlining in red those that reach
their limit; the Selection tool shows the widgets of live rectangles and polygons, the Direct Selection tool those of
any path. Alt-clicking one cycles their kind and double-clicking one opens Corners (`ui.corners {id?, corners?}`,
rounds the corners whose widgets show (every corner, or the Direct-Selected ones) on every selected path at once,
outlining in red those that reach their limit; the Selection tool shows the widgets of live rectangles and polygons,
the Direct Selection tool those of any path. Several objects each with their own corners are one call:
`items: [{id, corners?}]` in place of `ids` and `corners`. Alt-clicking one cycles their kind and double-clicking one opens Corners (`ui.corners {id?, corners?}`,
dialog `corners`: `kind`, `radius`; OK runs `object.setLiveShape`).
```json
+2
View File
@@ -75,6 +75,8 @@ pub const INTRA_LAYER_ORDER: &[&[&str]] = &[
&["text", "effects"],
&["render", "eps"],
&["metafile", "eps"],
// Non-native art in Illustrator editing data is a PDF, drawn by the PDF importer.
&["pdf", "eps"],
&["plugins", "effects"],
];